Skip to content

Commit fedcce6

Browse files
ci: tag-driven release pipeline with TestPyPI RC track (#11)
* ci: tag-driven release pipeline with TestPyPI RC track Add a Release workflow triggered by tag push. Tag pattern decides the publish target: v0.5.0-rc1, v0.5.0-rc2, ... → TestPyPI (staging / dry-run) v0.5.0 → PyPI + GitHub Release Both publish paths use Trusted Publishing (OIDC) via pypa/gh-action-pypi-publish — no long-lived API tokens. The pypi environment is the recommended place to add a "required reviewers" protection rule so real-PyPI uploads pause for manual approval. Also extend ci.yml with a `uv build` step at the end of the test job. Verifies the package builds on every PR/push so packaging bugs surface before they hit a release tag, not after. First publish from either workflow needs a one-time pending-publisher setup on TestPyPI and PyPI plus the testpypi/pypi GitHub Environments to exist; documented in the PR description. * ci: harden release pipeline gates per PR review Two release-safety fixes: 1. publish-pypi and github-release now gate on `!contains(ref_name, '-')` instead of `!contains(ref_name, '-rc')`. The trigger pattern matches any v[digits].[digits].[digits]* tag, so a `v0.5.0-beta1` tag would previously skip the -rc check and land on real PyPI. Tightening to "no `-` at all" makes any non-rc pre-release tag a no-op (failsafe). 2. Add a "Verify pyproject.toml version matches tag" step at the top of the test job. Reads pyproject's version, compares to the pushed tag (with leading `v` stripped), and fails fast if they don't match. Both sides go through `packaging.version.Version` so PEP 440 equivalences like "0.5.0-rc1" ≡ "0.5.0rc1" are accepted. Catches the "tagged but forgot to bump pyproject" mistake before any build runs.
1 parent 37e0bfd commit fedcce6

2 files changed

Lines changed: 181 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,10 @@ jobs:
4949

5050
- name: Run tests (pytest)
5151
run: uv run pytest -q
52+
53+
# Build the wheel + sdist so packaging bugs (missing files in the
54+
# wheel, malformed metadata, README that won't render on PyPI)
55+
# surface here rather than waiting for a release tag. No upload —
56+
# the artifact is discarded after the job ends.
57+
- name: Build package
58+
run: uv build

.github/workflows/release.yml

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
name: Release
2+
3+
# Tag conventions:
4+
# - v0.5.0-rc1, v0.5.0-rc2, ... → TestPyPI only (staging / dry-run)
5+
# - v0.5.0 → PyPI + GitHub Release
6+
#
7+
# pyproject.toml's version field MUST match the tag (minus the leading "v").
8+
# PEP 440 normalizes "0.5.0-rc1" to "0.5.0rc1" at build time, so either form
9+
# is accepted in pyproject; the tag uses the dash form for readability.
10+
on:
11+
push:
12+
tags:
13+
- "v[0-9]*.[0-9]*.[0-9]*"
14+
15+
# Read-only by default. Each publishing job opts in to id-token: write
16+
# (Trusted Publishing OIDC); the github-release job opts in to contents:
17+
# write to create the release.
18+
permissions:
19+
contents: read
20+
21+
jobs:
22+
test:
23+
runs-on: ubuntu-latest
24+
steps:
25+
- name: Checkout
26+
uses: actions/checkout@v4
27+
with:
28+
# Conformance fixtures live in the openarmature-spec submodule.
29+
submodules: recursive
30+
31+
- name: Install uv
32+
uses: astral-sh/setup-uv@v4
33+
with:
34+
enable-cache: true
35+
36+
- name: Sync deps
37+
run: uv sync --frozen
38+
39+
# Fail fast if pyproject.toml's version doesn't match the pushed
40+
# tag. Both sides go through `packaging.version.Version` so PEP 440
41+
# equivalences like "0.5.0-rc1" ≡ "0.5.0rc1" are accepted.
42+
- name: Verify pyproject.toml version matches tag
43+
run: |
44+
uv run python <<'PY'
45+
import os
46+
import sys
47+
import tomllib
48+
49+
from packaging.version import Version
50+
51+
tag = os.environ["GITHUB_REF_NAME"].removeprefix("v")
52+
with open("pyproject.toml", "rb") as f:
53+
pyproj = tomllib.load(f)["project"]["version"]
54+
55+
if Version(pyproj) != Version(tag):
56+
sys.exit(
57+
f"::error::version mismatch: pyproject={pyproj!r} vs "
58+
f"tag={tag!r} (normalized: pyproject={Version(pyproj)} "
59+
f"tag={Version(tag)})"
60+
)
61+
print(
62+
f"OK: pyproject={pyproj} matches tag={tag} "
63+
f"(normalized: {Version(pyproj)})"
64+
)
65+
PY
66+
67+
- name: Lint (ruff check)
68+
run: uv run ruff check .
69+
70+
- name: Format check (ruff format --check)
71+
run: uv run ruff format --check .
72+
73+
- name: Type check (pyright)
74+
run: uv run pyright src/ tests/
75+
76+
- name: Run tests (pytest)
77+
run: uv run pytest -q
78+
79+
build:
80+
needs: test
81+
runs-on: ubuntu-latest
82+
steps:
83+
- name: Checkout
84+
uses: actions/checkout@v4
85+
with:
86+
submodules: recursive
87+
88+
- name: Install uv
89+
uses: astral-sh/setup-uv@v4
90+
with:
91+
enable-cache: true
92+
93+
- name: Sync deps
94+
run: uv sync --frozen
95+
96+
- name: Build package
97+
run: uv build
98+
99+
- name: Upload dist artifact
100+
uses: actions/upload-artifact@v4
101+
with:
102+
name: dist
103+
path: dist/
104+
105+
# Pre-release tags (vX.Y.Z-rc*) publish to TestPyPI for verification.
106+
# The job is gated on the tag's name containing "-rc"; non-RC tags skip
107+
# this job and proceed to publish-pypi instead.
108+
publish-testpypi:
109+
needs: build
110+
if: contains(github.ref_name, '-rc')
111+
runs-on: ubuntu-latest
112+
environment: testpypi
113+
permissions:
114+
id-token: write
115+
steps:
116+
- name: Download dist artifact
117+
uses: actions/download-artifact@v4
118+
with:
119+
name: dist
120+
path: dist/
121+
122+
- name: Publish to TestPyPI
123+
uses: pypa/gh-action-pypi-publish@release/v1
124+
with:
125+
repository-url: https://test.pypi.org/legacy/
126+
127+
# Real-release tags (vX.Y.Z, no suffix) publish to PyPI. Gated on the
128+
# tag containing NO `-` so that any pre-release suffix (`-rc`, `-beta`,
129+
# `-alpha`, `-dev`, ...) is failsafe — only `-rc` lands on TestPyPI;
130+
# the rest do nothing and require an explicit retag, preventing an
131+
# unintended PyPI upload from a misnamed tag. The pypi environment is
132+
# the recommended place to add a "required reviewers" protection rule
133+
# so the job pauses for manual approval before any real-PyPI upload.
134+
publish-pypi:
135+
needs: build
136+
if: ${{ !contains(github.ref_name, '-') }}
137+
runs-on: ubuntu-latest
138+
environment: pypi
139+
permissions:
140+
id-token: write
141+
steps:
142+
- name: Download dist artifact
143+
uses: actions/download-artifact@v4
144+
with:
145+
name: dist
146+
path: dist/
147+
148+
- name: Publish to PyPI
149+
uses: pypa/gh-action-pypi-publish@release/v1
150+
151+
# GitHub Release with auto-generated notes from commits since the last
152+
# tag. Only fires on real releases (no suffix); pre-release tags leave
153+
# no GitHub Release behind.
154+
github-release:
155+
needs: publish-pypi
156+
if: ${{ !contains(github.ref_name, '-') }}
157+
runs-on: ubuntu-latest
158+
permissions:
159+
contents: write
160+
steps:
161+
- name: Checkout
162+
uses: actions/checkout@v4
163+
164+
- name: Download dist artifact
165+
uses: actions/download-artifact@v4
166+
with:
167+
name: dist
168+
path: dist/
169+
170+
- name: Create GitHub Release
171+
uses: softprops/action-gh-release@v2
172+
with:
173+
files: dist/*
174+
generate_release_notes: true

0 commit comments

Comments
 (0)