Cutting a Release#

Estimated reading time: 16 minutes

A release is a short sequence of one-shot actions, two of which cannot be taken back. This page is that sequence, in order, with what to check between the steps.

It does not carry the reasoning — the release-execution bullet of spec §10 holds that, and the workflow files hold the mechanics. The split is the one Continuous Integration makes: a page that restated how ci-wheels publishes would drift from the workflow, while the order you do things in is written down nowhere else.

The Release Branch#

Every release is tagged from a dedicated branch named vA.B.x — a literal x, with the major and minor version in place of A and B, so v0.1.x carries v0.1.0 and every patch release after it. main is never tagged.

The branch is not ceremony. semver-pep440-release-branch, the version scheme pyproject.toml configures, reads it: one commit past v0.1.0 derives 0.1.1.dev1 on v0.1.x and 0.2.0.dev1 on main. The branch is what tells setuptools_scm that a patch line and the next minor line are different lines, and it is where a fix for a released version is prepared without waiting for whatever main has accumulated since.

Before You Tag#

Four tasks, all of which CI runs anyway. Running them locally first means a failure costs a minute rather than a tagged commit.

Task

What it does

tests

The suite, with image comparison enforced (Testing)

lint

Every pre-commit hook, over every file

docs

Builds the documentation and runs every gate over the result

changelog

Assembles changelog/ into CHANGELOG.rst. It takes the version the tag will carry: pixi run changelog --version 0.1.0. Add --draft to render it without writing or removing anything

And three things that must be true:

  • The release branch is green. Not “was green” — ci-tests, ci-docs and ci-wheels all run on a push to it, so a red one means the path the tag takes is already broken.

  • Every merged pull request left a fragment. pixi run changelog --draft renders what the release will say; a pull request missing from it is a fragment that was never written, and ci-changelog should have caught it.

  • The version you are about to tag does not exist on PyPI. See What Cannot Be Undone.

The Sequence#

  1. Get on the release branch. For a new minor version, cut it from main:

    $ git switch main && git pull
    $ git switch -c vA.B.x && git push -u origin vA.B.x
    

    For a patch release the branch already exists and carries its own history — check it out and put the fix on it, rather than branching again.

  2. Freeze the what’s new page. Write the release’s Announcements and Highlights into docs/src/reference/whatsnew/latest.rst, then:

    • replace |tp_version| and |build_date| with the literal version and release date — a frozen page that keeps them announces whatever version the next documentation build happens to be;

    • repoint its changelog link from changelog-latest to changelog-vX.Y.Z;

    • rename the file to A.B.rst — the major and minor only, since a patch release appends to this same page;

    • in whatsnew/index.rst, point the include at A.B.rst and replace latest with A.B in the toctree.

    Both index edits are obligatory. The rename takes latest.rst out of existence, so a toctree entry still naming it names nothing — on the one commit that gets tagged, built and published.

    changelog-vX.Y.Z does not exist yet — step 3 is what assembles the file that defines it. A pixi run docs run between this step and that one hits an undefined label on that link; that is expected, not a mistake, and resolves once step 3 is done.

  3. Assemble the changelog. pixi run changelog --version X.Y.Z. This writes CHANGELOG.rst and deletes the fragments it consumed, so it is a commit of its own and the diff is worth reading before you make it.

  4. Fill in the release metadata. CITATION.cff takes version and date-released (YYYY-MM-DD). ci-citation validates the file, and it runs only when that file changes — so this step is the only thing that will ever check it.

  5. Open a pull request into the release branch, and label it skip-changelog. Then let it go green — this is the last point at which anything is reversible for free.

    The label is not optional here, and this is the one pull request where it is not a shortcut. ci-changelog asks every pull request for a fragment named after its own number, and this one has deleted every fragment there was: they are not missing, they have been consumed into CHANGELOG.rst in the same diff. Writing a fragment first does not help, because the gate reads the pull request’s net change and step 3 removes it again.

    Nothing applies the label for you — ci-label adds it only for dependabot and pre-commit.ci — and without it the gate fails on the deleted paths rather than reporting a missing fragment, so the error will not tell you any of this.

  6. Merge it, and wait for the release branch. ci-wheels runs again on the merge commit: manifest gates MANIFEST.in against what the sdist carries, and build builds and smoke-tests both distributions. Everything the tag will do except the upload itself, run on the exact commit you are about to tag.

    publish-testpypi does not run here — it is scoped to main, which is where the code reached Test PyPI before the branch was cut, and a workflow_dispatch run does not reach it either: the job asks for a push event on main, and a dispatch is neither.

    A patch release is the case worth knowing, since its fix is prepared on the release branch and never passes through main, so nothing of it reaches Test PyPI ahead of PyPI. What the branch run does give you is the dist artifact, built and smoke-tested by the same job that will build what ships. Download it from the run and install it to get the same confidence without an upload:

    $ gh run download --branch vA.B.x --name dist --dir /tmp/dist
    $ python -m venv /tmp/patch-check
    $ /tmp/patch-check/bin/pip install /tmp/dist/*.whl
    $ /tmp/patch-check/bin/python -c "import tephpy; print(tephpy.__version__)"
    
  7. Tag it, and push the tag.

    $ git switch vA.B.x && git pull
    $ git tag -a vX.Y.Z -m "vX.Y.Z"
    $ git push origin vX.Y.Z
    

    The tag is what derives the version: setuptools_scm reads it, and no file in the repository carries a version number to bump.

    The releases page does the same thing through the browser, and will also draft the release notes and publish a GitHub release alongside the tag. Either way, make sure the target is the release branch and not main.

  8. Watch the wheels workflow. The tag push runs ci-wheels again, and this time publish-pypi runs instead of publish-testpypi. It is gated on build succeeding, so a failure before that point publishes nothing.

  9. Check what arrived. The project page on PyPI should show the new version, and an install from it into a throwaway environment should work — ci-wheels smoke-tests the wheel it built, not the wheel PyPI served.

    $ python -m venv /tmp/release-check
    $ /tmp/release-check/bin/pip install tephpy==X.Y.Z
    $ /tmp/release-check/bin/python -c "import tephpy; print(tephpy.__version__)"
    $ /tmp/release-check/bin/tephpy examples list
    
  10. Merge the release branch back into main — with a merge commit, not a squash. The pull request carries CHANGELOG.rst, the citation metadata, and any fix the release was made for.

    $ git fetch origin
    $ gh pr create --base main --head vA.B.x \
          --title "Merge back vA.B.x" --label skip-changelog
    

    skip-changelog for the reason step 5 needed it: this pull request carries the fragment deletions as well, and ci-changelog reads them the same way.

    The merge method is the whole point of this step, and it is not the default. main is configured for squash merges, and a squash would put the branch’s content on main while leaving the tag off it — after which setuptools_scm goes on deriving a version below the one just released. A merge commit makes the tag an ancestor of main, which moves main to the next minor line while the release branch stays on the patch line: 0.2.0.dev… and 0.1.1.dev… respectively, measured both ways.

    Two settings have to be relaxed for it, and both are put back afterwards:

    • Allow merge commits, in the repository’s pull-request settings. The repository is configured for squash merges only.

    • Require linear history, in main’s branch protection, turned off. A merge commit is not linear history, and the merge is refused while it stands.

    Then merge with Create a merge commit — not the default button — and restore both immediately. While they are relaxed any pull request can land a merge commit on main, so the window is a reason to do this promptly rather than leave it open.

    Do not delete the release branch when the pull request merges. The next patch release for this minor version is prepared on it.

  11. Reseed the what’s new page, on main, and only now.

    $ cp docs/src/reference/whatsnew/latest.rst.template \
         docs/src/reference/whatsnew/latest.rst
    

    In the same commit, put latest back at the head of whatsnew/index.rst’s toctree. The freeze removed it and nothing else puts it back, and a page the toctree does not name is unreachable from the section it belongs to.

    Leave the include alone: it stays on the release just frozen, so the page a reader meets is the newest release they can install rather than a placeholder for the next one.

    Seeding earlier does not work. On the release branch it would travel back through the merge-back as a second empty page; on main before the merge-back it would leave two pages both claiming to be newest.

  12. Activate the version on Read the Docs. Versioned hosting (stable and vX.Y) exists only once a tag does, so this step is possible only now.

  13. Announce it, if it is a release worth announcing.

A patch release re-enters at the freeze step against the existing A.B.rst, appending to its Patches section, and skips the reseed: latest.rst on main is already accumulating for the next minor version and is not what a patch describes.

What Cannot Be Undone#

Action

What recovery there is

Publishing to PyPI

None. A version number is consumed forever — yanking a release hides it from resolvers but never frees the number, and a file cannot be replaced. The recovery is to publish the next version

Pushing a tag

The tag can be deleted, but anything it already published cannot. Delete a tag only when you are certain publish-pypi did not run

Assembling the changelog

Free before the merge, since the fragments are still in git history. After it, restoring one means a new fragment

This is why the rehearsal below exists, and why steps 3 to 5 are a pull request rather than a push.

The First Release#

Everything below is one-time setup, and all of it must be done before the first v* tag.

The production publishing path has never run. publish-pypi declares environment: pypi and the repository has no such environment, so the job would fail at the point of publishing and nowhere earlier. Two things to create, in this order:

  1. A GitHub environment named exactly pypi. The name is not a label — PyPI matches on it.

  2. A PyPI Trusted Publisher for the project, naming this repository, the workflow file ci-wheels.yml, and that environment. Because the project already exists on PyPI, it is configured against the project rather than as a pending publisher.

Rehearse with a release candidate. Cut the release branch, then tag vX.Y.Zrc1 on it and let it publish. It exercises the whole production path for real, is throwaway in the sense that no resolver installs a pre-release by default, and turns every step above from something never done into something done once. The alternative is finding out whether the trusted publisher matches on the release itself, where What Cannot Be Undone applies.

Rehearse the merge-back with it. That is the step with two settings to relax by hand, a merge method to pick that is not the default button, and two settings to put back, and it is the one whose failure is quiet: a squash lands, everything looks merged, and main goes on deriving a version below the release. Better to find the sequence of clicks on a release candidate than on the release.

Two gates will move at the first tag, and neither is a defect:

  • tests/test_api_docstrings.py::test_target_version_uses_the_project_version_scheme asserts the derived version is 0.1.0. That stays right at the v0.1.0 tag and goes wrong on the next commit after it, when semver-pep440-release-branch derives 0.2.0.dev…. #227 records the snapshot rules that take over from the current equality rule.

  • check_versionadded compares every published object against one target version, which is right only while nothing predates the first release.