also looking at this
fix(release): require matching tag and package versions
#292
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.
| @@ -276,6 +276,9 @@ jobs: | ||
| 276 | 276 | - name: Run pickier (lint + format check) |
| 277 | 277 | run: bun run lint |
| 278 | 278 | |
| 279 | - name: Test release identity validation | |
| 280 | run: bun test scripts/release-version.test.ts | |
| 281 | ||
| 279 | 282 | integration: |
| 280 | 283 | runs-on: ubuntu-latest |
| 281 | 284 | needs: [zig-core, typescript-sdk, create-craft, ts-maps] |
| @@ -11,7 +11,19 @@ defaults: | ||
| 11 | 11 | shell: bash |
| 12 | 12 | |
| 13 | 13 | jobs: |
| 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 | ||
| 14 | 25 | pantry: |
| 26 | needs: [validate-release] | |
| 15 | 27 | name: pantry (${{ matrix.platform.name }}) |
| 16 | 28 | strategy: |
| 17 | 29 | fail-fast: false |
| @@ -326,8 +338,8 @@ jobs: | ||
| 326 | 338 | # half of that bargain: the run goes red, naming the assets that are missing. |
| 327 | 339 | verify-release: |
| 328 | 340 | name: verify release assets |
| 329 | needs: [pantry] | |
| 330 | if: ${{ !cancelled() }} | |
| 341 | needs: [validate-release, pantry] | |
| 342 | if: ${{ !cancelled() && needs.validate-release.result == 'success' }} | |
| 331 | 343 | runs-on: ubuntu-latest |
| 332 | 344 | permissions: |
| 333 | 345 | contents: read |
| @@ -413,8 +425,8 @@ jobs: | ||
| 413 | 425 | # `bun install`, and publishes packages built from TypeScript. Signing a Zig |
| 414 | 426 | # binary and publishing an SDK are independent, and the workflow now says |
| 415 | 427 | # 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' }} | |
| 418 | 430 | runs-on: ubuntu-latest |
| 419 | 431 | permissions: |
| 420 | 432 | contents: write |
| @@ -0,0 +1,47 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { checkReleaseVersion, validateReleaseVersion } from './release-version' | |
| 6 | ||
| 7 | test('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 | ||
| 12 | test('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 | ||
| 19 | test('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 | ||
| 31 | test('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 | }) | |
| @@ -0,0 +1,42 @@ | ||
| 1 | import { appendFileSync, readFileSync } from 'node:fs' | |
| 2 | import { join, resolve } from 'node:path' | |
| 3 | ||
| 4 | export 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 | ||
| 22 | export 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 | ||
| 37 | if (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 | } | |