ReviewOS

also looking at this

craft-native/craft

fix(release): require matching tag and package versions

#292
Merged glennmichael123 wants to merge codex/release-tag-validation into main
4 files +108 -4

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.

.github/workflows/ci.ymlmodified+3-0
Changes to .github/workflows/ci.yml
@@ -276,6 +276,9 @@ jobs:
276276 - name: Run pickier (lint + format check)
277277 run: bun run lint
278278
279 - name: Test release identity validation
280 run: bun test scripts/release-version.test.ts
281
279282 integration:
280283 runs-on: ubuntu-latest
281284 needs: [zig-core, typescript-sdk, create-craft, ts-maps]
.github/workflows/release.ymlmodified+16-4
Changes to .github/workflows/release.yml
@@ -11,7 +11,19 @@ defaults:
1111 shell: bash
1212
1313jobs:
14 validate-release:
15 name: validate release identity
16 runs-on: ubuntu-latest
17 permissions:
18 contents: read
19 steps:
20 - uses: actions/checkout@v6
21 - uses: oven-sh/setup-bun@v2
22 - name: Validate tag and workspace versions
23 run: bun scripts/release-version.ts
24
1425 pantry:
26 needs: [validate-release]
1527 name: pantry (${{ matrix.platform.name }})
1628 strategy:
1729 fail-fast: false
@@ -326,8 +338,8 @@ jobs:
326338 # half of that bargain: the run goes red, naming the assets that are missing.
327339 verify-release:
328340 name: verify release assets
329 needs: [pantry]
330 if: ${{ !cancelled() }}
341 needs: [validate-release, pantry]
342 if: ${{ !cancelled() && needs.validate-release.result == 'success' }}
331343 runs-on: ubuntu-latest
332344 permissions:
333345 contents: read
@@ -413,8 +425,8 @@ jobs:
413425 # `bun install`, and publishes packages built from TypeScript. Signing a Zig
414426 # binary and publishing an SDK are independent, and the workflow now says
415427 # so. `!cancelled()` rather than `always()` so a cancelled run still stops.
416 needs: [pantry]
417 if: ${{ !cancelled() }}
428 needs: [validate-release, pantry]
429 if: ${{ !cancelled() && needs.validate-release.result == 'success' }}
418430 runs-on: ubuntu-latest
419431 permissions:
420432 contents: write
scripts/release-version.test.tsadded+47-0
Changes to scripts/release-version.test.ts
@@ -0,0 +1,47 @@
1import { expect, test } from 'bun:test'
2import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { checkReleaseVersion, validateReleaseVersion } from './release-version'
6
7test('accepts a matching tag, including a prerelease', () => {
8 for (const version of ['0.0.93', '1.2.3-rc.1', '1.2.3+build.4'])
9 expect(() => validateReleaseVersion(`refs/tags/v${version}`, version, { sdk: version })).not.toThrow()
10})
11
12test('rejects branch dispatch, missing refs, mismatched tags and malformed versions', () => {
13 for (const ref of ['refs/heads/main', '', 'refs/tags/v0.0.92', 'refs/tags/0.0.93', 'refs/tags/v0.0.93/extra'])
14 expect(() => validateReleaseVersion(ref, '0.0.93', {})).toThrow()
15 for (const version of ['latest', '1.2', '01.2.3', '1.2.3\nversion=4.5.6', '1.2.3-01', '1.2.3-alpha..1', '1.2.3+foo..bar'])
16 expect(() => validateReleaseVersion(`refs/tags/v${version}`, version, {})).toThrow()
17})
18
19test('reports every mismatched manifest in one diagnostic', () => {
20 try {
21 validateReleaseVersion('refs/tags/v1.2.3', '1.2.3', { 'packages/android/package.json': '1.2.2', 'packages/ios/package.json': undefined, 'packages/zig/build.zig.zon': '1.0.0' })
22 throw new Error('expected validation to reject')
23 }
24 catch (error) {
25 expect(String(error)).toContain('packages/android/package.json')
26 expect(String(error)).toContain('packages/ios/package.json')
27 expect(String(error)).toContain('packages/zig/build.zig.zon')
28 }
29})
30
31test('discovers public workspaces and Zig metadata, excluding private examples', () => {
32 const temp = mkdtempSync(join(tmpdir(), 'craft-release-version-'))
33 try {
34 for (const name of ['sdk', 'example', 'zig']) mkdirSync(join(temp, 'packages', name), { recursive: true })
35 writeFileSync(join(temp, 'package.json'), JSON.stringify({ version: '1.2.3' }))
36 writeFileSync(join(temp, 'packages/sdk/package.json'), JSON.stringify({ name: 'fixture', version: '1.2.3' }))
37 writeFileSync(join(temp, 'packages/example/package.json'), JSON.stringify({ private: true, version: '0.0.1' }))
38 writeFileSync(join(temp, 'packages/zig/build.zig.zon'), '.{ .version = "1.2.3", }')
39 writeFileSync(join(temp, 'packages/zig/pantry.json'), JSON.stringify({ version: '1.2.3' }))
40 expect(checkReleaseVersion(temp, 'refs/tags/v1.2.3')).toBe('1.2.3')
41 writeFileSync(join(temp, 'packages/sdk/package.json'), JSON.stringify({ name: 'fixture', version: '1.2.2' }))
42 expect(() => checkReleaseVersion(temp, 'refs/tags/v1.2.3')).toThrow('packages/sdk/package.json')
43 }
44 finally {
45 rmSync(temp, { recursive: true, force: true })
46 }
47})
scripts/release-version.tsadded+42-0
Changes to scripts/release-version.ts
@@ -0,0 +1,42 @@
1import { appendFileSync, readFileSync } from 'node:fs'
2import { join, resolve } from 'node:path'
3
4export function validateReleaseVersion(ref: string, version: string, manifests: Record<string, unknown>): void {
5 const errors: string[] = []
6 const identifier = '(?:0|[1-9]\\d*|[\\da-z-]*[a-z-][\\da-z-]*)'
7 const semver = new RegExp(`^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-${identifier}(?:\\.${identifier})*)?(?:\\+[\\da-z-]+(?:\\.[\\da-z-]+)*)?$`, 'i')
8 if (!semver.test(version))
9 errors.push(`package.json: invalid release version ${JSON.stringify(version)}`)
10 if (!ref.startsWith('refs/tags/'))
11 errors.push(`Release requires a tag ref; received ${JSON.stringify(ref)}`)
12 else if (ref !== `refs/tags/v${version}`)
13 errors.push(`Tag ${ref.slice('refs/tags/'.length)} must equal v${version}`)
14 for (const [path, value] of Object.entries(manifests)) {
15 if (value !== version)
16 errors.push(`${path}: expected ${version}, received ${JSON.stringify(value)}`)
17 }
18 if (errors.length > 0)
19 throw new Error(`Release version validation failed:\n${errors.join('\n')}`)
20}
21
22export function checkReleaseVersion(root: string, ref: string): string {
23 const canonical = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version
24 const versions: Record<string, unknown> = {}
25 for (const path of new Bun.Glob('packages/*/package.json').scanSync({ cwd: root })) {
26 const pkg = JSON.parse(readFileSync(join(root, path), 'utf8'))
27 if (!pkg.private) versions[path] = pkg.version
28 }
29 const zigPath = 'packages/zig/build.zig.zon'
30 versions[zigPath] = readFileSync(join(root, zigPath), 'utf8').match(/\.version\s*=\s*"([^"]+)"/)?.[1]
31 const pantryPath = 'packages/zig/pantry.json'
32 versions[pantryPath] = JSON.parse(readFileSync(join(root, pantryPath), 'utf8')).version
33 validateReleaseVersion(ref, canonical, versions)
34 return canonical
35}
36
37if (import.meta.main) {
38 const version = checkReleaseVersion(resolve(import.meta.dir, '..'), process.env.GITHUB_REF ?? '')
39 console.log(`Verified release v${version}`)
40 if (process.env.GITHUB_OUTPUT)
41 appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`)
42}