| doc_radar |
|
|---|
We publish independently versioned open-source packages through one shared release lifecycle. This guide explains that lifecycle; each package keeps its own build and release commands in its contributor guide.
The live repository list shows the packages in the org.
Seven steps carry a change from a commit to a published artifact:
flowchart LR
Commit[Conventional Commit] --> Fragment[Towncrier Fragment]
Fragment --> PullRequest[Pull Request CI]
PullRequest --> Main[main]
Main --> ReleasePR[release-please App PR]
ReleasePR --> HumanGate[Fold notes + approve]
HumanGate --> Tag[Immutable version tag]
Tag --> Publish[OIDC registry publish]
Every step, spelled out:
- Conventional commit.
feat:/fix:/perf:/refactor:/deps:land in the changelog;docs:/test:/build:/ci:/chore:/style:stay hidden from it. The type is also what picks the next version — see What Picks the Number. - Towncrier fragment. A one- or two-sentence news fragment
(
changelog.d/+<slug>.<type>.md) lands in the same PR as the change it describes. This is the release note a person reads — the conventional commit type only drives versioning. - Pull request CI. Every repo runs its own build matrix (a Zig engine +
three bindings for the search family;
fmt/clippy/test/doc/msrv/denyfor the Rust-only repos) plus a stable, namedrelease-readyaggregate check. That single check is what the release preflight below waits on — it never re-derives the matrix. release-pleaseopens (or updates) a release PR. It reads the commit history since the last tag, proposes the next semver, and rewrites every manifest carrying anx-release-please-versionmarker (build.zig.zon,pyproject.toml,Cargo.toml, …) in one commit — so a Zig package with no native version-bump tooling still moves every mirrored number atomically. It also folds the fragments accumulated inchangelog.d/intoCHANGELOG.mdunder the proposed version, and keeps that fold current as new fragments land while the PR is open.- A human reads and approves. The release PR is the one manual gate in the whole pipeline: someone reads the folded notes, corrects prose a machine wouldn't get right, and merges.
- Merging tags
vX.Y.Z. The tag is what every downstream job keys off — the version a wheel, crate, or Go module publishes under is read from the tag, never typed by hand a second time. - The tag triggers the release pipeline, staged
preflight → build every channel → publish channels → verify channels → finalize the release, never publish-then-hope. The preflight and publish stages don't re-implement their checks per repository — every step below is one call into the sharedpackage-releaseaction (.github/actions/package-release/, used identically from every publishable repo's ownci.ymlandrelease.yml), so a fix to a check or its rejection message is one change, not seven:- Preflight re-derives nothing;
ci-statuswaits for and reads therelease-readycheck on the exact tagged commit,tag-ancestorconfirms the tag is reachable from the protected default branch,versionre-proves tag/manifest parity (every mirror carrying anx-release-please-versionmarker agrees, and agrees with the tag), andchangelog --require-fragments-emptyproves the fold actually happened — an exact## [X.Y.Z]heading, and nothing left un-folded inchangelog.d/— all on the tagged tree, not on whatever the branch has since become. - Build produces every artifact for every channel (PyPI wheel/sdist, crates.io package, Go module tag, GitHub Release) before anything irreversible happens, and proves a floor install/import on each one.
- Publish calls
registry-probebefore minting a credential:absentmeans publishable,presentmeans a prior run already got there (a successful retry, not a failure), anderror— the registry didn't answer — is never treated asabsent, so a network blip can't masquerade as a green light. A version whose registry bytes would genuinely differ from what's on disk is a hard collision that fails the run. A partial external outage — PyPI up, crates.io down — resumes by publishing only the missing channels on retry. - Verify re-resolves what was just published from the public index (PyPI, the sparse crates.io index, the Go module proxy), not from the job's own build output.
- Finalize flips the GitHub Release from draft to public and writes
its body from the curated
CHANGELOG.mdsection — nevergenerate_release_notes: true— only after every declared channel verified.
- Preflight re-derives nothing;
Cross-registry publish is not atomic and never will be: PyPI and crates.io have no shared transaction. The guarantee this pipeline makes instead is narrower and honest — no write starts before every local and CI precondition has passed, and any external partial failure is detected, immutable on the tag, and safely resumable, rather than silently repeated or silently abandoned.
Plain semver over the commit types in the window since the last tag. Every
repository here uses release-please's stock default strategy, and no
repository puts a thumb on the scale:
| In the window since the last tag | Next version |
|---|---|
any ! suffix, or a BREAKING CHANGE: footer |
major |
any feat |
minor |
anything else — fix, perf, refactor, deps, ci, … |
patch |
The largest change in the window wins; one feat among thirty fixes is a
minor. Read the corollary the other way too, because it surprises people: a
long unbroken run of minor releases is not the tooling refusing to cut a
patch. It means every window so far happened to carry a feature. A window that
genuinely holds no feat cuts a patch, with nothing to configure.
Below 1.0.0 the table shifts one column left, which is what
bump-minor-pre-major and bump-patch-for-minor-pre-major in a pre-1.0
repository's release-please-config.json are for: a breaking change takes the
minor rather than declaring 1.0.0 on your behalf, so 0.1.0 goes to 0.2.0
for both a break and a feature, and to 0.1.1 for everything else.
release-please reads those two settings only while the version is below
1.0.0. They go inert the moment a package ships 1.0.0, and should be deleted
from the config in that same release — left behind, they read like a bump
policy that is no longer being consulted, which is worse than absent.
To pin a number the table would not pick — a version chosen to line up with a
downstream pin, or a patch on a minor nobody cut — put a Release-As: footer
in a commit body on the default branch:
feat: name the package a listing governs, not the file it was written in
Release-As: 1.3.1
The newest such footer in the window wins and overrides everything above, including a breaking change. It is the one place in this pipeline where a human types a version, so type it deliberately: a release that skips a number is a question every downstream reader has to answer once.
A release page is written for the reader who was not following along. The
finalize step writes the body from the curated CHANGELOG.md section; what
follows is what a person owes the page on top of that fold — it is the prose
half of step 5, the one manual gate in the pipeline. irregex's releases are
the worked example.
- The title is
vX.Y.Z - <name>— a Title Case noun phrase or short sentence naming the release's one idea, informative before it is clever:v1.0.0 - The ABI Is a Promise,v2.1.1 - The Release Cuts Itself. Never a bare version, and never the package name repeated — the repository the page sits in already says it. - A narrative opens the body, above
### Added: two to four short paragraphs whose first sentence carries the whole release ("X.Y.Z is the release where …"). It names the biggest items with their real numbers, says what was wrong out loud rather than around it, and admits what the release does not do. The mechanism is anotetowncrier fragment, declared first in the config so it folds above the sections; on a page already published, edit the release body directly — the changelog file keeps only the fold. - The fold is the record. Below the narrative, the body is the exact
## [X.Y.Z]section fromCHANGELOG.md, unabridged — nevergenerate_release_notes: true, and never a summary assembled from commit subjects. A 1.0.0 whose fold outgrows GitHub's body ceiling links the changelog and carries the shape of it instead: the narrative, an Install section, thematic headed sections, and a closing section placing the package in the family. - The voice is the house shape. One idea per paragraph, three sentences as the ceiling. No tables and no horizontal rules. Bold only as a lead term inside a list item, never opening a paragraph. Headings are labels in Title Case. Links live inside sentences. A number in prose is checkable or absent. Instructions are imperative; descriptions take the subject as subject.
Every registry write authenticates with short-lived OIDC, minted per-run by
the registry itself — there is no PYPI_API_TOKEN or CARGO_REGISTRY_TOKEN
sitting in a repository secret to rotate or leak:
- PyPI — GitHub Actions OIDC exchanged for a scoped upload credential
(
environment: pypi,permissions: id-token: write), configured once per project as a PyPI Trusted Publisher naming this org, the repository, therelease.ymlworkflow, and thepypienvironment. - crates.io — the same OIDC exchange (
environment: crates-io), viarust-lang/crates-io-auth-action. A brand-new crate still needs one manualcargo publishwith an API token before crates.io will accept a Trusted Publisher configuration — crates.io requires the crate to already exist. - Go modules — no registry at all. The module proxy resolves a tagged commit directly from the repository; "publishing" is pushing the tag and then asking the proxy to ingest it.
An absent or misconfigured credential fails the publish job outright. None of these pipelines have an optional-token branch that quietly no-ops when a secret is missing — a channel either publishes or the run fails loudly on it.
irregex is the regex engine and the libirgx C-ABI floor; gist, relate,
and blast are three separate binaries built on top of it, each with its own
Python/Rust/Go bindings. They version independently — gist shipping a 1.4.0
says nothing about relate's number — but they are not independent at build
time:
- In CI and local development, every face resolves
irregex(and, forblast,relate) as a sibling checkout at its default branch —build.zig.zon's Zig dependency, the Goreplacedirective, theuvpath source, and the Rust path override all spell the same../../../irregex/...relative path. Nothing is patched for CI; the layout a contributor clones is the layout that builds. - In a published artifact, that path resolves to a real version range
read from the registry instead —
irregex>=1.0.0,<2ingist's andrelate'spyproject.toml, the equivalentirgx = { version = "1.0.0" }in eachCargo.toml.blastadditionally depends on the publishedgist-searchandrelate-searchranges. So there is a real publish order —irregex→ {gist,relate} →blast— and the release pipeline verifies it: a dependent face's floor-install check resolves against the registry, not a path, so it cannot race a dependency that hasn't landed yet. - The frozen baseline — the version all four crossed together, once, is 1.0.0, the release where the C ABI itself froze. They have diverged since; that starting line was never a promise to stay in lockstep.
This document deliberately names no roster of packages — that list is exactly the kind of fact that goes stale the day a new repository joins or an existing one graduates a tier, and the live repository list is always ahead of anything hand-maintained here. Read a repository's own tier from what it actually carries, not from this page:
- On the shared, fail-closed lifecycle — it keeps a
release.tomlmanifest at its root, arelease-please.ymlcalling thebilly-company-releaseApp, and aci.yml/release.ymlthat call the shared action below instead of hand-rolling their own version/changelog checks. That's the default for every active package in this org. - Pre-product, no release automation — it ships no
release.ymlat all, and its ownCONTRIBUTING.mdsays so plainly (a0.0.0version is usually the tell). Wiring release automation onto a package with nothing to version yet would document a promise the code doesn't keep. It graduates onto the shared lifecycle the day it ships something worth releasing — that's a change to its own repository, never to this page.
Every repository on the shared lifecycle calls the same composite action,
actions/package-release/, rather than keeping
its own copy of these checks. It reads that package's release.toml
manifest — canonical version source and its kind (a Zig build.zig.zon
field, a Cargo workspace version, or a single crate's own), the mirrors that
must agree with it, the changelog fragment directory and quality bar, the
registries it publishes to, and the name of its release-ready aggregate
check — and exposes seven commands (version, changelog, notes, ci-status,
tag-ancestor, registry-probe, selftest), each emitting one consistent,
::error::-annotated rejection for the fault it's checking rather than a
different error shape per repository. selftest runs offline, with no
network and no token, and checks command behavior against adverse fixtures.
Run it after touching the validation modules. A change to the lifecycle itself — a new rejection
class, a new registry, a new verification step — is a change to that one
action, not to seven copies of a workflow. Small, already-real drift that
isn't worth silently calling "standard" lives in
docs/alignment-backlog.md instead of being
glossed over here.
Our public package workflows use the billy-company-release GitHub App to
open release PRs and create tags where needed. Its installation tokens let
those events trigger downstream workflows.
Each consumer requests the permissions its step needs in its own
release-please.yml. Read that workflow for the package's authentication setup.
This document is the map, not the territory. Each repository keeps its own
CONTRIBUTING.md for what's genuinely local — wheel matrices, oracle
regeneration, its own contracts, the exact commands for its own release — and
links back here for the shared model rather than repeating it. Open that
repository's CONTRIBUTING.md directly; this page doesn't keep its own copy
of that link either.