Releasing

July 20, 2026 ยท View on GitHub

Releases are tag-driven: CI builds, validates, and publishes the distribution when a v* tag is pushed, and a tag only builds if every version source agrees with it.

Version sources

Each repository declares its version statically in several places -- pyproject.toml (project.version), <package>/__init__.py (__version__), and any plugin manifests. The authoritative list for a given repository is whatever its tag check verifies: see the "Check tag matches package versions" step in .github/workflows/build.yaml. docs/conf.py (release), where present, also carries the version but is not tag-checked; keep it in step when bumping.

Cutting a release

  1. Pick the version (X.Y.Z) and bump every checked source to it in one commit.
  2. Tag that commit vX.Y.Z and push the tag.
  3. CI verifies the tag matches all sources -- any mismatch fails the build with an error naming the offending file -- then builds the wheel and sdist with uv build and validates metadata with twine check.
  4. The publish job uploads the distribution to PyPI via trusted publishing.

First release only

Trusted publishing needs one-time wiring before the first tag:

  1. On PyPI, add a publisher for the package (a pending publisher if the project does not exist yet): the repository, workflow build.yaml, environment pypi.
  2. In the GitHub repository, create the pypi environment (Settings -> Environments) that the publish job targets.

Sanity checks before tagging

# suite + hooks green
uv run --no-sync pytest
uv run --no-sync pre-commit run --all-files

# distribution builds and self-describes correctly
uv build
uvx twine check dist/*