ReviewOS

also looking at this

craft-native/craft

fix: validate native SDK release prerequisites before tagging

#364
Merged chris wants to merge codex/native-sdk-release-preflight into main
7 files +153 -15
bun.lockmodified+0-0

Binary file. Nothing to show line by line.

docs/RELEASING.mdmodified+18-1
Changes to docs/RELEASING.md
@@ -11,7 +11,9 @@ They also read addressed native state from the main, child, and adopted windows.
1111Each installed-app smoke also checks creator teardown and adoption of a
1212surviving, unparented grandchild. The PKG case explicitly destroys the retained
1313macOS creator after close; DEB and MSI close already releases it.
14The release hook generates the next changelog section before that commit,
14The release command registers the workspace and Zig release manifests with
15Bumpx so their versions update and roll back together. The release hook
16generates the next changelog section before that commit,
1517deduplicates repeated issue references, and links the previous and new tags.
1618Do not run the hook again after it has written the section.
1719
@@ -61,3 +63,18 @@ registry publication can still fail after the native archives become public, so
6163the final workflow status remains the release signal. If a post-release registry
6264publication fails, the GitHub release still contains the full verified archive
6365set; inspect and rerun the failed job rather than republishing the draft.
66
67## Release prerequisites and failed tags
68
69Install the locked release tools with `bun install --frozen-lockfile` first.
70Preflight now checks the Bumpx and Logsmith CLIs, a clean `main`, and the current
71`origin/main` before changing versions. Use a separate release clone if `main`
72is in use by another worktree. Required release commands fail before committing,
73tagging or pushing; Bumpx restores the version files when they fail.
74
75Retain a tag that failed publication. Choose a new unused version with
76`bun run release:version <version>` and set `CRAFT_CHANGELOG_FROM` to the last
77successfully published tag. The standard version and patch commands run the
78same manifest synchronization, changelog generator and publication gates.
79Confirm the final Releaser result and actual registry artifacts before apps
80adopt the new version.
package.jsonmodified+3-2
Changes to package.json
@@ -61,11 +61,12 @@
6161 "changelog:generate": "bun scripts/generate-release-changelog.ts",
6262 "test:release": "bun test ./scripts/changelog-preview.test.ts ./scripts/release-workflow.test.ts ./scripts/release-manifest.test.ts ./scripts/verify-sbom.test.ts",
6363 "release": "bun scripts/release-version.ts --preflight && bun x --bun @stacksjs/bumpx prompt --recursive",
64 "release:patch": "bun scripts/release-version.ts --preflight && bun x --bun @stacksjs/bumpx patch --recursive --commit --tag --push --yes --no-changelog --execute \"bun scripts/sync-release-version.ts && bun scripts/generate-release-changelog.ts && git add packages/zig/build.zig.zon packages/zig/pantry.json CHANGELOG.md\""
64 "release:patch": "bun scripts/release-version.ts --preflight && bun x --bun @stacksjs/bumpx patch --recursive --files package.json,packages/zig/build.zig.zon,packages/zig/pantry.json --commit --tag --push --yes --no-changelog --execute \"bun scripts/generate-release-changelog.ts && git add CHANGELOG.md\"",
65 "release:version": "bun scripts/release-version.ts --preflight && bun x --bun @stacksjs/bumpx --recursive --files package.json,packages/zig/build.zig.zon,packages/zig/pantry.json --commit --tag --push --yes --no-changelog --execute \"bun scripts/generate-release-changelog.ts && git add CHANGELOG.md\""
6566 },
6667 "devDependencies": {
6768 "@stacksjs/buddy": "0.72.56",
68 "@stacksjs/bumpx": "^0.2.10",
69 "@stacksjs/bumpx": "^0.2.22",
6970 "@stacksjs/bunpress": "^0.2.13",
7071 "@stacksjs/env": "^0.72.50",
7172 "better-dx": "^0.2.38",
scripts/changelog-preview.test.tsmodified+49-5
Changes to scripts/changelog-preview.test.ts
@@ -1,5 +1,5 @@
11import { afterAll, expect, test } from 'bun:test'
2import { readFileSync, rmSync, writeFileSync } from 'node:fs'
2import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
33import { join } from 'node:path'
44import { dedupeChangelogReferences, previewChangelog } from './changelog-preview'
55import { generateReleaseChangelog } from './generate-release-changelog'
@@ -9,6 +9,7 @@ import { createTempDir, removeAllTempDirs } from '../packages/typescript/src/tem
99afterAll(removeAllTempDirs)
1010
1111const cli = join(import.meta.dir, 'changelog-preview.ts')
12const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`
1213function fixture(run: (directory: string, git: (...args: string[]) => string) => void) {
1314 const directory = createTempDir('craft-changelog-fixture-')
1415 const git = (...args: string[]) => {
@@ -109,29 +110,72 @@ test('failed release generation leaves the changelog unchanged', () => {
109110 })
110111})
111112
113test('release notes can include changes since the last published release after an abandoned tag', () => {
114 fixture((directory, git) => {
115 git('commit', '--allow-empty', '-qm', 'feat: native trail maps')
116 git('tag', 'v1.0.1')
117 writeFileSync(join(directory, 'package.json'), '{"name":"fixture","version":"1.0.2"}')
118 const content = generateReleaseChangelog(directory, 'v1.0.0')
119 expect(content).toContain('native trail maps')
120 expect(content).toContain('/compare/v1.0.0...v1.0.2)')
121 expect(git('tag', '--list', 'v1.0.1')).toBe('v1.0.1\n')
122 })
123})
124
112125test('patch release uses the guarded changelog generator before committing', () => {
113126 const project = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf8'))
114127 const command = project.scripts['release:patch'] as string
115128 expect(project.scripts['changelog:generate']).toBe('bun scripts/generate-release-changelog.ts')
116129 expect(command).toContain('--no-changelog')
117130 expect(command).toContain('bun scripts/generate-release-changelog.ts')
118 expect(command).toContain('git add packages/zig/build.zig.zon packages/zig/pantry.json CHANGELOG.md')
131 expect(command).toContain('--files package.json,packages/zig/build.zig.zon,packages/zig/pantry.json')
119132})
120133
121test('bumpx commits and tags the guarded changelog without its duplicate generator', () => {
134test('an explicit version commits and tags the guarded changelog without its duplicate generator', () => {
122135 fixture((directory, git) => {
136 mkdirSync(join(directory, 'packages/zig'), { recursive: true })
137 writeFileSync(join(directory, 'packages/zig/build.zig.zon'), '.{ .version = "1.0.1", }')
138 writeFileSync(join(directory, 'packages/zig/pantry.json'), '{"version":"1.0.1"}')
139 git('add', 'packages')
140 git('commit', '-qm', 'chore: register native release metadata')
123141 git('commit', '--allow-empty', '-qm', 'Merge pull request #326 from example/feature')
124142 const bumpx = Bun.resolveSync('@stacksjs/bumpx/bin/cli.js', import.meta.dir)
125143 const generator = join(import.meta.dir, 'generate-release-changelog.ts')
126144 const result = Bun.spawnSync([
127 process.execPath, bumpx, 'patch', '--commit', '--tag', '--no-push', '--yes', '--no-changelog',
128 '--execute', `bun ${JSON.stringify(generator)} && git add CHANGELOG.md`,
145 process.execPath, bumpx, '--recursive', '--files', 'package.json,packages/zig/build.zig.zon,packages/zig/pantry.json', '--commit', '--tag', '--no-push', '--yes', '--no-changelog',
146 '--execute', `${shellQuote(process.execPath)} ${shellQuote(generator)} && git add CHANGELOG.md`,
147 '1.0.2',
129148 ], { cwd: directory, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 })
130149 expect(result.exitCode, result.stderr.toString()).toBe(0)
131150 expect(git('tag', '--list', 'v1.0.2')).toBe('v1.0.2\n')
132151 const notes = git('show', 'HEAD:CHANGELOG.md')
133152 expect(notes).toContain('[Compare changes](https://github.com/example/fixture/compare/v1.0.0...v1.0.2)')
134153 expect(notes.match(/\[#326\]\(https:\/\/github\.com\/example\/fixture\/issues\/326\)/g)).toHaveLength(1)
154 expect(JSON.parse(git('show', 'HEAD:packages/zig/pantry.json')).version).toBe('1.0.2')
155 expect(git('show', 'HEAD:packages/zig/build.zig.zon')).toContain('"1.0.2"')
156 expect(git('status', '--porcelain')).toBe('')
157 })
158})
159
160test('the published release tool restores workspace and native versions when a required command fails', () => {
161 fixture((directory, git) => {
162 mkdirSync(join(directory, 'packages/zig'), { recursive: true })
163 writeFileSync(join(directory, 'packages/zig/build.zig.zon'), '.{ .version = "1.0.1", }')
164 writeFileSync(join(directory, 'packages/zig/pantry.json'), '{"version":"1.0.1"}')
165 git('add', 'packages')
166 git('commit', '-qm', 'chore: register native release metadata')
167 const head = git('rev-parse', 'HEAD')
168 const paths = ['package.json', 'packages/zig/build.zig.zon', 'packages/zig/pantry.json']
169 const before = paths.map(path => readFileSync(join(directory, path), 'utf8'))
170 const bumpx = Bun.resolveSync('@stacksjs/bumpx/bin/cli.js', import.meta.dir)
171 const result = Bun.spawnSync([
172 process.execPath, bumpx, 'patch', '--recursive', '--files', paths.join(','), '--commit', '--tag', '--no-push', '--yes', '--no-changelog',
173 '--execute', 'bun -e "process.exit(23)"',
174 ], { cwd: directory, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 })
175 expect(result.exitCode).not.toBe(0)
176 expect(paths.map(path => readFileSync(join(directory, path), 'utf8'))).toEqual(before)
177 expect(git('rev-parse', 'HEAD')).toBe(head)
178 expect(git('tag', '--list', 'v1.0.2')).toBe('')
135179 expect(git('status', '--porcelain')).toBe('')
136180 })
137181})
scripts/generate-release-changelog.tsmodified+3-3
Changes to scripts/generate-release-changelog.ts
@@ -12,12 +12,12 @@ function previousTag(cwd: string): string {
1212}
1313
1414/** Generate the next release section before bumpx commits and tags it. */
15export function generateReleaseChangelog(cwd: string): string {
15export function generateReleaseChangelog(cwd: string, fromRef?: string): string {
1616 const version = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')).version
1717 if (typeof version !== 'string' || !/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(version))
1818 throw new Error('The root package must have a valid release version')
1919
20 const from = previousTag(cwd)
20 const from = fromRef ?? previousTag(cwd)
2121 const to = `v${version}`
2222 if (from === to)
2323 throw new Error(`Release ${to} is already tagged`)
@@ -44,4 +44,4 @@ export function generateReleaseChangelog(cwd: string): string {
4444}
4545
4646if (import.meta.main)
47 generateReleaseChangelog(process.cwd())
47 generateReleaseChangelog(process.cwd(), process.env.CRAFT_CHANGELOG_FROM)
scripts/release-version.test.tsmodified+49-2
Changes to scripts/release-version.test.ts
@@ -1,7 +1,7 @@
11import { afterAll, expect, test } from 'bun:test'
2import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
2import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
33import { join } from 'node:path'
4import { validateReleaseCheckout, checkReleaseVersion, validateReleaseVersion } from './release-version'
4import { validateReleaseCheckout, validateReleaseGit, checkReleaseVersion, validateReleaseVersion } from './release-version'
55import { createTempDir, removeAllTempDirs } from '../packages/typescript/src/temp-dir'
66
77// Every scratch directory this file made goes when the file is done (#357).
@@ -60,3 +60,50 @@ test('refuses an incomplete sparse release checkout before a version bump', () =
6060 rmSync(manifest)
6161 expect(() => validateReleaseCheckout(root)).toThrow('packages/react/package.json')
6262})
63
64test('release preflight rejects missing tools before changing package versions or tags', () => {
65 const root = createTempDir('craft-release-prerequisites-')
66 mkdirSync(join(root, 'scripts'))
67 const manifest = '{"version":"1.2.3"}\n'
68 writeFileSync(join(root, 'package.json'), manifest)
69 writeFileSync(join(root, 'scripts/release-version.ts'), readFileSync(join(import.meta.dir, 'release-version.ts')))
70 expect(Bun.spawnSync(['git', 'init', '-q'], { cwd: root }).exitCode).toBe(0)
71 const result = Bun.spawnSync([process.execPath, 'scripts/release-version.ts', '--preflight'], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
72 expect(result.exitCode).not.toBe(0)
73 expect(result.stderr.toString()).toContain('bun install --frozen-lockfile')
74 expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(manifest)
75 expect(Bun.spawnSync(['git', 'tag', '--list'], { cwd: root, stdout: 'pipe' }).stdout.toString()).toBe('')
76})
77
78test('release Git preflight requires clean main and checks the current remote', () => {
79 const root = createTempDir('craft-release-main-')
80 const remote = join(root, 'remote.git')
81 const work = join(root, 'work')
82 mkdirSync(work)
83 const git = (cwd: string, ...args: string[]) => {
84 const result = Bun.spawnSync(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' })
85 expect(result.exitCode, result.stderr.toString()).toBe(0)
86 return result.stdout.toString()
87 }
88 git(root, 'init', '--bare', remote)
89 git(work, 'init', '-b', 'main')
90 git(work, 'config', 'user.email', 'release@example.com')
91 git(work, 'config', 'user.name', 'Release fixture')
92 git(work, 'commit', '--allow-empty', '-m', 'chore: initial release')
93 git(work, 'remote', 'add', 'origin', remote)
94 git(work, 'push', '-u', 'origin', 'main')
95 expect(() => validateReleaseGit(work)).not.toThrow()
96 writeFileSync(join(work, 'unfinished.txt'), 'unfinished')
97 expect(() => validateReleaseGit(work)).toThrow('clean main')
98 rmSync(join(work, 'unfinished.txt'))
99 git(work, 'switch', '-c', 'feature')
100 expect(() => validateReleaseGit(work)).toThrow('Release from main')
101 git(work, 'switch', 'main')
102 const peer = join(root, 'peer')
103 git(root, 'clone', '--branch', 'main', remote, peer)
104 git(peer, 'config', 'user.email', 'peer@example.com')
105 git(peer, 'config', 'user.name', 'Release peer')
106 git(peer, 'commit', '--allow-empty', '-m', 'chore: another source change')
107 git(peer, 'push', 'origin', 'main')
108 expect(() => validateReleaseGit(work)).toThrow('must match origin/main')
109})
scripts/release-version.tsmodified+31-2
Changes to scripts/release-version.ts
@@ -1,5 +1,5 @@
1import { appendFileSync, existsSync, readFileSync } from 'node:fs'
2import { join, resolve } from 'node:path'
1import { appendFileSync, existsSync, readFileSync, realpathSync } from 'node:fs'
2import { isAbsolute, join, relative, resolve, sep } from 'node:path'
33
44/** Fail before a bump if a sparse checkout omits manifests that CI will publish. */
55export function validateReleaseCheckout(root: string): void {
@@ -10,6 +10,33 @@ export function validateReleaseCheckout(root: string): void {
1010 throw new Error(`Release checkout omits package manifests: ${missing.join(', ')}. Expand the sparse checkout before releasing.`)
1111}
1212
13/** Refuse missing release tools before a version or immutable tag can change. */
14export function validateReleaseDependencies(root: string): void {
15 for (const cli of ['@stacksjs/bumpx/bin/cli.js', '@stacksjs/logsmith/bin/cli.js']) {
16 try {
17 const packageRoot = realpathSync(join(root, 'node_modules', ...cli.split('/').slice(0, 2)))
18 const resolved = relative(packageRoot, realpathSync(Bun.resolveSync(cli, root)))
19 if (resolved === '..' || resolved.startsWith(`..${sep}`) || isAbsolute(resolved)) throw new Error('The CLI resolved outside the installed project dependency')
20 }
21 catch (cause) {
22 throw new Error(`Cannot resolve release dependency ${cli}. Run bun install --frozen-lockfile before releasing.`, { cause })
23 }
24 }
25}
26
27/** Releases originate from a clean main that matches the current remote. */
28export function validateReleaseGit(root: string): void {
29 const git = (...args: string[]): string => {
30 const result = Bun.spawnSync(['git', ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 })
31 if (result.exitCode !== 0) throw new Error(`Release Git preflight failed: ${result.stderr.toString().trim()}`)
32 return result.stdout.toString().trim()
33 }
34 if (git('branch', '--show-current') !== 'main') throw new Error('Release from main, using an isolated checkout when main is already in use.')
35 if (git('status', '--porcelain')) throw new Error('Release requires a clean main checkout.')
36 git('fetch', 'origin', 'main')
37 if (git('rev-parse', 'HEAD') !== git('rev-parse', 'origin/main')) throw new Error('Release main must match origin/main. Synchronize and verify its checks before releasing.')
38}
39
1340export function validateReleaseVersion(ref: string, version: string, manifests: Record<string, unknown>): void {
1441 const errors: string[] = []
1542 const identifier = '(?:0|[1-9]\\d*|[\\da-z-]*[a-z-][\\da-z-]*)'
@@ -47,6 +74,8 @@ if (import.meta.main) {
4774 const root = resolve(import.meta.dir, '..')
4875 if (process.argv.includes('--preflight')) {
4976 validateReleaseCheckout(root)
77 validateReleaseDependencies(root)
78 validateReleaseGit(root)
5079 console.log('Release checkout contains every tracked package manifest')
5180 process.exit(0)
5281 }