name: Release # Tag-triggered release pipeline. Runs on every annotated tag matching # `v*.*.*`. Builds the production Docker image, pushes it to GHCR, generates # a CycloneDX SBOM from the locked dependency set, and publishes the GitHub # Release using release-drafter's pre-drafted body so notes match the merged # PR titles. # # Auth: uses GITHUB_TOKEN — no PAT required. # Storage: SBOM JSON is attached to the release, NOT uploaded as an Actions # artifact (account-wide artifact storage quota lives a precarious life). on: push: tags: - "v*.*.*" permissions: contents: write # required to create the GitHub Release packages: write # push the image to ghcr.io// jobs: release: name: Build, SBOM, Release runs-on: ubuntu-latest steps: # Actions are SHA-pinned because this workflow has elevated permissions # (contents: write + packages: write). Bump SHAs with the # vX.Y.Z # annotation when a new release lands and you've reviewed the diff. - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: "3.14" # Production-only sync — matches the Dockerfile's `uv sync --frozen # --no-dev` so the SBOM walks exactly the wheel set the image loads. # Including dev deps here would publish a CycloneDX document that # claims pytest/mypy/ruff are in the image and undermine the # SBOM-as-attestation property. - name: Install project (production deps only) run: uv sync --frozen --no-dev - name: Resolve image tags id: tags run: | VERSION="${GITHUB_REF_NAME#v}" IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}" { echo "version=${VERSION}" echo "image=${IMAGE}" echo "tag_version=${IMAGE}:${VERSION}" echo "tag_latest=${IMAGE}:latest" } >> "$GITHUB_OUTPUT" # Build the same Dockerfile used in `Container image scan (trivy)` — # tags both `:` and `:latest`. - name: Build Docker image run: | docker build \ -t "${{ steps.tags.outputs.tag_version }}" \ -t "${{ steps.tags.outputs.tag_latest }}" \ . - name: Log in to GHCR uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Push image run: | docker push "${{ steps.tags.outputs.tag_version }}" docker push "${{ steps.tags.outputs.tag_latest }}" # CycloneDX SBOM from the locked production wheel set. cyclonedx-bom # runs in an isolated `uvx --from` venv so it does not bleed into # `.venv` (that would put the SBOM-generation tooling in the SBOM). # cyclonedx-py then targets the project venv, walking only the prod # deps the image loads. # # Pinned to an exact version (not >=) so the SBOM bytes are # reproducible across release runs. - name: Generate SBOM (CycloneDX) run: | uvx --from "cyclonedx-bom==7.3.0" \ cyclonedx-py environment .venv > sbom.json # Sanity-check: reject zero-byte / malformed JSON before attaching. python -c "import json; data = json.load(open('sbom.json')); assert data.get('bomFormat') == 'CycloneDX', 'SBOM missing bomFormat field'" echo "SBOM components: $(python -c "import json; print(len(json.load(open('sbom.json')).get('components', [])))")" # release-drafter has been keeping a draft under v$VERSION updated on # every merge to main, so `gh release edit` promotes the existing # draft and attaches the SBOM. If no draft exists yet (first release # after the workflow lands), `gh release create` falls back to # auto-generated notes. - name: Publish release with SBOM env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="${GITHUB_REF_NAME}" if gh release view "$TAG" >/dev/null 2>&1; then echo "Promoting existing draft → published" gh release edit "$TAG" --draft=false gh release upload "$TAG" sbom.json --clobber else echo "No draft found — creating release with auto-generated notes" gh release create "$TAG" \ --title "$TAG" \ --generate-notes \ sbom.json fi