ReviewOS

also looking at this

craft-native/craft

fix(release): attach and verify generated SBOM assets

#294
Merged glennmichael123 wants to merge codex/release-sbom-attachment into main
5 files +108 -18
.github/workflows/ci.ymlmodified+3-0
Changes to .github/workflows/ci.yml
@@ -286,6 +286,9 @@ jobs:
286286 - name: Test complete release manifest verification
287287 run: bun test scripts/release-manifest.test.ts
288288
289 - name: Test release SBOM verification
290 run: bun test scripts/verify-sbom.test.ts
291
289292 integration:
290293 runs-on: ubuntu-latest
291294 needs: [zig-core, typescript-sdk, create-craft, ts-maps]
.github/workflows/release.ymlmodified+36-2
Changes to .github/workflows/release.yml
@@ -22,6 +22,10 @@ jobs:
2222 - name: Validate tag and workspace versions
2323 run: bun scripts/release-version.ts
2424
25 release-sbom:
26 needs: validate-release
27 uses: ./.github/workflows/sbom.yml
28
2529 pantry:
2630 needs: [validate-release]
2731 name: pantry (${{ matrix.platform.name }})
@@ -433,8 +437,8 @@ jobs:
433437
434438 verify-macos-downloads:
435439 name: verify downloaded ${{ matrix.platform.name }}
436 needs: [pantry]
437 if: ${{ !cancelled() }}
440 needs: [validate-release, pantry]
441 if: ${{ !cancelled() && needs.validate-release.result == 'success' }}
438442 strategy:
439443 fail-fast: false
440444 matrix:
@@ -460,6 +464,36 @@ jobs:
460464 ditto -x -k "$DOWNLOAD_DIR/craft-$PLATFORM.zip" "$DOWNLOAD_DIR/extracted"
461465 bun scripts/verify-macos-release.ts "$DOWNLOAD_DIR/extracted/craft" "${TAG#v}"
462466
467 attach-release-sbom:
468 name: attach and verify release SBOMs
469 needs: [pantry, release-sbom]
470 runs-on: ubuntu-latest
471 permissions:
472 contents: write
473 steps:
474 - uses: actions/checkout@v6
475 - uses: oven-sh/setup-bun@v2
476 - uses: actions/download-artifact@v4
477 with:
478 name: sbom
479 path: sbom
480 - name: Attach the SBOMs produced by this release run
481 env:
482 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
483 TAG: ${{ github.ref_name }}
484 run: |
485 set -euo pipefail
486 bun scripts/verify-sbom.ts sbom "$TAG"
487 gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" \
488 sbom/craft-sbom.json sbom/full-sbom.cyclonedx.json sbom/full-sbom.spdx.json --clobber
489 VERIFY_DIR=$(mktemp -d)
490 gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern 'craft-sbom.json' \
491 --pattern 'full-sbom.cyclonedx.json' --pattern 'full-sbom.spdx.json' --dir "$VERIFY_DIR"
492 bun scripts/verify-sbom.ts "$VERIFY_DIR" "$TAG"
493 for document in craft-sbom.json full-sbom.cyclonedx.json full-sbom.spdx.json; do
494 cmp "sbom/$document" "$VERIFY_DIR/$document"
495 done
496
463497 npm:
464498 name: npm
465499 # Ordered after the binaries, not gated on them.
.github/workflows/sbom.ymlmodified+2-16
Changes to .github/workflows/sbom.yml
@@ -3,14 +3,11 @@ name: SBOM
33on:
44 push:
55 branches: [main]
6 tags:
7 - 'v*'
8 release:
9 types: [published]
6 workflow_call:
107 workflow_dispatch:
118
129concurrency:
13 group: sbom-${{ github.head_ref || github.ref }}
10 group: sbom-${{ github.workflow }}-${{ github.head_ref || github.ref }}
1411 cancel-in-progress: true
1512
1613jobs:
@@ -208,17 +205,6 @@ jobs:
208205 path: sbom/
209206 retention-days: 90
210207
211 - name: Attach SBOM to release
212 if: github.event_name == 'release'
213 uses: softprops/action-gh-release@v1
214 with:
215 files: |
216 sbom/craft-sbom.json
217 sbom/full-sbom.cyclonedx.json
218 sbom/full-sbom.spdx.json
219 env:
220 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
221
222208 - name: Submit to Dependency Graph
223209 if: github.ref == 'refs/heads/main'
224210 uses: anchore/sbom-action@v0
scripts/verify-sbom.test.tsadded+34-0
Changes to scripts/verify-sbom.test.ts
@@ -0,0 +1,34 @@
1import { expect, test } from 'bun:test'
2import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { releaseSboms, verifySbomFiles } from './verify-sbom'
6
7const documents = {
8 'craft-sbom.json': { bomFormat: 'CycloneDX', specVersion: '1.5', metadata: { component: { name: 'craft', version: 'v0.0.93' } } },
9 'full-sbom.cyclonedx.json': { bomFormat: 'CycloneDX', specVersion: '1.6', components: [{ name: 'dependency', version: '1.0.0' }] },
10 'full-sbom.spdx.json': { spdxVersion: 'SPDX-2.3', SPDXID: 'SPDXRef-DOCUMENT', packages: [{ name: 'dependency' }] },
11}
12
13test('requires all three valid documents from the matching release', () => {
14 const directory = mkdtempSync(join(tmpdir(), 'craft-sbom-'))
15 try {
16 for (const [name, value] of Object.entries(documents)) writeFileSync(join(directory, name), JSON.stringify(value))
17 expect(() => verifySbomFiles(directory, 'v0.0.93')).not.toThrow()
18 expect(() => verifySbomFiles(directory, 'v0.0.94')).toThrow('release identity')
19 for (const name of releaseSboms) {
20 rmSync(join(directory, name))
21 expect(() => verifySbomFiles(directory, 'v0.0.93')).toThrow()
22 writeFileSync(join(directory, name), '{')
23 expect(() => verifySbomFiles(directory, 'v0.0.93')).toThrow()
24 writeFileSync(join(directory, name), '{}')
25 expect(() => verifySbomFiles(directory, 'v0.0.93')).toThrow()
26 writeFileSync(join(directory, name), JSON.stringify(documents[name]))
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')
30 }
31 finally {
32 rmSync(directory, { recursive: true, force: true })
33 }
34})
scripts/verify-sbom.tsadded+33-0
Changes to scripts/verify-sbom.ts
@@ -0,0 +1,33 @@
1import { readFileSync } from 'node:fs'
2import { join, resolve } from 'node:path'
3
4export const releaseSboms = ['craft-sbom.json', 'full-sbom.cyclonedx.json', 'full-sbom.spdx.json'] as const
5
6export function verifySbomFiles(directory: string, tag: string): void {
7 for (const name of releaseSboms) {
8 const document = JSON.parse(readFileSync(join(directory, name), 'utf8'))
9 if (name.endsWith('.spdx.json')) {
10 if (!/^SPDX-2\.\d+$/.test(document.spdxVersion ?? '') || document.SPDXID !== 'SPDXRef-DOCUMENT' || !Array.isArray(document.packages) || document.packages.length === 0)
11 throw new Error(`${name}: expected a populated SPDX document`)
12 }
13 else {
14 if (document.bomFormat !== 'CycloneDX' || !/^1\.\d+$/.test(document.specVersion ?? ''))
15 throw new Error(`${name}: expected a CycloneDX document`)
16 if (name === 'craft-sbom.json') {
17 if (document.metadata?.component?.name !== 'craft' || document.metadata.component.version !== tag)
18 throw new Error(`${name}: release identity does not match ${tag}`)
19 }
20 else if (!Array.isArray(document.components) || document.components.length === 0) {
21 throw new Error(`${name}: expected dependency components`)
22 }
23 }
24 }
25}
26
27if (import.meta.main) {
28 const [directory, tag] = Bun.argv.slice(2)
29 if (!directory || !/^v\d+\.\d+\.\d+/.test(tag ?? ''))
30 throw new Error('Usage: bun scripts/verify-sbom.ts <directory> <version-tag>')
31 verifySbomFiles(resolve(directory), tag)
32 console.log(`Verified ${releaseSboms.length} SBOM attachments for ${tag}`)
33}