Cutting a release
A release is one pushed tag. Everything after that is .github/workflows/release.yml, except
one approval and the archive step.
Before the tag
-
Everything intended for the release is merged, and
mainis green. Check the per-check list rather than a watch’s exit status, and confirm the run’sheadShamatches the commit you are about to tag:gh pr list # nothing in flight you meant to include gh run list --branch main --limit 3 gh run view <id> --json headSha --jq .headSha -
Set the version in
pyproject.tomland move the## [Unreleased]section ofCHANGELOG.mdunder the new version with today’s date, leaving a fresh emptyUnreleasedabove it. Add the compare link at the bottom. -
Update
CITATION.cff:versionanddate-released. -
Open that as its own pull request, like any other change, and merge it. The tag goes on the merged commit, not on a branch.
The tag
The verify job compares the tag with the packaged version as plain strings:
version=$(... tomllib ... pyproject.toml ...) # the raw string in the file
tag="${GITHUB_REF_NAME#v}"
test "$version" = "$tag"
So v2.0.0rc1 requires version = "2.0.0rc1" exactly. That the version is read straight out of
pyproject.toml is what makes this strict: a difference in spelling PEP 440 would normalise
away — 2.0.0-rc1, 2.0.0.rc1 — still fails, because nothing here normalises either side. Were
the version taken from the built wheel instead, all three spellings would compare equal and the
check would pass.
git checkout main && git pull
git tag v2.0.0
git push origin v2.0.0
The workflow triggers on tags matching v*.*.* and nothing else.
What runs
verify, dist and binaries declare no dependencies, so they start together. Only the last
two are ordered, and both wait on all three of the first:
| job | what it does |
|---|---|
verify | the tree at the tag passes the gate, and the tag matches the packaged version |
dist | builds the sdist and the wheel |
binaries | freezes a one-file binary on Linux, macOS and Windows |
pypi | needs all three, then waits for an approval, then publishes |
github-release | needs all three, creates the release and attaches the binaries |
The Linux binaries job gives the frozen GUI a display and checks that it opens a window:
a GUI that starts enters mainloop() and never returns, so the timeout’s exit status 124 is the
pass and anything else is a fault. Matching the output instead cannot tell a missing module from
a missing display — PyInstaller prints Failed to execute script for every unhandled exception.
pypi waits on all three because PyPI never accepts a second upload of a version. If a
binary fails for something needing a code change, publishing first would burn the version number
with nothing to go back to.
The approval
pypi names the pypi environment, which has a required_reviewers rule. Verified from the
API rather than from the workflow file, because an environment: key alone gates nothing —
GitHub creates an environment with no rules the first time a workflow names one:
gh api repos/CameronBrooks11/scadbatch/environments \
--jq '.environments[] | {name, rules: [.protection_rules[].type]}'
Approve it in the run’s page, or:
gh api repos/CameronBrooks11/scadbatch/actions/runs/<id>/pending_deployments \
-f state=approved -f comment=ok -F 'environment_ids[]=<environment id>'
comment is required by the endpoint, and the array needs key[]=value: -F’s type conversion
covers only true, false, null and integers, so -F environment_ids='[123]' sends the
string "[123]" and the request fails.
Publishing uses PyPI trusted publishing over OIDC: no token exists anywhere, and id-token: write is granted to that one job and nowhere else. A trusted publisher is configured with the
repository, the workflow filename and the environment name (PyPI’s
documentation walks
through exactly that), so renaming release.yml breaks publishing until the PyPI configuration
is updated to match.
After the workflow
-
Check the result rather than the workflow’s say-so:
curl -s https://pypi.org/pypi/scadbatch/json | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['info']['version'], sorted(d['releases']))" uvx --from scadbatch==<version> scadbatch --help -
The GitHub release is a prerelease unless the tag is exactly
vN.N.N—^v[0-9]+\.[0-9]+\.[0-9]+$, sov2.0.0rc1is marked prerelease automatically. -
Zenodo. With the Zenodo–GitHub integration enabled for the repository, publishing the GitHub release archives it and mints a DOI: one for the version plus a concept DOI covering all versions. Put the concept DOI in
CITATION.cffand the README badge: it resolves to the newest archived release, where a version DOI freezes a reader on the one they found. This is the step that is not automated. Done for 2.0.0 — concept10.5281/zenodo.24122494, version10.5281/zenodo.24122495— so a later release only needs the badge checked, since the concept DOI does not change.
Rehearsing without spending a version
A tag whose version does not match pyproject.toml fails verify, which blocks pypi — while
binaries, depending on nothing, still runs in full. So a throwaway tag exercises the expensive,
platform-dependent half for free. Not quickly, though: the version check is verify’s last
step, after just setup, just check and the test suite under xvfb-run, so the rehearsal pays
for a complete gate run before the mismatch is reported.
git tag v0.0.0-smoke && git push origin v0.0.0-smoke
# watch the binaries job; then
git tag -d v0.0.0-smoke && git push origin :refs/tags/v0.0.0-smoke
gh run delete <id>
This is worth doing when anything in the binaries job has changed. Cutting 2.0.0rc1 took
three release runs: the first two failed on Binary (ubuntu-latest) alone, with Publish to PyPI skipped both times, so nothing was published and the tag was simply re-pointed. That is
the pypi needs binaries rule working — but each attempt cost a full release run, and a
rehearsal tag would have found it without touching the real tag.
If a release fails
-
Before
pypiran — nothing was published. Delete the tag, fix, re-tag:git tag -d v2.0.0 && git push origin :refs/tags/v2.0.0 -
After
pypiran — the number is spent: PyPI does not accept a second upload for a version that already exists. Fix forward with the next patch version.