ADR-040: Build-Once, Gate-Complete Artifact Promotion
On this page
Status
Accepted (2026-07-14)
Realized by #1007 (subsumes #1025). Chronic gate failures that currently keep promotion parked are tracked separately: #1067 (cargo-test runner disk), #1068 (integration-tests image access).
Context
The production promotion pipeline had accreted five structural defects (#1007, verified against the live pipeline + registry on 2026-07-14):
-
Tag pipelines never existed.
workflow.rulesmatched only MRs and branches, so the tag-onlydocker-promoteandsbomrules were dead code — a pushed release tag created no pipeline at all. -
Promotion was gate-incomplete.
docker-promotedeclared an explicitneeds:list of six jobs and ignored every other blockingtest-stage job — the ADR-011/ADR-031 policy audits,typed-id-path-audit(#1025),quality-budgets,cargo-doctest,integration-tests, the GitLab security scanners,cargo-deny,cargo-macheteamong them. Promotion could become runnable, and pushlatest, while a blocking gate was still running or failing. -
The artifact-input map was untested and incomplete. Promotion triggered only on
/*.rs,/Cargo.toml,Cargo.lock,Dockerfile— but the images also consumerulesets/, migrations and static assets underservices/,tools/, the portal Dockerfile, and.dockerignore. A ruleset-only or migration-only merge left the registry artifact silently stale. -
Promotion rebuilt from source. The promote job ran
docker buildand pushed the result — the published image was not necessarily the artifact any gate had seen, andlatestmoved in the same breath with no ordering guard. -
The applicant portal had no production artifact. The root
Dockerfileexcludescanopy-portalby design (the Dioxusdxpipeline builds the WASM client + server together, ADR-008), and no CI job builtservices/canopy-portal/Dockerfileat all.
The observable consequence: the registry’s latest dated to 2026-03-30 —
three and a half months of merged main never reached the registry — and no
ADR covered image promotion, registry tagging, or container supply-chain.
Decision
-
Tag pipelines exist.
workflow.rulesgains- if: $CI_COMMIT_TAG; the gates whose rules were branch/MR-scoped (cargo-audit,cargo-machete,secrets-yaml-lint) run unconditionally on tags, so a tag pipeline is as gate-complete as main. -
Two deployable images, built once, under immutable staging refs.
build-service-image(rootDockerfile) andbuild-portal-image(services/canopy-portal/Dockerfile) run in theteststage on every artifact-affecting main/tag commit and push content-addressed staging refs$CI_REGISTRY_IMAGE/build:$CI_COMMIT_SHAand$CI_REGISTRY_IMAGE/build/portal:$CI_COMMIT_SHA(plus mutablemain-cachelayer-cache pointers). A pipeline whose commit already has a staging ref reuses it (build-once across pipelines: a release tag on a main-built commit promotes the exact digest main tested). Staging refs are pipeline-internal; the production repositories are$CI_REGISTRY_IMAGEand$CI_REGISTRY_IMAGE/portal. -
Promotion is a complete barrier + a registry-side retag.
docker-promotedeclares noneeds:— a promote-stage job withoutneedswaits for the entireteststage, so every blocking gate (including both image builds) gates every production-registry mutation; advisory (allow_failure) and manual jobs do not block, which is their documented contract. Promotion resolves the staging digests and retags them withdocker buildx imagetools create— neverdocker build. Every promoted commit gets an immutable:<short-sha>production ref; tag pipelines additionally get:<tag>. -
latestis guarded and serialized. The promote job moveslatestonly when$CI_COMMIT_SHAequals the currentrefs/heads/main(checked viagit ls-remoteat promote time), so an older pipeline finishing late can never movelatestbackwards;resource_group: registry-promoteserializes concurrent promotions. -
Every production digest has a retained SBOM. Each build job generates a CycloneDX SBOM from its pushed ref with a syft that is version-pinned (
SYFT_VERSION) and checksum-verified (SYFT_SHA256,sha256sum -cbefore the binary runs in a job holding registry push credentials), retained as a never-expiring artifact; the tags-onlysbomjob keeps producing the source-level cargo SBOM for releases. -
The invariants are statically gated.
cargo xtask ci-config-lint— a CI job and a pre-pushvalidatestatic gate (#896 subsumption) — parses both Dockerfiles'COPY/ADDsources (any case) and fails if the YAML-anchored artifact-input map misses any build input, and asserts: the tag workflow rule exists;docker-promotesits instage: promotewith noneeds:, never invokesdocker build/docker buildx build, and carries aresource_group; every:latestreference in the file sits inside the promote job after the main-head guard line; build jobs and promote name the same immutable$CI_COMMIT_SHAstaging-ref strings (a one-sided rename cannot land); the build-job base and promote both reference the*artifact-inputsanchor (trigger paths cannot drift); and the syft pin + checksum are present. Each assertion has a negative test, and the gate’s own test suite runs it against the real repo config.
Consequences
-
Promotion latency equals the slowest blocking gate (integration tests) — the price of gate-completeness. Force-merging past red CI still works for merges (the local battery is the merge gate, see Contributor Workflow Conventions); it no longer lets an untested image reach the registry.
-
A chronically red blocking gate now visibly parks promotion instead of being silently skipped past — as of acceptance, #1067 and #1068 do exactly that and must be fixed for the first promoted image under this ADR.
-
MR pipelines do not build images; a Dockerfile-breaking change surfaces on the main pipeline. Accepted: image builds are ~20–30 min each, and main breakage is visible + cheap to revert pre-1.0.
-
Build-once extends to test consumption (#1073, 2026-07-15): the CI integration stack pulls the staging refs instead of compiling the workspace in-daemon, so integration tests exercise the exact digests promotion retags — and
integration-testsconsequently runs only where the staging refs exist (tag pipelines, and main commits touching the artifact-input map), mirroring the build jobs' rules. See the ADR-015 amendment for the mechanism. -
Image signing/attestation (cosign, SLSA provenance) is out of scope here and remains open; the immutable digest + retained SBOM chain is the foundation it would build on.
-
Deploy tooling can now rely on:
:<short-sha>(immutable, every promoted main commit),:<tag>(releases),:latest(the newest gate-complete main head), for both the service image and…/portal.
References
-
#1007 (defect + acceptance criteria), #1025 (subsumed), #1066/#1067/#1068 (chronic CI failures found during delivery)
-
ADR-015 (the DinD integration job that now gates promotion)
-
ADR-017 (CI secrets)
-
ADR-033 §5 (the
test-clockfeature must never reach a production image — the build jobs pass noCARGO_FEATURES, keeping the Dockerfile’s empty default) -
ADR-008 (why the portal is a separate image)