Release Process¶
Overview¶
mhcgnomes/version.py is the source of truth for package versioning. Releases
use a hybrid flow:
- local release automation runs strict preflight checks, validates the version,
builds the package once locally, and pushes an annotated
v<version>tag - GitHub Actions rebuilds from that tag, verifies the tag still matches
version.py, checks the generated distributions, and publishes to PyPI using trusted publishing
This keeps the existing safety checks from deploy.py while removing local
PyPI credentials and local twine upload.
Detailed Plan¶
- Keep
version.pyas the only version source. - Generate the release tag from
version.pylocally rather than typing it by hand. - Reuse the same Python helper code for local release automation and CI tag/version validation.
- Trigger production publishing from
pushevents on tags matchingv*. - Treat GitHub Actions as the only publisher to PyPI and TestPyPI.
- Document the maintainer checklist so the release process is explicit and reviewable.
Maintainer TODOs¶
These are one-time repository setup tasks outside the codebase:
- [ ] Configure a PyPI trusted publisher for the GitHub Actions workflow
environment named
release - [ ] Configure a TestPyPI trusted publisher for the environment named
release_testpypi - [ ] Decide whether tag pushes matching
v*should be protected or restricted to maintainers - [ ] Decide whether to add release provenance or signing as a later hardening step
Release Checklist¶
- Update
mhcgnomes/version.pyto the new version and commit it onmain. - Run
./deploy.sh --dry-run --fetchto confirm the repository is clean, up-to-date, and ready to release. - Run
./deploy.sh --fetchfrom a clean local checkout ofmain. - Confirm the pushed
v<version>tag starts the.github/workflows/release.ymlworkflow. deploy.shnow does step 5 for you: it waits for the version to appear on PyPI and says which of three things happened. See below.- If needed, run
.github/workflows/release_testpypi.ymlmanually against an existing tag before a production release.
Did it actually ship?¶
deploy.sh used to end by printing "GitHub Actions will build and publish this
tag to PyPI" and exiting 0. That sentence has been false since
#83: the workflow's
trusted-publisher exchange fails with invalid-publisher, so the tag lands on
GitHub and nothing reaches PyPI, while the script reports success. Golden Rule
3 says done means deployed, and it was being satisfied by a sentence.
After pushing the tag, deploy.py polls PyPI for up to --pypi-timeout
seconds (default 300) and reports one of:
| outcome | exit | meaning |
|---|---|---|
OK: mhcgnomes <version> is on PyPI |
0 | released |
Could not reach PyPI to confirm ... |
0 | the question could not be asked; check by hand |
ERROR: v<version> is pushed but PyPI still has no <version> |
3 | not released |
Exit 3 rather than 1 so a caller can tell "the release did not land" from "the script could not run". A network failure is deliberately not the same answer as a missing release; treating them alike would fail deploys on flaky wifi.
When it exits 3, do not re-run deploy.sh — the tag exists by then, so the
run stops on ensure_tag_absent, which is the trap described in
#152. The error message
prints the upload command for the artifacts already in dist/:
python -m twine upload dist/mhcgnomes-<version>-py3-none-any.whl dist/mhcgnomes-<version>.tar.gz
--skip-pypi-check turns the whole thing off, and says plainly that nothing
has confirmed the release.
What deploy.sh Enforces¶
Which interpreter builds¶
deploy.py resolves a project venv (.venv, then venv) and puts its bin
on PATH, but the build runs under whichever interpreter can actually do
it, and the log says which and why:
OK: Using venv: /path/to/repo/.venv
OK: Build interpreter: /path/to/.venv/bin/python (project venv)
The venv is preferred only when it can import both build and setuptools --
python -m build --no-isolation needs the backend already present.
develop.sh installs .[dev,docs], which contains neither, so on a plain
development checkout the line reads:
OK: Build interpreter: /usr/local/bin/python3 (launching interpreter;
/path/to/.venv/bin/python cannot import build and setuptools)
That is not a failure. Before #101 the script printed the venv and silently built with the launching interpreter anyway; the fix is that it now tells you. To build under the venv, install the tools there:
.venv/bin/python -m pip install build setuptools wheel
setuptools is not optional and is not pulled in by build: Python 3.12
dropped it from new venvs, and --no-isolation means pyproject's
requires = ["setuptools>=61.0", "wheel"] must already be importable. Add
twine too if you publish from that venv.
To choose an interpreter explicitly, set DEPLOY_PYTHON. deploy.sh uses it
to launch deploy.py, and an explicit request outranks even a venv that could
build, so it is the way to force a specific interpreter:
DEPLOY_PYTHON=.venv/bin/python ./deploy.sh
The check runs before dist/, build/ and *.egg-info are cleaned, so a
missing build aborts without leaving the checkout worse off than it started.
deploy.sh still runs the full local test and lint gates before calling
deploy.py. deploy.py then enforces:
- current branch is
main - working tree is clean
- local
mainmatchesorigin/main - the release version parses from
mhcgnomes/version.py - the corresponding
v<version>tag does not already exist - a local source and wheel build succeeds before any tag is pushed
Failure Handling¶
- If the local preflight fails, no tag is created.
- If the CI tag/version validation fails, nothing is published.
- If the build or trusted publish step fails, fix the issue on
main, bump the version, and cut a new tag rather than reusing the failed version number.