Decision record

AD-0020 The image published by a tag-triggered workflow to GHCR

Status: accepted. Date: 2026-08-27.

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. The README says nothing of 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, a login to ghcr.io with the workflow token, and 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*'] with permissions: contents: read, packages: write, logs in to GHCR with the workflow token, builds for linux/amd64 and linux/arm64 by Go cross-compilation with CGO_ENABLED=0 and no QEMU, tags with type=semver,pattern= and latest, sets provenance: false and sbom: false, pushes, and then reads the manifest back and fails the job if either platform is missing or exceeds the size budget. 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 only for the case where a native build is ever wanted.

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 two jobs and nothing else.

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.

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. 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 follows the newest tag, so 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.

Requirements affected

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. Docker’s pages on multi-platform builds, QEMU emulation and cross-compilation, docker/metadata-action and docker/build-push-action. The distroless static base image. The demo being built for the Dockerfile and the publish job as they ship.