Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. Everything intended for the release is merged, and main is green. Check the per-check list rather than a watch’s exit status, and confirm the run’s headSha matches 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
    
  2. Set the version in pyproject.toml and move the ## [Unreleased] section of CHANGELOG.md under the new version with today’s date, leaving a fresh empty Unreleased above it. Add the compare link at the bottom.

  3. Update CITATION.cff: version and date-released.

  4. 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:

jobwhat it does
verifythe tree at the tag passes the gate, and the tag matches the packaged version
distbuilds the sdist and the wheel
binariesfreezes a one-file binary on Linux, macOS and Windows
pypineeds all three, then waits for an approval, then publishes
github-releaseneeds 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

  1. 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
    
  2. The GitHub release is a prerelease unless the tag is exactly vN.N.N — ^v[0-9]+\.[0-9]+\.[0-9]+$, so v2.0.0rc1 is marked prerelease automatically.

  3. 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.cff and 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 — concept 10.5281/zenodo.24122494, version 10.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 pypi ran — nothing was published. Delete the tag, fix, re-tag:

    git tag -d v2.0.0 && git push origin :refs/tags/v2.0.0
    
  • After pypi ran — the number is spent: PyPI does not accept a second upload for a version that already exists. Fix forward with the next patch version.