diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 0525dd9..f845926 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -40,7 +40,44 @@ jobs: runs-on: ubuntu-latest steps: - uses: googleapis/release-please-action@v4 + id: release with: config-file: release-please-config.json manifest-file: .release-please-manifest.json token: ${{ secrets.GITHUB_TOKEN }} + + # The steps below run only when release-please actually cut a release. + # pnpm dlx avoids adding @cyclonedx/cyclonedx-npm to the lockfile (CI-only + # tool per ADR-022); SHA-pinned action follows ADR-023 §1 Renovate pattern. + - uses: actions/checkout@v4 + if: ${{ steps.release.outputs.releases_created == 'true' }} + + - uses: pnpm/action-setup@v4 + if: ${{ steps.release.outputs.releases_created == 'true' }} + with: + version: 9 + + - uses: actions/setup-node@v4 + if: ${{ steps.release.outputs.releases_created == 'true' }} + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + if: ${{ steps.release.outputs.releases_created == 'true' }} + run: pnpm install --frozen-lockfile + + - name: Generate CycloneDX SBOM + if: ${{ steps.release.outputs.releases_created == 'true' }} + run: > + pnpm dlx @cyclonedx/cyclonedx-npm + --output-file sbom-${{ steps.release.outputs.tag_name }}.cdx.json + --output-format json + --ignore-npm-errors + + - name: Attach SBOM to GitHub release + if: ${{ steps.release.outputs.releases_created == 'true' }} + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + tag_name: ${{ steps.release.outputs.tag_name }} + files: sbom-${{ steps.release.outputs.tag_name }}.cdx.json diff --git a/docs/decisions/adr-023-ci-security-and-supply-chain.md b/docs/decisions/adr-023-ci-security-and-supply-chain.md index 1eeabca..1601480 100644 --- a/docs/decisions/adr-023-ci-security-and-supply-chain.md +++ b/docs/decisions/adr-023-ci-security-and-supply-chain.md @@ -288,6 +288,83 @@ GitHub repo. Configurations (`renovate.json`, `.socket.json`, `codeql.yml`, This template's own consumption of the stack — when it's eventually pushed to a GitHub remote — uses the same configurations unchanged. +### 9. Amendment — SBOM release artifact (CycloneDX) + +**Added:** 2026-05-20 (story `10-sbom-ci-workflow`) + +`.github/workflows/release-please.yml` is amended to generate a +[CycloneDX](https://cyclonedx.org/) SBOM and attach it to every GitHub +release cut by release-please. + +**Concrete step shape:** + +```yaml +- name: Generate CycloneDX SBOM + if: ${{ steps.release.outputs.releases_created == 'true' }} + run: > + pnpm dlx @cyclonedx/cyclonedx-npm + --output-file sbom-${{ steps.release.outputs.tag_name }}.cdx.json + --output-format json + --ignore-npm-errors + +- name: Attach SBOM to GitHub release + if: ${{ steps.release.outputs.releases_created == 'true' }} + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + tag_name: ${{ steps.release.outputs.tag_name }} + files: sbom-${{ steps.release.outputs.tag_name }}.cdx.json +``` + +**Prerequisites** (also conditional on `releases_created == 'true'`): +`actions/checkout@v4` → `pnpm/action-setup@v4` → `actions/setup-node@v4` +→ `pnpm install --frozen-lockfile`, providing the installed workspace +graph that `@cyclonedx/cyclonedx-npm` analyses. + +**Rationale:** + +- **Compliance surface.** Consumers pursuing SOC 2 / ISO 27001 / + FedRAMP / EU CRA must produce an inventory of every version that + shipped. A CycloneDX JSON SBOM attached to each GitHub release gives + auditors a machine-readable, per-release artifact without requiring + them to inspect or re-run the repo. +- **`pnpm dlx`, not `package.json`.** `@cyclonedx/cyclonedx-npm` is + a release-time audit tool, not a runtime or build dependency; adding + it to the lockfile would violate ADR-022's spirit (library evaluation + required for runtime deps in feature/core packages). `pnpm dlx` + fetches and discards it within the CI step. +- **`--ignore-npm-errors`.** `@cyclonedx/cyclonedx-npm` internally + invokes `npm ls` to traverse the dependency graph. In a pnpm + workspace, `npm ls` exits non-zero when it encounters dev-deps-of- + dev-deps that pnpm correctly elides from the install tree; the SBOM + content is unaffected. Without this flag the step exits 254 and + produces no file. `--ignore-npm-errors` instructs the tool to treat + those `npm ls` warnings as non-fatal and emit the SBOM regardless. +- **SHA-pinned action.** `softprops/action-gh-release` is pinned to a + 40-character commit SHA (`# v3.0.0` trailing comment) per §1's + Renovate `pinGitHubActionDigests` preset. Renovate will open a bump + PR when a newer release is available. +- **Conditional execution.** The SBOM steps run only when + `releases_created == 'true'` — every non-release push to `main` + skips them entirely. This keeps the workflow fast for the common case + (release-please just updating its rolling PR). +- **Root SBOM covers all workspace packages.** Running + `@cyclonedx/cyclonedx-npm` from the workspace root after + `pnpm install --frozen-lockfile` captures the full resolved + dependency graph across all packages. Per-package SBOMs are out of + scope (see story `10-sbom-ci-workflow` §Out of scope). + +**Failure-mode table row** (extends §5): + +| Gate | Layer | Hard block? | +| ------------------------------------------- | ------- | ----------------------- | +| SBOM generation (`@cyclonedx/...`) | release | Yes — release job fails | +| SBOM upload (`softprops/action-gh-release`) | release | Yes — release job fails | + +SBOM absence blocks the release job; it does **not** gate main CI +(the `ci.yml` `validate` job is unaffected). This matches the +principle that release assets are part of the release job, not part +of the per-PR validation loop. + ## Alternatives considered - **Dependabot for everything instead of Renovate.** Rejected. Less