From 4da9524623ff1c0dbeaa578ccf80f0e212a62b84 Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Tue, 8 Sep 2026 12:01:24 -0400 Subject: [PATCH 1/2] ci(release): generate + attach an SPDX SBOM to every published release (SUPPLY-001) Adds an `sbom` job to release.yml that runs after `release` (so the tag's GitHub Release already exists to attach onto). Downloads the ACTUAL PUBLISHED wheel from PyPI by name==version (never a hand-built files.pythonhosted.org URL, with a bounded retry for CDN indexing lag), generates an SPDX-JSON SBOM via anchore/sbom-action (pinned to a full commit SHA), and uploads it as wave_sdk-.spdx.json via `gh release upload --clobber`. `contents: write` is scoped to only this job, kept fully separate from publish's id-token: write. Also makes the workflow_dispatch `tag` input optional (default "latest"), resolving to the most recent GitHub Release's tag when blank/"latest" -- lets v2.2.0 be backfilled with an SBOM without a new PyPI publish. Ref wave-av/claude-workstation#4803 (SUPPLY-001). Co-Authored-By: Claude Fable 5.1 --- .github/workflows/release.yml | 123 +++++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cfa194a..135cfdb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,12 +27,35 @@ name: release # with generated notes and the sdist+wheel attached. Idempotent: # if the release already exists, it edits/uploads onto it # instead of failing. +# sbom -- contents: write only (separate job, separate scope from +# `publish`'s id-token: write -- never merged). Runs after +# `release` (so the tag's GitHub Release always already exists +# to attach onto). Downloads the ACTUAL PUBLISHED wheel from +# PyPI by name==version (`pip download wave-sdk==`, +# never a hand-built files.pythonhosted.org URL) so the SBOM +# always describes exactly what PyPI serves -- not a possibly +# nondeterministic local rebuild -- generates an SPDX-JSON SBOM +# for it with anchore/sbom-action (wraps syft, pinned to a full +# commit SHA), and uploads it onto the tag's Release as +# `wave_sdk-.spdx.json` via `gh release upload +# --clobber` (idempotent re-runs overwrite, never fail). # # Triggers on `v*` tag pushes AND `workflow_dispatch` with a `tag` input, so # an existing tag (e.g. the current v2.1.0, pushed before this workflow # existed) can be backfilled on demand: # gh workflow run release.yml --repo wave-av/sdk-python --ref main -f tag=v2.1.0 # +# The `tag` dispatch input is optional (default "latest"): a blank input or +# the literal "latest" resolves to the most recent GitHub Release's tag, so +# an SBOM can be backfilled onto whatever already shipped (e.g. v2.2.0) +# without typing/guessing a tag name and without a new PyPI publish -- the +# `publish` job's existing PyPI-version-exists 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 +# a full pipeline re-run against an already-released tag is a safe, +# idempotent SBOM-only backfill in practice: +# gh workflow run release.yml --repo wave-av/sdk-python --ref main -f tag=v2.2.0 +# # Every `uses:` below is pinned to a 40-character commit SHA with the human # version in a trailing comment -- a tag ref (`@v6`) is a mutable pointer an # upstream maintainer (or an attacker who compromises their account) can @@ -48,8 +71,13 @@ on: workflow_dispatch: inputs: tag: - description: "Tag to verify/publish/release, e.g. v2.1.0 (backfill an existing tag)" - required: true + description: >- + Tag to verify/publish/release/SBOM, e.g. v2.1.0 (backfill an + existing tag). Leave blank or "latest" to resolve the most recent + GitHub Release's tag -- e.g. to backfill an SBOM onto an + already-published release without a new PyPI publish. + required: false + default: "latest" type: string permissions: @@ -95,10 +123,28 @@ jobs: env: DISPATCH_TAG: ${{ github.event.inputs.tag }} PUSH_TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} run: | set -euo pipefail TAG="${DISPATCH_TAG:-$PUSH_TAG}" + # 0. A blank dispatch input, or the literal sentinel "latest", + # resolves to the most recent GitHub Release's tag -- lets an + # operator backfill an SBOM onto whatever already shipped + # without typing/guessing the tag name. `gh release view` + # (no arg) returns the release GitHub itself marks "Latest"; + # contents: read is sufficient to read releases on this repo. + if [[ "$TAG" == "latest" ]]; then + RESOLVED="$(gh release view --json tagName --jq .tagName)" + if [[ -z "$RESOLVED" ]]; then + echo "::error::could not resolve 'latest' -- no GitHub Release found in this repository" + exit 1 + fi + echo "resolved dispatch tag 'latest' -> $RESOLVED" + TAG="$RESOLVED" + fi + # 1. Format -- full-anchored regex, not a glob -- refuses anything # that isn't exactly `v`. This value flows into a `ref:` # on downstream checkout steps (as the resolved sha, never the @@ -293,3 +339,76 @@ jobs: --title "wave-sdk $TAG" \ --generate-notes fi + + # --------------------------------------------------------------------------- + # SUPPLY-001. Every published release carries an SBOM asset. Runs only after + # `release` (so the tag's GitHub Release already exists to attach onto -- + # this job never creates one itself). Downloads the ACTUAL PUBLISHED wheel + # from PyPI by name==version rather than reusing the `verify` job's locally + # built dist/ artifact: a rebuild is not guaranteed byte-identical to what + # PyPI actually serves, and the SBOM must describe what ships, not what was + # built pre-publish. `contents: write` is scoped to only this job -- kept + # fully separate from `publish`'s `id-token: write` (never merged). + # --------------------------------------------------------------------------- + sbom: + name: generate + attach SBOM + needs: [resolve-ref, verify, release] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Download the published wheel from PyPI (by name==version, never a direct URL) + env: + VERSION: ${{ needs.verify.outputs.version }} + run: | + set -euo pipefail + mkdir -p sbom-src + python -m pip install --upgrade pip + + # PyPI's CDN can lag a few seconds behind a Trusted-Publishing + # upload -- retry rather than fail on that race, whether this run + # is the normal post-publish path or a workflow_dispatch backfill. + ok=false + for attempt in 1 2 3 4 5 6; do + if python -m pip download "wave-sdk==${VERSION}" --no-deps -d sbom-src; then + ok=true + break + fi + echo "pip download attempt ${attempt} failed, retrying in 10s..." + sleep 10 + done + if [ "$ok" != "true" ]; then + echo "::error::could not download wave-sdk==${VERSION} from PyPI after 6 attempts" + exit 1 + fi + ls -la sbom-src + + - name: Generate SPDX-JSON SBOM for the published wheel + uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 + with: + path: sbom-src + format: spdx-json + output-file: wave_sdk-${{ needs.verify.outputs.version }}.spdx.json + + - name: Validate and upload the SBOM to the GitHub Release for this tag + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.resolve-ref.outputs.tag }} + VERSION: ${{ needs.verify.outputs.version }} + run: | + set -euo pipefail + FILE="wave_sdk-${VERSION}.spdx.json" + test -s "$FILE" || { echo "::error::${FILE} is missing or empty"; exit 1; } + python3 -c " + import json, sys + d = json.load(open('${FILE}')) + assert d.get('packages'), 'SBOM has no packages[]' + print('SBOM OK:', d.get('spdxVersion'), '-', len(d['packages']), 'package(s)') + " + gh release upload "$TAG" "$FILE" --repo "${{ github.repository }}" --clobber + echo "::notice::uploaded ${FILE} to release ${TAG}" From c3eda1e815d3a3a6f8aeeb1c28805ca2e352cdec Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Tue, 8 Sep 2026 12:23:47 -0400 Subject: [PATCH 2/2] ci(release): make SBOM attach atomic with release; fix dispatch-latest; harden download Addresses review findings on PR #54 (cubic, CodeRabbit/zizmor). sbom now runs BEFORE release and uploads only a workflow artifact (no repo write); release downloads both the dist and sbom artifacts and attaches them in the SAME gh release create/upload call so a Release is never visible without its SBOM. resolve-ref now branches explicitly on github.event_name instead of a bash fallback, so a blank workflow_dispatch input resolves to latest instead of the dispatch branch name. pip download now passes --only-binary=:all:. Moved github.repository interpolation out of run: bodies into GH_REPO env to close a zizmor template-injection finding. Re-verified: actionlint clean, zizmor clean on changed lines, pytest -q 84 passed. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/release.yml | 205 +++++++++++++++++++--------------- 1 file changed, 115 insertions(+), 90 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 135cfdb..b8726a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: release # Codified release-on-tag pipeline for wave-sdk (PyPI: wave-sdk, PyPI Trusted # Publishing, no stored token -- decision IGV-D-010: no per-repo publish -# secrets). Four jobs, each with its own minimal `permissions:`: +# secrets). Five jobs, each with its own minimal `permissions:`: # # resolve-ref -- resolves + strictly validates the tag (push ref or # dispatch input) BEFORE it reaches any git/python/gh command as @@ -23,22 +23,19 @@ name: release # (checked live against pypi.org before publishing), the publish # step is skipped with a clear log line and the job still # succeeds -- a re-run must be idempotent, not a hard failure. -# release -- contents: write only. Creates the GitHub Release for the tag -# with generated notes and the sdist+wheel attached. Idempotent: -# if the release already exists, it edits/uploads onto it -# instead of failing. -# sbom -- contents: write only (separate job, separate scope from -# `publish`'s id-token: write -- never merged). Runs after -# `release` (so the tag's GitHub Release always already exists -# to attach onto). Downloads the ACTUAL PUBLISHED wheel from -# PyPI by name==version (`pip download wave-sdk==`, -# never a hand-built files.pythonhosted.org URL) so the SBOM -# always describes exactly what PyPI serves -- not a possibly -# nondeterministic local rebuild -- generates an SPDX-JSON SBOM -# for it with anchore/sbom-action (wraps syft, pinned to a full -# commit SHA), and uploads it onto the tag's Release as -# `wave_sdk-.spdx.json` via `gh release upload -# --clobber` (idempotent re-runs overwrite, never fail). +# sbom -- contents: read (no repo write -- only builds an artifact). +# Downloads the ACTUAL PUBLISHED wheel from PyPI by +# name==version (`pip download wave-sdk==`, never a +# hand-built files.pythonhosted.org URL) so the SBOM always +# describes exactly what PyPI serves, not a possibly +# nondeterministic local rebuild; generates SPDX-JSON with +# anchore/sbom-action (wraps syft, pinned to a full commit SHA) +# and uploads it as a *workflow* artifact for `release` to pick +# up -- kept fully separate from `publish`'s id-token: write. +# release -- contents: write only. Creates/updates the GitHub Release for +# the tag with the sdist+wheel AND the SBOM attached in the SAME +# `gh release create`/`upload` call, so a Release is never +# visible without its SBOM. Idempotent (--clobber re-upload). # # Triggers on `v*` tag pushes AND `workflow_dispatch` with a `tag` input, so # an existing tag (e.g. the current v2.1.0, pushed before this workflow @@ -121,28 +118,39 @@ jobs: - name: Resolve and validate the tag to release id: resolve env: + EVENT_NAME: ${{ github.event_name }} DISPATCH_TAG: ${{ github.event.inputs.tag }} PUSH_TAG: ${{ github.ref_name }} GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} run: | set -euo pipefail - TAG="${DISPATCH_TAG:-$PUSH_TAG}" - - # 0. A blank dispatch input, or the literal sentinel "latest", - # resolves to the most recent GitHub Release's tag -- lets an - # operator backfill an SBOM onto whatever already shipped - # without typing/guessing the tag name. `gh release view` - # (no arg) returns the release GitHub itself marks "Latest"; - # contents: read is sufficient to read releases on this repo. - if [[ "$TAG" == "latest" ]]; then - RESOLVED="$(gh release view --json tagName --jq .tagName)" - if [[ -z "$RESOLVED" ]]; then - echo "::error::could not resolve 'latest' -- no GitHub Release found in this repository" - exit 1 + + # Branch on the EVENT, not on whether DISPATCH_TAG happens to be + # set -- a tag push's ambient github.ref_name is the pushed tag + # (always correct to use as-is), but a workflow_dispatch's + # github.ref_name is the branch it was run from (e.g. "main"), + # which must NEVER be treated as a candidate release tag. + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + TAG="${DISPATCH_TAG:-latest}" + + # A blank dispatch input, or the literal sentinel "latest", + # resolves to the most recent GitHub Release's tag -- lets an + # operator backfill an SBOM onto whatever already shipped + # without typing/guessing the tag name. `gh release view` + # (no arg) returns the release GitHub itself marks "Latest"; + # contents: read is sufficient to read releases on this repo. + if [[ -z "$TAG" || "$TAG" == "latest" ]]; then + RESOLVED="$(gh release view --json tagName --jq .tagName)" + if [[ -z "$RESOLVED" ]]; then + echo "::error::could not resolve 'latest' -- no GitHub Release found in this repository" + exit 1 + fi + echo "resolved dispatch tag 'latest' -> $RESOLVED" + TAG="$RESOLVED" fi - echo "resolved dispatch tag 'latest' -> $RESOLVED" - TAG="$RESOLVED" + else + TAG="$PUSH_TAG" fi # 1. Format -- full-anchored regex, not a glob -- refuses anything @@ -306,57 +314,26 @@ jobs: if: steps.pypi_check.outputs.exists == 'true' run: echo "::notice::wave-sdk ${{ needs.verify.outputs.version }} is already on PyPI -- skipping publish, proceeding to release job" - release: - name: create GitHub Release - needs: [resolve-ref, publish] - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: write - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - ref: ${{ needs.resolve-ref.outputs.sha }} - persist-credentials: false - - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: dist-${{ needs.resolve-ref.outputs.tag }} - path: dist - - - name: Create or update the GitHub Release for this tag (idempotent) - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ needs.resolve-ref.outputs.tag }} - run: | - set -e - if gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then - echo "::notice::release $TAG already exists -- uploading dist assets (clobber) instead of creating" - gh release upload "$TAG" dist/* --repo "${{ github.repository }}" --clobber - else - gh release create "$TAG" dist/* \ - --repo "${{ github.repository }}" \ - --title "wave-sdk $TAG" \ - --generate-notes - fi - # --------------------------------------------------------------------------- - # SUPPLY-001. Every published release carries an SBOM asset. Runs only after - # `release` (so the tag's GitHub Release already exists to attach onto -- - # this job never creates one itself). Downloads the ACTUAL PUBLISHED wheel - # from PyPI by name==version rather than reusing the `verify` job's locally - # built dist/ artifact: a rebuild is not guaranteed byte-identical to what - # PyPI actually serves, and the SBOM must describe what ships, not what was - # built pre-publish. `contents: write` is scoped to only this job -- kept - # fully separate from `publish`'s `id-token: write` (never merged). + # SUPPLY-001. Every published release carries an SBOM asset -- generated + # here as a build artifact, then attached by `release` (below) in the SAME + # `gh release create`/`upload` call as dist/*, so a Release is never + # visible without its SBOM (no window where the Release exists but the + # SBOM doesn't). Downloads the ACTUAL PUBLISHED wheel from PyPI by + # name==version (never a hand-built files.pythonhosted.org URL; + # --only-binary=:all: so a missing wheel fails loud instead of silently + # SBOM-ing the sdist) rather than reusing `verify`'s locally built dist/ + # artifact: a rebuild is not guaranteed byte-identical to what PyPI + # actually serves, and the SBOM must describe what ships, not what was + # built pre-publish. # --------------------------------------------------------------------------- sbom: - name: generate + attach SBOM - needs: [resolve-ref, verify, release] + name: generate SBOM + needs: [resolve-ref, verify, publish] runs-on: ubuntu-latest timeout-minutes: 10 permissions: - contents: write + contents: read # only reads PyPI + writes a local build artifact -- no repo write steps: - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -371,11 +348,12 @@ jobs: python -m pip install --upgrade pip # PyPI's CDN can lag a few seconds behind a Trusted-Publishing - # upload -- retry rather than fail on that race, whether this run - # is the normal post-publish path or a workflow_dispatch backfill. + # upload -- retry rather than fail on that race. --only-binary + # forces a hard failure instead of silently falling back to an + # sdist if no compatible wheel is found. ok=false for attempt in 1 2 3 4 5 6; do - if python -m pip download "wave-sdk==${VERSION}" --no-deps -d sbom-src; then + if python -m pip download "wave-sdk==${VERSION}" --no-deps --only-binary=:all: -d sbom-src; then ok=true break fi @@ -383,7 +361,7 @@ jobs: sleep 10 done if [ "$ok" != "true" ]; then - echo "::error::could not download wave-sdk==${VERSION} from PyPI after 6 attempts" + echo "::error::could not download a wave-sdk==${VERSION} wheel from PyPI after 6 attempts" exit 1 fi ls -la sbom-src @@ -395,20 +373,67 @@ jobs: format: spdx-json output-file: wave_sdk-${{ needs.verify.outputs.version }}.spdx.json - - name: Validate and upload the SBOM to the GitHub Release for this tag + - name: Validate the SBOM has a non-empty packages[] env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ needs.resolve-ref.outputs.tag }} - VERSION: ${{ needs.verify.outputs.version }} + FILE: wave_sdk-${{ needs.verify.outputs.version }}.spdx.json run: | set -euo pipefail - FILE="wave_sdk-${VERSION}.spdx.json" test -s "$FILE" || { echo "::error::${FILE} is missing or empty"; exit 1; } python3 -c " - import json, sys - d = json.load(open('${FILE}')) + import json, os + d = json.load(open(os.environ['FILE'])) assert d.get('packages'), 'SBOM has no packages[]' print('SBOM OK:', d.get('spdxVersion'), '-', len(d['packages']), 'package(s)') " - gh release upload "$TAG" "$FILE" --repo "${{ github.repository }}" --clobber - echo "::notice::uploaded ${FILE} to release ${TAG}" + + - name: Upload SBOM as a workflow artifact (for the release job to attach) + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sbom-${{ needs.resolve-ref.outputs.tag }} + path: wave_sdk-${{ needs.verify.outputs.version }}.spdx.json + retention-days: 14 + + release: + name: create GitHub Release + needs: [resolve-ref, publish, sbom] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write # create/upload the Release for this tag -- nothing else + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ needs.resolve-ref.outputs.sha }} + persist-credentials: false + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist-${{ needs.resolve-ref.outputs.tag }} + path: dist + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: sbom-${{ needs.resolve-ref.outputs.tag }} + path: sbom + + - name: Create or update the GitHub Release for this tag (idempotent) + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.resolve-ref.outputs.tag }} + run: | + set -euo pipefail + shopt -s nullglob + ASSETS=(dist/* sbom/*) + if [ "${#ASSETS[@]}" -eq 0 ]; then + echo "::error::no dist/ or sbom/ files to attach to the release" + exit 1 + fi + if gh release view "$TAG" >/dev/null 2>&1; then + echo "::notice::release $TAG already exists -- uploading dist+SBOM assets (clobber) instead of creating" + gh release upload "$TAG" "${ASSETS[@]}" --clobber + else + gh release create "$TAG" "${ASSETS[@]}" \ + --title "wave-sdk $TAG" \ + --generate-notes + fi