Skip to content

Commit 6bafc1d

Browse files
constkclaude
andauthored
chore: release workflow (tag-triggered, SBOM, GH Release publish) (#13) (#53)
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/<owner>/<repo>:<version> 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) <noreply@anthropic.com>
1 parent 7290e10 commit 6bafc1d

1 file changed

Lines changed: 115 additions & 0 deletions

File tree

.github/workflows/release.yml

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
name: Release
2+
3+
# Tag-triggered release pipeline. Runs on every annotated tag matching
4+
# `v*.*.*`. Builds the production Docker image, pushes it to GHCR, generates
5+
# a CycloneDX SBOM from the locked dependency set, and publishes the GitHub
6+
# Release using release-drafter's pre-drafted body so notes match the merged
7+
# PR titles.
8+
#
9+
# Auth: uses GITHUB_TOKEN — no PAT required.
10+
# Storage: SBOM JSON is attached to the release, NOT uploaded as an Actions
11+
# artifact (account-wide artifact storage quota lives a precarious life).
12+
13+
on:
14+
push:
15+
tags:
16+
- "v*.*.*"
17+
18+
permissions:
19+
contents: write # required to create the GitHub Release
20+
packages: write # push the image to ghcr.io/<owner>/<repo>
21+
22+
jobs:
23+
release:
24+
name: Build, SBOM, Release
25+
runs-on: ubuntu-latest
26+
steps:
27+
# Actions are SHA-pinned because this workflow has elevated permissions
28+
# (contents: write + packages: write). Bump SHAs with the # vX.Y.Z
29+
# annotation when a new release lands and you've reviewed the diff.
30+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
31+
32+
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
33+
34+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
35+
with:
36+
python-version: "3.14"
37+
38+
# Production-only sync — matches the Dockerfile's `uv sync --frozen
39+
# --no-dev` so the SBOM walks exactly the wheel set the image loads.
40+
# Including dev deps here would publish a CycloneDX document that
41+
# claims pytest/mypy/ruff are in the image and undermine the
42+
# SBOM-as-attestation property.
43+
- name: Install project (production deps only)
44+
run: uv sync --frozen --no-dev
45+
46+
- name: Resolve image tags
47+
id: tags
48+
run: |
49+
VERSION="${GITHUB_REF_NAME#v}"
50+
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
51+
{
52+
echo "version=${VERSION}"
53+
echo "image=${IMAGE}"
54+
echo "tag_version=${IMAGE}:${VERSION}"
55+
echo "tag_latest=${IMAGE}:latest"
56+
} >> "$GITHUB_OUTPUT"
57+
58+
# Build the same Dockerfile used in `Container image scan (trivy)` —
59+
# tags both `<image>:<version>` and `<image>:latest`.
60+
- name: Build Docker image
61+
run: |
62+
docker build \
63+
-t "${{ steps.tags.outputs.tag_version }}" \
64+
-t "${{ steps.tags.outputs.tag_latest }}" \
65+
.
66+
67+
- name: Log in to GHCR
68+
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
69+
with:
70+
registry: ghcr.io
71+
username: ${{ github.actor }}
72+
password: ${{ secrets.GITHUB_TOKEN }}
73+
74+
- name: Push image
75+
run: |
76+
docker push "${{ steps.tags.outputs.tag_version }}"
77+
docker push "${{ steps.tags.outputs.tag_latest }}"
78+
79+
# CycloneDX SBOM from the locked production wheel set. cyclonedx-bom
80+
# runs in an isolated `uvx --from` venv so it does not bleed into
81+
# `.venv` (that would put the SBOM-generation tooling in the SBOM).
82+
# cyclonedx-py then targets the project venv, walking only the prod
83+
# deps the image loads.
84+
#
85+
# Pinned to an exact version (not >=) so the SBOM bytes are
86+
# reproducible across release runs.
87+
- name: Generate SBOM (CycloneDX)
88+
run: |
89+
uvx --from "cyclonedx-bom==7.3.0" \
90+
cyclonedx-py environment .venv > sbom.json
91+
# Sanity-check: reject zero-byte / malformed JSON before attaching.
92+
python -c "import json; data = json.load(open('sbom.json')); assert data.get('bomFormat') == 'CycloneDX', 'SBOM missing bomFormat field'"
93+
echo "SBOM components: $(python -c "import json; print(len(json.load(open('sbom.json')).get('components', [])))")"
94+
95+
# release-drafter has been keeping a draft under v$VERSION updated on
96+
# every merge to main, so `gh release edit` promotes the existing
97+
# draft and attaches the SBOM. If no draft exists yet (first release
98+
# after the workflow lands), `gh release create` falls back to
99+
# auto-generated notes.
100+
- name: Publish release with SBOM
101+
env:
102+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
103+
run: |
104+
TAG="${GITHUB_REF_NAME}"
105+
if gh release view "$TAG" >/dev/null 2>&1; then
106+
echo "Promoting existing draft → published"
107+
gh release edit "$TAG" --draft=false
108+
gh release upload "$TAG" sbom.json --clobber
109+
else
110+
echo "No draft found — creating release with auto-generated notes"
111+
gh release create "$TAG" \
112+
--title "$TAG" \
113+
--generate-notes \
114+
sbom.json
115+
fi

0 commit comments

Comments
 (0)