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

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

docs/RELEASING.mdmodified+15-0
Changes to docs/RELEASING.md
@@ -61,3 +61,18 @@ registry publication can still fail after the native archives become public, so
6161the final workflow status remains the release signal. If a post-release registry
6262publication fails, the GitHub release still contains the full verified archive
6363set; inspect and rerun the failed job rather than republishing the draft.
64
65## Release prerequisites and failed tags
66
67Install the locked release tools with `bun install --frozen-lockfile` first.
68Preflight now checks the Bumpx and Logsmith CLIs, a clean `main`, and the current
69`origin/main` before changing versions. Use a separate release clone if `main`
70is in use by another worktree. Required release commands fail before committing,
71tagging or pushing; Bumpx restores the version files when they fail.
72
73Retain a tag that failed publication. Choose a new unused version with
74`bun run release:version <version>` and set `CRAFT_CHANGELOG_FROM` to the last
75successfully published tag. The standard version and patch commands run the
76same manifest synchronization, changelog generator and publication gates.
77Confirm the final Releaser result and actual registry artifacts before apps
78adopt the new version.
scripts/release-version.test.tsmodified+34-1
Changes to scripts/release-version.test.ts
@@ -1,7 +1,7 @@
11import { afterAll, expect, test } from 'bun:test'
22import { 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).
@@ -74,3 +74,36 @@ test('release preflight rejects missing tools before changing package versions o
7474 expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(manifest)
7575 expect(Bun.spawnSync(['git', 'tag', '--list'], { cwd: root, stdout: 'pipe' }).stdout.toString()).toBe('')
7676})
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+14-0
Changes to scripts/release-version.ts
@@ -20,6 +20,19 @@ export function validateReleaseDependencies(root: string): void {
2020 }
2121}
2222
23/** Releases originate from a clean main that matches the current remote. */
24export function validateReleaseGit(root: string): void {
25 const git = (...args: string[]): string => {
26 const result = Bun.spawnSync(['git', ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 })
27 if (result.exitCode !== 0) throw new Error(`Release Git preflight failed: ${result.stderr.toString().trim()}`)
28 return result.stdout.toString().trim()
29 }
30 if (git('branch', '--show-current') !== 'main') throw new Error('Release from main, using an isolated checkout when main is already in use.')
31 if (git('status', '--porcelain')) throw new Error('Release requires a clean main checkout.')
32 git('fetch', 'origin', 'main')
33 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.')
34}
35
2336export function validateReleaseVersion(ref: string, version: string, manifests: Record<string, unknown>): void {
2437 const errors: string[] = []
2538 const identifier = '(?:0|[1-9]\\d*|[\\da-z-]*[a-z-][\\da-z-]*)'
@@ -58,6 +71,7 @@ if (import.meta.main) {
5871 if (process.argv.includes('--preflight')) {
5972 validateReleaseCheckout(root)
6073 validateReleaseDependencies(root)
74 validateReleaseGit(root)
6175 console.log('Release checkout contains every tracked package manifest')
6276 process.exit(0)
6377 }