ci: publish multi-arch container images to GHCR - #22
Draft
Ladas wants to merge 1 commit into
Draft
Conversation
Ladas
force-pushed
the
feat/publish-container-images
branch
4 times, most recently
from
September 4, 2026 03:46
d4f1cbc to
fe8d711
Compare
ghcr.io/praxis-proxy/experimental has never been published: zero tags, zero releases, nothing to pull. The publish paths that exist go through the ghcr-publish composite action, which exposes only image-name, containerfile, push and registry -- so it structurally cannot build more than one platform or pass build-args. Every image in the org is amd64-only, which on Apple Silicon means running a latency-sensitive proxy under QEMU. Rewrite publish.yaml to build natively for both architectures: linux/amd64 on ubuntu-24.04 and linux/arm64 on ubuntu-24.04-arm (free for public repos), each pushed by digest, then merged into one manifest list with docker buildx imagetools create. QEMU was rejected deliberately -- a QEMU'd Rust build is roughly 5-10x native against a build that already takes ~12 minutes. Triggers gain push-to-main, which is the part that actually unblocks pulling: main publishes :main and :sha-<short>, a version tag publishes :X.Y.Z, :X.Y, :latest and :sha-<short>. container.yaml stays the pull_request smoke test and is unmodified. latest is made explicit rather than relying on metadata-action's implicit default-branch behaviour, so it cannot leak onto main pushes. Passes FEATURES=otel. The otel feature is config-gated and costs ~1.4 MB, so shipping two image variants would mean two tags to explain, two things to test, and an observability demo that silently degrades if someone pulls the wrong one. Until the ARG FEATURES plumbing lands this build-arg is an unconsumed no-op. Remove release.yaml's container job. It and this workflow both fired on a version tag and pushed the same image -- one single-arch, one multi-arch -- and whichever finished last won. github-release depended on that job, so it is rewired to [validate, test, publish-dry-run], the same gates the container job had, and a release still cannot be cut on failing tests. The matrix is inlined here rather than added to the ghcr-publish action. Teaching that action multi-arch would fix praxis core's arm64 gap too, but it changes a composite every workflow depends on and wants its own change. Signed-off-by: Ladislav Smola <lsmola@redhat.com>
Ladas
force-pushed
the
feat/publish-container-images
branch
from
September 4, 2026 13:50
fe8d711 to
1adb64d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gaps
ghcr.io/praxis-proxy/experimentalhas never been published. Zero tags,zero releases; the registry returns
DENIEDfor the repository. There isnothing to pull.
ghcr.io/praxis-proxy/praxis:0.5.3'smanifest list carries
linux/amd64and a provenance entry, nothing else. OnApple Silicon that runs under QEMU — the wrong way to demo a proxy whose value
is measured in microseconds.
publish.yamlandrelease.yamlboth went through the local.github/actions/ghcr-publishcomposite, which accepts only
image-name,containerfile,pushandregistry. Noplatforms, nobuild-args: one implicit platform, defaultcargo features.
FEATURESwas not wired to CI at all, so any published image would shipwithout the
otelfeature regardless of what the Containerfile supported.What this PR does
Two files. No new files, no renames, Containerfile untouched.
publish.yaml— rewrittenNative multi-arch, no QEMU. A matrix builds
linux/amd64onubuntu-24.04andlinux/arm64onubuntu-24.04-arm(free for publicrepos), each pushed by digest, then a
mergejob combines both into onemanifest list with
docker buildx imagetools create. This is the standarddocker/build-push-action multi-platform pattern. QEMU was rejected on
purpose: a QEMU'd Rust build runs roughly 5–10x slower than native against a
build that already takes ~12 minutes.
Triggers. Previously
workflow_dispatchonly. Now:main(code paths only):main,:sha-<short>release.yamlon av*.*.*tag:X.Y.Z,:X.Y,:latest,:sha-<short>workflow_dispatchThe rolling
:maintag is the part that turns "nothing to pull" intosomething pullable. It carries
container.yaml'spaths-ignorelist, so adocs- or demo-only commit does not spend two native release builds
republishing an identical binary.
container.yamlstays thepull_requestsmoke test and is unmodified; this workflow never runs on PRs.
Tag publishes stay behind the release gates.
publish.yamlgainsworkflow_calland does not trigger on tags itself.needscannot crossworkflow files, so a tag trigger here would publish
:X.Y.Zeven whenmake testfailed — precisely the gate the removedcontainerjob provided.release.yamlcalls it from apublish-imagejob that needs[validate, test, publish-dry-run], andgithub-releasenow needspublish-imagetoo, so a Release is never cut for a tag whose image failedto build (
docker pull …:X.Y.Z→manifest unknown).The image name is lowercased at runtime.
github.repositorypreservescase and a registry reference must be lowercase, so a run from a fork with a
capitalised owner failed in buildx before building anything — including the
fork dispatch this PR asks a reviewer to do.
FEATURES=otelas a build-arg. Theotelfeature is config-gated andcosts ~1.4 MB, so shipping two variants would mean two tags to explain, two
things to test, and a demo that silently degrades if someone pulls the wrong
one. feat: add OTel observability benchmark demo #13 has merged, so
ARG FEATURESand theio.praxis.build.featureslabel are in
main: what a pulled image contains is inspectable rather thanassumed.
container.yamlbuilds with the sameFEATURES. Otherwise CI wouldsmoke-test a default-feature binary while publishing an otel one, and an
otel-only startup failure would reach GHCR with no coverage.
latestmade explicit.flavor: latest=falseplus atype=rawgated ona real tag check (
github.ref_type == 'tag', name starts withv, no-),rather than metadata-action's implicit default-branch behaviour. A bare
startsWith(github.ref, 'refs/tags/v')would have moved:latestonto aworkflow_dispatchrun againstv1.2.3-rc1or a non-semverv2, since thepush filter does not apply to dispatch.
Both metadata calls share one tag policy. The per-arch build job bakes
org.opencontainers.image.versioninto each image; without the same rules itlabelled a tag build
v1.2.3while the merge job pushed1.2.3.cache-to: mode=min. The cargo build runs underRUN --mount=type=cache,which BuildKit does not export to the GHA cache, so
mode=maxuploaded everybuilder layer against the shared 10 GB repo budget to cache everything except
the expensive step. Making the dependency layer genuinely cacheable is a
Containerfile change, separate from this one.
Artifact actions on node24.
upload-artifactv7.0.1 anddownload-artifactv8.0.1; the v4 pins were the onlynode20actions left ina repo pinned to node24 everywhere else, and node20 is removed from hosted
runners on 2026-09-23.
The dead
.github/actions/ghcr-publishcomposite is deleted. After thisrewrite it had zero callers. praxis core has the same arm64 gap but consumes
praxis-proxy/conventions' copy, which is a separate change to a shared repo.cancel-in-progress: false. Was conditional on the ref. A multi-archpush has two phases — per-arch digests, then the manifest that ties them
together — and cancelling between them leaves pushed digests with no tag.
A second push to the same ref now queues instead.
fail-fast: false. If one architecture fails, the other finishes so thefailure is diagnosable in isolation.
merge'sneedsstill refuses to builda manifest from a partial matrix, so nothing half-built ships.
Every action pinned to a commit SHA with a version comment, matching the
repo's convention. All seven SHAs verified against their tags.
release.yaml— single-arch container job removedIt and the rewritten
publish.yamlboth fired on av*.*.*tag and bothpushed to the same image — one single-arch, one multi-arch — and whichever
finished last won the tag. Removed, so there is exactly one publisher per
trigger.
github-releasedepended on that job. It is replaced bypublish-image, ajob that calls
publish.yamlbehind[validate, test, publish-dry-run]— thesame gates the container job had — and
github-releaseneeds both. So therelease pipeline still runs in one ordered chain; the only change is that the
image it publishes is now multi-arch.
Docs and examples
docs/release.mddescribed the container image as step 4 of the releasepipeline and
publish.yamlas dispatch-only, both of which the rewritechanges; the tag table gained
latestand the first-publish note below.examples/configs/{minimal,gateway}.yamltold users to runghcr.io/praxis-proxy/experimental:latest, a tag only a version tag producesand none has been cut — they now name
:main.Deliberate scope
The matrix is inlined here rather than added to
ghcr-publish. Teaching thataction multi-arch would fix praxis core's arm64 gap as well, but it changes a
composite every publish workflow depends on and wants its own change.
The Containerfile is untouched. The OpenShift hygiene items (numeric
USER,group-0 permissions, OCI labels) are a separate, image-contents change and
should not ride on a workflow PR.
Before merging — this workflow has never executed
It only triggers on push-to-main and tags, so its first real run is after
merge. Validate it first by dispatching on the fork:
Ladas/experimental→ Actions → Publish → run fromfeat/publish-container-images. The workflow lowercases the repository name atruntime, so this resolves to
ghcr.io/ladas/experimental, a harmless target,and it exercises the arm64 runners, the digest push, and the manifest merge end
to end.
After merging — one manual step
The first push creates the GHCR package, and a new package is private by
default: it inherits the repository's access permissions but not its
visibility. Until a maintainer flips it (Packages → experimental → Package
settings → Change visibility),
docker pullfrom outside the org returnsdenied. Noted indocs/release.md.Known gap, not fixed here
On a push to
mainthis workflow andcontainer.yamlbuild the same amd64image independently — this one publishes it,
container.yamlsmoke-tests it —and nothing links them, so
:maincan be republished on a commit whose smoketest fails. Closing it means running the smoke test against the built digest
before the merge job, which is worth doing on its own.
Verified
--build-arg FEATURES=otelon anM4 Max and produces a
linux/arm64imageactionlintwithshellcheckclean on both workflowsOrdering
Rebased onto
mainafter #13 merged, soARG FEATURESalready exists and thefirst
:mainimage ships with OTel built in.