ADR-0013: Container Image and Helm Chart Delivery
Status
Accepted — 2026-09-26. Supersedes decision 4 of ADR-0012 (container delivery); ADR-0012's other decisions stand.
Context
ADR-0012 decision 4 records the production image as build/package/Dockerfile and states that no
workflow builds or publishes it. The chart's default image reference was
ghcr.io/cordanallm/praetor:v1.0.0, a tag nothing had pushed, so every helm install of
deploy/helm/praetor ended in ImagePullBackOff.
Two Dockerfiles described one image. build/package/Dockerfile compiled praetorctl from source in
a builder stage. The root Dockerfile, which the go-service flavor requires
(internal/flavor/definitions.go, GoServiceFlavor.RequiredTemplates), copied a praetor binary no
build produces. Meanwhile the release job already compiles praetorctl for linux/amd64 and
linux/arm64 (.goreleaser.yaml, builds), signs and attests what it releases, and publishes the
GitHub release last so an immutable release accepts every asset (#43,
releasing guide). HISS-19 counts the second Dockerfile as a second
implementation of one behaviour.
Decision
- One image path. The root
Dockerfiledefines the image andbuild/package/Dockerfileis removed. GoReleaser'sdockers_v2block builds the image in the release job from thepraetorctlbinaries the same run compiled, so the image carries the binary the release archives carry. The runtime stays the digest-pinnedgcr.io/distroless/static-debian13:nonroot, running as65532:65532withpraetorctl serve -addr=:8080. The OCI metadata (title, source, licenses, version, revision) is written once in thedockers_v2block, which applies it as image labels and as manifest-list annotations; theDockerfilecarries no second copy. - Image publication. buildx pushes a
linux/amd64andlinux/arm64manifest list toghcr.io/cordanallm/praetor:<version>, where<version>is the release tag without itsv, with an SBOM attestation and buildx's provenance attestation. Thedocker_signsblock signs the pushed digest keyless with cosign. The release job reads that digest fromdist/artifacts.json, verifies the signature on it against the workflow identity, and fails when a platform the digest holds lacks the SBOM or the provenance attestation. - Chart publication. The release job runs
helm lintbefore anything is pushed, packages the chart with--versionand--app-versionset to the release version, pushes it tooci://ghcr.io/cordanallm/charts, signs the digesthelm pushreports and verifies that signature. Every image and chart step runs before the release is published, so a failure in one leaves the release a draft. - Default image tag. An empty
image.tagmeans the chart'sappVersion(praetor.imageindeploy/helm/praetor/templates/_helpers.tpl), so a published chart pulls the image released with it. A setimage.tagwins. An emptyimage.repositoryfails the render rather than producing an image reference the cluster rejects at pull time. - Names. GHCR accepts lowercase names only, so the image and chart repositories are the
lowercased
.standards.yamlidentity..goreleaser.yaml,deploy/helm/praetor/values.yamland the release job'sIMAGEandCHART_REPOSITORYeach write it down.
The chart settings ADR-0012 decision 4 describes (read-only root filesystem, memory-backed scratch
volume, probes on /livez and /readyz) are unchanged, and GitOps wiring stays operator data in
the operational fork.
Consequences
Positive
helm install praetor oci://ghcr.io/cordanallm/charts/praetor --version <version>pulls an image that exists and was built from the same commit.- The image, the chart and the release archives come from one build, and each published artifact is signed and its signature verified before the release is published.
Negative / Trade-offs
- GHCR creates a package private on its first push. The operator makes
praetorandcharts/praetorpublic once, in the organisation's package settings, before anonymous pulls work. - The build context holds only prebuilt binaries, so
docker build .in a checkout has nothing to copy;goreleaser release --snapshot --cleanbuilds the image locally. - The image and chart are pushed before the release is published. When a later step fails, their tags stay on GHCR next to a draft release, and a rerun of the job pushes over them.
- The in-tree chart keeps placeholder versions (
deploy/helm/praetor/Chart.yaml), so an install from the directory needs--set image.tag=<released version>until a release of itsappVersionexists. - Keyless signing and verification of the image and chart, and the attestation read, run against GHCR for the first time on a real tag push; no local run reaches GHCR with the workflow's identity.
Neutral
- SLSA Build Level 3 is still not claimed (#330). buildx's provenance attestation describes the image
build; the signed in-toto statement over
checksums.txtstill covers the archives.
Verification & Compliance
internal/deploychart/chart_test.go:TestChartPassesHelmLint,TestDefaultImageTagIsTheChartAppVersion,TestExplicitImageTagWinsOverTheAppVersion,TestEmptyImageRepositoryFailsTheRenderandTestPackagedAppVersionDrivesTheImageTag, which packages the chart the way the release job does.internal/forge/workflow_guard_test.go:TestContainerReferencesFollowTheManifestIdentity,TestContainerReferencesFailForAnotherIdentityandTestContainerReferenceViolationsBoundarieshold decision 5.internal/forge/release_flow_test.go:TestEngineReleaseFlowVerifiesImageAndChartBeforePublishingandTestContainerFlowViolationsSyntheticShapeshold the order in decisions 2 and 3, the attestation check included.- The release job runs
goreleaser checkbefore it builds, so an invaliddockers_v2ordocker_signsblock fails before anything is pushed.
References
- ADR-0012 decision 4 (superseded by this record), ADR-0005.
- Issue #43 (immutable releases), #330 (SLSA level measurement).