ADR 0012: Images are built natively per architecture, uncached, and published only past a guard
Status
Accepted.
Context
The repository shipped a Containerfile and documented a container deployment,
but published no image, so every container user compiled the crate. A
contribution (#3) added a
workflow that published one on a release tag. Its approach was right: a
tag-only trigger, GHCR with the workflow’s own token, and actions pinned by SHA.
Four of its details were not.
- It built the lab’s binary. The
Containerfilecompiled--profile e2e, which is release without fat LTO, tuned for the e2e lab’s inner loop. Nothing outside the binary tells the two profiles apart, so a published image of the wrong one would not have been noticed. - It emulated arm64 with QEMU. The release profile is fat LTO with one codegen unit. Under emulation that is the slowest build this project has, with no timeout.
- It configured a build cache that could not help.
type=ghais scoped to the ref, so a tag never reads another tag’s entries. The build’s real cache is twoRUN --mount=type=cachemounts, which no cache exporter preserves. AndCOPY . .sits directly above the one expensiveRUN, so layer reuse buys nothing. The cost was real, though:mode=maxwrites gigabytes into the repository’s shared 10 GB Actions cache, and evicts therust-cacheentries every CI job depends on. - Nothing checked the tag. CI runs on pushes to
main, not on tags. A tag that did not match the manifest’s version, or that pointed at a commit CI had never passed, would have published.
An image is also the one prebuilt artifact this project distributes, and its users run it as their certificate authority. That calls for provenance an operator can check.
Decision
- The
Containerfiletakes the cargo profile as a build argument,CARGO_PROFILE, defaulting torelease. The lab passese2eexplicitly. The default is the distribution build, so a hand-runpodman build .reproduces the published image instead of a near miss. - Each architecture builds on a native runner,
ubuntu-latestandubuntu-24.04-arm, as a matrix. Each leg pushes one single-architecture image by digest, with no tag. A final job joins the two digests into one manifest list and tags that, after checking there are exactly two. - No build cache, and the workflow says why, since an absent cache is the first thing a reader would add.
- A guard job runs before any build. It refuses a tag that differs from
[workspace.package].version, or from any crate’s=x.y.zpin. It refuses a tag off its release line,mainforX.Y.0andrelease/X.Yfor a patch (ADR 0013). It also refuses a tag whose commit has no successfulpushrun ofci.ymlon that branch. It fails rather than waits: the release procedure tags only once CI is green. - Build provenance is attested once, on the manifest list’s digest, and pushed to the registry. BuildKit’s own per-image attestations are off: with them on, each leg pushes an index instead of an image, and joining those indexes would carry attestation manifests that nothing references.
- A release tag publishes
X.Y.Z,X.Yandlatest. The floatingX.Yis the newest release of its line. It never crosses a minor, so it never picks up a breaking change, and an operator following it gets patch releases unattended. There is no floatingX: before 1.0 a minor release is where breaking changes land. Both floating tags move only on a tag push, so a manual republish of an older tag moves neither.latestalso needs the tag to be the highest release, so a patch to an older line leaves it alone. - Every push to
mainpublishesedgeandsha-<commit>, once all ofci.ymlhas passed on it:ci.ymlcalls this workflow as its last job. The build is the same release build, attested the same way; only the tags differ. - The image carries the default feature set, the same binary
cargo install acme-proxyproduces.hsmneeds a build of one’s own.
Consequences
- An uncached release build takes tens of minutes per architecture, and now
runs on every merge to
mainas well as on every release. A public repository’s runners are not billed, andtimeout-minutesis set to stop a wedged builder, not as an estimate. Merges tomainqueue rather than cancel each other, since a cancelled publish leaves orphaned manifests. edgeand thesha-tags accumulate a version per merge in GHCR. Pruning them is a registry policy, and it must keep untagged versions (below).- A hand-run
podman build .is now as slow as a release build. Contributors building the lab image by hand pass--build-arg CARGO_PROFILE=e2e, astests/e2e/common.rsdoes. - The attestation covers the manifest list. Verifying by tag finds it, since a tag resolves to the list. Verifying the digest of one architecture’s image finds nothing. If that ever matters, the fix is a second attestation per leg, not moving this one.
- The per-architecture images show in GHCR as untagged versions. The manifest list references them, so a cleanup policy must never prune untagged versions.
- A package that the workflow’s token creates under an organisation starts private. The first release needs a one-time change to inherit the repository’s visibility, and the workflow’s run summary says so.
- A failed architecture fails the release with no partial publish. The two legs
are independent (
fail-fast: false), so the healthy one still shows whether the fault is the architecture or the change.
Enforced by
guardin.github/workflows/release.yml: the version and pin check, the release-line check, the CI check, and the highest-release check that gateslatest.- The
imagejob in.github/workflows/ci.yml, whichneeds:every other job before it publishesedge. - The digest-count check in that workflow’s
publishjob. tests/e2e/common.rs, whose image build namesCARGO_PROFILE=e2e; nothing else selects the lab’s profile.