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

  1. Keep version.py as the only version source.
  2. Generate the release tag from version.py locally rather than typing it by hand.
  3. Reuse the same Python helper code for local release automation and CI tag/version validation.
  4. Trigger production publishing from push events on tags matching v*.
  5. Treat GitHub Actions as the only publisher to PyPI and TestPyPI.
  6. 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

  1. Update mhcgnomes/version.py to the new version and commit it on main.
  2. Run ./deploy.sh --dry-run --fetch to confirm the repository is clean, up-to-date, and ready to release.
  3. Run ./deploy.sh --fetch from a clean local checkout of main.
  4. Confirm the pushed v<version> tag starts the .github/workflows/release.yml workflow.
  5. deploy.sh now does step 5 for you: it waits for the version to appear on PyPI and says which of three things happened. See below.
  6. If needed, run .github/workflows/release_testpypi.yml manually 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 main matches origin/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.