Decision record
AD-0020 The image published by a tag-triggered workflow to GHCR
Status: accepted, amended twice, once when the publishing job was built and once when a native run of the image on both architectures was added to the workflow. Date: 2026-08-27.
Amendment, 2026-08-29: the decision as first accepted put latest on the image
in the same push as the version tag and measured the result afterwards. An
image over the size budget would therefore already be what an untagged pull
returns before the gate could reject it. The version tag now goes up alone and
latest is moved only after both platforms have been measured and passed.
Amendment, 2026-09-24: latest now also waits for the image to run natively
on both architectures. After the gate, a job runs one leg per platform, and
each leg pulls the pushed digest with no credential on a runner of that
platform’s architecture, starts the container and checks what it serves.
latest moves only once both legs have passed.
Context
The launch shape is fixed: one image, one command, docker run --rm
-p 8080:8080 ghcr.io/roarge/sysml-federation, on Linux, macOS and Windows
with nothing but Docker installed. Nothing in the README says how a visitor
obtains the demo, and the brief says where the image lives and not how it
gets there or who builds it. The repository’s CI policy said that it “runs
the unit tests and nothing else”, and test.yml runs on pull requests and
pushes to main with permissions: contents: read.
The registry has rules of its own. A package first published under a
personal account is private by default even from a public repository, is
made public once by hand in the package settings, and cannot be made
private again. Publishing from a workflow needs contents: read and
packages: write, and a login to ghcr.io with the workflow token. The
repository that contains the workflow is linked to the package automatically,
which gives the package the repository’s access permissions though not its
visibility. docker/metadata-action writes the OCI labels, turns
refs/tags/v1.2.3 into the tag 1.2.3 and adds latest for semver tags.
Every process in the demo is Go, and the one foreign binary, the Cosmo
router, is already published for linux/amd64 and linux/arm64. Docker’s own
page says emulation with QEMU “can be much slower than native builds,
especially for compute-heavy tasks like compilation” and gives the
cross-compilation form with FROM --platform=$BUILDPLATFORM, TARGETOS
and TARGETARCH. The base is gcr.io/distroless/static-debian13
at about 2 MiB, the same base the router uses. COPY --from
accepts an image reference, the router is statically linked and released
under Apache 2.0 with its LICENSE attached, and every router bump means a
rebuild. docker/build-push-action adds provenance attestations by
default, and with them present the GHCR package page shows an
unknown/unknown platform row. Whether COPY --from resolves the
target platform’s variant during a multi-platform build was not confirmed
in the reference text.
Decision
We will publish the image from a second GitHub Actions workflow,
.github/workflows/publish.yml, that runs on push: tags: ['v*']. The
workflow grants permissions job by job, logs in to GHCR with the workflow
token, and builds for linux/amd64 and linux/arm64 by Go cross-compilation with
CGO_ENABLED=0 and no QEMU. It tags with
type=semver,pattern={{version}} alone with the metadata action’s latest
flavour turned off, sets provenance: false
and sbom: false, and pushes that one tag. It then reads the manifest back
and fails the job if either platform is missing or exceeds the size budget.
After the gate a second job runs one leg per platform on a native runner,
ubuntu-24.04 for linux/amd64 and ubuntu-24.04-arm for linux/arm64. Each
leg pulls the pushed digest with no credential and no platform named, so that
Docker picks the variant as it does on a user’s machine, then starts the
container and checks what it serves. Only once the gate and both legs have
passed does a third job put latest on the digest that was measured and run.
The job that builds and pushes and the job that moves latest hold
contents: read and packages: write, and the native legs hold none.
A version with a pre-release suffix is measured and run like any other, and the
third job then leaves latest alone. As a result, a release candidate is
published under its own tag and is never what an untagged pull returns. The
package is made public once by hand. test.yml keeps its read-only permissions
and runs the unit tests only, and the CI policy line becomes
“unit tests, plus image publishing on tags” (SC-07).
Alternatives considered
A manual push from the maintainer’s machine, the one alternative considered. It would have left the CI policy line untouched. It lost because a manual push has neither the automatic link between package and repository that a workflow publish gives nor the tag alignment the metadata action supplies.
Multi-platform build by QEMU emulation, the form Docker’s own GitHub Actions example uses. It lost because emulation is slow for compilation and unnecessary when every stage is Go and the foreign binary is already published for both architectures. Native arm64 runners, free for public repositories, are named by the research for the case where a native build is ever wanted. The build still runs on one amd64 runner, and an arm64 runner is used only to run the arm64 image after the gate.
Keeping provenance and SBOM attestations. They give SLSA provenance, which some readers regard as good hygiene for a public image. They lost because the package page would show a third platform called unknown/unknown, which reads as a broken build unless the README explains it, and the design removes the row rather than explaining it.
Publishing on pushes to main as well, with sha and edge tags, which
the packaging research recommends beside the tag trigger. The design takes
the tag trigger only, and SC-07 names the v* tag as the only publishing
trigger.
Moving latest in the same push as the version tag, which is what the
metadata action does for a semver tag unless it is told otherwise, and which
this record first accepted. Registry-counted sizes cannot be had without
pushing, so the gate can only run after the push, and with latest already
moved an over-budget image is what an untagged pull returns until somebody
notices. Turning latest off in the metadata step and putting it on the
measured digest afterwards costs one more step and one more registry call.
Two other ways of running the image were rejected. Running it only after
latest has moved would report on an image an untagged pull already returns,
which makes the run a report rather than a gate. That is the same wrong order
the first amendment corrected for the size gate. Running the arm64 image under
emulation on the amd64 runner would not be the native run an arm64 host makes,
and it would bring QEMU into a workflow whose build sets up none.
Consequences
The launch line works for nobody until the package has been flipped to
public by hand, and the flip cannot be undone. SR-01’s demonstration
from a host not authenticated to GHCR is what proves the flip was made.
The native legs pull without a credential too, so while the package is private
they fail, and latest is not moved until the package is public and the
workflow has run again.
Two workflow files with different permissions. test.yml stays read-only
and publish.yml alone holds packages: write, and the allowlist tracks
it (SC-04).
The workflow is also the test bench for SR-05 and SR-06. After the push it
reads the manifest back and fails if either platform is absent or if any
platform’s compressed layers exceed 80 MB, and latest waits on that result
and on the two native legs.
The router alone is about 40 MB
compressed and sets the floor, which leaves about 40 MB for the demo.
One spike stands behind this record. Before the first tag is pushed,
the router binary inside the built arm64 layer is inspected to confirm
that COPY --from picked the arm64 variant. If it did not, the arm64
image would carry an amd64 router and fail on Apple Silicon. The outcome is
in Five spikes before the first line.
Provenance is off, so the image ships without SLSA attestations, and the
package page lists exactly two platforms. Every router bump is a
rebuild of the image and a new tag, because the binary is copied rather than
pulled at runtime. The root NOTICE names the router at a pinned
version that moves with each bump (SR-08). Its licence text is fetched at
build time at the pinned tag and sits beside /router (SR-07).
latest is what the launch line pulls, since it names no tag, so what latest
points at decides what a stranger gets (SR-01). It is moved by each full
release whose run passes every check the workflow holds: the gate and, since
the second amendment, both native legs. This means it can drift from what the
README describes if the README is not updated in the same tagged commit, a cost
the packaging research names and the design accepts. It can also lag the newest
tag in the registry instead of tracking it. A failed gate or a failed leg
leaves the version tag published and latest where it was, and a pre-release
moves nothing. An untagged pull therefore returns the last full release whose
run passed every check the workflow held at the time, rather than whatever went
up most recently. Cutting a release means reading the run rather than assuming
that a tag in the registry is what latest names.
Requirements affected
SR-01, SR-05, SR-06, SR-08, SC-07
Sources
GitHub’s documentation on package visibility for a package first published under a personal account, and on the permissions a publishing workflow needs. GitHub’s reference for its hosted runners, which lists the ubuntu-24.04 and ubuntu-24.04-arm labels. Docker’s pages on multi-platform builds, QEMU emulation and cross-compilation, docker/metadata-action and docker/build-push-action. The page on multi-platform builds says that on a pull “Docker automatically selects the correct variant based on the host’s architecture”. The distroless static base image. The image in the example’s README for the Dockerfile and the publish job as they ship.