Claude commited on
Commit
2091813
·
unverified ·
1 Parent(s): f8fc53f

Add tag-triggered PyPI release workflow with OIDC trusted publishing

Browse files

Release on `v*.*.*` tag push:
build → publish-testpypi → smoke-test → publish-pypi → github-release

Uses PyPI Trusted Publishing (OIDC), so no long-lived API tokens in
secrets. The pyproject.toml version is validated against the tag in
the build step so a mismatched tag fails fast before we burn an
immutable TestPyPI version.

`workflow_dispatch` supports a `testpypi_only` dry-run path for
testing the pipeline without touching real PyPI; pairs with
scaffolded `pypi` and `testpypi` GitHub environments (both need
`id-token: write`). One-time setup required on pypi.org +
test.pypi.org to register the trusted publisher before first run —
documented inline in the workflow header.

Files changed (1) hide show
  1. .github/workflows/release.yml +199 -0
.github/workflows/release.yml ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Release to PyPI
2
+
3
+ # Tag-triggered release for `qcal-copilot`. Pipeline:
4
+ #
5
+ # build → publish-testpypi → smoke-test-testpypi → publish-pypi → github-release
6
+ #
7
+ # Uses PyPI Trusted Publishing (OIDC), so no long-lived tokens in secrets.
8
+ # BEFORE this workflow can publish, configure the trusted publisher once on
9
+ # each index:
10
+ #
11
+ # TestPyPI: https://test.pypi.org/manage/account/publishing/
12
+ # PyPI: https://pypi.org/manage/account/publishing/
13
+ #
14
+ # For both, register:
15
+ # repo owner: athurlow
16
+ # repo name: qcal
17
+ # workflow: release.yml
18
+ # environment: pypi (for PyPI)
19
+ # testpypi (for TestPyPI)
20
+ #
21
+ # Matching GitHub environments `pypi` and `testpypi` must exist with
22
+ # `id-token: write` permitted — that's the only way OIDC tokens get minted.
23
+ #
24
+ # Manual dry-run: Actions tab → Run workflow → set `testpypi_only: true`
25
+ # to publish a pre-release to TestPyPI without touching PyPI. The tag push
26
+ # path always publishes to both.
27
+
28
+ on:
29
+ push:
30
+ tags:
31
+ - 'v*.*.*'
32
+ workflow_dispatch:
33
+ inputs:
34
+ testpypi_only:
35
+ description: "Publish to TestPyPI only (skip PyPI + GitHub release)"
36
+ type: boolean
37
+ default: true
38
+
39
+ env:
40
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
41
+
42
+ jobs:
43
+ # -------------------------------------------------------------------------
44
+ # 1. Build sdist + wheel once. Downstream jobs consume the artifact so the
45
+ # exact bytes published to TestPyPI are the same ones published to PyPI.
46
+ # -------------------------------------------------------------------------
47
+ build:
48
+ name: Build sdist + wheel
49
+ runs-on: ubuntu-latest
50
+ steps:
51
+ - uses: actions/checkout@v4
52
+ with:
53
+ fetch-depth: 0
54
+
55
+ - uses: actions/setup-python@v5
56
+ with:
57
+ python-version: "3.12"
58
+
59
+ - name: Install build tooling
60
+ run: python -m pip install --upgrade pip build
61
+
62
+ # Fail the release if pyproject.toml's version doesn't match the tag,
63
+ # so we never ship a 0.1.0 wheel tagged as v0.2.0 (or vice versa).
64
+ # Skipped on workflow_dispatch since there's no tag to compare against.
65
+ - name: Verify pyproject version matches tag
66
+ if: github.event_name == 'push'
67
+ run: |
68
+ tag_version="${GITHUB_REF_NAME#v}"
69
+ pkg_version=$(python -c "
70
+ import tomllib, pathlib
71
+ print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])
72
+ ")
73
+ if [ "${tag_version}" != "${pkg_version}" ]; then
74
+ echo "::error::Tag v${tag_version} does not match pyproject.toml version ${pkg_version}."
75
+ echo "Bump pyproject.toml or retag before pushing."
76
+ exit 1
77
+ fi
78
+ echo "Version check OK: ${pkg_version}"
79
+
80
+ - name: Build distributions
81
+ run: python -m build
82
+
83
+ - name: List built artifacts
84
+ run: ls -la dist/
85
+
86
+ - uses: actions/upload-artifact@v4
87
+ with:
88
+ name: dist
89
+ path: dist/
90
+ if-no-files-found: error
91
+
92
+ # -------------------------------------------------------------------------
93
+ # 2. Publish to TestPyPI first, always.
94
+ # -------------------------------------------------------------------------
95
+ publish-testpypi:
96
+ name: Publish to TestPyPI
97
+ needs: build
98
+ runs-on: ubuntu-latest
99
+ environment:
100
+ name: testpypi
101
+ url: https://test.pypi.org/project/qcal-copilot/
102
+ permissions:
103
+ id-token: write # required for Trusted Publishing OIDC
104
+ steps:
105
+ - uses: actions/download-artifact@v4
106
+ with:
107
+ name: dist
108
+ path: dist/
109
+
110
+ - uses: pypa/gh-action-pypi-publish@release/v1
111
+ with:
112
+ repository-url: https://test.pypi.org/legacy/
113
+ # TestPyPI sometimes has leftover versions from prior dry runs;
114
+ # don't fail the release over it.
115
+ skip-existing: true
116
+
117
+ # -------------------------------------------------------------------------
118
+ # 3. Smoke-test: install from TestPyPI in a clean runner and assert that
119
+ # the CLI + Python imports work. If this fails, do NOT publish to PyPI.
120
+ # -------------------------------------------------------------------------
121
+ smoke-test-testpypi:
122
+ name: Smoke-test TestPyPI install
123
+ needs: publish-testpypi
124
+ runs-on: ubuntu-latest
125
+ steps:
126
+ - uses: actions/setup-python@v5
127
+ with:
128
+ python-version: "3.12"
129
+
130
+ - name: Install from TestPyPI
131
+ run: |
132
+ # TestPyPI doesn't mirror transitive deps — fall back to PyPI for those.
133
+ # Retry a few times because TestPyPI's CDN takes 30-60s to propagate
134
+ # a fresh upload before the new version becomes resolvable.
135
+ for attempt in 1 2 3 4 5; do
136
+ if python -m pip install \
137
+ --index-url https://test.pypi.org/simple/ \
138
+ --extra-index-url https://pypi.org/simple/ \
139
+ qcal-copilot; then
140
+ exit 0
141
+ fi
142
+ echo "Attempt ${attempt} failed, sleeping before retry..."
143
+ sleep 15
144
+ done
145
+ echo "::error::pip install from TestPyPI failed after 5 attempts."
146
+ exit 1
147
+
148
+ - name: Verify CLI + imports
149
+ run: |
150
+ qcal version
151
+ python -c "
152
+ import qcal
153
+ from qcal import analyzer, codegen, data, decoder, fit, simulator, config, cli
154
+ assert qcal.__version__, 'missing __version__'
155
+ print('smoke test OK, version =', qcal.__version__)
156
+ "
157
+
158
+ # -------------------------------------------------------------------------
159
+ # 4. Publish to real PyPI. Skipped on workflow_dispatch dry runs.
160
+ # -------------------------------------------------------------------------
161
+ publish-pypi:
162
+ name: Publish to PyPI
163
+ needs: smoke-test-testpypi
164
+ if: github.event_name == 'push' || github.event.inputs.testpypi_only != 'true'
165
+ runs-on: ubuntu-latest
166
+ environment:
167
+ name: pypi
168
+ url: https://pypi.org/project/qcal-copilot/
169
+ permissions:
170
+ id-token: write
171
+ steps:
172
+ - uses: actions/download-artifact@v4
173
+ with:
174
+ name: dist
175
+ path: dist/
176
+
177
+ - uses: pypa/gh-action-pypi-publish@release/v1
178
+
179
+ # -------------------------------------------------------------------------
180
+ # 5. Attach the built wheel + sdist to a GitHub Release, with auto-generated
181
+ # notes. Tag-push only.
182
+ # -------------------------------------------------------------------------
183
+ github-release:
184
+ name: Create GitHub Release
185
+ needs: publish-pypi
186
+ if: github.event_name == 'push'
187
+ runs-on: ubuntu-latest
188
+ permissions:
189
+ contents: write
190
+ steps:
191
+ - uses: actions/download-artifact@v4
192
+ with:
193
+ name: dist
194
+ path: dist/
195
+
196
+ - uses: softprops/action-gh-release@v2
197
+ with:
198
+ files: dist/*
199
+ generate_release_notes: true