Skip to content

Commit 6d7a83e

Browse files
jmeridthzkoppert
andauthored
feat(release): add SBOM-to-archive attestation linkage (#141)
* 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 <jmeridth@gmail.com> * Update release.yaml Co-authored-by: Zack Koppert <zkoppert@github.com> Signed-off-by: Jason Meridth <jmeridth@gmail.com> --------- Signed-off-by: jmeridth <jmeridth@gmail.com> Signed-off-by: Jason Meridth <jmeridth@gmail.com> Co-authored-by: Zack Koppert <zkoppert@github.com>
1 parent 50ddb99 commit 6d7a83e

2 files changed

Lines changed: 60 additions & 4 deletions

File tree

.github/workflows/release.yaml

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ jobs:
132132
contents: write # Upload release assets
133133
id-token: write # Federate for artifact attestation
134134
attestations: write # Generate artifact attestations
135+
outputs:
136+
sbom_matrix: ${{ steps.sbom-matrix.outputs.matrix }}
137+
is_public: ${{ steps.repo-visibility.outputs.is_public }}
135138
steps:
136139
- name: Harden the runner (Audit all outbound calls)
137140
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
@@ -227,6 +230,55 @@ jobs:
227230
run: |
228231
echo "::warning::Artifact attestation skipped — not available for private user-owned repositories. Make this repository public to enable attestation."
229232
233+
- name: Generate SBOM attestation matrix
234+
id: sbom-matrix
235+
if: ${{ inputs.create-attestation && steps.repo-visibility.outputs.is_public == 'true' }}
236+
run: |
237+
shopt -s nullglob
238+
sboms=(dist/*.spdx.json)
239+
if [ ${#sboms[@]} -eq 0 ]; then
240+
echo "matrix=" >> "$GITHUB_OUTPUT"
241+
exit 0
242+
fi
243+
matrix=$(printf '%s\n' "${sboms[@]}" | jq -R '{"sbom": ., "archive": sub("\\.spdx\\.json$"; "")}' | jq -s -c '{"include": .}')
244+
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
245+
246+
- name: Upload dist for SBOM attestation
247+
if: ${{ inputs.create-attestation && steps.repo-visibility.outputs.is_public == 'true' && steps.sbom-matrix.outputs.matrix != '' }}
248+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
249+
with:
250+
name: release-dist
251+
path: dist
252+
retention-days: 1
253+
254+
attest_sboms:
255+
needs: release_goreleaser
256+
if: ${{ inputs.create-attestation && needs.release_goreleaser.outputs.is_public == 'true' && needs.release_goreleaser.outputs.sbom_matrix != '' }}
257+
runs-on: ubuntu-latest
258+
permissions:
259+
id-token: write # Federate for SBOM attestation
260+
attestations: write # Generate SBOM attestations
261+
strategy:
262+
fail-fast: false
263+
matrix: ${{ fromJson(needs.release_goreleaser.outputs.sbom_matrix) }}
264+
steps:
265+
- name: Harden the runner (Audit all outbound calls)
266+
uses: step-security/harden-runner@9ca718d3bf646d6534007c269a635b3e54cadf99 # v2.19.2
267+
with:
268+
egress-policy: audit
269+
270+
- name: Download dist
271+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
272+
with:
273+
name: release-dist
274+
path: dist
275+
276+
- name: Attest SBOM
277+
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
278+
with:
279+
subject-path: "${{ matrix.archive }}"
280+
sbom-path: "${{ matrix.sbom }}"
281+
230282
release_image:
231283
needs: create_release
232284
if: ${{ inputs.image-name != '' && needs.create_release.outputs.full-tag != '' }}
@@ -306,12 +358,13 @@ jobs:
306358
echo "::warning::Artifact attestation skipped — not available for private user-owned repositories. Make this repository public to enable attestation."
307359
308360
publish_release:
309-
needs: [create_release, release_goreleaser, release_image]
361+
needs: [create_release, release_goreleaser, attest_sboms, release_image]
310362
if: >
311363
always() &&
312364
inputs.publish &&
313365
needs.create_release.result == 'success' &&
314366
(needs.release_goreleaser.result == 'success' || needs.release_goreleaser.result == 'skipped') &&
367+
(needs.attest_sboms.result == 'success' || needs.attest_sboms.result == 'skipped') &&
315368
(needs.release_image.result == 'success' || needs.release_image.result == 'skipped')
316369
runs-on: ubuntu-latest
317370
permissions:

docs/release.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,10 @@ The workflow runs up to six jobs:
8181
8282
1. **create_release** - Always runs. Creates a draft release via release-drafter, then creates and pushes the full and major version git tags.
8383
2. **release_goreleaser** - Runs when `goreleaser-config-path` is set. Builds Go binaries, uploads artifacts to the draft release, and optionally creates attestations.
84-
3. **release_image** - Runs when `image-name` is set. Builds and pushes a multi-platform Docker image, and optionally creates attestations.
85-
4. **publish_release** - Runs when `publish` is true and all preceding jobs succeed (or are skipped). Publishes the draft release.
86-
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.
84+
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.
85+
4. **release_image** - Runs when `image-name` is set. Builds and pushes a multi-platform Docker image, and optionally creates attestations.
86+
5. **publish_release** - Runs when `publish` is true and all preceding jobs succeed (or are skipped). Publishes the draft release.
87+
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.
8788

8889
## GoReleaser Configuration
8990

@@ -103,6 +104,8 @@ Without these settings, GoReleaser will attempt to create its own GitHub release
103104

104105
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.
105106

107+
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).
108+
106109
## Notes
107110

108111
- 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.

0 commit comments

Comments
 (0)