ci(release): generate + attach an SPDX SBOM to every published release (SUPPLY-001)

Merged
YA
2mo ago
+160-16

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 (the release job creates/updates it), so sbom never has to create one
    itself and there's no race on gh 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.org URL. This is deliberate on every run, not just backfills: the
    verify job's dist/ 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 inline python3 -c assertion before uploading,
    then gh release upload "$TAG" "$FILE" --clobber attaches it as
    wave_sdk-<version>.spdx.json — idempotent, matching the existing release job's
    --clobber convention.
  • permissions: contents: write scoped to only the sbom job — kept fully separate from
    publish's id-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 syft 1.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.json produced:
    spdxVersion: SPDX-2.3, packages count: 1, package name
    wave_sdk-2.2.0-py3-none-any.whl — non-empty packages[], confirming the CI step (which
    wraps the same syft engine via anchore/sbom-action) will produce equivalent output.
  • pytest -q against 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 a run: shell body in the new job — all
    untrusted/derived values (TAG, VERSION) flow through env: 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.


View with [code]smith Autofix with [code]smith
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.