ReviewOS

also looking at this

craft-native/craft

fix(ci): gate release publishing on SBOM scan

#314
Merged glennmichael123 wants to merge codex/release-sbom-prerequisite into main
5 files +44 -9
.github/workflows/release.ymlmodified+7-3
Changes to .github/workflows/release.yml
@@ -25,9 +25,13 @@ jobs:
2525 release-sbom:
2626 needs: validate-release
2727 uses: ./.github/workflows/sbom.yml
28 with:
29 enforce_high: true
2830
2931 pantry:
30 needs: [validate-release]
32 # The reusable SBOM workflow includes the vulnerability scan. Publishing
33 # must wait for its existing Critical gate, not merely attach it afterward.
34 needs: [validate-release, release-sbom]
3135 name: pantry (${{ matrix.platform.name }})
3236 strategy:
3337 fail-fast: false
@@ -65,7 +69,7 @@ jobs:
6569 run: ${{ matrix.platform.deps }}
6670
6771 - name: Install Dependencies
68 run: bun install
72 run: bun install --frozen-lockfile
6973
7074 - name: Extract Version
7175 id: version
@@ -532,7 +536,7 @@ jobs:
532536 - name: Setup Pantry (provides Bun, Zig, etc.)
533537 uses: pantry-pm/pantry/packages/action@235036fa0f48bae99b2293df5a3dc35c809b1777 # pinned: last SHA whose bundled typescript resolves on linux-x64
534538 - name: Install Dependencies
535 run: bun install
539 run: bun install --frozen-lockfile
536540
537541 - name: Test package regressions
538542 run: bun run test:packages
.github/workflows/sbom.ymlmodified+8-1
Changes to .github/workflows/sbom.yml
@@ -4,6 +4,11 @@ on:
44 push:
55 branches: [main]
66 workflow_call:
7 inputs:
8 enforce_high:
9 description: Fail the release scan when High findings remain
10 type: boolean
11 default: false
712 workflow_dispatch:
813
914concurrency:
@@ -26,7 +31,7 @@ jobs:
2631 bun-version: latest
2732
2833 - name: Install dependencies
29 run: bun install
34 run: bun install --frozen-lockfile
3035
3136 - name: Install SBOM tools
3237 run: |
@@ -121,6 +126,8 @@ jobs:
121126
122127 - name: Scan and validate vulnerability report
123128 run: bun scripts/scan-sbom.ts sbom/full-sbom.cyclonedx.json scan-results
129 env:
130 CRAFT_FAIL_HIGH: ${{ inputs.enforce_high || false }}
124131
125132 - name: Upload scan results
126133 if: ${{ always() }}
scripts/release-workflow.test.tsmodified+9-1
Changes to scripts/release-workflow.test.ts
@@ -7,7 +7,8 @@ type Job = {
77 needs?: string | string[]
88 if?: string
99 uses?: string
10 steps?: { name?: string, run?: string, uses?: string }[]
10 with?: { enforce_high?: boolean }
11 steps?: { name?: string, run?: string, uses?: string, env?: Record<string, string> }[]
1112 strategy?: { matrix: { platform: { name: string, os: string }[] } }
1213}
1314const release = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows/release.yml'), 'utf8')) as { jobs: Record<string, Job> }
@@ -41,6 +42,10 @@ test('npm publication follows artifact validation, and macOS downloads run on bo
4142 ])
4243 expect(steps(release.jobs['verify-macos-downloads'])).toContain('scripts/verify-macos-release.ts')
4344 expect(release.jobs['release-sbom'].uses).toBe('./.github/workflows/sbom.yml')
45 expect(release.jobs['release-sbom'].with?.enforce_high).toBe(true)
46 expect(needs(release.jobs.pantry)).toContain('release-sbom')
47 expect(steps(release.jobs.pantry)).toContain('bun install --frozen-lockfile')
48 expect(steps(release.jobs.npm)).toContain('bun install --frozen-lockfile')
4449 expect(needs(release.jobs['attach-release-sbom'])).toContain('release-sbom')
4550 expect(steps(release.jobs['attach-release-sbom'])).toContain('cmp "sbom/$document" "$VERIFY_DIR/$document"')
4651})
@@ -66,6 +71,7 @@ test('a registry network failure remains a visible best-effort warning', () => {
6671test('SBOM generation validates required documents before uploading artifacts', () => {
6772 const workflow = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows/sbom.yml'), 'utf8')) as { jobs: Record<string, Job> }
6873 const generate = workflow.jobs.generate.steps!
74 expect(generate.find(step => step.name === 'Install dependencies')?.run).toBe('bun install --frozen-lockfile')
6975 const validation = generate.findIndex(step => step.name === 'Validate SBOMs')
7076 const upload = generate.findIndex(step => step.uses?.startsWith('actions/upload-artifact@'))
7177 expect(validation).toBeGreaterThan(-1)
@@ -74,4 +80,6 @@ test('SBOM generation validates required documents before uploading artifacts',
7480 expect(command).toContain('bun scripts/verify-sbom.ts sbom "v$VERSION"')
7581 expect(command).toContain("require('./package.json').version")
7682 expect(command).not.toContain('||')
83 const scan = workflow.jobs['vulnerability-scan'].steps!.find(step => step.name === 'Scan and validate vulnerability report')
84 expect(scan?.env?.CRAFT_FAIL_HIGH).toBe('$')
7785})
scripts/scan-sbom.test.tsmodified+10-1
Changes to scripts/scan-sbom.test.ts
@@ -2,7 +2,7 @@ import { afterEach, expect, test } from 'bun:test'
22import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
33import { tmpdir } from 'node:os'
44import { join } from 'node:path'
5import { scanSbom, summarizeVulnerabilities } from './scan-sbom'
5import { enforceScanPolicy, scanSbom, summarizeVulnerabilities } from './scan-sbom'
66
77const roots: string[] = []
88afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
@@ -18,6 +18,15 @@ test('counts all supported severities without mistaking absent results for zero'
1818 expect(() => summarizeVulnerabilities(invalid)).toThrow()
1919})
2020
21test('High findings block release scans and Critical findings block every scan', () => {
22 const high = summarizeVulnerabilities(report([match('High')])).counts
23 expect(() => enforceScanPolicy(high, false)).not.toThrow()
24 expect(() => enforceScanPolicy(high, true)).toThrow('High vulnerabilities block this release')
25 const critical = summarizeVulnerabilities(report([match('Critical')])).counts
26 expect(() => enforceScanPolicy(critical, false)).toThrow('Critical vulnerabilities')
27 expect(() => enforceScanPolicy(critical, true)).toThrow('Critical vulnerabilities')
28})
29
2130function fixture(source: string) {
2231 const root = mkdtempSync(join(tmpdir(), 'craft-grype-test-'))
2332 roots.push(root)
scripts/scan-sbom.tsmodified+10-3
Changes to scripts/scan-sbom.ts
@@ -27,6 +27,11 @@ export function summarizeVulnerabilities(report: unknown) {
2727 return { counts, text: `${lines.join('\n')}\n` }
2828}
2929
30export function enforceScanPolicy(counts: ReturnType<typeof summarizeVulnerabilities>['counts'], failHigh: boolean): void {
31 if (counts.Critical > 0) throw new Error('Critical vulnerabilities found in dependencies')
32 if (failHigh && counts.High > 0) throw new Error('High vulnerabilities block this release')
33}
34
3035export function scanSbom(sbom: string, output: string, executable = 'grype') {
3136 const reportPath = resolve(output, 'vulnerabilities.json')
3237 const textPath = resolve(output, 'vulnerabilities.txt')
@@ -50,12 +55,14 @@ export function scanSbom(sbom: string, output: string, executable = 'grype') {
5055
5156if (import.meta.main) {
5257 try {
58 const setting = process.env.CRAFT_FAIL_HIGH ?? 'false'
59 if (!['true', 'false'].includes(setting)) throw new Error('CRAFT_FAIL_HIGH must be true or false')
5360 const { counts } = scanSbom(process.argv[2] || 'sbom/full-sbom.cyclonedx.json', process.argv[3] || 'scan-results')
54 const summary = ['## Vulnerability scan results', '', '| Severity | Count |', '| --- | --- |', ...severities.map(severity => `| ${severity} | ${counts[severity]} |`), '', 'Policy: Critical findings fail this check. High findings remain unresolved release-review items (#279); this report is not a release approval.', ''].join('\n')
61 const summary = ['## Vulnerability scan results', '', '| Severity | Count |', '| --- | --- |', ...severities.map(severity => `| ${severity} | ${counts[severity]} |`), '', 'Policy: Critical findings fail every scan. High findings fail release scans; they remain review items on main (#279).', ''].join('\n')
5562 console.log(summary)
5663 if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary)
57 if (counts.High > 0) console.warn('::warning::High vulnerability findings require review under #279')
58 if (counts.Critical > 0) throw new Error('Critical vulnerabilities found in dependencies')
64 if (counts.High > 0 && setting === 'false') console.warn('::warning::High vulnerability findings require review under #279')
65 enforceScanPolicy(counts, setting === 'true')
5966 }
6067 catch (error) {
6168 console.error(error instanceof Error ? error.message : String(error))