Skip to content

Commit 021ab04

Browse files
committed
ci: add Trusted Publisher workflow for PyPI release-pypi.yml (PEP 740)
OIDC + PyPI Trusted Publishers — no long-lived API token in repo secrets. Every publish mints a short-lived OIDC token at workflow runtime and exchanges it with PyPI's Trusted Publisher endpoint for a one-time upload privilege scoped to this exact workflow run. This was on the 3.1 deferred list; shipping it now in the 3.0.0b1 release cycle because: 1. Same effort whether we set it up now or for 3.1 2. b1 → b2 → … → stable will all benefit from the same workflow 3. Match the npm side, which has used trusted publishers + sigstore provenance since SDK 4.0.0 (memory note: never local `npm publish` again). Workflow shape: push tag v* → build wheel + sdist, auto-publish to TestPyPI workflow_dispatch target=pypi → live PyPI publish, gated by the `pypi` environment's reviewer approval Pre-release detection (PEP 440): version containing a, b, or rc is treated as a pre-release. Beta tags can NOT be auto-promoted to live PyPI — every PyPI publish requires explicit workflow_dispatch with target=pypi. Documented in workflow comments: one-time setup steps on PyPI side (TestPyPI + PyPI trusted publisher entries) + GitHub side (testpypi and pypi environments).
1 parent 2ebec7e commit 021ab04

2 files changed

Lines changed: 186 additions & 3 deletions

File tree

.github/workflows/release-pypi.yml

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
name: Publish to PyPI
2+
3+
# OIDC trusted-publisher workflow (PEP 740 + PyPI Trusted Publishers).
4+
# No long-lived API tokens stored in repo secrets — GitHub mints a
5+
# short-lived OIDC token at runtime, the pypa/gh-action-pypi-publish
6+
# action exchanges it with PyPI's Trusted Publisher endpoint for a
7+
# one-time upload privilege scoped to this exact workflow run.
8+
#
9+
# ──────────────────────────────────────────────────────────────────────────────
10+
# PyPI side — ONE-TIME SETUP per publisher
11+
#
12+
# TestPyPI (https://test.pypi.org/manage/account/publishing/):
13+
# - "Add a new pending publisher"
14+
# - PyPI project name: agirails
15+
# - Owner: agirails
16+
# - Repository name: sdk-python
17+
# - Workflow filename: release-pypi.yml
18+
# - Environment name: testpypi
19+
#
20+
# PyPI (https://pypi.org/manage/project/agirails/settings/publishing/):
21+
# - "Add a new trusted publisher" (project already exists from 2.0.0+)
22+
# - Owner: agirails
23+
# - Repository name: sdk-python
24+
# - Workflow filename: release-pypi.yml
25+
# - Environment name: pypi
26+
#
27+
# GitHub side — ONE-TIME SETUP
28+
# Settings → Environments:
29+
# - Create "testpypi" (no protections needed)
30+
# - Create "pypi" (recommend: required reviewers = repo admin)
31+
#
32+
# ──────────────────────────────────────────────────────────────────────────────
33+
# How to release
34+
#
35+
# Beta (b1, b2, …, rcN):
36+
# git tag v3.0.0b1
37+
# git push origin v3.0.0b1
38+
# → Workflow auto-publishes to TestPyPI only.
39+
#
40+
# Stable:
41+
# git tag v3.0.0
42+
# git push origin v3.0.0
43+
# → Workflow publishes to TestPyPI automatically.
44+
# → For PyPI: open Actions tab → "Publish to PyPI" → "Run workflow"
45+
# → target=pypi → click Run. Environment gate requires reviewer
46+
# approval before the upload step actually runs.
47+
#
48+
# Emergency (manual trigger for either target):
49+
# Actions tab → workflow_dispatch with target=testpypi or pypi
50+
#
51+
# Pre-release detection: a version is considered pre-release if it
52+
# contains "a", "b", or "rc" per PEP 440 (e.g. 3.0.0b1, 3.0.0rc2).
53+
54+
on:
55+
push:
56+
tags:
57+
- 'v*'
58+
workflow_dispatch:
59+
inputs:
60+
target:
61+
description: 'Publish target'
62+
required: true
63+
type: choice
64+
default: testpypi
65+
options:
66+
- testpypi
67+
- pypi
68+
69+
permissions:
70+
contents: read
71+
72+
jobs:
73+
build:
74+
name: Build wheel + sdist
75+
runs-on: ubuntu-latest
76+
outputs:
77+
version: ${{ steps.version.outputs.version }}
78+
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
79+
steps:
80+
- uses: actions/checkout@v4
81+
82+
- name: Set up Python
83+
uses: actions/setup-python@v5
84+
with:
85+
python-version: '3.11'
86+
87+
- name: Install build
88+
run: python -m pip install --upgrade build
89+
90+
- name: Build distribution
91+
run: python -m build
92+
93+
- name: Extract version
94+
id: version
95+
run: |
96+
VERSION=$(python -c "import tomllib; print(tomllib.loads(open('pyproject.toml','rb').read().decode())['project']['version'])")
97+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
98+
# PEP 440 pre-release markers.
99+
if [[ "$VERSION" =~ (a|b|rc)[0-9]+ ]]; then
100+
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
101+
else
102+
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
103+
fi
104+
echo "Built version: $VERSION"
105+
106+
- name: Verify tag matches version
107+
if: github.event_name == 'push'
108+
run: |
109+
TAG="${GITHUB_REF#refs/tags/v}"
110+
VERSION="${{ steps.version.outputs.version }}"
111+
if [[ "$TAG" != "$VERSION" ]]; then
112+
echo "::error::Tag v$TAG does not match pyproject.toml version $VERSION"
113+
exit 1
114+
fi
115+
116+
- name: Upload artifacts
117+
uses: actions/upload-artifact@v4
118+
with:
119+
name: dist
120+
path: dist/
121+
if-no-files-found: error
122+
123+
testpypi:
124+
name: Publish to TestPyPI
125+
needs: build
126+
runs-on: ubuntu-latest
127+
# Auto on tag push (any version). Manual: target=testpypi.
128+
if: |
129+
github.event_name == 'push' ||
130+
(github.event_name == 'workflow_dispatch' && github.event.inputs.target == 'testpypi')
131+
environment:
132+
name: testpypi
133+
url: https://test.pypi.org/project/agirails/${{ needs.build.outputs.version }}/
134+
permissions:
135+
id-token: write # OIDC token issuance
136+
steps:
137+
- name: Download artifacts
138+
uses: actions/download-artifact@v4
139+
with:
140+
name: dist
141+
path: dist/
142+
143+
- name: Publish to TestPyPI
144+
uses: pypa/gh-action-pypi-publish@release/v1
145+
with:
146+
repository-url: https://test.pypi.org/legacy/
147+
# Skip if a duplicate upload would error — useful when re-running
148+
# a failed workflow that already uploaded.
149+
skip-existing: true
150+
151+
pypi:
152+
name: Publish to PyPI
153+
needs: build
154+
runs-on: ubuntu-latest
155+
# Live PyPI: workflow_dispatch with target=pypi (forces manual gate even
156+
# for stable tags). Beta versions can NOT be auto-promoted by accident.
157+
if: |
158+
github.event_name == 'workflow_dispatch' && github.event.inputs.target == 'pypi'
159+
environment:
160+
name: pypi
161+
url: https://pypi.org/project/agirails/${{ needs.build.outputs.version }}/
162+
permissions:
163+
id-token: write
164+
steps:
165+
- name: Download artifacts
166+
uses: actions/download-artifact@v4
167+
with:
168+
name: dist
169+
path: dist/
170+
171+
- name: Publish to PyPI
172+
uses: pypa/gh-action-pypi-publish@release/v1
173+
with:
174+
skip-existing: true

CHANGELOG.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
> Subsequent betas bump the suffix (``3.0.0b2``, ``3.0.0b3``, …).
1818
> Stable ``3.0.0`` ships after at least one beta cycle without
1919
> integrator-reported regressions.
20+
>
21+
> **Trusted Publisher workflow shipped this release** (pulled in
22+
> from the 3.1+ deferred list): ``.github/workflows/release-pypi.yml``
23+
> uses GitHub Actions OIDC + PyPI Trusted Publishers per PEP 740.
24+
> No long-lived API token is stored in repo secrets; every publish
25+
> mints a fresh short-lived credential at workflow runtime. Beta
26+
> tags auto-publish to TestPyPI; live PyPI publishes require an
27+
> explicit ``workflow_dispatch`` with ``target=pypi`` + the
28+
> ``pypi`` environment's reviewer approval.
2029
2130
## [3.0.0] — 2026-05-20
2231

@@ -348,9 +357,9 @@ swaps needed only if:
348357
- **Pydantic at HTTP/wire boundaries** — current builders + receipts
349358
use dataclasses. Pydantic gives nicer parse errors at the
350359
agirails.app / `actp serve` ingress; tracked as a 3.1 refactor.
351-
- **Workflow-attested PyPI publish (PEP 740)** — Python equivalent of
352-
the npm OIDC + sigstore + SLSA provenance chain. Current 3.0.0
353-
ships through the standard `poetry publish` API-token path.
360+
- ~~**Workflow-attested PyPI publish (PEP 740)** — Python equivalent
361+
of the npm OIDC + sigstore + SLSA provenance chain.~~ **Shipped
362+
earlier than planned in 3.0.0b1** — see the beta section above.
354363

355364
---
356365

0 commit comments

Comments
 (0)