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 |
|---|---|
|
The suite, with image comparison enforced (Testing) |
|
Every pre-commit hook, over every file |
|
Builds the documentation and runs every gate over the result |
|
Assembles |
And three things that must be true:
The release branch is green. Not “was green” —
ci-tests,ci-docsandci-wheelsall 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 --draftrenders what the release will say; a pull request missing from it is a fragment that was never written, andci-changelogshould have caught it.The version you are about to tag does not exist on PyPI. See What Cannot Be Undone.
The Sequence#
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.
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-latesttochangelog-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 theincludeatA.B.rstand replacelatestwithA.Bin the toctree.
Both index edits are obligatory. The rename takes
latest.rstout of existence, so a toctree entry still naming it names nothing — on the one commit that gets tagged, built and published.changelog-vX.Y.Zdoes not exist yet — step 3 is what assembles the file that defines it. Apixi run docsrun 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.Assemble the changelog.
pixi run changelog --version X.Y.Z. This writesCHANGELOG.rstand deletes the fragments it consumed, so it is a commit of its own and the diff is worth reading before you make it.Fill in the release metadata.
CITATION.cfftakesversionanddate-released(YYYY-MM-DD).ci-citationvalidates the file, and it runs only when that file changes — so this step is the only thing that will ever check it.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-changelogasks 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 intoCHANGELOG.rstin 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-labeladds it only fordependabotandpre-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.Merge it, and wait for the release branch.
ci-wheelsruns again on the merge commit:manifestgatesMANIFEST.inagainst what the sdist carries, andbuildbuilds 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-testpypidoes not run here — it is scoped tomain, which is where the code reached Test PyPI before the branch was cut, and aworkflow_dispatchrun does not reach it either: the job asks for apushevent onmain, 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 thedistartifact, 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__)"
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_scmreads 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.Watch the wheels workflow. The tag push runs
ci-wheelsagain, and this timepublish-pypiruns instead ofpublish-testpypi. It is gated onbuildsucceeding, so a failure before that point publishes nothing.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-wheelssmoke-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
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-changelogfor the reason step 5 needed it: this pull request carries the fragment deletions as well, andci-changelogreads them the same way.The merge method is the whole point of this step, and it is not the default.
mainis configured for squash merges, and a squash would put the branch’s content onmainwhile leaving the tag off it — after whichsetuptools_scmgoes on deriving a version below the one just released. A merge commit makes the tag an ancestor ofmain, which movesmainto the next minor line while the release branch stays on the patch line:0.2.0.dev…and0.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.
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
latestback at the head ofwhatsnew/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
includealone: 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
mainbefore the merge-back it would leave two pages both claiming to be newest.Activate the version on Read the Docs. Versioned hosting (
stableandvX.Y) exists only once a tag does, so this step is possible only now.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 |
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:
A GitHub environment named exactly
pypi. The name is not a label — PyPI matches on it.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_schemeasserts the derived version is0.1.0. That stays right at thev0.1.0tag and goes wrong on the next commit after it, whensemver-pep440-release-branchderives0.2.0.dev…. #227 records the snapshot rules that take over from the current equality rule.check_versionaddedcompares every published object against one target version, which is right only while nothing predates the first release.