ReviewOS

also looking at this

craft-native/craft

fix(release): gate registry notification on final artifact checks

#305
Merged glennmichael123 wants to merge codex/release-integration-checks into main
6 files +104 -14
.github/workflows/ci.ymlmodified+1-1
Changes to .github/workflows/ci.yml
@@ -299,7 +299,7 @@ jobs:
299299 run: bun test scripts/release-manifest.test.ts
300300
301301 - 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
303303
304304 integration:
305305 runs-on: ubuntu-latest
.github/workflows/release.ymlmodified+11-2
Changes to .github/workflows/release.yml
@@ -397,6 +397,12 @@ jobs:
397397 gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern release-manifest.json --dir "$ARCHIVE_DIR/verified"
398398 bun scripts/release-manifest.ts verify "$ARCHIVE_DIR" "$ARCHIVE_DIR/verified/release-manifest.json"
399399
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:
400406 # Tell the registry this version exists, with the token this workflow
401407 # already holds to publish to it.
402408 #
@@ -421,11 +427,14 @@ jobs:
421427 exit 0
422428 fi
423429
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 \
425431 -X POST https://registry.pantry.dev/api/rebuild \
426432 -H "Authorization: Bearer $PANTRY_TOKEN" \
427433 -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
429438
430439 if [ "$code" = "200" ]; then
431440 echo "Registry notified: craft-native.org queued for indexing."
.github/workflows/sbom.ymlmodified+2-7
Changes to .github/workflows/sbom.yml
@@ -50,13 +50,8 @@ jobs:
5050
5151 - name: Validate SBOMs
5252 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"
6055
6156 - name: Generate SBOM summary
6257 run: |
scripts/release-workflow.test.tsadded+77-0
Changes to scripts/release-workflow.test.ts
@@ -0,0 +1,77 @@
1import { expect, test } from 'bun:test'
2import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5
6type 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}
13const release = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows/release.yml'), 'utf8')) as { jobs: Record<string, Job> }
14const needs = (job: Job) => typeof job.needs === 'string' ? [job.needs] : job.needs ?? []
15const steps = (job: Job) => job.steps?.map(step => step.run ?? '').join('\n') ?? ''
16
17test('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
24test('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
33test('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
48test('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
66test('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})
scripts/verify-sbom.test.tsmodified+12-3
Changes to scripts/verify-sbom.test.ts
@@ -5,7 +5,7 @@ import { join } from 'node:path'
55import { releaseSboms, verifySbomFiles } from './verify-sbom'
66
77const 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' }] },
99 'full-sbom.cyclonedx.json': { bomFormat: 'CycloneDX', specVersion: '1.6', components: [{ name: 'dependency', version: '1.0.0' }] },
1010 'full-sbom.spdx.json': { spdxVersion: 'SPDX-2.3', SPDXID: 'SPDXRef-DOCUMENT', packages: [{ name: 'dependency' }] },
1111}
@@ -25,8 +25,17 @@ test('requires all three valid documents from the matching release', () => {
2525 expect(() => verifySbomFiles(directory, 'v0.0.93')).toThrow()
2626 writeFileSync(join(directory, name), JSON.stringify(documents[name]))
2727 }
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 }
3039 }
3140 finally {
3241 rmSync(directory, { recursive: true, force: true })
scripts/verify-sbom.tsmodified+1-1
Changes to scripts/verify-sbom.ts
@@ -17,7 +17,7 @@ export function verifySbomFiles(directory: string, tag: string): void {
1717 if (document.metadata?.component?.name !== 'craft' || document.metadata.component.version !== tag)
1818 throw new Error(`${name}: release identity does not match ${tag}`)
1919 }
20 else if (!Array.isArray(document.components) || document.components.length === 0) {
20 if (!Array.isArray(document.components) || document.components.length === 0) {
2121 throw new Error(`${name}: expected dependency components`)
2222 }
2323 }