Skip to Content
ProjectReleasing

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:

PlatformTarget
Linuxx86_64 (manylinux2014, glibc 2.17), aarch64 (manylinux_2_28, glibc 2.28)
macOSaarch64, x86_64
Windowsx64

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:

FieldValue
PyPI project nameligase-bio
OwnerNeoforge-Labs
Repository nameligase
Workflow namerelease.yml
Environment namepypi

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

  1. Make sure main is green, including the demo job.
  2. Move the CHANGELOG.md entries under [Unreleased] into a new dated version section, and add its link at the bottom.
  3. If anything in those entries can alter a pipeline’s results or refuse something that used to pass, write it in MIGRATING.md under 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, in pytest and again by step 7’s version job.
  4. Bump version in the workspace [workspace.package]. It is the single source of truth: the crate, the Python distribution and ligase.__version__ all inherit it, and a test asserts they agree.
  5. Rehearse. Run the workflow with dry-run: it builds every wheel, the sdist, verifies the wheel needs no toolchain, and runs cargo publish --dry-run, uploading nothing.
  6. Optionally run it with testpypi and install from there.
  7. 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.0 on a tree that says 0.2.0 would release something nobody named.
  • a version with no dated ## [X.Y.Z] - YYYY-MM-DD section in CHANGELOG.md — step 2 above, left undone.
  • a changelog section that points at MIGRATING.md where 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.

Last updated on