Cutting a release¶
End-to-end steps for publishing a pyrxd version to GitHub and PyPI. Written after cutting 0.18.0, and reflecting what actually worked rather than what the steps looked like beforehand.
Preconditions¶
Every fix for the release is merged to
main.pyproject.tomlversionis bumped. This is the single source of truth —__version__reads it viaimportlib.metadata.CHANGELOG.mdhas a## [X.Y.Z] — YYYY-MM-DDsection.CI is green on the release PR. The
publish.ymlworkflow re-checks that the version matches the tag, but catching a mismatch here is cheaper.
1. Merge the release PR¶
main is protected (strict, one approval), so a solo release uses the admin
override:
gh pr merge <N> --repo MudwoodLabs/pyrxd --squash --delete-branch --admin
--repo is not optional in this clone — see Gotchas.
Then confirm the squash preserved the tree that CI actually tested:
git checkout main && git pull --ff-only
git rev-parse main^{tree} # compare against the PR head's tree
Identical trees mean the CI run on the PR head covers the squashed commit byte-for-byte. If they differ, the squash picked up something else and the release commit is not what was tested.
2. Create the release — this also creates the tag¶
Publishing the GitHub Release is the step that fires publish.yml and uploads
to PyPI. Pushing a tag alone deliberately does not publish.
gh release create vX.Y.Z \
--repo MudwoodLabs/pyrxd \
--target <sha-of-release-commit> \
--title "pyrxd X.Y.Z" \
--notes-file docs/_static/announce/pyrxd-X.Y.Z-github-release.md
--target <sha> has GitHub create the tag server-side at exactly that commit.
This is preferred over git tag + git push origin vX.Y.Z:
it is one step instead of two,
it pins the tag to an explicit sha rather than whatever HEAD happens to be,
it does not run the
pre-pushhook, which has an open failure mode when pushing to GitHub (see Gotchas).
If you also want the tag locally, fetch it back rather than pushing your own:
git fetch origin --tags
3. Verify¶
Do not assume the publish succeeded because the release exists.
# workflow finished, both jobs green
gh run list --workflow publish.yml --repo MudwoodLabs/pyrxd --limit 1
# SBOM attached to the release
gh release view vX.Y.Z --repo MudwoodLabs/pyrxd --json assets --jq '.assets[].name'
# PyPI has the version, with wheel + sdist.
# USE THE SIMPLE INDEX, not the JSON API — see the note below.
curl -s https://pypi.org/simple/pyrxd/ | grep -o 'pyrxd-X\.Y\.Z[^"#<]*' | sort -u
# clean-venv install actually works
python3 -m venv /tmp/verify && /tmp/verify/bin/pip install -q --no-cache-dir pyrxd==X.Y.Z
/tmp/verify/bin/python -c "import pyrxd; print(pyrxd.__version__)"
PyPI’s JSON API lags a publish; the simple index does not. Measured cutting
0.24.0: several minutes after a fully successful upload,
https://pypi.org/pypi/pyrxd/json still reported info.version as the PREVIOUS
release and listed zero files under the new one, while
https://pypi.org/simple/pyrxd/ already showed both the wheel and the sdist and
the job log confirmed both uploads with attestations.
They are different cache paths. Following the old version of this step exactly would have produced a confident “the publish failed” for a release that had in fact succeeded — a check whose stale answer is indistinguishable from a real failure. Verify with the simple index, and treat a stale JSON answer as expected rather than as evidence.
The clean-venv install below is the check that actually settles it: it installs
the published artifact and runs it. Prefer --no-cache-dir, or a local pip cache
can serve you the previous version and report success.
publish.yml targets the pypi environment. If that environment is configured
with required reviewers, the run parks awaiting approval — it has not failed.
4. Announce¶
Post the Discord announcement from docs/_static/announce/. Discord’s 2,000
character limit counts UTF-16 code units, not characters — emoji outside the
BMP are surrogate pairs and cost two each, so a plain len() under-reports.
Measure with:
python3 -c "s=open('docs/_static/announce/pyrxd-X.Y.Z-announcement.md').read().rstrip(); \
print(len(s.encode('utf-16-le'))//2)"
Gotchas¶
gh needs --repo in this clone. Some clones carry a second remote besides
origin → MudwoodLabs/pyrxd (the counter-leg Solidity lives in its own
repository), and with more than one remote gh cannot infer the target. Without --repo it errors with “No default remote repository
has been set” — and in some invocations does nothing at all. Either pass
--repo or set it once with gh repo set-default MudwoodLabs/pyrxd.
git push to GitHub fails if the pre-push hook runs longer than ~5 minutes.
Git opens the connection ~1 s before running the hook, so it sits idle for the
hook’s whole duration, and GitHub closes an idle git-receive-pack session
somewhere between 300 s and 395 s. Git then writes the pack to a dead socket and
dies with 141 (SIGPIPE) — nothing transferred, no git-level error. This cost
two release pushes during the 0.18.0 cut. Full evidence in
docs/solutions/integration-issues/long-pre-push-hook-makes-git-push-to-github-fail-with-sigpipe.md.
Both mitigations are in place: the hook now runs task ci-fast (4.5 s, versus 395 s
for full task ci), and step 2 above does not push tags at all. If you hit it
anyway, retry, or run task ci yourself and push with --no-verify.
Two things that make it hard to spot: piping the push (git push ... | tail) reports
tail’s exit status, so 141 reads as 0; and ssh’s Connection to github.com closed by remote host. lands mid-line in test output, so it is easy to grep past.
The ! prefix is a Claude Code convention, not a shell one. In bash, ! is
the pipeline negation operator and binds looser than &&, so
! cd /path && git push parses as (! cd /path) && (git push) — cd succeeds,
the negation makes it false, and the push silently never runs.
The pre-push hook needs task on PATH, which lives in .venv/bin. Without
it the hook aborts and the push does nothing.