Releasing
Ligase ships twice from one tag: the engine as the ligase crate on
crates.io, and the Python package as ligase-bio on PyPI.
Publishing is never automatic. .github/workflows/release.yml runs on a
manual trigger whose default uploads nothing, and on a v* tag.
Why wheels matter here
rusqlite is built with its bundled feature, so building from source
compiles SQLite from C as well as the Rust engine. Someone installing from an
sdist needs a Rust toolchain and a C compiler. For the people this is for —
scientists, often in a notebook — that is a wall, so the release builds wheels
and the sdist is only a fallback.
Because crates/ligase-py is built against abi3-py311, one wheel serves
every Python from 3.11 upwards. The matrix is five builds, not twenty:
| Platform | Target |
|---|---|
| Linux | x86_64 (manylinux2014, glibc 2.17), aarch64 (manylinux_2_28, glibc 2.28) |
| macOS | aarch64, x86_64 |
| Windows | x64 |
A wheel that still needs a compiler would defeat the point, so the
verify-wheel job installs the Linux wheel into a fresh virtualenv with
cargo removed from PATH and fails if import ligase or ligase --help
does not work. It fails loudly if cargo is still reachable, because then the
test would prove nothing.
Why the two Linux wheels have different floors
manylinux: auto means manylinux2014, which is CentOS 7 and glibc 2.17. That
reach is worth keeping on x86_64, because a good deal of this audience is
given an elderly cluster login node and no say in it.
aarch64 cannot have it. The auto cross toolchain is GCC 4.8.5, which
defines __aarch64__ and not __ARM_ARCH, and that pair is exactly what
ring’s asm_base.h refuses:
include/ring-core/asm_base.h:73:2: error: #error "ARM assembler must define __ARM_ARCH"__ARM_ARCH is an ACLE macro that GCC gained for aarch64 after 4.8; the
manylinux_2_28 image’s GCC 7.5.0 defines it as 8, in both the C and the
assembler-with-cpp paths. So that one wheel is built in the newer image and
needs glibc 2.28 — no distro older than that ran aarch64 in anger, so the
reach being given up is theoretical.
This is worth knowing because of how it failed. ring is not a direct
dependency: it arrived in v0.2 underneath ureq’s rustls, which is why the
same workflow built v0.1.0’s aarch64 wheel without complaint. And
verify-wheel, pypi and crate all needs: the wheels, so one broken
target took the entire release with it.
One-time setup
Two steps need account access and cannot be done from a workflow or by an assistant. Do them once.
1. PyPI Trusted Publishing
Trusted Publishing lets PyPI verify the workflow’s OIDC identity, so no API token is ever stored in this repository. Create a pending publisher at https://pypi.org/manage/account/publishing/ with exactly these values:
| Field | Value |
|---|---|
| PyPI project name | ligase-bio |
| Owner | Neoforge-Labs |
| Repository name | ligase |
| Workflow name | release.yml |
| Environment name | pypi |
Repeat it on https://test.pypi.org/manage/account/publishing/ with the
environment testpypi if you want to rehearse against TestPyPI.
Then create the matching GitHub environments (Settings → Environments):
pypi, testpypi, and crates-io. Adding required reviewers to pypi and
crates-io is worth it — publishing cannot be undone.
2. A crates.io token
crates.io has no OIDC equivalent, so this one is a secret. Generate a token
scoped to publish-new and publish-update at
https://crates.io/settings/tokens , then add it as CARGO_REGISTRY_TOKEN in
the crates-io environment — not as a repository-wide secret, so it is only
readable by the job that publishes.
If you would rather not store a token at all, skip this and run
cargo publish -p ligase from your own machine after cargo login. The
workflow’s rehearsal step still checks the crate packages correctly.
Releasing
- Make sure
mainis green, including thedemojob. - Move the
CHANGELOG.mdentries under[Unreleased]into a new dated version section, and add its link at the bottom. - If anything in those entries can alter a pipeline’s results or refuse
something that used to pass, write it in
MIGRATING.mdunder a## <version>heading — what breaks, the symptom, and the exact edit — and link to it from the changelog entry. A changelog body is not a release signal (D57). The pair is checked, inpytestand again by step 7’sversionjob. - Bump
versionin the workspace[workspace.package]. It is the single source of truth: the crate, the Python distribution andligase.__version__all inherit it, and a test asserts they agree. - Rehearse. Run the workflow with
dry-run: it builds every wheel, the sdist, verifies the wheel needs no toolchain, and runscargo publish --dry-run, uploading nothing. - Optionally run it with
testpypiand install from there. - Tag and push:
git tag -a v0.1.0 -m "0.1.0" && git push origin v0.1.0. The tag publishes to both registries.
A manual run with publish does the same thing without a tag, for the
case where a tag already exists.
What the workflow checks before it publishes
Both registries are quiet about a version they already hold: PyPI’s
skip-existing passes over a file it has, and the crate job asks the index
first so a re-run or a retag skips cleanly rather than failing on
already exists. That is what makes a re-run safe. It is also what makes a
release carrying the wrong version number look like a success — v0.2.0 was
first tagged on a tree that still called itself 0.1.0, with the changelog
unrolled, and every step downstream would have skipped its work and reported
nothing wrong.
So a version job runs before either publisher, and refuses two things:
- a tag whose name disagrees with
[workspace.package].version.v0.3.0on a tree that says0.2.0would release something nobody named. - a version with no dated
## [X.Y.Z] - YYYY-MM-DDsection inCHANGELOG.md— step 2 above, left undone. - a changelog section that points at
MIGRATING.mdwhere that file has no section for the version — step 3 above, left half done. A pointer into an empty page is worse than no pointer, because a consumer who follows it stops looking (D57).
pypi and crate both needs: it, so neither can run without it. The second
check is also a test, in tests/test_import.py, so a bump whose changelog did
not move fails pytest before it ever reaches CI.
What cannot be undone
Both registries are append-only. crates.io has no delete, only cargo yank,
which stops new dependents resolving to a version but leaves it downloadable.
PyPI deletion is possible but the version number is burned forever: 0.1.0
can never be uploaded again, even deleted.
So the version number is spent the moment it uploads. Rehearse with dry-run
first — that is what it is for.