Branching and Releases¶
How changes travel from a feature branch to a published release, and what is mechanical versus what a maintainer decides.
The Branch Model¶
flowchart LR
A["feature/<topic>"] -->|PR| B[develop]
B -->|PR| C[main]
C --> D[release packages]
feature/*: all work happens here, one topic per branch. Branched fromdevelop.develop: the integration branch. Feature branches merge in by pull request; CI must be green before the merge button works.main: always releasable. Receives only pull requests fromdevelop, again gated on CI. Every merge tomainrebuilds the release packages; a merge that bumps the version also publishes them.
Both protected branches require exactly one status check: the aggregate ci-ok.
Requiring one stable check name means adding a CI job (or a matrix row) can never
silently escape the protection rules. ci-ok passes when every upstream job either
succeeded or was skipped by one of two deliberate conditions; a job that runs and fails
always fails the gate.
The two deliberate skips exist to keep billed minutes proportional to risk. First, a
change confined to docs/, mkdocs.yml, and overrides/ skips the whole test matrix
(the changes job classifies it); the docs gates, the executable doc examples, and the
strict site build still run on every change. Second, the expensive lanes — the two
middle interpreters and the macOS row, which bills at 10x Linux — run only on the pull
request into main, where a merge should prove the release is buildable, and not on
feature PRs into develop.
CI itself is triggered by pull requests into either protected branch, and by pushes to
main (which run the bracketed matrix only when code changed, never the main-PR-only
lanes; their verdict was already produced on the promotion PR). Pushes to develop do
not trigger it: develop only takes merges through a pull request that already had to
be green.
Promotions from develop to main use a merge commit, never a squash or rebase merge:
those mint new commit ids on main and permanently diverge the two branches. After the
promotion merges, fast-forward develop onto main so both branches point at the same
commit.
Versioning¶
- The single source of truth is
project.versioninpyproject.toml;anyinfer.__version__mirrors it and a test keeps them in agreement. - Pre-1.0, versions follow
0.MINOR.PATCH: breaking changes bump MINOR, everything else bumps PATCH. From 1.0.0 on, plain SemVer. - Bumping the version is an ordinary change: edit both files on a feature branch and let
it ride to
mainthroughdevelop. - Pushing that bump also starts the
changelog workflow,
which pins the accrued
## Unreleasedchangelog entries to the new version and opens a fresh empty## Unreleasedabove them. The entries themselves were written as each branch merged, so a bump publishes prose that has already been reviewed.
What a Release Is¶
The release workflow
runs on every merge to main:
- It reads
project.versionand checks whether tagv<version>already exists. -
It rebuilds the release artifacts, so
mainis continuously proven releasable:- the library: sdist + wheel,
twine check-ed and smoke-installed, on every merge; - the demo bundles: standalone PyInstaller builds of the pack-in demo app on
native runners for Windows (x64), macOS (arm64 and x64), and Linux (x64 and
arm64), named without a version
(
anyinfer-demo-<os>-<arch>.zip) so the site's downloads page can linkreleases/latest/download/URLs that never go stale; - the sidecar bundles: native builds on the same runners, named
anyinfer-serve-<os>-<arch>.zip, with a build-time--helpsmoke test.
Since freezing a PySide6 application on five runners (two of them macOS, at 10x Linux billing) is the most expensive thing this repository asks CI to do, the bundle matrix depends on the version: a version bump builds all five, and an unchanged version builds only the Linux x64 canary that catches a change breaking the frozen build at all. Platform-specific freeze breakage therefore surfaces at the version bump rather than at the merge before it; no release can be cut without all five going green. 3. Only when the version is new does it tag
v<version>and create the GitHub Release, with every package attached and aSHA256SUMSfile covering every artifact. An unchanged version (docs-only merges, CI tweaks) leaves what it built as workflow artifacts and cuts nothing; releases stay 1:1 with versions.The release notes are that version's section of
CHANGELOG.md, sliced out verbatim, plus a compare link. They are not generated from merged pull request titles: those record how the work was organized rather than what changed, and a partner reading "Develop by @anthturner in #3" learns nothing. Because the notes and the changelog are the same bytes, they cannot drift apart, and because the section was reviewed on the branch that bumped the version, nothing unreviewed reaches the release page. - the library: sdist + wheel,
Publishing a release therefore takes exactly one deliberate act: merging a version bump
to main. There is no separate tagging step to forget or get wrong, and a release's tag
always points at the exact commit it was built from.
The docs site redeploys on every merge to main and again when a release publishes, so
the site and the newest release never disagree for long.
PyPI¶
The release workflow's publish-pypi job uploads the library distribution to
PyPI on the same condition that cuts a GitHub
Release: a new version on main. It downloads the library-dist artifact rather than
rebuilding, so what lands on the index is byte-for-byte what twine check passed, what
the smoke test installed, and what is attached to the release — one build, three
destinations.
Uploads authenticate by Trusted Publishing (OIDC): PyPI mints a short-lived credential for a workflow run whose repository, workflow file, and environment match the project's publisher configuration. No API token exists in this repo's secrets, so there is none to leak or rotate.
Because publishing is irreversible (a version number on PyPI can be yanked but never
reused), the job runs in the pypi environment, which is where a required-reviewer gate
belongs if you want a human to approve each upload. The version bump is still the single
deliberate act; the environment just adds a pause before the copy leaves the building.
Checklist for Cutting a Release¶
developis green and contains everything the release should.- Read
## UnreleasedinCHANGELOG.md: it is what the release will say. Fix anything wrong there first. - On a feature branch: bump
project.versionandanyinfer.__version__. Push, and the changelog workflow pins those entries to the new version. - PR into
develop; merge when green. PRdevelopintomain; merge when green. - Watch the release workflow attach
v<version>and publish the wheel to PyPI. - Verify the downloads page, checksum file, and PyPI project page.
- Confirm the artifact-signing posture is what you intend to ship, and that it matches
what the downloads page tells users. Today that means: the wheel and sdist go to PyPI
through Trusted Publishing (OIDC, no long-lived token),
SHA256SUMSis attached to the Release, and native bundles are not code-signed. Signing and notarization are a tracked release-infrastructure gap, not an oversight — if that changes, this step and the note below change with it.
If a step fails, when a release goes wrong lists the recovery for each failure mode.
Native beta bundles are not code-signed. macOS Gatekeeper and Windows SmartScreen may therefore require an explicit local approval. Signing and notarization require external certificates and are a release-infrastructure follow-up; the wheel, source distribution, checksums, and reproducible workflow remain the authoritative 0.1 release path.
When a Release Goes Wrong¶
Publishing is the only irreversible step in this repository: a version number on PyPI can be yanked but never reused, not even after deleting the file. Recovery by failure mode:
| Symptom | What happened | What to do |
|---|---|---|
Release cut, publish-pypi failed |
The publisher fields or the pypi environment do not match the run |
Fix the registration on PyPI, then re-run the failed job from the Actions run page. |
| Upload rejected: file already exists | That version was uploaded before | Nothing to recover. Bump to the next patch version and let it ride to main again. |
| A published version is broken | It is on the index and installable | Yank it (Manage → Releases → Yank): resolvers stop selecting it while existing pins keep working. Then release a fix. Deleting instead burns the number permanently. |
| Release cut from the wrong commit | The tag points somewhere unintended | Delete the GitHub Release and its tag, fix main, and bump the version; reusing the tag would disagree with whatever PyPI already accepted. |
| Run stuck before uploading | The pypi environment is waiting on a required reviewer |
Approve the deployment on the run page. A run left pending is failed automatically after 30 days. |
Re-running publish-pypi never rebuilds: it downloads the same library-dist artifact the
build job produced, so a retry cannot ship different bytes than the ones already attached
to the GitHub Release. That artifact is subject to the repository's normal artifact
retention (90 days by default); after it expires, re-run the whole workflow rather than
the single job.