Skip to content

Commit 2adb9a9

Browse files
fedorovclaude
andcommitted
Route pre-releases to TestPyPI and full releases to PyPI
The single upload job published everything to PyPI, so there was no way to rehearse an upload without consuming the real version number -- and neither index lets a version be reused, even after deletion. Now the GitHub release decides. A release tagged v* publishes to TestPyPI if it is marked a pre-release and to PyPI if it is not; the two conditions are mutually exclusive, so nothing lands on both. Tagging v1.10.0rc1 as a pre-release therefore exercises the whole path -- OIDC, metadata, wheel tags, upload -- and leaves 1.10.0 untouched. It is also a legitimate pre-release rather than a wasted number, since 1.10.0rc1 sorts before 1.10.0. The v-prefix check is unchanged and still applies to both: binaries-* releases carry prebuilt archives, not a Python package, and must never reach an index. Verified that v1.10.0rc1 resolves to 1.10.0rc1 under the configured tag_regex. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 963cfb5 commit 2adb9a9

2 files changed

Lines changed: 67 additions & 22 deletions

File tree

.github/workflows/cd.yml

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,21 +117,53 @@ jobs:
117117
- name: Show wheel tags
118118
run: ls -la all/*/*
119119

120-
upload_all:
120+
# Where a release goes is decided by two things, both read off the GitHub release itself.
121+
#
122+
# The tag prefix picks out releases *of this package*. build-binaries.yml also publishes
123+
# GitHub releases -- tagged binaries-<version>-<n>, carrying the prebuilt plastimatch
124+
# archives that plastimatchUrls.cmake pins -- and those must never reach an index. Without
125+
# the tag check every one of them would trigger an upload of a wheel built from whatever the
126+
# pins happened to reference at the time.
127+
#
128+
# The prerelease flag then picks the index. Mark the release a pre-release (and tag it
129+
# v<version>rc<n>) to rehearse on TestPyPI; a full release publishes to PyPI. Nothing goes to
130+
# both, so a rehearsal cannot consume the real version number -- which matters because
131+
# neither index ever lets a version be reused, even after deletion.
132+
133+
upload_testpypi:
134+
name: Publish to TestPyPI
135+
needs: [check_dist]
136+
environment: testpypi
137+
permissions:
138+
id-token: write
139+
runs-on: ubuntu-latest
140+
if: >-
141+
github.event_name == 'release' && github.event.action == 'published'
142+
&& startsWith(github.event.release.tag_name, 'v')
143+
&& github.event.release.prerelease
144+
145+
steps:
146+
- uses: actions/download-artifact@v8
147+
with:
148+
pattern: cibw-*
149+
path: dist
150+
merge-multiple: true
151+
152+
- uses: pypa/gh-action-pypi-publish@release/v1
153+
with:
154+
repository-url: https://test.pypi.org/legacy/
155+
156+
upload_pypi:
121157
name: Publish to PyPI
122158
needs: [check_dist]
123159
environment: pypi
124160
permissions:
125161
id-token: write
126162
runs-on: ubuntu-latest
127-
# Only v-prefixed tags are releases of this package. build-binaries.yml also publishes
128-
# GitHub releases -- tagged binaries-<version>-<n>, carrying the prebuilt plastimatch
129-
# archives that plastimatchUrls.cmake pins -- and those must never reach PyPI. Without the
130-
# tag check every one of them would trigger an upload, publishing a wheel built from
131-
# whatever the pins happened to reference at the time.
132163
if: >-
133164
github.event_name == 'release' && github.event.action == 'published'
134165
&& startsWith(github.event.release.tag_name, 'v')
166+
&& !github.event.release.prerelease
135167
136168
steps:
137169
- uses: actions/download-artifact@v8

README.md

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -228,27 +228,40 @@ not reachable that way, so repointing the pins at a private repository breaks ev
228228
build, not just CI. This is why the pins still reference the fork: that repository is public.
229229

230230
**2. Register a Trusted Publisher**, so there is no API token to manage. Because the project
231-
does not exist on PyPI yet, this is a *pending* publisher, added at
232-
<https://pypi.org/manage/account/publishing/>:
231+
does not exist on either index yet, these are *pending* publishers:
233232

234-
| Field | Value |
235-
| --- | --- |
236-
| PyPI project name | `plastimatch` |
237-
| Owner | the account or organisation owning this repository at publish time |
238-
| Repository name | `plastimatch-python-distributions` |
239-
| Workflow name | `cd.yml` |
240-
| Environment name | `pypi` |
233+
| Field | PyPI | TestPyPI |
234+
| --- | --- | --- |
235+
| Register at | <https://pypi.org/manage/account/publishing/> | <https://test.pypi.org/manage/account/publishing/> |
236+
| Project name | `plastimatch` | `plastimatch` |
237+
| Owner | `ImagingDataCommons` | `ImagingDataCommons` |
238+
| Repository name | `plastimatch-python-distributions` | `plastimatch-python-distributions` |
239+
| Workflow name | `cd.yml` | `cd.yml` |
240+
| Environment name | `pypi` | `testpypi` |
241241

242242
The owner must match wherever the repository actually lives when the workflow runs — an OIDC
243-
claim is checked against it, so registering the wrong owner fails the upload. Register it after
244-
any planned move, not before.
243+
claim is checked against it, so registering the wrong owner fails the upload. Register after
244+
any planned move, not before. The environment names must match the `environment:` keys on the
245+
`upload_pypi` and `upload_testpypi` jobs in `cd.yml`.
246+
247+
Both environments are created automatically on first use, but creating them explicitly with a
248+
required reviewer makes every publish need a human approval — recommended for `pypi`, given
249+
that uploads cannot be undone.
245250

246-
Registering the same on <https://test.pypi.org/manage/account/publishing/> allows a rehearsal,
247-
which is worth doing because a published version can never be reused.
251+
### Cutting a release
252+
253+
Which index a release goes to is decided by the GitHub release itself, so nothing ever lands on
254+
both and a rehearsal cannot consume the real version number:
255+
256+
| Release | Tag | Publishes to |
257+
| --- | --- | --- |
258+
| pre-release | `v1.10.0rc1` | TestPyPI |
259+
| full release | `v1.10.0` | PyPI |
248260

249-
The `pypi` GitHub environment referenced by `cd.yml` is created automatically on first use, but
250-
creating it explicitly with a required reviewer makes every publish need a human approval —
251-
recommended, given that uploads cannot be undone.
261+
So the sequence is: tag `v1.10.0rc1` and mark the GitHub release as a pre-release, confirm the
262+
upload and that `pip install -i https://test.pypi.org/simple/ plastimatch` gives a working
263+
executable, then tag `v1.10.0` as a full release. `1.10.0rc1` sorts before `1.10.0`, so the
264+
rehearsal is also a legitimate pre-release rather than a number burned for nothing.
252265

253266
Two things worth knowing before the first upload:
254267

0 commit comments

Comments
 (0)