From 7742475db026fdec8de50b2754e02ab10c385cb3 Mon Sep 17 00:00:00 2001 From: jmeridth Date: Mon, 18 May 2026 18:15:00 -0500 Subject: [PATCH] feat(release): add SBOM-to-archive attestation linkage ## What Add a new `attest_sboms` matrix job that runs `actions/attest-sbom` per (archive, SBOM) pair after `release_goreleaser` produces artifacts. Expose `sbom_matrix` and `is_public` as outputs from `release_goreleaser`, upload `dist/` as a workflow artifact when SBOMs are detected, and gate `publish_release` on `attest_sboms` so the draft is published only after all SBOM attestations complete. ## Why Today the workflow generates build provenance attestations against every artifact in `dist/`, including `*.spdx.json` files as standalone subjects, but it does not link an archive to its corresponding SBOM. Consumers running `gh attestation verify --predicate-type https://spdx.dev/Document` against a downloaded archive cannot discover its SBOM via the attestation graph. Adding `actions/attest-sbom` linkage closes that gap and removes the need for per-consumer follow-on jobs that replicate the matrix dance locally. ## Notes - New job runs only when `create-attestation: true`, the repo is public, and `dist/*.spdx.json` files exist. Behavior is unchanged when SBOMs are not produced or attestation is off. - Matrix is generated from `dist/*.spdx.json` filenames, stripping `.spdx.json` to derive the archive path. Requires GoReleaser's default `${artifact}.spdx.json` naming pattern (or any pattern that maps 1:1 by the same suffix strip). - `publish_release` now waits for `attest_sboms` to succeed-or-skip, so SBOM attestation failures will block publish (mirrors the existing gate on `release_goreleaser` and `release_image`). Matrix uses `fail-fast: false` so one failed pair does not cancel the others. - `dist/` upload uses 1-day retention; consumed by the matrix job immediately, no long-term need. - `release_goreleaser` continues to do `attest-build-provenance` over `dist/*.spdx.json`; the new linkage is additive, not a replacement. Signed-off-by: jmeridth --- .github/workflows/release.yaml | 55 +++++++++++++++++++++++++++++++++- docs/release.md | 9 ++++-- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index bbe5289..f00007d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -132,6 +132,9 @@ jobs: contents: write # Upload release assets id-token: write # Federate for artifact attestation attestations: write # Generate artifact attestations + outputs: + sbom_matrix: ${{ steps.sbom-matrix.outputs.matrix }} + is_public: ${{ steps.repo-visibility.outputs.is_public }} steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9ca718d3bf646d6534007c269a635b3e54cadf99 # v2.19.2 @@ -227,6 +230,55 @@ jobs: run: | echo "::warning::Artifact attestation skipped — not available for private user-owned repositories. Make this repository public to enable attestation." + - name: Generate SBOM attestation matrix + id: sbom-matrix + if: ${{ inputs.create-attestation && steps.repo-visibility.outputs.is_public == 'true' }} + run: | + shopt -s nullglob + sboms=(dist/*.spdx.json) + if [ ${#sboms[@]} -eq 0 ]; then + echo "matrix=" >> "$GITHUB_OUTPUT" + exit 0 + fi + matrix=$(printf '%s\n' "${sboms[@]}" | jq -R '{"sbom": ., "archive": sub("\\.spdx\\.json$"; "")}' | jq -s -c '{"include": .}') + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + + - name: Upload dist for SBOM attestation + if: ${{ inputs.create-attestation && steps.repo-visibility.outputs.is_public == 'true' && steps.sbom-matrix.outputs.matrix != '' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-dist + path: dist + retention-days: 1 + + attest_sboms: + needs: release_goreleaser + if: ${{ inputs.create-attestation && needs.release_goreleaser.outputs.is_public == 'true' && needs.release_goreleaser.outputs.sbom_matrix != '' }} + runs-on: ubuntu-latest + permissions: + id-token: write # Federate for SBOM attestation + attestations: write # Generate SBOM attestations + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.release_goreleaser.outputs.sbom_matrix) }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9ca718d3bf646d6534007c269a635b3e54cadf99 # v2.19.2 + with: + egress-policy: audit + + - name: Download dist + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-dist + path: dist + + - name: Attest SBOM + uses: actions/attest-sbom@4651f806c01d8637787e274ac3bdf724ef169f34 # v4.1.0 + with: + subject-path: "${{ matrix.archive }}" + sbom-path: "${{ matrix.sbom }}" + release_image: needs: create_release if: ${{ inputs.image-name != '' && needs.create_release.outputs.full-tag != '' }} @@ -306,12 +358,13 @@ jobs: echo "::warning::Artifact attestation skipped — not available for private user-owned repositories. Make this repository public to enable attestation." publish_release: - needs: [create_release, release_goreleaser, release_image] + needs: [create_release, release_goreleaser, attest_sboms, release_image] if: > always() && inputs.publish && needs.create_release.result == 'success' && (needs.release_goreleaser.result == 'success' || needs.release_goreleaser.result == 'skipped') && + (needs.attest_sboms.result == 'success' || needs.attest_sboms.result == 'skipped') && (needs.release_image.result == 'success' || needs.release_image.result == 'skipped') runs-on: ubuntu-latest permissions: diff --git a/docs/release.md b/docs/release.md index a5d87d5..ca81580 100644 --- a/docs/release.md +++ b/docs/release.md @@ -81,9 +81,10 @@ The workflow runs up to six jobs: 1. **create_release** - Always runs. Creates a draft release via release-drafter, then creates and pushes the full and major version git tags. 2. **release_goreleaser** - Runs when `goreleaser-config-path` is set. Builds Go binaries, uploads artifacts to the draft release, and optionally creates attestations. -3. **release_image** - Runs when `image-name` is set. Builds and pushes a multi-platform Docker image, and optionally creates attestations. -4. **publish_release** - Runs when `publish` is true and all preceding jobs succeed (or are skipped). Publishes the draft release. -5. **release_discussion** - Runs after `publish_release` succeeds and when `create-discussion` is set. Both `discussion-category-id` and `discussion-repository-id` secrets are required if so. Creates a GitHub Discussions announcement only after the release is successfully published. +3. **attest_sboms** - Runs when `create-attestation: true`, the repository is public, and GoReleaser produced `*.spdx.json` files. Creates an SBOM attestation linking each archive to its corresponding SBOM via `actions/attest-sbom`. Fans out across archive/SBOM pairs via a matrix. +4. **release_image** - Runs when `image-name` is set. Builds and pushes a multi-platform Docker image, and optionally creates attestations. +5. **publish_release** - Runs when `publish` is true and all preceding jobs succeed (or are skipped). Publishes the draft release. +6. **release_discussion** - Runs after `publish_release` succeeds and when `create-discussion` is set. Both `discussion-category-id` and `discussion-repository-id` secrets are required if so. Creates a GitHub Discussions announcement only after the release is successfully published. ## GoReleaser Configuration @@ -103,6 +104,8 @@ Without these settings, GoReleaser will attempt to create its own GitHub release If your GoReleaser config includes an `sboms:` block that calls `syft`, the workflow detects it via `yq` and installs syft automatically before running GoReleaser. Generated `*.spdx.json` files are uploaded alongside the archives and included in the build provenance attestation when `create-attestation: true`. No additional configuration is needed beyond declaring `sboms:` in your GoReleaser config. +When `create-attestation: true` and SBOMs are produced, the `attest_sboms` job additionally runs `actions/attest-sbom` per (archive, SBOM) pair. This creates an SBOM attestation linking each archive to its corresponding `.spdx.json`, on top of the build provenance attestations generated by `release_goreleaser`. SBOM linkage requires GoReleaser to emit one SBOM per archive using the default `${artifact}.spdx.json` naming pattern (or any naming that strips `.spdx.json` to yield the matching archive path). + ## Notes - The draft-first pattern supports repositories with **immutable releases** enabled. The release is created as a draft, artifacts are uploaded, and only then is it published.