Skip to content

Commit e49fe35

Browse files
committed
fix(ci): restore changesets version PR flow
1 parent 8f4d144 commit e49fe35

6 files changed

Lines changed: 315 additions & 164 deletions

File tree

.github/workflows/release.yml

Lines changed: 51 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,33 @@ name: Release
22

33
on:
44
push:
5-
branches: [main, alpha, beta, rc]
5+
branches: [main, alpha, beta, rc, 'v[0-9]', '*-pre', '*-maint']
66

77
concurrency:
8-
group: ${{ github.workflow }}-${{ github.event.number || github.ref }}
9-
cancel-in-progress: true
8+
group: ${{ github.workflow }}-${{ github.ref }}
9+
cancel-in-progress: false
1010

1111
env:
1212
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
13-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
1413

15-
permissions: {}
14+
permissions:
15+
contents: read
1616

1717
jobs:
1818
release:
1919
name: Release
20-
if: ${{ github.repository_owner == 'TanStack' && !contains(github.event.head_commit.message, '[skip ci]') }}
20+
if: ${{ github.repository_owner == 'TanStack' }}
2121
runs-on: ubuntu-latest
2222
permissions:
2323
contents: write
2424
id-token: write
25-
pull-requests: read
26-
statuses: read
25+
pull-requests: write
2726
steps:
2827
- name: Checkout
2928
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
3029
with:
3130
fetch-depth: 0
32-
persist-credentials: false
31+
persist-credentials: true # changesets/action pushes version PR commits
3332
- name: Setup Tools
3433
uses: tanstack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main
3534
- name: Build
@@ -40,28 +39,49 @@ jobs:
4039
run: pnpm --filter @tanstack/cli exec playwright install --with-deps chrome
4140
- name: E2E Blocking
4241
run: pnpm --filter @tanstack/cli test:e2e
43-
- name: Generate Semantic Changeset Fallback
44-
run: pnpm run changeset:generate
4542

46-
- name: Prepare Release Context
47-
id: release
48-
run: pnpm run release:prepare
43+
- name: Enter Pre-Release Mode
44+
if: "(contains(github.ref_name, '-pre') || github.ref_name == 'alpha' || github.ref_name == 'beta' || github.ref_name == 'rc') && !hashFiles('.changeset/pre.json')"
45+
run: |
46+
BRANCH="${GITHUB_REF_NAME}"
47+
if [[ "$BRANCH" == "alpha" || "$BRANCH" == "beta" || "$BRANCH" == "rc" ]]; then
48+
pnpm changeset pre enter "$BRANCH"
49+
else
50+
pnpm changeset pre enter pre
51+
fi
52+
- name: Determine dist-tag
53+
id: dist-tag
54+
run: |
55+
BRANCH="${GITHUB_REF_NAME}"
56+
if [[ "$BRANCH" == "alpha" || "$BRANCH" == "beta" || "$BRANCH" == "rc" ]]; then
57+
echo "prerelease=true" >> "$GITHUB_OUTPUT"
58+
echo "tag=$BRANCH" >> "$GITHUB_OUTPUT"
59+
elif [[ "$BRANCH" == *-pre ]]; then
60+
echo "prerelease=true" >> "$GITHUB_OUTPUT"
61+
echo "tag=pre" >> "$GITHUB_OUTPUT"
62+
elif [[ "$BRANCH" == *-maint ]]; then
63+
echo "tag=maint" >> "$GITHUB_OUTPUT"
64+
elif [[ "$BRANCH" =~ ^v[0-9]+$ ]]; then
65+
echo "tag=$BRANCH" >> "$GITHUB_OUTPUT"
66+
else
67+
echo "latest=true" >> "$GITHUB_OUTPUT"
68+
fi
4969
50-
- name: Version Packages
51-
if: steps.release.outputs.has_changesets == 'true'
52-
run: pnpm run changeset:version
53-
54-
- name: Detect Versioning Changes
55-
if: steps.release.outputs.has_changesets == 'true'
56-
id: changes
57-
run: pnpm run release:detect-changes
58-
59-
- name: Commit Version Updates
60-
if: steps.release.outputs.has_changesets == 'true' && steps.changes.outputs.has_changes == 'true'
61-
run: pnpm run release:commit-and-push
62-
63-
- name: Publish Packages
64-
if: steps.release.outputs.has_changesets == 'true' && steps.changes.outputs.has_changes == 'true'
70+
- name: Create Release Pull Request or Publish
71+
id: changesets
72+
uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b # v1.8.0
73+
with:
74+
version: pnpm run changeset:version
75+
publish: pnpm run changeset:publish ${{ steps.dist-tag.outputs.tag && format('--tag {0}', steps.dist-tag.outputs.tag) }}
76+
title: 'ci: Version Packages'
77+
commit: 'ci: changeset release'
78+
env:
79+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
80+
- name: Create GitHub Release
81+
if: steps.changesets.outputs.published == 'true'
82+
run: node scripts/create-github-release.mjs ${PRERELEASE_FLAG} ${LATEST_FLAG}
6583
env:
66-
NPM_TAG: ${{ steps.release.outputs.npm_tag }}
67-
run: pnpm changeset publish --tag "$NPM_TAG"
84+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
85+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
86+
PRERELEASE_FLAG: ${{ steps.dist-tag.outputs.prerelease == 'true' && '--prerelease' }}
87+
LATEST_FLAG: ${{ steps.dist-tag.outputs.latest == 'true' && '--latest' }}

package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,6 @@
2020
"prepare": "husky install",
2121
"changeset": "changeset",
2222
"changeset:generate": "node scripts/generate-semantic-changeset.mjs",
23-
"release:prepare": "node scripts/prepare-release.mjs",
24-
"release:detect-changes": "node scripts/release-detect-changes.mjs",
25-
"release:commit-and-push": "node scripts/release-commit-and-push.mjs",
2623
"changeset:publish": "changeset publish",
2724
"changeset:version": "changeset version && pnpm install --no-frozen-lockfile"
2825
},

scripts/create-github-release.mjs

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
// @ts-nocheck
2+
import { execFileSync, execSync } from 'node:child_process'
3+
import fs, { globSync } from 'node:fs'
4+
import { tmpdir } from 'node:os'
5+
import path from 'node:path'
6+
7+
const rootDir = path.join(import.meta.dirname, '..')
8+
const ghToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
9+
const workspaceDirs = ['packages', 'cli-aliases']
10+
11+
const usernameCache = {}
12+
async function resolveUsername(email) {
13+
if (!ghToken || !email) return null
14+
if (usernameCache[email] !== undefined) return usernameCache[email]
15+
16+
try {
17+
const res = await fetch(`https://api.github.com/search/users?q=${email}`, {
18+
headers: { Authorization: `token ${ghToken}` },
19+
})
20+
const data = await res.json()
21+
const login = data?.items?.[0]?.login || null
22+
usernameCache[email] = login
23+
return login
24+
} catch {
25+
usernameCache[email] = null
26+
return null
27+
}
28+
}
29+
30+
const prAuthorCache = {}
31+
async function resolveAuthorForPR(prNumber) {
32+
if (prAuthorCache[prNumber] !== undefined) return prAuthorCache[prNumber]
33+
34+
if (!ghToken) {
35+
prAuthorCache[prNumber] = null
36+
return null
37+
}
38+
39+
try {
40+
const res = await fetch(
41+
`https://api.github.com/repos/TanStack/cli/pulls/${prNumber}`,
42+
{ headers: { Authorization: `token ${ghToken}` } },
43+
)
44+
const data = await res.json()
45+
const login = data?.user?.login || null
46+
prAuthorCache[prNumber] = login
47+
return login
48+
} catch {
49+
prAuthorCache[prNumber] = null
50+
return null
51+
}
52+
}
53+
54+
// This runs after the "ci: Version Packages" PR is merged, so HEAD is the
55+
// release commit.
56+
const releaseLogs = execSync(
57+
'git log --oneline --grep="^ci: Version Packages" --grep="^ci: changeset release" --format=%H',
58+
)
59+
.toString()
60+
.trim()
61+
.split('\n')
62+
.filter(Boolean)
63+
64+
const currentRelease = releaseLogs[0] || 'HEAD'
65+
const previousRelease = releaseLogs[1]
66+
67+
const bumpedPackages = []
68+
for (const workspaceDir of workspaceDirs) {
69+
const absWorkspaceDir = path.join(rootDir, workspaceDir)
70+
const pkgJsonPaths = globSync('*/package.json', { cwd: absWorkspaceDir })
71+
72+
for (const relPath of pkgJsonPaths) {
73+
const fullPath = path.join(absWorkspaceDir, relPath)
74+
const currentPkg = JSON.parse(fs.readFileSync(fullPath, 'utf-8'))
75+
if (currentPkg.private) continue
76+
77+
const repoRelPath = `${workspaceDir}/${relPath}`
78+
if (previousRelease) {
79+
try {
80+
const prevContent = execFileSync(
81+
'git',
82+
['show', `${previousRelease}:${repoRelPath}`],
83+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },
84+
)
85+
const prevPkg = JSON.parse(prevContent)
86+
if (prevPkg.version !== currentPkg.version) {
87+
bumpedPackages.push({
88+
name: currentPkg.name,
89+
version: currentPkg.version,
90+
prevVersion: prevPkg.version,
91+
dir: path.dirname(repoRelPath),
92+
})
93+
}
94+
} catch {
95+
bumpedPackages.push({
96+
name: currentPkg.name,
97+
version: currentPkg.version,
98+
prevVersion: null,
99+
dir: path.dirname(repoRelPath),
100+
})
101+
}
102+
} else {
103+
bumpedPackages.push({
104+
name: currentPkg.name,
105+
version: currentPkg.version,
106+
prevVersion: null,
107+
dir: path.dirname(repoRelPath),
108+
})
109+
}
110+
}
111+
}
112+
113+
bumpedPackages.sort((a, b) => a.name.localeCompare(b.name))
114+
115+
const rangeFrom = previousRelease || `${currentRelease}~1`
116+
const rawLog = execSync(
117+
`git log ${rangeFrom}..${currentRelease} --pretty=format:"%h %ae %s" --no-merges`,
118+
{ encoding: 'utf-8' },
119+
).trim()
120+
121+
const typeOrder = [
122+
'breaking',
123+
'feat',
124+
'fix',
125+
'perf',
126+
'refactor',
127+
'docs',
128+
'chore',
129+
'test',
130+
'ci',
131+
]
132+
const typeLabels = {
133+
breaking: 'Breaking Changes',
134+
feat: 'Features',
135+
fix: 'Fix',
136+
perf: 'Performance',
137+
refactor: 'Refactor',
138+
docs: 'Documentation',
139+
chore: 'Chore',
140+
test: 'Tests',
141+
ci: 'CI',
142+
}
143+
const typeIndex = (type) => {
144+
const index = typeOrder.indexOf(type)
145+
return index === -1 ? 99 : index
146+
}
147+
148+
const groups = {}
149+
const commits = rawLog ? rawLog.split('\n') : []
150+
151+
for (const line of commits) {
152+
const match = line.match(/^(\w+)\s+(\S+)\s+(.*)$/)
153+
if (!match) continue
154+
const [, hash, email, subject] = match
155+
156+
if (
157+
subject.startsWith('ci: Version Packages') ||
158+
subject.startsWith('ci: changeset release')
159+
) {
160+
continue
161+
}
162+
163+
const conventionalMatch = subject.match(/^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.*)$/)
164+
const type = conventionalMatch ? conventionalMatch[1] : 'other'
165+
const isBreaking = conventionalMatch ? Boolean(conventionalMatch[3]) : false
166+
const scope = conventionalMatch ? conventionalMatch[2] || '' : ''
167+
const message = conventionalMatch ? conventionalMatch[4] : subject
168+
169+
if (!['chore', 'feat', 'fix', 'perf', 'refactor', 'build'].includes(type)) {
170+
continue
171+
}
172+
173+
const prMatch = message.match(/\(#(\d+)\)/)
174+
const prNumber = prMatch ? prMatch[1] : null
175+
176+
const bucket = isBreaking ? 'breaking' : type
177+
if (!groups[bucket]) groups[bucket] = []
178+
groups[bucket].push({ hash, email, scope, message, prNumber })
179+
}
180+
181+
const sortedTypes = Object.keys(groups).sort(
182+
(a, b) => typeIndex(a) - typeIndex(b),
183+
)
184+
185+
let changelogMd = ''
186+
for (const type of sortedTypes) {
187+
const label = typeLabels[type] || type.charAt(0).toUpperCase() + type.slice(1)
188+
changelogMd += `### ${label}\n\n`
189+
190+
for (const commit of groups[type]) {
191+
const scopePrefix = commit.scope ? `${commit.scope}: ` : ''
192+
const cleanMessage = commit.message.replace(/\s*\(#\d+\)/, '')
193+
const prRef = commit.prNumber ? ` (#${commit.prNumber})` : ''
194+
const username = commit.prNumber
195+
? await resolveAuthorForPR(commit.prNumber)
196+
: await resolveUsername(commit.email)
197+
const authorSuffix = username ? ` by @${username}` : ''
198+
199+
changelogMd += `- ${scopePrefix}${cleanMessage}${prRef} (${commit.hash})${authorSuffix}\n`
200+
}
201+
changelogMd += '\n'
202+
}
203+
204+
if (!changelogMd.trim()) {
205+
changelogMd = '- No changelog entries\n\n'
206+
}
207+
208+
const now = new Date()
209+
const date = now.toISOString().slice(0, 10)
210+
const time = now.toISOString().slice(11, 16).replace(':', '')
211+
const tagName = `release-${date}-${time}`
212+
const titleDate = `${date} ${now.toISOString().slice(11, 16)}`
213+
214+
const isPrerelease = process.argv.includes('--prerelease')
215+
const isLatest = process.argv.includes('--latest')
216+
217+
const body = `Release ${titleDate}
218+
219+
## Changes
220+
221+
${changelogMd}
222+
## Packages
223+
224+
${bumpedPackages.map((pkg) => `- ${pkg.name}@${pkg.version}`).join('\n')}
225+
`
226+
227+
let tagExists = false
228+
try {
229+
execSync(`git rev-parse ${tagName}`, { stdio: 'ignore' })
230+
tagExists = true
231+
} catch {
232+
// Tag does not exist yet.
233+
}
234+
235+
if (!tagExists) {
236+
execSync(`git tag -a -m "${tagName}" ${tagName}`)
237+
execSync('git push --tags')
238+
}
239+
240+
const prereleaseFlag = isPrerelease ? '--prerelease' : ''
241+
const latestFlag = isLatest ? ' --latest' : ''
242+
const tmpFile = path.join(tmpdir(), `release-notes-${tagName}.md`)
243+
fs.writeFileSync(tmpFile, body)
244+
245+
try {
246+
execSync(
247+
`gh release create ${tagName} ${prereleaseFlag} --title "Release ${titleDate}" --notes-file ${tmpFile}${latestFlag}`,
248+
{ stdio: 'inherit' },
249+
)
250+
console.info(`GitHub release ${tagName} created.`)
251+
} catch (error) {
252+
if (!tagExists) {
253+
console.info(`Release creation failed, cleaning up tag ${tagName}...`)
254+
try {
255+
execSync(`git push --delete origin ${tagName}`, { stdio: 'ignore' })
256+
execSync(`git tag -d ${tagName}`, { stdio: 'ignore' })
257+
} catch {
258+
// Best effort cleanup.
259+
}
260+
}
261+
throw error
262+
} finally {
263+
fs.unlinkSync(tmpFile)
264+
}

0 commit comments

Comments
 (0)