Releasing
Krabka releases the whole workspace as one unit. A release is an annotated git
tag vX.Y.Z on main, and the tag is the name a person quotes in an incident,
in a bug report, and in a rollback. The changelog records
what each tag contains.
The push of the tag starts
release.yml. That workflow first verifies
that the tag is releasable at all (step 4 below), then signs the image
ci.yml’s delivery job already built for that commit, attests the SBOM and
the provenance, moves the vX.Y.Z image tag – and latest, when the tag is
the newest one – to the signed digest, and creates the GitHub release. Nothing
else creates a release, and no other branch does.
Only a maintainer with push access to the repository can do steps 3 and 4.
The broker release and the tested ecosystem set are separate claims. Before a
release is advertised as an ecosystem-qualified stack, the manually dispatched
ecosystem qualification workflow must pass on the candidate recorded in
qualification/ecosystem-eight-gate-baseline.json. See
Ecosystem qualification. A broker release may exist without
that result; it must not be described as the tested operator/CLI/o11y stack.
1. Prepare the version
Set the new version in these places, then run cargo generate-lockfile to
update Cargo.lock:
[workspace.package] versionin the rootCargo.toml.- The first-party
versionrequirements in[workspace.dependencies], and the same requirements in each memberCargo.toml. A path dependency also carries a version, and Cargo refuses a publish when it is stale. versioninMODULE.bazelandWORKSPACE_VERSIONinbazel/defs.bzl.- Each
#![doc(html_root_url = "https://docs.rs/krabka-<name>/<version>")].
aspect check-version-pins reads every one of those places and fails on any
disagreement, so run it once the bump is made rather than grepping for the
version you replaced. It runs in the checks job of
ci.yml on every push, and again in the release
workflow with the tag as the version each pin must name.
aspect check-version-pins
aspect check-version-pins --expected v0.5.2 # what the release job runs
WORKSPACE_VERSION is the entry worth being careful about. bazel/defs.bzl
stamps it into the purl of every crate, so aspect sbom writes a stale one
into every component of the bill of materials the release then signs and
attests.
2. Prepare the changelog
Move the [Unreleased] entries of CHANGELOG.md under a new
## [X.Y.Z] - YYYY-MM-DD heading, and leave [Unreleased] empty above it.
Write what changed for a reader who runs the broker, not a list of commit
subjects.
A release that adds or changes a persisted format names, in its entry, the feature level that turns the new format on, and updates Persisted formats in the same pull request. From 1.0.0 on, every 1.x release reads every format that an earlier 1.x release wrote. A change that cannot keep that promise waits for the next major version.
Then fix the link definitions at the end of the file. Add one for the new tag,
and move the [Unreleased] comparison onto that tag as well: it names the
previous one, so leaving it alone would keep listing everything this release
just shipped as unreleased.
[Unreleased]: https://github.com/krabka-io/krabka-broker/compare/vX.Y.Z...HEAD
[X.Y.Z]: https://github.com/krabka-io/krabka-broker/releases/tag/vX.Y.Z
A changelog entry that quotes a performance number takes it from the latest
scheduled run of the bench job in
ci.yml – the nightly criterion lane – and not
from the tables in the source comments. Those tables record why a decision was
made and are not re-measured when the code around them changes, so a release
that repeats one is republishing an undated figure. The job summary of that run
holds the bench_ratio tables, and its criterion-baseline artifact holds the
samples; if the newest scheduled run is red, say no number rather than reaching
for an older one. The same applies to the sendfile_min default and the two
PERF -- measured; decision: KEEP sites: check the run before repeating them in
release notes.
Open a pull request with the version bump and the changelog entry together, and merge it. The tag names that merge commit.
3. Tag the release
Take the merge commit of that pull request, and tag it. Use an annotated tag,
git tag -a. An annotated tag is an object of its own: it records who cut the
release and when, and a person can read that with git show v0.5.2. Push the
tag to origin, because release.yml calls gh release create --verify-tag,
which fails when the remote does not hold the tag.
git checkout main
git pull --ff-only
git tag -a v0.5.2 -m "krabka-broker v0.5.2"
git push origin v0.5.2
Never move or delete a tag that is pushed. The signed image digest and the attestations point at it. Release a new patch version instead.
4. Check the result
Watch the release workflow. Its verify job runs before anything is built or
signed, and it refuses the release unless all three of these hold:
- The tagged commit is an ancestor of
origin/main. A tag pushed from a feature branch is not a release. aspect check-version-pins --expected <version>passes, so the tree the tag names says the version the tag does.ci.ymlhas apushrun for that commit whose conclusion issuccess.gateis the single required context onmainand depends on every job that gates a merge, so a green run of it is what says the commit was tested. Apull_requestrun does not count: it tested the merge of the pull request, not this commit.
A cosign signature says who built an image, never that the image was tested. Those three checks are what stands behind it.
The release job then signs the image delivery already pushed for that
commit – ghcr.io/krabka-io/krabka-broker:<commit sha> – rather than
building it again, so the digest that is signed is the digest that was tested.
Delivery hashes the built image manifest, checks the registry against it, and
records that digest in the delivery-image Actions artifact. Release
fetches that artifact from the successful main push run, requires the commit
tag to resolve to that exact digest, and resolves the immutable reference before
signing. Missing or expired evidence, an unavailable image, or a substituted
commit tag stops the release before signing, attestations or release tags change.
There is no rebuild fallback. The provenance records the CI run and immutable
image reference in internalParameters.imageSource.
The ARM64 half is the one thing release builds. It pushes
--platforms=//:linux_arm64 of //packaging:image by digest, with no tag of
its own, and docker buildx imagetools create joins that manifest and the
tested AMD64 manifest, each by digest, into the vX.Y.Z index. The job checks
both children of the index before it moves a tag and again before it signs.
It never rebuilds the AMD64 half. Through v1.0.0 it did, through
image_index’s platform split, and required the rebuilt child to equal the
tested digest. That transition moves every action into another output
directory, so the rebuild compiled the broker from scratch rather than from
the cache delivery used, and the check held only while such a rebuild was
byte-for-byte identical to the build CI tested. For v1.0.0 it was not.
If a release run fails in the workflow rather than in the commit, do not move
the tag and do not cut a patch only to re-run it. Fix the workflow on main,
then release the existing tag from there:
gh workflow run release.yml --ref main -f tag=v1.0.0
The dispatched run checks out the tag and holds it to the same verify checks
as a push. Its signing identity is the workflow on the branch it ran from,
.../release.yml@refs/heads/main, not @refs/tags/vX.Y.Z. Verify such a
release with that identity.
The artifact is retained for 90 days. If it is unavailable, rerun main CI for the release commit and require that complete run to succeed before rerunning the release workflow. A locally rebuilt image or a manually replaced commit tag cannot stand in for delivery evidence. Commits from before delivery recorded digest artifacts require a new release commit that includes this workflow; their old CI run cannot provide the evidence.
aspect axl-tests --suite check-delivery-image checks matching, missing,
substituted and malformed digests. To repeat the registry check without
publishing, download the successful run’s delivery-image artifact and run:
aspect check-delivery-image --expected delivery-image.digest \
--image ghcr.io/krabka-io/krabka-broker:<commit-sha>
The command prints only the approved digest on success. Repeating it with a missing image reference or another image’s digest must fail.
It fails the release rather than publishing an unsigned or unverified image,
because it runs cosign verify and cosign verify-attestation against the
digest it signed.
latest moves onto the release only when the tag is the newest v* tag in the
repository, by sort -V. A patch cut after a later minor – a v0.5.4 tagged
after v0.6.0 – gets its own vX.Y.Z tag and leaves latest where it is.
When the workflow is green, confirm the release yourself:
cosign verify \
--certificate-identity https://github.com/krabka-io/krabka-broker/.github/workflows/release.yml@refs/tags/v0.5.2 \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/krabka-io/krabka-broker:v0.5.2
Also compare the release tag’s digest against the delivery artifact:
aspect check-delivery-image --expected delivery-image.digest \
--image ghcr.io/krabka-io/krabka-broker:v0.5.2
The GitHub release carries sbom.cdx.json for the same build.
Crates.io
The same tag push also starts
publish.yml, which publishes the
storage-layer library crates to crates.io. The two workflows are independent:
neither waits on the other, they hold separate concurrency groups, and
publish.yml touches neither the image nor the GitHub release. Each one checks
on its own that the tag is releasable, with the same three checks as step 4, so
a tag that release.yml refuses publishes nothing either.
The published crates
| Crate | Depends on (workspace) | Why it is published |
|---|---|---|
krabka-macros |
krabka-log derives with it |
|
krabka-verified |
Library for other repositories | |
krabka-log |
krabka-macros, krabka-verified |
Library for other repositories |
Every other member sets publish = false, the broker among them. A new member
crate is published unless its manifest sets publish = false, so a crate that
is not a library for other repositories sets it.
krabka-log also depends on krabka-ids, krabka-protocol,
krabka-compression and krabka-units, and krabka-verified on krabka-ids.
Those come from krabka-protocol. cargo publish ignores [patch.crates-io],
so each requirement must name a release that crates.io has, and krabka-protocol
releases before this repository does.
Each published crate sets repository, description, readme and an
include list. The list ships the library source and the README, and leaves
out tests, benches and fixtures. Cargo prints an “ignoring test” warning for
each [[test]] and [[bench]] that the package leaves out. The warnings are
expected.
krabka-verified holds the Creusot-proved kernels. Its contracts are
creusot-std attributes that a normal build erases, so the published crate
builds with stable rustc and needs no proof toolchain. Its include list
ships src/, build.rs (which registers cfg(creusot)) and the README. The
Why3 session artifacts under verif/ live outside the crate and are not in the
package. The proofs job in ci.yml is what checks them.
Every krabka-* normal dependency of a published crate carries a version as
well as its path. A path or git dependency that is not on crates.io may
appear only as a dev-dependency, and without a version, so that cargo drops
it from the published manifest.
Before you tag
Step 1’s version bump covers the published crates: they inherit
[workspace.package] version, and aspect check-version-pins reads the
version of each path dependency.
Before you merge the release pull request, run a dry run from its branch: start
publish.yml from the Actions tab with dry_run on. It packages each crate
and builds it from the packaged sources, as crates.io users get them. Locally:
cargo publish --dry-run --locked -p krabka-macros -p krabka-verified -p krabka-log
What the workflow does
The plan job holds no credential. It:
- stops unless the tagged commit is an ancestor of
origin/main. - stops unless a
pushrun ofci.ymlpassed on that commit. - stops unless
aspect check-version-pins --expected <tag>passes. - asks the crates.io API which crate versions exist, and keeps the others.
- runs
cargo publish --dry-runover the crates that it kept. Cargo resolves a pending sibling from the packages it has just made, and every other dependency from crates.io.
The publish job runs in the crates-io environment. It uploads the pending
crates one at a time, in dependency order. Cargo waits until each crate is in
the index before it uploads the next one.
A rerun is safe. The plan job skips each version that crates.io already
has, so a rerun after a failure uploads only the rest.
A manual run takes two inputs:
dry_run, on by default. Off, the run uploads, and it must start from av*tag.crates, a space-separated list of crate names. A run with a list publishes only those crates. Use it when one crate cannot publish and the others must not wait for it.
Credentials: bootstrap, then trusted publishing
The publish job authenticates with one of two credentials:
- A token. When the
CARGO_REGISTRY_TOKENsecret of thecrates-ioenvironment is set, the job uses it. - Trusted publishing. When that secret is not set, the job runs
rust-lang/crates-io-auth-action. The action exchanges the job’s GitHub OIDC token for a crates.io token. That token expires after 30 minutes, and the action revokes it when the job ends. No long-lived secret exists.
crates.io allows trusted publishing only for a crate that already exists. So the first release of each crate name needs the token, and every later release uses trusted publishing.
Once: the GitHub environment
In the repository settings, open Environments and create crates-io. Under
Deployment branches and tags, allow only tags that match v*. Add required
reviewers if a person should approve each publish.
First publish of a crate name
- Sign in to crates.io with the account that will own the crates. Under
Account Settings, open API Tokens and create a token with the
publish-newandpublish-updatescopes. Limit it to the crate nameskrabka-macros,krabka-verifiedandkrabka-log, or the patternkrabka-*, and give it a short expiry. - Add the token to the
crates-ioenvironment as the secretCARGO_REGISTRY_TOKEN. - Push the release tag, or start
publish.ymlon it withdry_runoff.
crates.io limits new crate names to a burst of five, then one every ten
minutes, so the three names go up in one burst. On a 429 answer the job waits
ten minutes and tries again.
Then: trusted publishing for each crate
For each published crate:
- On crates.io, open the crate, then Settings, then Trusted Publishing.
- Add a GitHub publisher with these values:
- Repository owner:
krabka-io - Repository name:
krabka-broker - Workflow filename:
publish.yml - Environment:
crates-io
- Repository owner:
When all three crates have a publisher, delete the CARGO_REGISTRY_TOKEN
secret, and revoke the token on crates.io. The next release uses trusted
publishing. Its log says “publishing through trusted publishing”.
A crate that joins the published set later needs the token once, for its first release. Add the secret again for that release, configure the new crate’s publisher, and delete the secret again.