ci(release): generate + attach an SPDX SBOM to every published release (SUPPLY-001)
Why
GA gate criterion SUPPLY-001 ("every published release carries an SBOM asset"), tracked
in wave-av/claude-workstation#4803, is currently a fail: this repo's release.yml has no
SBOM step, and the current v2.2.0 GitHub Release carries only the sdist and wheel — no
SPDX/CycloneDX asset. Confirmed live before writing any code:
$ gh release view v2.2.0 --repo wave-av/sdk-python --json assets
{"assets":[
{"name":"wave_sdk-2.2.0-py3-none-any.whl", ...},
{"name":"wave_sdk-2.2.0.tar.gz", ...}
]}
No SBOM asset present.
Design
Adds a fifth job, sbom, to the existing resolve-ref -> verify -> publish -> release
pipeline in .github/workflows/release.yml:
needs: [resolve-ref, verify, release]— runs only after the tag's GitHub Release
already exists (thereleasejob creates/updates it), sosbomnever has to create one
itself and there's no race ongh release upload.- Downloads the ACTUAL PUBLISHED wheel from PyPI by
name==version—
python -m pip download "wave-sdk==${VERSION}" --no-deps— never a hand-built
files.pythonhosted.orgURL. This is deliberate on every run, not just backfills: the
verifyjob'sdist/artifact is a fresh local build, and a rebuild is not guaranteed
byte-identical to what PyPI actually serves (build timestamps, wheel-tag ordering, etc). The
SBOM should describe what ships, not what was built pre-publish. A 6-attempt/10s-backoff
retry absorbs PyPI's CDN indexing lag immediately after a Trusted-Publishing upload. anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26(v0.24.2, pinned to a full
commit SHA) generates SPDX-JSON (format: spdx-json) for the downloaded wheel.- Validates non-empty
packages[]with an inlinepython3 -cassertion before uploading,
thengh release upload "$TAG" "$FILE" --clobberattaches it as
wave_sdk-<version>.spdx.json— idempotent, matching the existingreleasejob's
--clobberconvention. permissions: contents: writescoped to only thesbomjob — kept fully separate from
publish'sid-token: write(that job's permissions are untouched; no scope is merged or
widened anywhere).
Backfill for v2.2.0 (no on: release workflow added)
release.yml's release job creates GitHub Releases via gh release create using
GITHUB_TOKEN, which does not trigger release: events in other workflows — so per the
task brief, this is a job added inside release.yml, not a separate on: release listener.
The existing workflow_dispatch.tag input is now required: false, default: "latest":
a blank input or the literal "latest" resolves (in resolve-ref, via
gh release view --json tagName --jq .tagName) to the most recent GitHub Release's tag before
the existing format/existence/ancestry checks run unchanged. This lets an operator backfill an
SBOM onto v2.2.0 without typing/guessing a tag and without a new PyPI publish — publish's
existing pypi_version_exists.py check already no-ops the publish step for a version PyPI
already has, and release's existing create-or-clobber-upload logic already handles a
pre-existing release, so the full pipeline re-run is a safe, idempotent SBOM-only backfill in
practice. Exact backfill command:
gh workflow run release.yml --repo wave-av/sdk-python --ref main -f tag=v2.2.0
(or omit -f tag= / pass -f tag=latest to target whatever is currently marked "Latest").
Verification performed
actionlint .github/workflows/release.yml— exit 0, no findings.python3 -c "import yaml; yaml.safe_load(open('.github/workflows/release.yml'))"— parses
clean, 5 jobs:resolve-ref, verify, publish, release, sbom.- Local syft dry-run (receipt): installed
syft1.51.1 via Homebrew,
pip download wave-sdk==2.2.0 --no-deps -d /tmp/w(real PyPI wheel), then
syft wave_sdk-2.2.0-py3-none-any.whl -o spdx-json=wave_sdk-2.2.0.spdx.jsonproduced:
spdxVersion: SPDX-2.3,packages count: 1, package name
wave_sdk-2.2.0-py3-none-any.whl— non-emptypackages[], confirming the CI step (which
wraps the samesyftengine viaanchore/sbom-action) will produce equivalent output. pytest -qagainst the unmodified repo (venv, Python 3.12,pip install -e ".[dev,realtime,x402]"): 84 passed, matching the measured baseline in the brief — this
PR is CI-config-only and touches no application code.- Confirmed no
${{ }}interpolated raw into arun:shell body in the new job — all
untrusted/derived values (TAG,VERSION) flow throughenv:only, matching this file's
existing convention. - Grepped the diff for every private-repo name on the redaction list
(agent-money/wave-surfer/wave-gateway/wave-media-engine/wave-clip-engine/wave-transports/
wave-platform-workers/wave-flash) — zero matches.
Overlap with PR #48
Checked wave-av/sdk-python#48 ("ci(release): create GitHub Release after successful PyPI
publish") per instruction before designing this. It is stale and superseded, not a base to
stack on: its diff (gh pr diff 48) applies against an older ~134-line release.yml with a
different build -> publish -> release job shape; the current main already has a more
sophisticated resolve-ref -> verify -> publish -> release pipeline (landed via a separate,
later PR) that already creates/uploads the GitHub Release with contents: write scoped to that
job — functionally superseding #48. gh pr view 48 --json mergeable,mergeStateStatus reports
CONFLICTING / DIRTY against current main. This PR is therefore self-contained, branched
directly off origin/main, and builds its sbom job on top of the release job that already
exists on main today — not on #48.
Scope note
.github/workflows/release.yml is now 414 lines. It is a single cohesive release pipeline
(five jobs sharing resolve-ref's tag/sha resolution); splitting sbom into a separate
reusable workflow was considered but rejected to keep the tag-resolution/ancestry-trust logic
and the release lifecycle in one auditable file, consistent with this file's existing
single-file convention for resolve-ref/verify/publish/release.
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.
[!NOTE]
Medium Risk
Changes the production release pipeline and GitHub Release assets; scope stays CI-only with minimal permissions on the new job, but a misconfiguration could block releases or ship incomplete assets.
Overview
Extends SUPPLY-001 by adding an sbom job to the release workflow: after PyPI publish, it downloads the published wave-sdk wheel from PyPI (pip download, wheel-only, with retries for CDN lag), generates SPDX-JSON via pinned anchore/sbom-action, validates a non-empty packages[], and uploads the file as a workflow artifact. The release job now depends on sbom, pulls that artifact, and attaches dist + SBOM together in one gh release create/upload (with a hard fail if no assets).
resolve-ref is tightened for manual runs: dispatch resolves the tag from the event type (never treating main as a tag), and the optional workflow_dispatch tag input defaults to latest, resolving to GitHub’s latest release tag so operators can idempotently backfill SBOMs on already-shipped versions without republishing to PyPI.
Reviewed by Cursor Bugbot for commit c3eda1e815d3a3a6f8aeeb1c28805ca2e352cdec. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by Sourcery
Add SBOM generation and attachment to the release pipeline so every published release carries a validated SPDX asset and existing releases can be backfilled safely.
New Features:
- Generate and attach a validated SPDX-JSON SBOM for the exact wheel published to PyPI on every GitHub release.
- Support idempotent SBOM backfills for the latest or a specified existing release through workflow dispatch.
Enhancements:
- Ensure release assets are created or updated together with the SBOM, while keeping repository write permissions scoped to the release job.
CI:
- Extend the release workflow with a dedicated SBOM generation and artifact handoff job, including retries for PyPI indexing delays.
Tests:
- Validate the generated SBOM contains a non-empty package list.