Release architecture¶
For: whoever is cutting the next release, and anyone reviewing a change to
.github/workflows/release.yml. If you want to install Mira rather than
publish it, you want Install.
One binary, one chart, one number¶
Mira publishes four coordinates and they all carry the same version string:
| Coordinate | Where |
|---|---|
Tarballs, SBOM, SHA256SUMS |
GitHub Release assets on the vX.Y.Z tag |
| Multi-arch image | ghcr.io/trianalab/mira:X.Y.Z (and :latest) |
| Helm chart | ghcr.io/trianalab/charts/mira:X.Y.Z |
| Three crates | miradb, miradb-core, miradb-proto on crates.io |
One number across all four, so there is nothing to compute: bumping is an edit
to Cargo.toml and a make drift run.
The crates are miradb-* because mira on crates.io is an unrelated crate
from 2024. Only the registry knows those names: the dependency keys, the use
paths, the [lib] names and the installed binary are all still mira.
Cargo.toml's [workspace.dependencies] block is where that is set up.
Cutting a release¶
- Write the changelog section first, under
## [Unreleased]inCHANGELOG.md. This is not optional decoration: the release job extracts that section verbatim as the Release notes, and an empty one degrades to--generate-notes. Mira does not use Conventional Commits, so generated notes are a list of imperative prose subjects — worse than what you would have written. Step 2 refuses if you skip this. - Bump the version.
Cargo.toml's[workspace.package] versionis the source of truth and everything else restates it;bumpwrites all of them and promotes the changelog section you just wrote,driftchecks it. The full list, and what guards each one, is under Where the version lives. - Open a normal PR. The point is to make CI run
drift,chartanddocsagainst the bumped tree before a tag can be cut from it. - Tag the merged commit.
metare-checks the tag againstCargo.tomland fails the whole run before anything builds if they disagree, becausemira --versionprintsCARGO_PKG_VERSIONand nothing downstream can fix that afterwards. - Watch
verify-release. It is the terminal job and the only one whose success means anything to a stranger.
A tag with a suffix — v0.2.0-rc.1 — is marked a GitHub prerelease, and a plain
vX.Y.Z is not, including while Mira is pre-1.0. That is deliberate:
releases/latest excludes prereleases, and that endpoint is what
scripts/get-mira.sh resolves when no --version is given, so flagging 0.x
would turn the install one-liner off. The pre-1.0 warning lives in CHANGELOG.md
and SECURITY.md instead.
To rehearse without publishing, run the workflow from the Actions tab:
workflow_dispatch sets publish=false and version=<crate>-dev.<sha7>, and
every push, sign and upload step is skipped.
Two irreversible facts¶
A published coordinate is immutable. Every publisher asks the registry
whether the coordinate already exists and refuses rather than overwriting it. So
a re-run of a tag that partly published is a red run, not a silent swap of
bytes somebody has already verified against SHA256SUMS. Recovery from a failed
publish is a version bump — never a retag, never a force-push of the tag.
:latest moves on the image. By the time an image push has happened,
ghcr.io/trianalab/mira:latest points at it whether or not the Release was ever
created. There is no unwinding that except by publishing a newer version.
Both are why concurrency here is deliberately not cancel-in-progress: a
cancelled release is a half-published one, and letting a superseded run finish is
strictly cheaper than reconciling that by hand.
The job graph¶
Every job needs: meta, and every job is in verify-release's needs closure —
scripts/check_ci.py enforces that statically, which is how a publisher that
silently skips is caught at PR time rather than discovered in a green run that
published nothing.
meta ──> build (×4 targets) ──┬─> package ──────────┐
└─> image ──> chart ──┴─> release ──> crates ──> verify-release
chart is downstream of image, not a sibling of it: the chart advertises an
image coordinate, and publishing a chart that points at an image which failed to
push is the one ordering mistake that produces a green run and a broken
helm install.
meta computes version, publish and prerelease once. Deriving them
per job is how a release ends up tagged v0.2.0 with a binary that prints
0.1.0.
build is a four-way matrix: {x86_64,aarch64}-unknown-linux-gnu on
ubuntu-22.04 / ubuntu-22.04-arm, {x86_64,aarch64}-apple-darwin on
macos-15. The Ubuntu images are pinned to 22.04 rather than -latest because
that is the GLIBC_2.34 floor the README promises; make glibc-floor asserts it
on the built binary. Each leg runs make dist-tarball; the x86_64 Linux leg also
runs make dist-sbom.
package merges the artifacts, runs make dist-sums, and raises one SLSA
provenance attestation over dist/SHA256SUMS — so every tarball and the SBOM
are subjects of it transitively, at the cost of one attestation instead of six.
image untars the two Linux binaries into dist/linux/{amd64,arm64}/mira
and builds with BIN=prebuilt, so neither stage runs a command and a
linux/amd64,linux/arm64 build is buildx copying files the host already has —
no QEMU. It refuses an existing coordinate before pushing, because
gh release create would only refuse the duplicate after :latest had already
moved.
chart passes --version/--app-version "$VERSION" to helm package, which
overrides Chart.yaml without editing the tree. It also pushes a
:artifacthub.io OCI artifact — the repository-ownership proof Artifact Hub
looks for.
release calls gh release create. gh is preinstalled on the runner and
does exactly this, so there is no third-party release action holding a write
token.
crates runs make publish — cargo publish --workspace, which works the
order out of the dependency graph and waits for the index to serve each member
before building the next. It is last because it is the least reversible thing
the workflow does: a ghcr tag can be overwritten and a GitHub Release deleted,
but a crates.io version is consumed on upload and cargo yank only hides it.
It needs a CARGO_REGISTRY_TOKEN secret, and fails with a message naming it if
it is missing — everything else has already published by then, so the fix is to
re-run the job, not to bump.
verify-release throws away every artifact and output the run produced,
checks out nothing, re-downloads what a stranger would download, and verifies it
with the same commands Install tells a stranger to run. Its
first step is the only one that does not authenticate, and it is there for the
trap below.
A package's first push is private¶
GitHub creates a ghcr.io package private, and the release that publishes a
coordinate for the first time is the release that creates it. Nothing in the
workflow can change that — visibility is a setting on the package, not a field
in a manifest, and there is no API for it.
So v0.0.1 published an image and a chart that nobody could pull. docker run
ghcr.io/trianalab/mira and helm install oci://ghcr.io/trianalab/charts/mira —
the two lines the README hands a reader — answered DENIED, Artifact Hub's
first tracking pass failed with the same error, and the run was green, because
every step that touched the registry had logged in first.
verify-release now asks for an anonymous pull token before it authenticates,
for both coordinates, and fails the release if either is refused. That failure
is not a broken release: the bytes are published and correct, and the fix is the
package's own settings page rather than a version bump. It is the only check
here whose remedy is a click.
The crates have the mirror-image problem and it is checked the same way: the
crates job knows the upload returned 200, which is not the same as a stranger
being able to resolve it. verify-release reads index.crates.io — the sparse
index Cargo itself resolves against, not the API — for all three names.
What is signed, and what is not¶
| Artifact | Checksummed | Cosign | SLSA provenance |
|---|---|---|---|
| Release tarballs | SHA256SUMS |
— | via SHA256SUMS |
| CycloneDX SBOM | SHA256SUMS |
— | via SHA256SUMS |
SHA256SUMS itself |
— | — | yes, directly |
Image (mira:X.Y.Z) |
digest | yes, over the digest | yes, pushed to the registry |
Chart (charts/mira:X.Y.Z) |
digest | yes, over the digest | — |
:artifacthub.io metadata |
— | — | — |
crates (miradb*) |
registry .crate checksum |
— | — |
Everything signed is signed over its digest, never over a tag. A tag is a mutable pointer; a signature over one says nothing about the bytes that came back.
The image signature is written in the legacy .sig-tag layout
(--new-bundle-format=false --use-signing-config=false) rather than as an OCI
referrer. That is not a preference — Artifact Hub does not read referrers, and an
unverifiable badge on the page people land on is worse than an old-format
signature.
verify-release re-runs gh attestation verify and both cosign verify calls
with the certificate identity pinned to release.yml@refs/tags/, so a signature
raised by any other workflow, or on any other ref, fails the release.
Where the version lives¶
Cargo.toml's [workspace.package] version is the source; everything below
restates it, and the right-hand column is what stops it rotting.
| Site | Written by | Gate |
|---|---|---|
Cargo.toml [workspace.package] |
make bump |
the source |
Cargo.toml miradb-core / miradb-proto path-dep pins |
make bump |
make drift |
charts/mira/Chart.yaml — version, appVersion, the scanned image tag |
make bump |
make drift |
charts/mira/tests/statefulset_test.yaml |
make bump |
make drift, and the chart suite |
docs/install.md — --version v, V=, helm install --version, the chart coordinate |
make bump |
make drift |
README.md — --version v |
make bump |
make drift |
SECURITY.md — the supported-versions line |
make bump |
make drift |
.github/ISSUE_TEMPLATE/bug_report.yml — the mira X.Y.Z placeholder |
make bump |
make drift |
CHANGELOG.md — the heading and the link definitions |
make bump |
none (prose) |
Cargo.lock |
cargo update --workspace |
--locked fails the build |
charts/mira/README.md |
helm-docs |
make helm-docs-check |
make bump TO=X.Y.Z writes every row above and then regenerates the last two,
so step 1 is one command and make drift is how you check it did. The middle
column exists because the writer and the gate are deliberately the same thing:
VERSION_SITES in scripts/check_drift.py is one table of anchored patterns,
read forwards to check and backwards to write. A gate maintained separately
from the writer drifts, and it drifts in the bad direction — the writer is what
people actually run.
Anchored patterns rather than a find-and-replace, because this page is full of sentences that name a past release on purpose, and a bump must not rewrite one of them. For the same reason a pattern matching nothing is a failure rather than a pass: a gate for a line that has moved is a gate that is off.
CHANGELOG.md is the one site still ungated, and it is ungated because the
prose is the point — nothing can check that a human wrote the right notes. What
make bump does mechanically is promote ## [Unreleased] to ## [X.Y.Z], open
a fresh empty one and move the link definitions. It refuses if
## [Unreleased] is empty, since release.yml publishes that section verbatim
and an empty one silently degrades to --generate-notes.
This existed as a forty-line stub of an idea through two releases before it was
written, and both paid for it: the 0.0.1 cut left SECURITY.md saying there was
no tagged release on the day there was one, and 0.0.2 hand-edited the same three
ungated sites again — missing the issue-template placeholder, which nothing had
ever checked.
What the tag path does not re-run¶
ci.yml triggers on push-to-main, on pull requests and on a Monday cron. A tag
push triggers release.yml only — so no tests, no clippy and no make drift
run on the release path. The tag-versus-Cargo.toml assertion is the only thing
re-checked.
This is survivable because nothing reaches main un-gated and tags are cut from
already-green commits. The residual hole is a tag cut from a stale main
commit: helm package --version/--app-version overrides two of Chart.yaml's
three version fields, but not the artifacthub.io/images annotation, so a chart
could ship advertising an image tag that is not the one being released.
Rehearsal, and what only the tag can run¶
ci.yml's release-dry-run leg runs make dist and make publish-dry — the
real tarball, SBOM and checksum targets, then a full cargo publish --workspace
that packages all three crates, resolves each against the one before it out of a
temporary registry and compiles them, stopping at the upload — on every code PR.
workflow_dispatch runs the whole DAG with publish=false. Both have passed.
publish=false skips every network-publishing step, so cosign sign,
helm push, the Digest: scrape off helm push's stderr and the Artifact Hub
oras push were all executing for the first time on v0.0.1. All four worked.
The crates job and the anonymous-pull check both ran for the first time on
v0.0.2, which is also the first release whose make publish actually uploaded.
Both worked. That upload is unrehearsable by construction — publish-dry does
everything except the one irreversible thing — so every release after it is
still trusting a step that only the tag can run. verify-release fails loudly
when it is wrong, and recovery is a bump to the next patch.