Releasing#
SafeLint releases are automated. You never pull, tag, push, or click "create
release" by hand. The one deliberate action is the version bump in
pyproject.toml - that commit is what authorises a release. Everything after the
merge (tag, PyPI upload, GitHub release, CHANGELOG dating, branch sync) is done
by GitHub Actions.
The branch flow#
Work happens on a feature/* branch that PRs into development, never straight
into main.
- A pre-release version (
X.Y.ZrcN) releases fromdevelopment. - A final version (
X.Y.Z) releases frommain.
Cutting a release-candidate (RC)#
- On your
feature/*branch, when the work is ready, bumpproject.versioninpyproject.tomlto the next RC, e.g.2.9.0rc1(iteraterc2,rc3, ... on further rounds). - Add your CHANGELOG entries under
## [Unreleased]. Do not date the heading - the workflow does that at the final release. - Open the PR into
development, get it reviewed, and merge.
On merge, publish.yml runs: it sees a new pre-release version on development,
tags v2.9.0rc1, builds, publishes to PyPI (the pypi-rc environment, no
approval needed), and creates a GitHub pre-release whose notes are the
current [Unreleased] section.
Cutting a final release#
- Open a
development -> mainPR. - In it, flip
project.versionto the finalX.Y.Z(drop thercNsuffix). Leave the CHANGELOG heading as## [Unreleased]. - Merge.
On merge to main, publish.yml:
- auto-dates the CHANGELOG: renames
## [Unreleased]to## [X.Y.Z] - <date>, re-adds an empty## [Unreleased], updates the compare-link footers, and commits that tomain; - tags
vX.Y.Z, builds, and publishes to PyPI (thepypienvironment, which is reviewer-gated - a maintainer approves the production upload); - creates the GitHub release with notes from the freshly-dated
[X.Y.Z]section.
Then sync-development.yml resets development to main so the two branches
stay identical (and the next release branch always rebases cleanly).
What is manual vs automated#
| Manual (yours) | Automated (the workflows) |
|---|---|
| Choosing the version number | Creating and pushing the vX.Y.Z tag |
| Writing the CHANGELOG entries | Dating the CHANGELOG at the final release |
| Reviewing and merging the PRs | Building + publishing to PyPI |
| Creating the GitHub release + notes | |
Keeping development in sync with main |
Version numbers follow the project's semver rule: additive work (new rules,
languages, flags) is the next MINOR (X.Y.0); bugfixes and internal tooling
are the next PATCH (X.Y.Z); nothing is ever a MAJOR bump for additive work.
How the gate decides#
publish.yml runs on every push to main / development, but only releases
when all of these hold, otherwise it skips:
project.versionis not already tagged (so an ordinary push that did not bump the version does nothing), and- the version type matches the branch (a pre-release only releases from
development, a final only frommain).
This is why a merge that does not change the version, or a stray final version on
development, is a safe no-op.
The workflows and helpers#
.github/workflows/publish.yml- the branch-triggered release (gate -> build -> publish -> github-release). Its filename is kept aspublish.ymlso the PyPI Trusted-Publishing trust is unchanged..github/workflows/sync-development.yml- resetsdevelopmenttomainafter eachmainpush (reset-if-safe: only whendevelopmenthas no commitsmainlacks; otherwise it opens a tracking issue and fails, never clobbering work).scripts/changelog_section.py- extracts a CHANGELOG section as release notes.scripts/date_changelog.py- the final-release CHANGELOG date-stamp.bin/sync.sh- a local helper to fast-forward and checkmain/developmentbefore you start a feature (fast-forward-only, never discards local work).
One-time setup (maintainers)#
Already configured for this repo; documented here for reference and for forks:
RELEASE_PATsecret - a fine-grained PAT (contents: write) used to push the tag / date-stamp commit to protectedmainand to force-pushdevelopment. Its actor must be in the branch-ruleset bypass list.pypienvironment - with a required-reviewer protection rule (the final release gate).pypi-rcenvironment - no protection rule (hands-off RC publishing).- PyPI Trusted Publishing - the trusted-publisher entry (or entries) must
trust the
publish.ymlworkflow from both thepypiandpypi-rcenvironments (leave the Environment field blank for one entry, or add a second entry forpypi-rc).
The full design rationale, edge cases, and loop-safety notes live in
plan/release-automation.md in the repository.
If a release step fails#
The stages are independent jobs, so a later failure does not undo an earlier
success. For example, if the tag and PyPI upload succeeded but github-release
failed, the tag and PyPI release are already live - create the GitHub release
manually for that tag (gh release create vX.Y.Z --notes-file <notes>
[--prerelease]) and fix the workflow, rather than re-running (the gate would
skip a re-run because the tag now exists).