Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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/<owner>/<repo>

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 `<image>:<version>` and `<image>: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
Loading