From c196f9ad20be9ca2a12576c4d32e9973145c553d Mon Sep 17 00:00:00 2001 From: "const.koutsakis@aurecongroup.com" Date: Mon, 27 Apr 2026 03:17:17 +1000 Subject: [PATCH] chore: release workflow (tag-triggered, SBOM, GH Release publish) (#13) Port .github/workflows/release.yml from Teller; bump python-version to 3.14, update setup-uv pin to v8 commit, checkout pin to v4 latest. Add a docker login + push pair so the built image lands at ghcr.io//: AND :latest (acceptance criterion: image must publish to GHCR). Compute the lowercase repo path via parameter expansion since GHCR rejects mixed-case path components. Permissions: contents:write + packages:write. SBOM pinned to cyclonedx-bom==7.3.0 in a uvx venv so the generator itself doesn't end up in the SBOM. Sanity-check the JSON before upload. Closes #13 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 115 ++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..577bef4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,115 @@ +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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + 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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + 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