Release runbook
How to ship a locus-tag patch to PyPI, crates.io, GitHub Releases, and the versioned docs site. Two workflows fan out from a tag push:
.github/workflows/release.yml— wheels (PyPI), crate (crates.io), and the GitHub Release page with CHANGELOG-extracted notes + attached artefacts..github/workflows/docs.yml— versioned docs site, deployed togh-pagesviamike.
cargo-release (config:
release.toml)
drives the version bump locally; CI takes over once the v* tag
lands. This document is the operator's end-to-end view.
1 — Topology
A v* tag push fans out as follows:
release.yml:
verify-tag (refuses mismatched tags)
├─► build-rust (cargo package -p locus-core)
├─► build-wheels-linux (manylinux + musllinux × x86_64/aarch64)
├─► build-wheels-macos (x86_64/aarch64)
├─► build-wheels-windows (x64)
└─► build-sdist
│
▼
wheel-validate (smoke on x86_64 + aarch64 QEMU)
│
┌──────────────────────────────┼──────────────────────────────┐
▼ ▼ ▼
publish-pypi publish-crates-io create-github-release
(OIDC + PEP 740) (OIDC, locus-core) (notes + artefacts; --prerelease on RC)
docs.yml:
build + spell ─► deploy (mike → gh-pages, X.Y + stable alias)
verify-tag gates publishing: tags ending in -rc.* set
outputs.publish=false, both publish jobs skip, and
create-github-release runs with --prerelease. The docs deploy job
deliberately skips RC tags so the stable alias keeps pointing at the
last real release.
A monthly cron (0 6 1 * *) on release.yml exercises only the Linux
wheel build to catch toolchain drift between releases. macOS (10×
billing) and Windows (2× billing) are reserved for actual release
builds.
Neither publish job is wrapped in a GitHub environment: for now —
GitHub Actions only enforces required-reviewer protection on private
repos under Pro/Team tiers. The safety net is verify-tag plus
deliberate tag pushes. When this repo upgrades to a tier that enforces
environment protections, set environment: release on publish-pypi
and publish-crates-io and add reviewers there.
2 — One-time setup
Done once per repo, then the per-release flow takes over.
2.1 PyPI Trusted Publisher
At https://pypi.org/manage/project/locus-tag/settings/publishing/, add a Trusted Publisher entry:
| Field | Value |
|---|---|
| Owner | NoeFontana |
| Repository | locus-tag |
| Workflow | release.yml |
| Environment | (leave blank) |
When the repo eventually goes public — or this account upgrades to a
tier that enforces environment protection rules — set Environment to
release here and add environment: release to the publish-pypi
job.
2.2 crates.io Trusted Publisher
crates.io scopes Trusted Publishers per crate. At https://crates.io/me/trusted-publishers add:
| Crate | Repository | Workflow | Environment |
|---|---|---|---|
locus-core |
NoeFontana/locus-tag |
release.yml |
(blank) |
locus-py is not published (publish = false in its Cargo.toml).
2.3 gh-pages bootstrap (for mike versioned docs)
- Push the docs workflow → first deploy lands a
gh-pagesbranch withmain+latest. - Repo Settings → Pages → Source: Deploy from a branch, Branch: gh-pages / (root).
- Locally (one-shot, by a maintainer with push perms):
This redirects
uv run mike set-default --push latesthttps://noefontana.github.io/locus-tag/to/locus-tag/latest/. After the first real release tag fires (which updatesstable), switch the default:uv run mike set-default --push stable
3 — Per-release flow (operator)
3.1 Pre-release checklist
- [ ] All feature branches merged into
main. - [ ] Working tree clean.
- [ ] On
mainwith latest pulled. - [ ]
cargo nextest runandpytestgreen on the latestmain(run via thetestingskill). - [ ] CHANGELOG block exists for the new version. Under
## Unreleasedconfirm the entries are real and complete; thecreate-github-releasejob will fail if the resulting section is empty or missing.
3.2 Cut the version bump
Create a release branch from latest main:
git checkout -b release-vX.Y.Z
Run cargo release with --no-publish (CI handles the publish):
# patch (perf/bugfix)
cargo release patch --execute --no-publish \
--allow-branch release-vX.Y.Z --config release.toml
# minor (breaking / structural)
cargo release minor --execute --no-publish \
--allow-branch release-vX.Y.Z --config release.toml
cargo-release bumps locus-core and locus-py to the new version,
runs uv lock (via pre-release-hook), updates pyproject.toml
(via pre-release-replacements), commits, tags vX.Y.Z, and pushes.
Edit CHANGELOG.md in the same branch: rename ## Unreleased to
## [X.Y.Z] - YYYY-MM-DD and start a fresh empty ## Unreleased block
above it. Amend the bump commit (or add a second commit) and force-push.
3.3 Open the back-merge PR
Open release-vX.Y.Z → main via gh pr create --draft. Wait for
the standard PR-gate CI (ci.yml) to go green, then mark ready and
squash-merge once approved. The v* tag was already pushed in §3.2,
so release.yml may already be running — that's fine, it operates off
the tag, not the PR.
3.4 Watch the publish
gh run watch <release.yml run id>until all 8 jobs are green.- Verify the GitHub Release page renders the CHANGELOG body and lists all wheels + sdist + .crate as assets.
- Verify on https://pypi.org/project/locus-tag/ that the new version is live with a sigstore attestation badge (PEP 740).
- Verify on https://crates.io/crates/locus-core that the new version is live.
- Verify the docs site:
/locus-tag/X.Y/exists and/locus-tag/stable/redirects there. Themike deploystep also updateslateston the next push tomain.
4 — Prerelease convention
We use -rc.N by convention but the publish gate is a strict
positive match on bare semver (v[0-9]+.[0-9]+.[0-9]+). Any tag
with a -X suffix — -rc.N, -rc1 (no dot, common typo), -alpha.N,
-beta.N, -dev, +build.42 — routes to the dry-run path and
does not publish to either registry. This is symmetric across
release.yml (publish gate) and docs.yml (stable-alias update).
# Bump to 0.5.1-rc.1 locally (no automation — edit manifests by hand
# and tag manually, or use `cargo release rc` if available).
git tag -a v0.5.1-rc.1 -m "dry-run release build (build + smoke, publish skipped)"
git push origin v0.5.1-rc.1
What happens:
- verify-tag strips the prerelease suffix (${tag%%-*}) before
comparing against on-disk versions, so the manifests can stay at
0.5.0 until the real bump.
- The publish gate's strict regex ^[0-9]+\.[0-9]+\.[0-9]+$ rejects
the suffixed tag → outputs.publish=false → publish-pypi and
publish-crates-io skip.
- All build-wheels-*, build-sdist, and wheel-validate jobs run
(including musllinux smoke under python:3.11-alpine).
- create-github-release posts a GitHub Release with body extracted
from ## [0.5.1] if it exists, otherwise ## Unreleased with a
one-line "notes inherited" preamble. The Release is marked
--prerelease. The step is idempotent — re-tagging the same name
updates notes + re-uploads artefacts via gh release upload
--clobber instead of failing.
- docs.yml deploy job skips the tag-deploy step entirely for any
prerelease tag, so stable stays at the last real release.
When the dry-run is green, delete the RC GitHub Release (the tag can stay for history), then follow §3 to ship the real version.
5 — Rollback
5.1 PyPI
PyPI permits yank but never re-upload of the same version. To withdraw a botched release:
# from the PyPI project page → "Manage release" → "Yank"
# or via twine / pypi-cli
Yanked versions can no longer be pip install'd without an exact
== pin, but stay available for reproducibility. To ship a fix,
publish under X.Y.Z+1 — there is no rollback to a previous version
number.
5.2 crates.io
crates.io permits cargo yank only as a deprecation signal:
cargo yank --version X.Y.Z --crate locus-core
Yanked versions can no longer be selected by a fresh resolve, but
existing Cargo.lock files continue to use them. To ship a fix,
publish under X.Y.Z+1.
5.3 GitHub Release
gh release delete vX.Y.Z --cleanup-tag # drops the GH Release AND the tag
# Tag deletion is destructive — only do this if you also intend to
# re-tag the same SHA. Otherwise:
gh release delete vX.Y.Z # keeps the tag for history
5.4 Docs (mike)
# Remove the versioned page entirely
uv run mike delete --push X.Y
# Or just rebuild it from a different ref
git checkout vX.Y.Z-fix
uv run mike deploy --push --update-aliases X.Y stable
5.5 Partial-failure scenario: publish-pypi succeeded, publish-crates-io failed
The most common rollback case. publish-pypi succeeded and the wheel
is live; publish-crates-io failed (e.g., transient network, OIDC
hiccup, or crates.io index lag). Options:
- Re-run only the failed job if the failure is transient
(
gh run rerun <id> --failed). Most cases resolve here. - If the failure is permanent (e.g., a typo'd
Cargo.tomldescription), yank the PyPI release and bump toX.Y.Z+1for both registries.
The reverse case (publish-crates-io succeeded, publish-pypi
failed) is symmetric: yank the crate, bump, re-tag.
6 — Release history
| Tag | Date | Type | Notes |
|---|---|---|---|
v0.5.0-rc.1 |
2026-05-20 | RC dry-run | Validated pipeline before v0.5.0 |
v0.5.0 |
2026-05-20 | Minor | Unified JSON-profile config refactor |
Append new entries here as releases land.