also looking at this
fix: validate native SDK release prerequisites before tagging
#364
7 files
+153
-15
bun.lockmodified+0-0
Binary file. Nothing to show line by line.
| @@ -11,7 +11,9 @@ They also read addressed native state from the main, child, and adopted windows. | ||
| 11 | 11 | Each installed-app smoke also checks creator teardown and adoption of a |
| 12 | 12 | surviving, unparented grandchild. The PKG case explicitly destroys the retained |
| 13 | 13 | macOS creator after close; DEB and MSI close already releases it. |
| 14 | The release hook generates the next changelog section before that commit, | |
| 14 | The release command registers the workspace and Zig release manifests with | |
| 15 | Bumpx so their versions update and roll back together. The release hook | |
| 16 | generates the next changelog section before that commit, | |
| 15 | 17 | deduplicates repeated issue references, and links the previous and new tags. |
| 16 | 18 | Do not run the hook again after it has written the section. |
| 17 | 19 | |
| @@ -61,3 +63,18 @@ registry publication can still fail after the native archives become public, so | ||
| 61 | 63 | the final workflow status remains the release signal. If a post-release registry |
| 62 | 64 | publication fails, the GitHub release still contains the full verified archive |
| 63 | 65 | set; inspect and rerun the failed job rather than republishing the draft. |
| 66 | ||
| 67 | ## Release prerequisites and failed tags | |
| 68 | ||
| 69 | Install the locked release tools with `bun install --frozen-lockfile` first. | |
| 70 | Preflight 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` | |
| 72 | is in use by another worktree. Required release commands fail before committing, | |
| 73 | tagging or pushing; Bumpx restores the version files when they fail. | |
| 74 | ||
| 75 | Retain 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 | |
| 77 | successfully published tag. The standard version and patch commands run the | |
| 78 | same manifest synchronization, changelog generator and publication gates. | |
| 79 | Confirm the final Releaser result and actual registry artifacts before apps | |
| 80 | adopt the new version. | |
| @@ -61,11 +61,12 @@ | ||
| 61 | 61 | "changelog:generate": "bun scripts/generate-release-changelog.ts", |
| 62 | 62 | "test:release": "bun test ./scripts/changelog-preview.test.ts ./scripts/release-workflow.test.ts ./scripts/release-manifest.test.ts ./scripts/verify-sbom.test.ts", |
| 63 | 63 | "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\"" | |
| 65 | 66 | }, |
| 66 | 67 | "devDependencies": { |
| 67 | 68 | "@stacksjs/buddy": "0.72.56", |
| 68 | "@stacksjs/bumpx": "^0.2.10", | |
| 69 | "@stacksjs/bumpx": "^0.2.22", | |
| 69 | 70 | "@stacksjs/bunpress": "^0.2.13", |
| 70 | 71 | "@stacksjs/env": "^0.72.50", |
| 71 | 72 | "better-dx": "^0.2.38", |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { afterAll, expect, test } from 'bun:test' |
| 2 | import { readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 2 | import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import { dedupeChangelogReferences, previewChangelog } from './changelog-preview' |
| 5 | 5 | import { generateReleaseChangelog } from './generate-release-changelog' |
| @@ -9,6 +9,7 @@ import { createTempDir, removeAllTempDirs } from '../packages/typescript/src/tem | ||
| 9 | 9 | afterAll(removeAllTempDirs) |
| 10 | 10 | |
| 11 | 11 | const cli = join(import.meta.dir, 'changelog-preview.ts') |
| 12 | const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'` | |
| 12 | 13 | function fixture(run: (directory: string, git: (...args: string[]) => string) => void) { |
| 13 | 14 | const directory = createTempDir('craft-changelog-fixture-') |
| 14 | 15 | const git = (...args: string[]) => { |
| @@ -109,29 +110,72 @@ test('failed release generation leaves the changelog unchanged', () => { | ||
| 109 | 110 | }) |
| 110 | 111 | }) |
| 111 | 112 | |
| 113 | test('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 | ||
| 112 | 125 | test('patch release uses the guarded changelog generator before committing', () => { |
| 113 | 126 | const project = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf8')) |
| 114 | 127 | const command = project.scripts['release:patch'] as string |
| 115 | 128 | expect(project.scripts['changelog:generate']).toBe('bun scripts/generate-release-changelog.ts') |
| 116 | 129 | expect(command).toContain('--no-changelog') |
| 117 | 130 | 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') | |
| 119 | 132 | }) |
| 120 | 133 | |
| 121 | test('bumpx commits and tags the guarded changelog without its duplicate generator', () => { | |
| 134 | test('an explicit version commits and tags the guarded changelog without its duplicate generator', () => { | |
| 122 | 135 | 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') | |
| 123 | 141 | git('commit', '--allow-empty', '-qm', 'Merge pull request #326 from example/feature') |
| 124 | 142 | const bumpx = Bun.resolveSync('@stacksjs/bumpx/bin/cli.js', import.meta.dir) |
| 125 | 143 | const generator = join(import.meta.dir, 'generate-release-changelog.ts') |
| 126 | 144 | 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', | |
| 129 | 148 | ], { cwd: directory, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }) |
| 130 | 149 | expect(result.exitCode, result.stderr.toString()).toBe(0) |
| 131 | 150 | expect(git('tag', '--list', 'v1.0.2')).toBe('v1.0.2\n') |
| 132 | 151 | const notes = git('show', 'HEAD:CHANGELOG.md') |
| 133 | 152 | expect(notes).toContain('[Compare changes](https://github.com/example/fixture/compare/v1.0.0...v1.0.2)') |
| 134 | 153 | 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 | ||
| 160 | test('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('') | |
| 135 | 179 | expect(git('status', '--porcelain')).toBe('') |
| 136 | 180 | }) |
| 137 | 181 | }) |
| @@ -12,12 +12,12 @@ function previousTag(cwd: string): string { | ||
| 12 | 12 | } |
| 13 | 13 | |
| 14 | 14 | /** Generate the next release section before bumpx commits and tags it. */ |
| 15 | export function generateReleaseChangelog(cwd: string): string { | |
| 15 | export function generateReleaseChangelog(cwd: string, fromRef?: string): string { | |
| 16 | 16 | const version = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')).version |
| 17 | 17 | if (typeof version !== 'string' || !/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(version)) |
| 18 | 18 | throw new Error('The root package must have a valid release version') |
| 19 | 19 | |
| 20 | const from = previousTag(cwd) | |
| 20 | const from = fromRef ?? previousTag(cwd) | |
| 21 | 21 | const to = `v${version}` |
| 22 | 22 | if (from === to) |
| 23 | 23 | throw new Error(`Release ${to} is already tagged`) |
| @@ -44,4 +44,4 @@ export function generateReleaseChangelog(cwd: string): string { | ||
| 44 | 44 | } |
| 45 | 45 | |
| 46 | 46 | if (import.meta.main) |
| 47 | generateReleaseChangelog(process.cwd()) | |
| 47 | generateReleaseChangelog(process.cwd(), process.env.CRAFT_CHANGELOG_FROM) | |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { afterAll, expect, test } from 'bun:test' |
| 2 | import { mkdirSync, rmSync, writeFileSync } from 'node:fs' | |
| 2 | import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | 3 | import { join } from 'node:path' |
| 4 | import { validateReleaseCheckout, checkReleaseVersion, validateReleaseVersion } from './release-version' | |
| 4 | import { validateReleaseCheckout, validateReleaseGit, checkReleaseVersion, validateReleaseVersion } from './release-version' | |
| 5 | 5 | import { createTempDir, removeAllTempDirs } from '../packages/typescript/src/temp-dir' |
| 6 | 6 | |
| 7 | 7 | // 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', () = | ||
| 60 | 60 | rmSync(manifest) |
| 61 | 61 | expect(() => validateReleaseCheckout(root)).toThrow('packages/react/package.json') |
| 62 | 62 | }) |
| 63 | ||
| 64 | test('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 | ||
| 78 | test('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 | }) | |
| @@ -1,5 +1,5 @@ | ||
| 1 | import { appendFileSync, existsSync, readFileSync } from 'node:fs' | |
| 2 | import { join, resolve } from 'node:path' | |
| 1 | import { appendFileSync, existsSync, readFileSync, realpathSync } from 'node:fs' | |
| 2 | import { isAbsolute, join, relative, resolve, sep } from 'node:path' | |
| 3 | 3 | |
| 4 | 4 | /** Fail before a bump if a sparse checkout omits manifests that CI will publish. */ |
| 5 | 5 | export function validateReleaseCheckout(root: string): void { |
| @@ -10,6 +10,33 @@ export function validateReleaseCheckout(root: string): void { | ||
| 10 | 10 | throw new Error(`Release checkout omits package manifests: ${missing.join(', ')}. Expand the sparse checkout before releasing.`) |
| 11 | 11 | } |
| 12 | 12 | |
| 13 | /** Refuse missing release tools before a version or immutable tag can change. */ | |
| 14 | export 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. */ | |
| 28 | export 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 | ||
| 13 | 40 | export function validateReleaseVersion(ref: string, version: string, manifests: Record<string, unknown>): void { |
| 14 | 41 | const errors: string[] = [] |
| 15 | 42 | const identifier = '(?:0|[1-9]\\d*|[\\da-z-]*[a-z-][\\da-z-]*)' |
| @@ -47,6 +74,8 @@ if (import.meta.main) { | ||
| 47 | 74 | const root = resolve(import.meta.dir, '..') |
| 48 | 75 | if (process.argv.includes('--preflight')) { |
| 49 | 76 | validateReleaseCheckout(root) |
| 77 | validateReleaseDependencies(root) | |
| 78 | validateReleaseGit(root) | |
| 50 | 79 | console.log('Release checkout contains every tracked package manifest') |
| 51 | 80 | process.exit(0) |
| 52 | 81 | } |