also looking at this
fix: validate complete workspace release before tagging
#365
5 files
+73
-33
bun.lockmodified+0-0
Binary file. Nothing to show line by line.
| @@ -60,13 +60,13 @@ | ||
| 60 | 60 | "changelog:preview": "bun scripts/changelog-preview.ts", |
| 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 | "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 --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\"" | |
| 63 | "release": "bun scripts/release-version.ts --preflight && bun x --bun @stacksjs/bumpx prompt --recursive --commit --tag --push --no-changelog --execute \"bun scripts/release-version.ts --pending && bun scripts/generate-release-changelog.ts && git add CHANGELOG.md\"", | |
| 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/release-version.ts --pending && 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 --commit --tag --push --yes --no-changelog --execute \"bun scripts/release-version.ts --pending && bun scripts/generate-release-changelog.ts && git add CHANGELOG.md\"" | |
| 66 | 66 | }, |
| 67 | 67 | "devDependencies": { |
| 68 | 68 | "@stacksjs/buddy": "0.72.56", |
| 69 | "@stacksjs/bumpx": "^0.2.22", | |
| 69 | "@stacksjs/bumpx": "^0.2.23", | |
| 70 | 70 | "@stacksjs/bunpress": "^0.2.13", |
| 71 | 71 | "@stacksjs/env": "^0.72.50", |
| 72 | 72 | "better-dx": "^0.2.38", |
| @@ -124,52 +124,63 @@ test('release notes can include changes since the last published release after a | ||
| 124 | 124 | |
| 125 | 125 | test('patch release uses the guarded changelog generator before committing', () => { |
| 126 | 126 | const project = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf8')) |
| 127 | const command = project.scripts['release:patch'] as string | |
| 128 | 127 | expect(project.scripts['changelog:generate']).toBe('bun scripts/generate-release-changelog.ts') |
| 129 | expect(command).toContain('--no-changelog') | |
| 130 | expect(command).toContain('bun scripts/generate-release-changelog.ts') | |
| 131 | expect(command).toContain('--files package.json,packages/zig/build.zig.zon,packages/zig/pantry.json') | |
| 128 | for (const name of ['release', 'release:patch', 'release:version']) { | |
| 129 | const command = project.scripts[name] as string | |
| 130 | expect(command).toContain('bun scripts/release-version.ts --preflight') | |
| 131 | expect(command).toContain('--no-changelog') | |
| 132 | expect(command).not.toContain('--files') | |
| 133 | expect(command).toContain('bun scripts/release-version.ts --pending && bun scripts/generate-release-changelog.ts') | |
| 134 | } | |
| 132 | 135 | }) |
| 133 | 136 | |
| 134 | test('an explicit version commits and tags the guarded changelog without its duplicate generator', () => { | |
| 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') | |
| 141 | git('commit', '--allow-empty', '-qm', 'Merge pull request #326 from example/feature') | |
| 137 | function nativeWorkspace(directory: string, git: (...args: string[]) => string): string[] { | |
| 138 | mkdirSync(join(directory, 'packages/zig'), { recursive: true }) | |
| 139 | mkdirSync(join(directory, 'packages/sdk'), { recursive: true }) | |
| 140 | writeFileSync(join(directory, 'package.json'), '{"name":"fixture","version":"1.0.1","workspaces":["packages/*"]}') | |
| 141 | writeFileSync(join(directory, 'packages/sdk/package.json'), '{"name":"fixture-sdk","version":"1.0.0"}') | |
| 142 | writeFileSync(join(directory, 'packages/zig/build.zig.zon'), '.{ .version = "1.0.0", }') | |
| 143 | writeFileSync(join(directory, 'packages/zig/pantry.json'), '{"version":"1.0.0"}') | |
| 144 | // This is the real repository's ignore rule; explicit additionalFiles must | |
| 145 | // retain the tracked native manifest while recursive workspace discovery runs. | |
| 146 | writeFileSync(join(directory, '.gitignore'), 'pantry\n') | |
| 147 | writeFileSync(join(directory, 'bumpx.config.ts'), readFileSync(join(import.meta.dir, '..', 'bumpx.config.ts'))) | |
| 148 | git('add', '.') | |
| 149 | git('commit', '-qm', 'chore: register native and workspace release metadata') | |
| 150 | return ['package.json', 'packages/sdk/package.json', 'packages/zig/build.zig.zon', 'packages/zig/pantry.json'] | |
| 151 | } | |
| 152 | ||
| 153 | test('the real release configuration versions every workspace and native manifest before tagging', () => { | |
| 154 | for (const release of ['1.0.2', 'patch']) fixture((directory, git) => { | |
| 155 | const paths = nativeWorkspace(directory, git) | |
| 142 | 156 | const bumpx = Bun.resolveSync('@stacksjs/bumpx/bin/cli.js', import.meta.dir) |
| 143 | 157 | const generator = join(import.meta.dir, 'generate-release-changelog.ts') |
| 158 | const validator = join(import.meta.dir, 'release-version.ts') | |
| 159 | // The validator must read the fixture checkout rather than its own source root. | |
| 160 | mkdirSync(join(directory, 'scripts')) | |
| 161 | writeFileSync(join(directory, 'scripts/release-version.ts'), readFileSync(validator)) | |
| 162 | git('add', 'scripts') | |
| 163 | git('commit', '-qm', 'chore: install actual release validator') | |
| 144 | 164 | const result = Bun.spawnSync([ |
| 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', | |
| 165 | process.execPath, bumpx, '--recursive', '--commit', '--tag', '--no-push', '--yes', '--no-changelog', | |
| 166 | '--execute', `${shellQuote(process.execPath)} scripts/release-version.ts --pending && ${shellQuote(process.execPath)} ${shellQuote(generator)} && git add CHANGELOG.md`, release, | |
| 148 | 167 | ], { cwd: directory, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }) |
| 149 | 168 | expect(result.exitCode, result.stderr.toString()).toBe(0) |
| 150 | 169 | expect(git('tag', '--list', 'v1.0.2')).toBe('v1.0.2\n') |
| 151 | const notes = git('show', 'HEAD:CHANGELOG.md') | |
| 152 | expect(notes).toContain('[Compare changes](https://github.com/example/fixture/compare/v1.0.0...v1.0.2)') | |
| 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"') | |
| 170 | for (const path of paths) expect(git('show', `HEAD:${path}`)).toContain('1.0.2') | |
| 171 | expect(git('show', 'HEAD:CHANGELOG.md')).toContain('/compare/v1.0.0...v1.0.2)') | |
| 156 | 172 | expect(git('status', '--porcelain')).toBe('') |
| 157 | 173 | }) |
| 158 | 174 | }) |
| 159 | 175 | |
| 160 | test('the published release tool restores workspace and native versions when a required command fails', () => { | |
| 176 | test('the published release tool restores all workspace and native versions on a failed command', () => { | |
| 161 | 177 | 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') | |
| 178 | const paths = nativeWorkspace(directory, git) | |
| 167 | 179 | const head = git('rev-parse', 'HEAD') |
| 168 | const paths = ['package.json', 'packages/zig/build.zig.zon', 'packages/zig/pantry.json'] | |
| 169 | 180 | const before = paths.map(path => readFileSync(join(directory, path), 'utf8')) |
| 170 | 181 | const bumpx = Bun.resolveSync('@stacksjs/bumpx/bin/cli.js', import.meta.dir) |
| 171 | 182 | const result = Bun.spawnSync([ |
| 172 | process.execPath, bumpx, 'patch', '--recursive', '--files', paths.join(','), '--commit', '--tag', '--no-push', '--yes', '--no-changelog', | |
| 183 | process.execPath, bumpx, 'patch', '--recursive', '--commit', '--tag', '--no-push', '--yes', '--no-changelog', | |
| 173 | 184 | '--execute', 'bun -e "process.exit(23)"', |
| 174 | 185 | ], { cwd: directory, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }) |
| 175 | 186 | expect(result.exitCode).not.toBe(0) |
| @@ -179,3 +190,26 @@ test('the published release tool restores workspace and native versions when a r | ||
| 179 | 190 | expect(git('status', '--porcelain')).toBe('') |
| 180 | 191 | }) |
| 181 | 192 | }) |
| 193 | ||
| 194 | test('pending validation rejects an incomplete workspace before a tag or commit exists', () => { | |
| 195 | fixture((directory, git) => { | |
| 196 | const paths = nativeWorkspace(directory, git) | |
| 197 | mkdirSync(join(directory, 'scripts')) | |
| 198 | writeFileSync(join(directory, 'scripts/release-version.ts'), readFileSync(join(import.meta.dir, 'release-version.ts'))) | |
| 199 | git('add', 'scripts') | |
| 200 | git('commit', '-qm', 'chore: install actual release validator') | |
| 201 | const head = git('rev-parse', 'HEAD') | |
| 202 | const before = paths.map(path => readFileSync(join(directory, path), 'utf8')) | |
| 203 | const bumpx = Bun.resolveSync('@stacksjs/bumpx/bin/cli.js', import.meta.dir) | |
| 204 | const result = Bun.spawnSync([ | |
| 205 | process.execPath, bumpx, '1.0.2', '--files', 'package.json', '--commit', '--tag', '--no-push', '--yes', '--no-changelog', | |
| 206 | '--execute', `${shellQuote(process.execPath)} scripts/release-version.ts --pending`, | |
| 207 | ], { cwd: directory, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }) | |
| 208 | expect(result.exitCode).not.toBe(0) | |
| 209 | expect(`${result.stdout.toString()}${result.stderr.toString()}`).toContain('packages/sdk/package.json') | |
| 210 | expect(paths.map(path => readFileSync(join(directory, path), 'utf8'))).toEqual(before) | |
| 211 | expect(git('rev-parse', 'HEAD')).toBe(head) | |
| 212 | expect(git('tag', '--list', 'v1.0.2')).toBe('') | |
| 213 | expect(git('status', '--porcelain')).toBe('') | |
| 214 | }) | |
| 215 | }) | |
| @@ -79,7 +79,10 @@ if (import.meta.main) { | ||
| 79 | 79 | console.log('Release checkout contains every tracked package manifest') |
| 80 | 80 | process.exit(0) |
| 81 | 81 | } |
| 82 | const version = checkReleaseVersion(root, process.env.GITHUB_REF ?? '') | |
| 82 | const ref = process.argv.includes('--pending') | |
| 83 | ? `refs/tags/v${JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version}` | |
| 84 | : process.env.GITHUB_REF ?? '' | |
| 85 | const version = checkReleaseVersion(root, ref) | |
| 83 | 86 | console.log(`Verified release v${version}`) |
| 84 | 87 | if (process.env.GITHUB_OUTPUT) |
| 85 | 88 | appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`) |