Skip to content

Commit 08c7c30

Browse files
stefanhuberclaude
andauthored
feat: add release pipeline with PyPI publishing and dynamic versioning (#2)
Add GitHub Actions release workflow triggered on GitHub Release publish events. The pipeline runs lint, format check, and test matrix before building and publishing to PyPI via Trusted Publisher OIDC with Sigstore signing. Switch from hardcoded version to hatch-vcs for automatic git-tag-based versioning. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e35c4e6 commit 08c7c30

12 files changed

Lines changed: 356 additions & 2 deletions

File tree

.github/workflows/release.yml

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: Release Pipeline
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
jobs:
8+
lint:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
- uses: actions/setup-python@v5
13+
with:
14+
python-version: "3.13"
15+
cache: pip
16+
- run: pip install -e ".[dev]"
17+
- run: ruff check .
18+
- run: ruff format --check .
19+
20+
test:
21+
needs: lint
22+
runs-on: ubuntu-latest
23+
strategy:
24+
matrix:
25+
python-version: ["3.10", "3.12", "3.13"]
26+
steps:
27+
- uses: actions/checkout@v4
28+
- uses: actions/setup-python@v5
29+
with:
30+
python-version: ${{ matrix.python-version }}
31+
cache: pip
32+
- run: pip install -e ".[dev]"
33+
- run: pytest
34+
35+
build:
36+
needs: test
37+
runs-on: ubuntu-latest
38+
steps:
39+
- uses: actions/checkout@v4
40+
with:
41+
fetch-depth: 0
42+
- uses: actions/setup-python@v5
43+
with:
44+
python-version: "3.13"
45+
cache: pip
46+
- run: pip install build
47+
- run: python -m build
48+
- uses: actions/upload-artifact@v4
49+
with:
50+
name: dist
51+
path: dist/
52+
53+
publish:
54+
needs: build
55+
runs-on: ubuntu-latest
56+
environment: pypi
57+
permissions:
58+
id-token: write
59+
steps:
60+
- uses: actions/download-artifact@v4
61+
with:
62+
name: dist
63+
path: dist/
64+
- uses: pypa/gh-action-pypi-publish@release/v1
65+
with:
66+
attestations: true

CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,9 @@ engraft apply --template <file> --values <file> # run the tool
3535
- Linting: ruff (rules: E, F, I, UP, B)
3636
- Testing: pytest, tests mirror src structure (`tests/test_actions/`)
3737
- Line length: 88 characters
38+
- Versioning: hatch-vcs (version derived from git tags, not hardcoded)
39+
40+
## CI/CD
41+
42+
- **PR pipeline** (`.github/workflows/pr.yml`): lint, format check, test matrix (3.10, 3.12, 3.13)
43+
- **Release pipeline** (`.github/workflows/release.yml`): triggered on GitHub Release publish → lint → format check → test matrix → build → publish to PyPI (Trusted Publisher OIDC + Sigstore signing)

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,16 @@ Replace an entire file with a source file referenced by a variable.
135135

136136
The variable value is a path relative to the values file directory. Useful for binary files like images.
137137

138+
## Releasing
139+
140+
Versioning is automatic via [hatch-vcs](https://github.com/ofek/hatch-vcs) — the package version is derived from git tags.
141+
142+
To publish a new release:
143+
144+
1. Create a GitHub Release with a tag matching `vX.Y.Z` (e.g., `v0.2.0`)
145+
2. The release pipeline automatically runs lint, format check, and tests
146+
3. If all checks pass, the package is built and published to PyPI with Sigstore signing
147+
138148
## Development
139149

140150
```bash
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-04-04
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
## Context
2+
3+
The project uses hatchling for building and has an existing PR pipeline (`.github/workflows/pr.yml`) that runs ruff lint, ruff format check, and pytest across Python 3.10/3.12/3.13. There is no release automation — publishing to PyPI is not yet set up. The version is hardcoded as `0.1.0` in `pyproject.toml`.
4+
5+
## Goals / Non-Goals
6+
7+
**Goals:**
8+
- Automate PyPI publishing on every GitHub Release
9+
- Gate releases on the same quality checks as PRs (lint, format, test)
10+
- Use modern PyPI authentication (Trusted Publisher / OIDC) — no stored API tokens
11+
- Sign releases with Sigstore attestations
12+
- Derive package version from git tags via hatch-vcs
13+
14+
**Non-Goals:**
15+
- TestPyPI publishing (not needed, Trusted Publisher is already configured)
16+
- Changelog generation or release notes automation
17+
- Multi-platform wheel builds (pure Python package, universal wheel is sufficient)
18+
- Reusable workflow extraction (duplicating checks is simpler for this project size)
19+
20+
## Decisions
21+
22+
### 1. Trigger: `on: release: types: [published]`
23+
**Rationale**: The user wants to use GitHub's Release UI. This trigger fires when a release is published (which also creates a tag). Preferred over `on: push: tags` because it ties the workflow to an intentional release action, not just any tag push.
24+
25+
### 2. Trusted Publisher with OIDC (no API tokens)
26+
**Rationale**: PyPI's Trusted Publisher feature uses GitHub's OIDC identity provider. The workflow gets a short-lived token automatically — no secrets to rotate or leak. This is PyPI's recommended approach. Requires a GitHub environment named `pypi` with `id-token: write` permission.
27+
28+
### 3. Sigstore signing via `pypa/gh-action-pypi-publish`
29+
**Rationale**: The `pypa/gh-action-pypi-publish` action supports `attestations: true` which generates Sigstore attestations automatically. This provides cryptographic proof that the package was built from this repository. No additional tooling needed.
30+
31+
### 4. hatch-vcs for dynamic versioning
32+
**Rationale**: Eliminates version string duplication between `pyproject.toml` and git tags. The version is the single source of truth from the tag. Requires adding `hatch-vcs` as a build dependency and switching to `dynamic = ["version"]` in `pyproject.toml`. Alternative considered: manual version bumps with a CI validation step — rejected because it adds friction and is error-prone.
33+
34+
### 5. Duplicate quality checks (don't reuse PR workflow)
35+
**Rationale**: The quality gate jobs are small (lint + format + test). Extracting a reusable workflow adds indirection without meaningful DRY benefit at this project size. Duplication keeps the release workflow self-contained and easy to understand.
36+
37+
### 6. Build with `python -m build`
38+
**Rationale**: Standard Python packaging tool that produces both sdist and wheel. Works with hatchling backend. The `build` package is installed in the CI job, not added as a project dependency.
39+
40+
## Risks / Trade-offs
41+
42+
- **[Tag/version mismatch]** → hatch-vcs derives version from tags. If a release is created without a proper `v*` tag prefix, the version may be unexpected. Mitigation: document tag format convention (`vX.Y.Z`).
43+
- **[OIDC trust scope]** → The GitHub environment `pypi` must be correctly configured. If misconfigured, the publish step will fail with an auth error. Mitigation: clear error message from the action; one-time setup already done.
44+
- **[Duplicated CI logic]** → Quality checks exist in both `pr.yml` and `release.yml`. If one is updated, the other may drift. Mitigation: acceptable trade-off for simplicity; both files are small and co-located.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
## Why
2+
3+
The project has no automated release process. Publishing to PyPI is manual and error-prone, with no guarantee that quality checks pass before a release goes out. A release pipeline triggered by GitHub Releases ensures every published version is linted, tested, and signed.
4+
5+
## What Changes
6+
7+
- Add a GitHub Actions workflow (`release.yml`) triggered on `release: published` events that runs lint/format/test gates, builds the package, and publishes to PyPI via Trusted Publisher OIDC with Sigstore signing.
8+
- Switch from hardcoded version in `pyproject.toml` to `hatch-vcs` so the package version is derived automatically from git tags.
9+
- Add `build` as a build-time or CI dependency for producing sdist and wheel artifacts.
10+
11+
## Capabilities
12+
13+
### New Capabilities
14+
- `release-pipeline`: GitHub Actions workflow for automated quality-gated release and PyPI publishing
15+
- `dynamic-versioning`: Switch to hatch-vcs for git-tag-based version derivation
16+
17+
### Modified Capabilities
18+
- `pr-ci-pipeline`: No requirement change — the release pipeline duplicates the same checks independently
19+
20+
## Impact
21+
22+
- **Files added**: `.github/workflows/release.yml`
23+
- **Files modified**: `pyproject.toml` (versioning config, build dependencies)
24+
- **Dependencies added**: `hatch-vcs` (build-time), `build` (CI-only)
25+
- **External config required**: GitHub environment `pypi` must exist with OIDC trust; Trusted Publisher already configured on pypi.org
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Version is derived from git tags
4+
The package version SHALL be dynamically derived from git tags using `hatch-vcs` instead of being hardcoded in `pyproject.toml`.
5+
6+
#### Scenario: Tagged commit (release build)
7+
- **WHEN** the current commit has a tag matching `v*` (e.g., `v0.2.0`)
8+
- **THEN** the package version SHALL be the tag version without the `v` prefix (e.g., `0.2.0`)
9+
10+
#### Scenario: Untagged commit (development build)
11+
- **WHEN** the current commit does not have a version tag
12+
- **THEN** the package version SHALL be a dev version derived from the nearest tag (e.g., `0.2.0.dev3+gabcdef`)
13+
14+
### Requirement: pyproject.toml uses dynamic version field
15+
The `pyproject.toml` SHALL declare `dynamic = ["version"]` and remove the hardcoded `version` field. The `[tool.hatch.version]` section SHALL specify `source = "vcs"`.
16+
17+
#### Scenario: pyproject.toml configuration
18+
- **WHEN** inspecting `pyproject.toml`
19+
- **THEN** the `[project]` section SHALL contain `dynamic = ["version"]` and SHALL NOT contain a `version` key
20+
- **THEN** the `[tool.hatch.version]` section SHALL contain `source = "vcs"`
21+
22+
### Requirement: hatch-vcs is a build dependency
23+
The `hatch-vcs` package SHALL be listed in the `[build-system] requires` array.
24+
25+
#### Scenario: Build system includes hatch-vcs
26+
- **WHEN** inspecting `pyproject.toml` `[build-system]` section
27+
- **THEN** the `requires` array SHALL include `hatch-vcs`
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Release workflow triggers on GitHub Release publish
4+
The release workflow SHALL be triggered when a GitHub Release is published (`on: release: types: [published]`).
5+
6+
#### Scenario: Release is published via GitHub UI
7+
- **WHEN** a user publishes a GitHub Release
8+
- **THEN** the release workflow starts execution
9+
10+
#### Scenario: Draft release is created
11+
- **WHEN** a user creates a draft release without publishing
12+
- **THEN** the release workflow SHALL NOT trigger
13+
14+
### Requirement: Quality gate runs lint and format checks
15+
The release workflow SHALL run `ruff check .` and `ruff format --check .` as a quality gate before building.
16+
17+
#### Scenario: Lint check passes
18+
- **WHEN** all source files pass ruff lint rules
19+
- **THEN** the workflow proceeds to the test stage
20+
21+
#### Scenario: Lint or format check fails
22+
- **WHEN** any source file fails ruff lint or format check
23+
- **THEN** the workflow SHALL fail and NOT proceed to build or publish
24+
25+
### Requirement: Quality gate runs tests across Python matrix
26+
The release workflow SHALL run `pytest` across Python versions 3.10, 3.12, and 3.13.
27+
28+
#### Scenario: All tests pass on all Python versions
29+
- **WHEN** pytest passes on Python 3.10, 3.12, and 3.13
30+
- **THEN** the workflow proceeds to the build stage
31+
32+
#### Scenario: Tests fail on any Python version
33+
- **WHEN** pytest fails on any matrix version
34+
- **THEN** the workflow SHALL fail and NOT proceed to build or publish
35+
36+
### Requirement: Build produces sdist and wheel artifacts
37+
The release workflow SHALL build both an sdist and a wheel using `python -m build`.
38+
39+
#### Scenario: Successful build
40+
- **WHEN** the quality gate passes
41+
- **THEN** the workflow builds sdist and wheel artifacts in `dist/`
42+
43+
### Requirement: Publish to PyPI using Trusted Publisher OIDC
44+
The release workflow SHALL publish to PyPI using `pypa/gh-action-pypi-publish` with OIDC authentication (no API tokens). The job SHALL use the `pypi` GitHub environment.
45+
46+
#### Scenario: Successful publish
47+
- **WHEN** build artifacts exist and OIDC authentication succeeds
48+
- **THEN** the package is published to pypi.org
49+
50+
#### Scenario: OIDC authentication fails
51+
- **WHEN** the Trusted Publisher configuration is missing or misconfigured
52+
- **THEN** the publish step SHALL fail with an authentication error
53+
54+
### Requirement: Releases are signed with Sigstore attestations
55+
The release workflow SHALL enable Sigstore attestations (`attestations: true`) when publishing.
56+
57+
#### Scenario: Package is published with attestation
58+
- **WHEN** a package is successfully published to PyPI
59+
- **THEN** Sigstore attestations SHALL be generated for the published artifacts
60+
61+
### Requirement: Publish depends on all quality gates passing
62+
The publish job SHALL only run after lint, format, test, and build jobs all succeed.
63+
64+
#### Scenario: Quality gate fails
65+
- **WHEN** any quality gate job (lint, test) fails
66+
- **THEN** the build and publish jobs SHALL NOT execute
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
## 1. Dynamic Versioning
2+
3+
- [x] 1.1 Update `pyproject.toml`: add `hatch-vcs` to `[build-system] requires`
4+
- [x] 1.2 Update `pyproject.toml`: remove hardcoded `version`, add `dynamic = ["version"]`, add `[tool.hatch.version]` with `source = "vcs"`
5+
6+
## 2. Release Workflow
7+
8+
- [x] 2.1 Create `.github/workflows/release.yml` with `on: release: types: [published]` trigger
9+
- [x] 2.2 Add lint job: checkout, setup Python 3.13, install deps, run `ruff check .` and `ruff format --check .`
10+
- [x] 2.3 Add test job (depends on lint): checkout, setup Python matrix (3.10, 3.12, 3.13), install deps, run `pytest`
11+
- [x] 2.4 Add build job (depends on test): checkout, setup Python 3.13, install `build`, run `python -m build`, upload `dist/` as artifact
12+
- [x] 2.5 Add publish job (depends on build): use `pypi` environment, download artifact, publish with `pypa/gh-action-pypi-publish` with `attestations: true`, set `id-token: write` permission
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Version is derived from git tags
4+
The package version SHALL be dynamically derived from git tags using `hatch-vcs` instead of being hardcoded in `pyproject.toml`.
5+
6+
#### Scenario: Tagged commit (release build)
7+
- **WHEN** the current commit has a tag matching `v*` (e.g., `v0.2.0`)
8+
- **THEN** the package version SHALL be the tag version without the `v` prefix (e.g., `0.2.0`)
9+
10+
#### Scenario: Untagged commit (development build)
11+
- **WHEN** the current commit does not have a version tag
12+
- **THEN** the package version SHALL be a dev version derived from the nearest tag (e.g., `0.2.0.dev3+gabcdef`)
13+
14+
### Requirement: pyproject.toml uses dynamic version field
15+
The `pyproject.toml` SHALL declare `dynamic = ["version"]` and remove the hardcoded `version` field. The `[tool.hatch.version]` section SHALL specify `source = "vcs"`.
16+
17+
#### Scenario: pyproject.toml configuration
18+
- **WHEN** inspecting `pyproject.toml`
19+
- **THEN** the `[project]` section SHALL contain `dynamic = ["version"]` and SHALL NOT contain a `version` key
20+
- **THEN** the `[tool.hatch.version]` section SHALL contain `source = "vcs"`
21+
22+
### Requirement: hatch-vcs is a build dependency
23+
The `hatch-vcs` package SHALL be listed in the `[build-system] requires` array.
24+
25+
#### Scenario: Build system includes hatch-vcs
26+
- **WHEN** inspecting `pyproject.toml` `[build-system]` section
27+
- **THEN** the `requires` array SHALL include `hatch-vcs`

0 commit comments

Comments
 (0)