also looking at this
fix(release): gate registry notification on final artifact checks
#305
6 files
+104
-14
| @@ -299,7 +299,7 @@ jobs: | ||
| 299 | 299 | run: bun test scripts/release-manifest.test.ts |
| 300 | 300 | |
| 301 | 301 | - name: Test release SBOM verification |
| 302 | run: bun test scripts/verify-sbom.test.ts | |
| 302 | run: bun test scripts/verify-sbom.test.ts scripts/release-workflow.test.ts | |
| 303 | 303 | |
| 304 | 304 | integration: |
| 305 | 305 | runs-on: ubuntu-latest |
| @@ -397,6 +397,12 @@ jobs: | ||
| 397 | 397 | gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern release-manifest.json --dir "$ARCHIVE_DIR/verified" |
| 398 | 398 | bun scripts/release-manifest.ts verify "$ARCHIVE_DIR" "$ARCHIVE_DIR/verified/release-manifest.json" |
| 399 | 399 | |
| 400 | notify-registry: | |
| 401 | name: notify registry after artifact verification | |
| 402 | needs: [verify-release, verify-macos-downloads, attach-release-sbom] | |
| 403 | runs-on: ubuntu-latest | |
| 404 | permissions: {} | |
| 405 | steps: | |
| 400 | 406 | # Tell the registry this version exists, with the token this workflow |
| 401 | 407 | # already holds to publish to it. |
| 402 | 408 | # |
| @@ -421,11 +427,14 @@ jobs: | ||
| 421 | 427 | exit 0 |
| 422 | 428 | fi |
| 423 | 429 | |
| 424 | code=$(curl -sS -o /tmp/index.out -w '%{http_code}' --max-time 30 \ | |
| 430 | if ! code=$(curl -sS -o /tmp/index.out -w '%{http_code}' --max-time 30 \ | |
| 425 | 431 | -X POST https://registry.pantry.dev/api/rebuild \ |
| 426 | 432 | -H "Authorization: Bearer $PANTRY_TOKEN" \ |
| 427 | 433 | -H 'Content-Type: application/json' \ |
| 428 | -d '{"domain":"craft-native.org"}') | |
| 434 | -d '{"domain":"craft-native.org"}'); then | |
| 435 | echo "::warning::Registry request failed — it will index this on its next sweep." | |
| 436 | exit 0 | |
| 437 | fi | |
| 429 | 438 | |
| 430 | 439 | if [ "$code" = "200" ]; then |
| 431 | 440 | echo "Registry notified: craft-native.org queued for indexing." |
| @@ -50,13 +50,8 @@ jobs: | ||
| 50 | 50 | |
| 51 | 51 | - name: Validate SBOMs |
| 52 | 52 | run: | |
| 53 | # Basic validation | |
| 54 | for file in sbom/*.json; do | |
| 55 | if [ -f "$file" ]; then | |
| 56 | echo "Validating $file..." | |
| 57 | python3 -m json.tool "$file" > /dev/null && echo " ✓ Valid JSON" || echo " ✗ Invalid JSON" | |
| 58 | fi | |
| 59 | done | |
| 53 | VERSION=$(bun -p "require('./package.json').version") | |
| 54 | bun scripts/verify-sbom.ts sbom "v$VERSION" | |
| 60 | 55 | |
| 61 | 56 | - name: Generate SBOM summary |
| 62 | 57 | run: | |
| @@ -0,0 +1,77 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | ||
| 6 | type Job = { | |
| 7 | needs?: string | string[] | |
| 8 | if?: string | |
| 9 | uses?: string | |
| 10 | steps?: { name?: string, run?: string, uses?: string }[] | |
| 11 | strategy?: { matrix: { platform: { name: string, os: string }[] } } | |
| 12 | } | |
| 13 | const release = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows/release.yml'), 'utf8')) as { jobs: Record<string, Job> } | |
| 14 | const needs = (job: Job) => typeof job.needs === 'string' ? [job.needs] : job.needs ?? [] | |
| 15 | const steps = (job: Job) => job.steps?.map(step => step.run ?? '').join('\n') ?? '' | |
| 16 | ||
| 17 | test('every publishing path depends on release identity validation', () => { | |
| 18 | for (const name of ['pantry', 'npm', 'release-sbom', 'verify-release', 'verify-macos-downloads']) | |
| 19 | expect(needs(release.jobs[name])).toContain('validate-release') | |
| 20 | for (const name of ['npm', 'verify-release', 'verify-macos-downloads']) | |
| 21 | expect(release.jobs[name].if).toBe("$") | |
| 22 | }) | |
| 23 | ||
| 24 | test('registry indexing waits for the final manifest, macOS downloads and attached SBOMs', () => { | |
| 25 | const notify = release.jobs['notify-registry'] | |
| 26 | expect(notify).toBeDefined() | |
| 27 | expect(needs(notify).sort()).toEqual(['attach-release-sbom', 'verify-macos-downloads', 'verify-release']) | |
| 28 | expect(notify.if).toBeUndefined() // Default success gating: never bypass failed prerequisites. | |
| 29 | expect(steps(notify)).toContain('https://registry.pantry.dev/api/rebuild') | |
| 30 | expect(steps(release.jobs['verify-release'])).not.toContain('/api/rebuild') | |
| 31 | }) | |
| 32 | ||
| 33 | test('npm publication follows artifact validation, and macOS downloads run on both matching architectures', () => { | |
| 34 | const npm = release.jobs.npm.steps!.map(step => step.run ?? '') | |
| 35 | const verify = npm.indexOf('bun run verify:npm') | |
| 36 | expect(verify).toBeGreaterThan(-1) | |
| 37 | expect(npm.indexOf('pantry publish --npm --access public')).toBeGreaterThan(verify) | |
| 38 | expect(release.jobs['verify-macos-downloads'].strategy?.matrix.platform).toEqual([ | |
| 39 | { os: 'macos-15', name: 'darwin-arm64' }, | |
| 40 | { os: 'macos-15-intel', name: 'darwin-x64' }, | |
| 41 | ]) | |
| 42 | expect(steps(release.jobs['verify-macos-downloads'])).toContain('scripts/verify-macos-release.ts') | |
| 43 | expect(release.jobs['release-sbom'].uses).toBe('./.github/workflows/sbom.yml') | |
| 44 | expect(needs(release.jobs['attach-release-sbom'])).toContain('release-sbom') | |
| 45 | expect(steps(release.jobs['attach-release-sbom'])).toContain('cmp "sbom/$document" "$VERIFY_DIR/$document"') | |
| 46 | }) | |
| 47 | ||
| 48 | test('a registry network failure remains a visible best-effort warning', () => { | |
| 49 | const command = steps(release.jobs['notify-registry']) | |
| 50 | expect(command).not.toBe('') | |
| 51 | const root = mkdtempSync(join(tmpdir(), 'craft-registry-notify-')) | |
| 52 | try { | |
| 53 | const curl = join(root, 'curl') | |
| 54 | writeFileSync(curl, '#!/bin/sh\nexit 28\n') | |
| 55 | chmodSync(curl, 0o755) | |
| 56 | const result = Bun.spawnSync(['/bin/bash', '-e', '-o', 'pipefail', '-c', command], { | |
| 57 | env: { ...process.env, PATH: root, PANTRY_TOKEN: 'fixture-not-a-token' }, | |
| 58 | stdout: 'pipe', stderr: 'pipe', | |
| 59 | }) | |
| 60 | expect(result.exitCode).toBe(0) | |
| 61 | expect(result.stdout.toString()).toContain('::warning::Registry request failed') | |
| 62 | } | |
| 63 | finally { rmSync(root, { recursive: true, force: true }) } | |
| 64 | }) | |
| 65 | ||
| 66 | test('SBOM generation validates required documents before uploading artifacts', () => { | |
| 67 | const workflow = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows/sbom.yml'), 'utf8')) as { jobs: Record<string, Job> } | |
| 68 | const generate = workflow.jobs.generate.steps! | |
| 69 | const validation = generate.findIndex(step => step.name === 'Validate SBOMs') | |
| 70 | const upload = generate.findIndex(step => step.uses?.startsWith('actions/upload-artifact@')) | |
| 71 | expect(validation).toBeGreaterThan(-1) | |
| 72 | expect(upload).toBeGreaterThan(validation) | |
| 73 | const command = generate[validation]!.run! | |
| 74 | expect(command).toContain('bun scripts/verify-sbom.ts sbom "v$VERSION"') | |
| 75 | expect(command).toContain("require('./package.json').version") | |
| 76 | expect(command).not.toContain('||') | |
| 77 | }) | |
| @@ -5,7 +5,7 @@ import { join } from 'node:path' | ||
| 5 | 5 | import { releaseSboms, verifySbomFiles } from './verify-sbom' |
| 6 | 6 | |
| 7 | 7 | const documents = { |
| 8 | 'craft-sbom.json': { bomFormat: 'CycloneDX', specVersion: '1.5', metadata: { component: { name: 'craft', version: 'v0.0.93' } } }, | |
| 8 | 'craft-sbom.json': { bomFormat: 'CycloneDX', specVersion: '1.5', metadata: { component: { name: 'craft', version: 'v0.0.93' } }, components: [{ name: 'dependency', version: '1.0.0' }] }, | |
| 9 | 9 | 'full-sbom.cyclonedx.json': { bomFormat: 'CycloneDX', specVersion: '1.6', components: [{ name: 'dependency', version: '1.0.0' }] }, |
| 10 | 10 | 'full-sbom.spdx.json': { spdxVersion: 'SPDX-2.3', SPDXID: 'SPDXRef-DOCUMENT', packages: [{ name: 'dependency' }] }, |
| 11 | 11 | } |
| @@ -25,8 +25,17 @@ test('requires all three valid documents from the matching release', () => { | ||
| 25 | 25 | expect(() => verifySbomFiles(directory, 'v0.0.93')).toThrow() |
| 26 | 26 | writeFileSync(join(directory, name), JSON.stringify(documents[name])) |
| 27 | 27 | } |
| 28 | writeFileSync(join(directory, 'full-sbom.cyclonedx.json'), JSON.stringify({ bomFormat: 'CycloneDX', specVersion: '1.6', components: [] })) | |
| 29 | expect(() => verifySbomFiles(directory, 'v0.0.93')).toThrow('dependency components') | |
| 28 | for (const name of ['craft-sbom.json', 'full-sbom.cyclonedx.json'] as const) { | |
| 29 | for (const components of [undefined, []]) { | |
| 30 | writeFileSync(join(directory, name), JSON.stringify({ ...documents[name], components })) | |
| 31 | expect(() => verifySbomFiles(directory, 'v0.0.93')).toThrow('dependency components') | |
| 32 | } | |
| 33 | writeFileSync(join(directory, name), JSON.stringify(documents[name])) | |
| 34 | } | |
| 35 | for (const packages of [undefined, [], 'not-an-inventory']) { | |
| 36 | writeFileSync(join(directory, 'full-sbom.spdx.json'), JSON.stringify({ ...documents['full-sbom.spdx.json'], packages })) | |
| 37 | expect(() => verifySbomFiles(directory, 'v0.0.93')).toThrow('populated SPDX') | |
| 38 | } | |
| 30 | 39 | } |
| 31 | 40 | finally { |
| 32 | 41 | rmSync(directory, { recursive: true, force: true }) |
| @@ -17,7 +17,7 @@ export function verifySbomFiles(directory: string, tag: string): void { | ||
| 17 | 17 | if (document.metadata?.component?.name !== 'craft' || document.metadata.component.version !== tag) |
| 18 | 18 | throw new Error(`${name}: release identity does not match ${tag}`) |
| 19 | 19 | } |
| 20 | else if (!Array.isArray(document.components) || document.components.length === 0) { | |
| 20 | if (!Array.isArray(document.components) || document.components.length === 0) { | |
| 21 | 21 | throw new Error(`${name}: expected dependency components`) |
| 22 | 22 | } |
| 23 | 23 | } |