Release Pipeline

How a new version of vfairness gets from a commit to PyPI, what a maintainer does, and how quality is guaranteed at each step.

Releases are automated, tag-triggered, and rehearsed on TestPyPI before they touch PyPI, with a required human approval for the production publish. The workflow lives at .github/workflows/release.yml.

Nothing is published from a laptop

Every artifact this pipeline releases to PyPI is built, verified, and published by CI from a tagged commit, using short-lived OIDC credentials rather than a stored token. The first pipeline publish, the 0.1.0 beta, has not happened yet: the 0.0.1 upload that has sat on PyPI since March 2026 is a pre-pipeline name reservation and carries none of these guarantees. See the Quality & Hardening page for the gates that run before a release can even be cut.

At a Glance

  • Tag-triggered. Pushing a vX.Y.Z tag runs the release workflow; normal pushes never publish.
  • TestPyPI first, then PyPI, with a required human approval before the production publish.
  • Trusted Publishing (OIDC). No long-lived PyPI API token is ever stored; the upload uses short-lived, workflow-scoped credentials.
  • Every build is verified (twine check, check-wheel-contents), gets a CycloneDX SBOM, and is installed into a clean environment and smoke-tested before any publish.
  • Build provenance attestations are attached to every pipeline upload to PyPI automatically.

How a Release Flows

release.yml runs four jobs in order; each runs only if the previous ones passed, so a failure anywhere stops the release before it reaches PyPI.

1. Build and verify distribution

Builds the sdist and wheel with python -m build, gates on twine check (metadata and README render) and check-wheel-contents (nothing missing or stray in the wheel), and generates a CycloneDX SBOM. The artifacts are uploaded for the later jobs.

2. Clean-room install smoke test VB-REL-3

Installs the freshly built wheel into a fresh environment and exercises the public API from outside the source tree, so a missing data file, wrong entry point, unshipped subpackage, or missing dependency blocks the release. This catches packaging defects the source-tree test suite cannot see.

3. Publish to TestPyPI

Uploads to TestPyPI via Trusted Publishing as a dress rehearsal. A re-run is idempotent (skip-existing).

4. Publish to PyPI

Runs only after a maintainer approves the pypi environment, then uploads to PyPI via Trusted Publishing with provenance attestations.

Cutting a Release

For a maintainer, a release is four small steps; the pipeline does the rest.

  1. Confirm the quality gates are green on main (see Quality & Hardening).
  2. Bump the version. There is a single source of truth in src/vfairness/__init__.py (__version__); the build reads it from there.
  3. Update the changelog. Move the [Unreleased] entries under a new [X.Y.Z] heading with the date.
  4. Tag and push an annotated tag:
git tag -a v0.1.0 -m "vfairness 0.1.0"
git push origin v0.1.0

Build, verify, smoke, and TestPyPI run automatically. When the run reaches the pypi environment it pauses for approval; approve it to publish to PyPI.

Why a plain 0.1.0 and not a PEP 440 pre-release

The beta ships as a normal SemVer 0.x version with the Development Status :: 4 - Beta classifier, so pip install vfairness finds it without the --pre flag (a pre-release suffix would hide the package from every default resolve, the wrong trade for a library seeking testers). SemVer already reserves API stability for 1.0.0. PEP 440 pre-release suffixes such as 0.9.0b1 remain available for rehearsing a specific future release; those do require pip install --pre vfairness.

One-Time Setup (Prerequisite)

Before the first release, two things must exist. They are configured once by a maintainer with PyPI and repository-admin access.

  1. A Trusted Publisher for project "vfairness" on both PyPI and TestPyPI, bound to the owner, this repository, and workflow release.yml.
  2. GitHub Environments testpypi and pypi, with a required reviewer on pypi so a human approves the production publish.
Until this is done

The publish jobs fail closed; the build, verify, and smoke jobs still run, so the pipeline is exercised end to end without publishing.

How Quality Is Guaranteed

Quality is enforced in two layers, so a release can only ship code that has already cleared every gate.

Before a change can reach a release

Every push and pull request that touches the library must pass the standing gates: ruff lint and format, mypy (with a strict island on the core), the full test suite on Python 3.11 / 3.12 / 3.13, branch coverage plus a PR-only diff-cover gate, security scanning (Bandit and pip-audit), and, when the relevant paths change, cross-library metric parity and the Pulse output contract. Mutation testing runs weekly and on demand as an informational report, not a blocking gate. CodeQL and an OpenSSF Scorecard are configured but stay skipped while the repository is private. The full inventory is on the Quality & Hardening page.

At release time

The pipeline runs additional checks on the exact artifact that will ship: twine check, check-wheel-contents, a CycloneDX SBOM, and the clean-room install smoke test. TestPyPI is the dry run before PyPI.

Security of the Pipeline

  • No stored PyPI token. Publishing uses OIDC short-lived credentials via PyPI Trusted Publishing, so there is no long-lived secret to leak or rotate.
  • Provenance attestations are attached to every pipeline upload, so consumers can verify such an artifact was built by this workflow. The pre-pipeline 0.0.1 upload from March 2026 carries no attestations; the first attested artifact will be the 0.1.0 beta publish.
  • A CycloneDX SBOM records the dependency set of each release.
  • TestPyPI-first and a required human approval stand between a tag and a production publish.
  • Least-privilege tokens. The workflow runs with contents: read, and id-token: write is granted only on the publish jobs that need it.

Versioning and Stability

  • Semantic Versioning, with a single source of version truth in src/vfairness/__init__.py.
  • The beta line is plain SemVer 0.x (0.1.0, 0.2.0, ...) carrying the Beta trove classifier; PEP 440 pre-release suffixes (0.9.0b1) stay available for rehearsals and install with pip install --pre.
  • The frozen public surface and deprecation policy govern what a version bump may change (see API_STABILITY.md); every change is announced in the changelog.

If Something Goes Wrong

Releases are immutable

A PyPI version cannot be overwritten. To correct a bad release, publish a new patch version and, if needed, yank the bad one on PyPI (yank hides it from new resolves without breaking existing pins). TestPyPI is the rehearsal: a failure there stops the pipeline before it touches PyPI.