Releasing signalJourney¶
A release is a pull request that sets the version and the changelog, followed by one tag push.
Everything after the tag push is automatic, and scripts/release_check.py is the one place the rules live: CI and the publish workflow both call it, and tests/test_release_check.py tests it.
The rules¶
- Tags are exactly
vX.Y.Z, digits only.v0.2,0.2.0andv0.2.0-rc1are refused, and a refused tag publishes nothing. - The version has one source:
__version__insrc/signaljourney_validator/__init__.py.pyproject.tomldeclaresdynamic = ["version"]and reads it, so one edit per release cannot drift. - The tag equals
v+__version__, which equals the version in the built wheel and sdist, and the newest dated entry ofCHANGELOG.md. That entry must not be empty, and## [Unreleased]must be empty, so nothing unreleased can ship under a tagged version's notes. The changelog structure and thepyproject.tomlversion rules are checked again at tag time. - The GitHub release is titled exactly with the tag (
v0.2.0), and its notes are the changelog entry. - A tag is never moved, reused or deleted. To fix a bad release, release the next version. An existing PyPI file name can never be uploaded again, so a reused version cannot be published even if the tag could be.
- Every code change updates the changelog.
A pull request that touches
src/,pyproject.tomlorMANIFEST.inmust changeCHANGELOG.mdinside## [Unreleased], or carry the labelno-changelog. TheChangelogworkflow checks this (moving a file out ofsrc/counts as a change), and it also requires the Keep a Changelog headings (Added,Changed,Deprecated,Removed,Fixed,Security) and an untouched0.1.0section, which predates these rules and is frozen.
Version policy¶
Minor versions are reserved for huge breaking changes. Every other release is a patch bump.
Making a release¶
- In a pull request into
main, move the contents of## [Unreleased]under a new heading## [X.Y.Z] - YYYY-MM-DD(today's date), leave## [Unreleased]in place and empty above it, and set__version__ = "X.Y.Z". Choose the number by the version policy. - Check it locally:
uv run python scripts/release_check.py static uv run python scripts/release_check.py notes X.Y.Z # the release notes uv build && uv run python scripts/release_check.py tag vX.Y.Z --dist dist/ # needs [Unreleased] empty uv run --with pytest --with pytest-cov pytest tests/test_release_check.py -q - Merge the pull request once
Continuous IntegrationandChangelogare green. - Tag the merge commit and push the tag, from an up-to-date
main:git tag -a vX.Y.Z -m vX.Y.Z <merge commit> git push origin vX.Y.Z
What the tag push does¶
.github/workflows/publish.yml runs these jobs in order; each needs the one before it.
| Job | What it does |
|---|---|
check |
The tagged commit is on main, and release_check.py tag passes. |
build |
uv build, a check that the installed wheel carries every schema file and validates an example, release_check.py tag --dist dist/, and an upload of dist/. |
testpypi |
Publishes dist/ to TestPyPI (environment testpypi). |
smoke-testpypi |
First requires every file in dist/ to have the same sha256 as the file TestPyPI holds for that version (release_check.py digests); a difference fails the run at once. Then, in a fresh virtual environment, installs signalJourney==X.Y.Z from TestPyPI with --no-deps, installs the package's own Requires-Dist from PyPI, runs uv pip check, and validates a packaged example. It retries up to 5 times, 30 seconds apart, only while the index does not list the files yet. No dependency ever comes from TestPyPI. |
pypi |
Publishes the same dist/ to PyPI (environment pypi). |
release |
Creates the GitHub release with --title "$TAG" and the changelog entry as notes. This is the only job that can write to the repository. |
Publishing uses trusted publishing, so no token is stored.
Both indexes must have publish.yml registered as a trusted publisher of the project signalJourney, with the environments testpypi and pypi.
When a run stops half way¶
Use Re-run failed jobs, not Re-run all jobs.
Builds are not reproducible, so a full re-run builds different bytes: TestPyPI keeps the first upload of each file (skip-existing), and PyPI refuses a second upload of a file name, so the files could no longer be the ones that were smoke-tested.
The digest comparison in smoke-testpypi exists to catch exactly that, and it fails closed.
Finished jobs keep their results and their dist artifact, which is why a failed-job re-run is safe.
checkorbuildfailed. Nothing was published. Fix the cause onmainthrough a pull request, then release the next version, because a tag is never moved.testpypifailed. PyPI has not been touched. Re-run the failed job; if it cannot succeed, fix the cause and release the next version.smoke-testpypifailed on a digest mismatch. TestPyPI holds other bytes than this run built, usually because an earlier run of the same tag uploaded first. Nothing reached PyPI, and it must not: bump the version through a release pull request and push a new tag. Never re-tag, and never retry past a mismatch.smoke-testpypifailed for another reason (an index delay beyond the retries, a broken install). PyPI has not been touched. Re-run the failed job for a delay; otherwise fix the cause and release the next version, and TestPyPI may keep the failed version.pypifailed. Re-run the failed job after the cause is gone. If PyPI reports that files already exist, check what it holds:https://pypi.org/project/signaljourney/X.Y.Z/#files.- Everything was uploaded (the wheel and the sdist, with the digests of the
distartifact): the version is complete and thereleasejob was skipped; create the release by hand. - Only one file was accepted (for example the wheel, not the sdist): the version cannot be completed, because PyPI never accepts a file name twice and a rebuilt file would differ from the smoke-tested one.
Yank that version on PyPI with a note that it is incomplete, and release the next version.
Do not upload the missing file by hand.
To create the release by hand with the same title and notes:
uv run python scripts/release_check.py notes X.Y.Z > notes.md gh release create vX.Y.Z --title vX.Y.Z --notes-file notes.md --verify-tag releasefailed. Re-run the failed job: an existing release is edited to the same title and notes, so the result is the same.
A version published to PyPI cannot be replaced. A broken release is yanked on PyPI and superseded by the next version.
How the checks are protected¶
The Changelog workflow runs the base branch's copy of scripts/release_check.py against a pull request, with python3 -I so a file in the checkout cannot shadow the standard library.
That is a convenience and not a security boundary.
Under pull_request GitHub runs the pull request's own copy of the workflow file, so a pull request that edits changelog.yml can change or skip the step, and one that edits the script is judged by the base copy only while the workflow is left alone.
The pull request's own copy of the script is exercised by its tests in Continuous Integration, which the same pull request can also edit.
The first pull request that introduces the script has no base copy, and is judged by its own.
What enforces the rules is the repository: a ruleset on main that requires the Changelog and Continuous Integration checks and a review from the code owners of .github/workflows/ and scripts/release_check.py (add those paths to a CODEOWNERS file and turn on "Require review from Code Owners").
Without that review, the checks stop mistakes, not a determined change to the checks themselves.
Recommended repository settings¶
These belong to the repository owner; nothing in the workflows changes them.
- A tag ruleset on
v*(patternv[0-9]*.[0-9]*.[0-9]*) that limits creation to maintainers and blocks update and deletion. - Required reviewers on the
pypienvironment, so a tag push waits for a person before anything reaches PyPI. - A
mainruleset that requires theContinuous Integrationjobs and theChangelogcheck, and requires a code-owner review of.github/workflows/andscripts/release_check.py(with those paths inCODEOWNERS). - Trusted publishers registered for
publish.ymlwith the environmentstestpypiandpypi.