Releasing the software¶
How a merged commit becomes a tag, a PyPI release and a DOI. The companion to
Publishing a dataset, which covers the data-v* tags
instead — the two are deliberately separate, and pyproject.toml enforces the
separation.
Nothing here is triggered by hand. What a human does is merge the release pull request, and everything after that is automatic. That gate exists because of Zenodo: every GitHub Release mints a DOI, and a DOI cannot be retracted. A typo fix should not be able to mint a citable version of the software without someone deciding it should.
What the pieces are¶
Piece |
Decides |
|---|---|
Conventional Commit messages |
which section of the changelog a commit lands in, and whether it bumps |
|
the bump rules, the changelog sections, the tag format |
|
the version we are on now |
|
opens and maintains the release PR; publishes when it merges |
|
which tags |
No version string is committed anywhere. hatch-vcs derives it from
git describe, so the tag is the version and there is nothing to forget to
bump.
One-time setup¶
Six things, none of which can be done from a commit. Until they are done the release workflow is inert rather than wrong — it opens a release PR and stops.
Mint
RELEASE_PLEASE_TOKEN_SPECMOD. Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token.Field
Value
Repository access
Only select repositories →
SpecModContents
Read and write
Pull requests
Read and write
Expiration
up to 366 days — see the renewal note below
Then Settings → Secrets and variables → Actions → New repository secret, named exactly
RELEASE_PLEASE_TOKEN_SPECMOD.This is what makes the release PR run CI. Without it the PR is opened by
GITHUB_TOKEN, which starts no workflow runs at all, so the three required checks never report and the PR is blocked with nothing to fix. The token never touches PyPI — the upload authenticates by OIDC — so this does not reintroduce a publishing secret.It expires. When it does, the symptom is the old one returning: a release PR with no checks on it. Renew the token and update the secret; nothing in the repository changes. A GitHub App token via
actions/create-github-app-tokenavoids the expiry entirely and is the better answer if this becomes annoying.Let Actions open pull requests. Settings → Actions → General → Workflow permissions → tick Allow GitHub Actions to create and approve pull requests. Only needed as a fallback — with
RELEASE_PLEASE_TOKEN_SPECMODset, the PR is opened by a user, not by Actions — but leave it on so the workflow still works if the token lapses.Create the
pypienvironment. Settings → Environments → New environment →pypi.Required reviewers here are optional, and worth thinking about before adding: they hold the
publishjob atwaitinguntil a human approves the deployment, and that approval cannot be given from the GitHub mobile app — the button exists only on the web UI and the REST API. Merging the release PR is already a deliberate human act, andtools/check_built_version.pyis what actually prevents a wrong version reaching PyPI, so the reviewer gate buys less than it looks like it does.Register the trusted publisher on PyPI. On the project page (or, for the first ever upload, under Publishing → Add a new pending publisher):
Field
Value
Owner
sgjholtRepository
SpecModWorkflow
release.ymlEnvironment
pypiThe workflow name is matched against the OIDC token, so it must be the file that actually runs. If the workflow is ever renamed, this has to be changed in the same sitting or the upload fails authentication.
Turn on the Zenodo webhook. Log into Zenodo with GitHub, find
SpecModin the repository list, and flip the switch. It takes effect for releases made after it is on, so do it before the first release rather than after.CITATION.cffsupplies the metadata.Require the checks on
main. Branch protection → Require status checks to pass → add exactly three:ci,docs,build.Those are job names, not workflow names, and only these three are stable:
ciis an aggregator that fails unless every job intest.ymlsucceeded, so the sixtest (os, version)matrix checks do not need naming and the list survives a matrix change. Requiringtestor the workflow names instead matches nothing, and a required check that never reports blocks every merge.The release pull request goes through them like any other — but only because of step 1. Without that token no runs are created on it at all: there is nothing to approve and nothing to re-run, and the PR sits blocked behind an empty check list.
What a version number means while this is 0.x¶
SemVer, with the pre-1.0 rule taken literally: a breaking change bumps the minor, and there is no deprecation cycle. The package is alpha, the API is still being worked out, and shipping shims for names that are about to move again would cost more than it protects.
So 0.2.0 → 0.3.0 may rename things, and 0.2.0 → 0.2.1 will not. Anyone
depending on this for published work should pin an exact version; the
configuration stamp on every output covers the rest.
1.0.0 is the release that says the API has stopped moving, which is why
bump-minor-pre-major exists — it must be a decision, not something a feat!:
does on its way past.
The cycle¶
Merge work into
mainwith Conventional Commit messages. Nothing is released.release.ymlopens (or updates) a pull request titledchore(main): release <version>, carrying the generatedCHANGELOG.mdand the new version in.release-please-manifest.json. It accrues every commit since the last release.Read the changelog. This is the review, and the only one — after the merge nothing else is asked.
If the changelog carries anything on the roadmap, move it there first, in its own commit on
mainbefore merging the release PR. See below.Merge it. release-please creates the tag and the GitHub Release; the
publishjob builds the sdist and wheel from the tag, checks their version against it, and uploads to PyPI; Zenodo mints the DOI from the release webhook.
To skip a release, do not merge the PR. It stays open and keeps accruing.
Keeping the roadmap honest¶
The roadmap distinguishes In progress — not yet released from Shipped in vX.Y.Z, and that distinction is only worth anything if someone maintains it. Step 4 is where it is maintained, because that is the one moment the information exists: the changelog in front of you says exactly what this release carries.
Only entries move by hand. The version in “Where it is now” is a
{{ release }} substitution resolved at build time, so it needs no
maintenance and cannot disagree with the build it appears in.
A release that carries nothing from the roadmap — most patch releases — needs no edit at all. The test is whether an entry under In progress is in the changelog you just read.
Things that are the way they are for a reason¶
The publish job is in release.yml, not a separate publish.yml. The
obvious design — a workflow keyed on release: published — could not fire at
all when release-please ran on the default GITHUB_TOKEN, because “events
triggered by the GITHUB_TOKEN will not create a new workflow run”. So the
publish job gates on release-please’s own release_created output instead.
RELEASE_PLEASE_TOKEN_SPECMOD removes that constraint — a release created by
a PAT does start workflow runs — so a separate publish.yml would now work.
It is still not worth splitting: one workflow means one concurrency group, one
place to read, and no dependency on the ordering of two runs that would race
whenever two merges land together. The release_created gate is also exact,
where an event key would fire on any release including one made by hand.
Zenodo was never affected either way: it listens to the release webhook, which is not subject to the token restriction.
include-component-in-tag is false. Left on, the tag is
specmod-v0.2.0, which pyproject.toml’s --match v[0-9]* does not describe
and its tag_regex does not parse — so the build would fall back to
0.1.1.postN.devN and upload under that name, permanently.
tests/test_release_config.py asserts the two formats agree, and
tools/check_built_version.py checks the actual artefact between the build
and the upload.
bump-minor-pre-major is true. The history already contains two
breaking commits (feat!: make the package installable and importable,
fix!: default multitaper adaptive weighting off). Without this setting
either one reads as a 1.0.0 — a number that says the API has stopped
moving, minted with a DOI that cannot be withdrawn. Below 1.0 a breaking
change bumps the minor instead, which is what §3.1 of the refactor plan
assumes throughout.
The changelog sections are listed explicitly. The default preset hides
refactor, docs, build, test, ci, style and chore. Measured at
v0.2.0 over this repository’s 167 conventional commits, that default would
have printed 76 of them and dropped the other 91 — a release that is mostly a
refactor would ship an almost empty changelog. refactor, docs and build are shown; test,
ci, style and chore stay hidden.
The first release was enormous, and that was correct. There were no tags,
so the manifest declared 0.1.1 — the version the Magna paper cites, which is
the pre-refactor code. Everything since was genuinely the delta, so the first
changelog covered the whole refactor, and bump-minor-pre-major turned its two
breaking commits into 0.2.0 rather than a 1.0.0 nobody meant. Later
releases are ordinary.
Checking a release went out¶
PyPI: the version appears at https://pypi.org/p/specmod, and
pip install specmod==<version>in a clean environment works.The version is real:
python -c "import specmod; print(specmod.__version__)"from that install prints the tag without itsv, not a.postN.devN.Zenodo: a new DOI under the concept DOI, with
CITATION.cff’s metadata.Docs: Read the Docs builds the new tag as its own version and moves
stableonto it. Two things to check the first few times: that the tag was activated (an automation rule does it, see Documentation workflow), and that the new version’s sidebar shows the release number rather than0.0.0— the latter means the shallow-clone fix in.readthedocs.yamldid not take.latestmoved earlier, when the release PR merged.
If the publish job fails after the release exists — a transient PyPI error,
say — re-run that job from the Actions UI. Do not re-run release-please
expecting a second attempt: release_created is only true on the run that
created the release.
When the release PR has no checks on it¶
Not a red check — no checks at all, an empty list, and the PR blocked
because the three required ones never reported. This means release-please fell
back to GITHUB_TOKEN: either RELEASE_PLEASE_TOKEN_SPECMOD is unset, or it
expired, or its repository access no longer covers SpecMod. Renew it
(one-time setup, step 1); nothing in the repository needs changing.
To unblock the release PR that is already open, without waiting for a new
commit: close it and immediately reopen it. That re-fires
pull_request.reopened from your account rather than from the token, and the
checks run. It is the same trick that got v0.2.0 out, and it is safe — the
autorelease: pending label survives, so release-please still recognises the
merge as a release, and no history is touched.