Skip to content

Commit e9f2baa

Browse files
committed
ci: ship direct branch releases with semantic fallback
1 parent 95abdf6 commit e9f2baa

File tree

6 files changed

+378
-15
lines changed

6 files changed

+378
-15
lines changed

.github/workflows/release.yml

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,11 @@ env:
1414
permissions:
1515
contents: write
1616
id-token: write
17-
pull-requests: write
1817

1918
jobs:
2019
release:
2120
name: Release
22-
if: github.repository_owner == 'TanStack'
21+
if: ${{ github.repository_owner == 'TanStack' && !contains(github.event.head_commit.message, '[skip ci]') }}
2322
runs-on: ubuntu-latest
2423
steps:
2524
- name: Checkout
@@ -36,17 +35,28 @@ jobs:
3635
run: pnpm --filter @tanstack/cli exec playwright install --with-deps chrome
3736
- name: E2E Blocking
3837
run: pnpm --filter @tanstack/cli test:e2e
39-
- name: Run Changesets (version or publish)
40-
uses: changesets/action@v1.5.3
41-
with:
42-
version: pnpm run changeset:version
43-
publish: pnpm run changeset:publish
44-
commit: "ci: Version Packages"
45-
title: "ci: Version Packages"
38+
- name: Generate Semantic Changeset Fallback
39+
run: pnpm run changeset:generate
40+
41+
- name: Prepare Release Context
42+
id: release
43+
run: pnpm run release:prepare
44+
45+
- name: Version Packages
46+
if: steps.release.outputs.has_changesets == 'true'
47+
run: pnpm run changeset:version
48+
49+
- name: Detect Versioning Changes
50+
if: steps.release.outputs.has_changesets == 'true'
51+
id: changes
52+
run: pnpm run release:detect-changes
53+
54+
- name: Commit Version Updates
55+
if: steps.release.outputs.has_changesets == 'true' && steps.changes.outputs.has_changes == 'true'
56+
run: pnpm run release:commit-and-push
57+
58+
- name: Publish Packages
59+
if: steps.release.outputs.has_changesets == 'true' && steps.changes.outputs.has_changes == 'true'
4660
env:
47-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
48-
- name: Comment on PRs about release
49-
if: steps.changesets.outputs.published == 'true'
50-
uses: tanstack/config/.github/comment-on-release@main
51-
with:
52-
published-packages: ${{ steps.changesets.outputs.publishedPackages }}
61+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
62+
run: pnpm run changeset:publish -- --tag "${{ steps.release.outputs.npm_tag }}"

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
"update-outdated": "node scripts/check-outdated-packages.js --update",
2020
"prepare": "husky install",
2121
"changeset": "changeset",
22+
"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",
2226
"changeset:publish": "changeset publish",
2327
"changeset:version": "changeset version && pnpm install --no-frozen-lockfile"
2428
},
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { execSync } from 'node:child_process'
2+
import { existsSync } from 'node:fs'
3+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
4+
import path from 'node:path'
5+
6+
const RELEASE_COMMIT_PREFIX = 'ci: Version Packages'
7+
const WORKSPACE_DIRS = ['packages', 'cli-aliases']
8+
const PATCH_TYPES = new Set(['fix', 'perf', 'refactor', 'docs', 'chore', 'build', 'ci', 'test', 'style'])
9+
10+
function runGit(args) {
11+
return execSync(`git ${args}`, { encoding: 'utf8' }).trim()
12+
}
13+
14+
async function getPendingChangesetFiles() {
15+
const changesetDir = path.resolve('.changeset')
16+
if (!existsSync(changesetDir)) return []
17+
18+
const entries = await readdir(changesetDir, { withFileTypes: true })
19+
return entries
20+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
21+
.map((entry) => entry.name)
22+
.filter((name) => name !== 'README.md')
23+
}
24+
25+
async function getPublishablePackages() {
26+
const packages = []
27+
28+
for (const workspaceDir of WORKSPACE_DIRS) {
29+
const absWorkspaceDir = path.resolve(workspaceDir)
30+
if (!existsSync(absWorkspaceDir)) continue
31+
32+
const dirEntries = await readdir(absWorkspaceDir, { withFileTypes: true })
33+
for (const dirEntry of dirEntries) {
34+
if (!dirEntry.isDirectory()) continue
35+
36+
const relDir = path.join(workspaceDir, dirEntry.name)
37+
const packageJsonPath = path.resolve(relDir, 'package.json')
38+
if (!existsSync(packageJsonPath)) continue
39+
40+
const raw = await readFile(packageJsonPath, 'utf8')
41+
const pkg = JSON.parse(raw)
42+
if (pkg.private || typeof pkg.name !== 'string') continue
43+
44+
packages.push({
45+
name: pkg.name,
46+
dir: relDir.replace(/\\/g, '/'),
47+
})
48+
}
49+
}
50+
51+
return packages
52+
}
53+
54+
function getBaseSha() {
55+
const lastReleaseSha = runGit(`log --format=%H --grep="^${RELEASE_COMMIT_PREFIX}" -n 1 HEAD`)
56+
if (lastReleaseSha) return lastReleaseSha
57+
58+
const roots = runGit('rev-list --max-parents=0 HEAD')
59+
return roots.split('\n').map((line) => line.trim()).filter(Boolean).at(-1)
60+
}
61+
62+
function parseCommitRecords(baseSha) {
63+
const raw = runGit(`log --format=%H%x1f%s%x1f%b%x1e ${baseSha}..HEAD`)
64+
if (!raw) return []
65+
66+
return raw
67+
.split('\x1e')
68+
.map((entry) => entry.trim())
69+
.filter(Boolean)
70+
.map((entry) => {
71+
const [sha, subject, body = ''] = entry.split('\x1f')
72+
return {
73+
sha,
74+
subject: subject ?? '',
75+
body,
76+
}
77+
})
78+
}
79+
80+
function getCommitBump(commit) {
81+
const headerMatch = commit.subject.match(/^([a-z]+)(\([^)]*\))?(!)?:\s+/i)
82+
const type = headerMatch?.[1]?.toLowerCase()
83+
const breaking = Boolean(headerMatch?.[3]) || /BREAKING CHANGE:/i.test(commit.body)
84+
85+
if (breaking) return 'major'
86+
if (type === 'feat') return 'minor'
87+
if (type && PATCH_TYPES.has(type)) return 'patch'
88+
return null
89+
}
90+
91+
function mergeBump(current, incoming) {
92+
const rank = { patch: 1, minor: 2, major: 3 }
93+
if (!current) return incoming
94+
if (!incoming) return current
95+
return rank[incoming] > rank[current] ? incoming : current
96+
}
97+
98+
function getChangedFiles(baseSha) {
99+
const raw = runGit(`diff --name-only ${baseSha}..HEAD`)
100+
if (!raw) return []
101+
return raw.split('\n').map((line) => line.trim()).filter(Boolean)
102+
}
103+
104+
function getTargetPackageNames(changedFiles, packages) {
105+
const affected = new Set()
106+
let globalImpact = false
107+
108+
for (const file of changedFiles) {
109+
const normalized = file.replace(/\\/g, '/')
110+
111+
if (
112+
normalized.startsWith('.changeset/') ||
113+
normalized === 'pnpm-lock.yaml' ||
114+
normalized === 'package.json'
115+
) {
116+
globalImpact = true
117+
continue
118+
}
119+
120+
let matched = false
121+
for (const pkg of packages) {
122+
if (normalized === pkg.dir || normalized.startsWith(`${pkg.dir}/`)) {
123+
affected.add(pkg.name)
124+
matched = true
125+
}
126+
}
127+
128+
if (!matched) {
129+
globalImpact = true
130+
}
131+
}
132+
133+
if (globalImpact) {
134+
return packages.map((pkg) => pkg.name)
135+
}
136+
137+
return [...affected]
138+
}
139+
140+
async function writeChangesetFile(packageNames, bump, commits) {
141+
const changesetDir = path.resolve('.changeset')
142+
if (!existsSync(changesetDir)) {
143+
await mkdir(changesetDir, { recursive: true })
144+
}
145+
146+
const branchName = process.env.GITHUB_REF_NAME || runGit('branch --show-current') || 'branch'
147+
const shortSha = runGit('rev-parse --short HEAD')
148+
const fileName = `auto-semantic-${branchName}-${shortSha}.md`.replace(/[^a-zA-Z0-9._-]/g, '-')
149+
const filePath = path.join(changesetDir, fileName)
150+
151+
const frontmatter = packageNames
152+
.sort((a, b) => a.localeCompare(b))
153+
.map((pkgName) => `'${pkgName}': ${bump}`)
154+
.join('\n')
155+
156+
const bullets = commits
157+
.slice(0, 6)
158+
.map((commit) => `- ${commit.subject} (${commit.sha.slice(0, 7)})`)
159+
.join('\n')
160+
161+
const content = `---\n${frontmatter}\n---\n\nAuto-generated changeset from semantic commits on ${branchName}.\n\n${bullets}\n`
162+
163+
await writeFile(filePath, content, 'utf8')
164+
return fileName
165+
}
166+
167+
async function main() {
168+
const pendingChangesets = await getPendingChangesetFiles()
169+
if (pendingChangesets.length > 0) {
170+
console.log(`Found ${pendingChangesets.length} authored changeset(s); skipping semantic fallback.`)
171+
return
172+
}
173+
174+
const baseSha = getBaseSha()
175+
const commits = parseCommitRecords(baseSha)
176+
const releaseCommits = commits
177+
.filter((commit) => !commit.subject.startsWith(RELEASE_COMMIT_PREFIX))
178+
.map((commit) => ({ ...commit, bump: getCommitBump(commit) }))
179+
.filter((commit) => Boolean(commit.bump))
180+
181+
if (releaseCommits.length === 0) {
182+
console.log('No semantic commits eligible for release; skipping generated changeset.')
183+
return
184+
}
185+
186+
const packages = await getPublishablePackages()
187+
if (packages.length === 0) {
188+
console.log('No publishable workspace packages found; skipping generated changeset.')
189+
return
190+
}
191+
192+
const changedFiles = getChangedFiles(baseSha)
193+
const targetPackages = getTargetPackageNames(changedFiles, packages)
194+
if (targetPackages.length === 0) {
195+
console.log('No affected publishable packages detected; skipping generated changeset.')
196+
return
197+
}
198+
199+
let bump = null
200+
for (const commit of releaseCommits) {
201+
bump = mergeBump(bump, commit.bump)
202+
}
203+
204+
if (!bump) {
205+
console.log('Unable to determine bump level; skipping generated changeset.')
206+
return
207+
}
208+
209+
const fileName = await writeChangesetFile(targetPackages, bump, releaseCommits)
210+
211+
console.log(
212+
`Generated ${fileName} (${bump}) for ${targetPackages.length} package(s) based on semantic commits.`,
213+
)
214+
}
215+
216+
main().catch((error) => {
217+
console.error(error)
218+
process.exit(1)
219+
})

scripts/prepare-release.mjs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { execSync } from 'node:child_process'
2+
import { appendFileSync, existsSync, readdirSync, readFileSync } from 'node:fs'
3+
import path from 'node:path'
4+
5+
function run(command) {
6+
return execSync(command, { encoding: 'utf8' }).trim()
7+
}
8+
9+
function getReleaseChannel(branch) {
10+
if (branch === 'main') {
11+
return { npmTag: 'latest', prerelease: false }
12+
}
13+
14+
if (branch === 'alpha' || branch === 'beta' || branch === 'rc') {
15+
return { npmTag: branch, prerelease: true }
16+
}
17+
18+
throw new Error(`Unsupported release branch: ${branch}`)
19+
}
20+
21+
function getPendingChangesets() {
22+
const changesetDir = path.resolve('.changeset')
23+
if (!existsSync(changesetDir)) return []
24+
25+
return readdirSync(changesetDir).filter(
26+
(name) => name.endsWith('.md') && name !== 'README.md',
27+
)
28+
}
29+
30+
function ensureReleaseMode({ prerelease, npmTag }) {
31+
const prePath = path.resolve('.changeset/pre.json')
32+
if (!existsSync(prePath)) {
33+
if (prerelease) {
34+
run(`pnpm changeset pre enter ${npmTag}`)
35+
}
36+
return
37+
}
38+
39+
const preState = JSON.parse(readFileSync(prePath, 'utf8'))
40+
41+
if (prerelease) {
42+
if (preState.tag !== npmTag) {
43+
throw new Error(
44+
`Expected prerelease tag '${npmTag}' but found '${preState.tag}' in .changeset/pre.json`,
45+
)
46+
}
47+
48+
if (preState.mode !== 'pre') {
49+
run(`pnpm changeset pre enter ${npmTag}`)
50+
}
51+
return
52+
}
53+
54+
if (preState.mode === 'pre') {
55+
throw new Error(
56+
'Main branch is in prerelease mode. Remove or exit prerelease state before stable releases.',
57+
)
58+
}
59+
}
60+
61+
function setOutput(name, value) {
62+
const outputPath = process.env.GITHUB_OUTPUT
63+
if (!outputPath) return
64+
appendFileSync(outputPath, `${name}=${value}\n`)
65+
}
66+
67+
function main() {
68+
const branch = process.env.GITHUB_REF_NAME || run('git branch --show-current')
69+
const channel = getReleaseChannel(branch)
70+
const pending = getPendingChangesets()
71+
72+
if (pending.length > 0) {
73+
ensureReleaseMode(channel)
74+
}
75+
76+
setOutput('npm_tag', channel.npmTag)
77+
setOutput('prerelease', String(channel.prerelease))
78+
setOutput('has_changesets', String(pending.length > 0))
79+
setOutput('pending_count', String(pending.length))
80+
81+
console.log(
82+
`Release prep: branch=${branch} tag=${channel.npmTag} prerelease=${channel.prerelease} changesets=${pending.length}`,
83+
)
84+
}
85+
86+
main()
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { execSync } from 'node:child_process'
2+
3+
function run(command) {
4+
return execSync(command, { encoding: 'utf8' }).trim()
5+
}
6+
7+
function main() {
8+
const branch = process.env.GITHUB_REF_NAME || run('git branch --show-current')
9+
10+
run('git add -A')
11+
run(
12+
`git -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' commit -m "ci: Version Packages [skip ci]"`,
13+
)
14+
run(`git push --follow-tags origin "HEAD:${branch}"`)
15+
16+
console.log(`Committed and pushed release changes to ${branch}.`)
17+
}
18+
19+
main()

0 commit comments

Comments
 (0)