Skip to content

ci: publish multi-arch container images to GHCR - #22

Draft
Ladas wants to merge 1 commit into
praxis-proxy:mainfrom
Ladas:feat/publish-container-images
Draft

ci: publish multi-arch container images to GHCR#22
Ladas wants to merge 1 commit into
praxis-proxy:mainfrom
Ladas:feat/publish-container-images

Conversation

@Ladas

@Ladas Ladas commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

The gaps

  • ghcr.io/praxis-proxy/experimental has never been published. Zero tags,
    zero releases; the registry returns DENIED for the repository. There is
    nothing to pull.
  • Every image in the org is amd64-only. ghcr.io/praxis-proxy/praxis:0.5.3's
    manifest list carries linux/amd64 and a provenance entry, nothing else. On
    Apple Silicon that runs under QEMU — the wrong way to demo a proxy whose value
    is measured in microseconds.
  • The publish path structurally cannot fix either. publish.yaml and
    release.yaml both went through the local .github/actions/ghcr-publish
    composite, which accepts only image-name, containerfile, push and
    registry. No platforms, no build-args: one implicit platform, default
    cargo features.
  • FEATURES was not wired to CI at all, so any published image would ship
    without the otel feature regardless of what the Containerfile supported.

What this PR does

Two files. No new files, no renames, Containerfile untouched.

publish.yaml — rewritten

  • Native multi-arch, no QEMU. A matrix builds linux/amd64 on
    ubuntu-24.04 and linux/arm64 on ubuntu-24.04-arm (free for public
    repos), each pushed by digest, then a merge job combines both into one
    manifest list with docker buildx imagetools create. This is the standard
    docker/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_dispatch only. Now:

    Trigger Tags
    push to main (code paths only) :main, :sha-<short>
    called by release.yaml on a v*.*.* tag :X.Y.Z, :X.Y, :latest, :sha-<short>
    workflow_dispatch whatever the ref yields

    The rolling :main tag is the part that turns "nothing to pull" into
    something pullable. It carries container.yaml's paths-ignore list, so a
    docs- or demo-only commit does not spend two native release builds
    republishing an identical binary. container.yaml stays the pull_request
    smoke test and is unmodified; this workflow never runs on PRs.

  • Tag publishes stay behind the release gates. publish.yaml gains
    workflow_call and does not trigger on tags itself. needs cannot cross
    workflow files, so a tag trigger here would publish :X.Y.Z even when
    make test failed — precisely the gate the removed container job provided.
    release.yaml calls it from a publish-image job that needs
    [validate, test, publish-dry-run], and github-release now needs
    publish-image too, so a Release is never cut for a tag whose image failed
    to build (docker pull …:X.Y.Zmanifest unknown).

  • The image name is lowercased at runtime. github.repository preserves
    case 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=otel as a build-arg. The otel feature is config-gated and
    costs ~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 FEATURES and the io.praxis.build.features
    label are in main: what a pulled image contains is inspectable rather than
    assumed.

  • container.yaml builds with the same FEATURES. Otherwise CI would
    smoke-test a default-feature binary while publishing an otel one, and an
    otel-only startup failure would reach GHCR with no coverage.

  • latest made explicit. flavor: latest=false plus a type=raw gated on
    a real tag check (github.ref_type == 'tag', name starts with v, no -),
    rather than metadata-action's implicit default-branch behaviour. A bare
    startsWith(github.ref, 'refs/tags/v') would have moved :latest onto a
    workflow_dispatch run against v1.2.3-rc1 or a non-semver v2, since the
    push filter does not apply to dispatch.

  • Both metadata calls share one tag policy. The per-arch build job bakes
    org.opencontainers.image.version into each image; without the same rules it
    labelled a tag build v1.2.3 while the merge job pushed 1.2.3.

  • cache-to: mode=min. The cargo build runs under RUN --mount=type=cache,
    which BuildKit does not export to the GHA cache, so mode=max uploaded every
    builder 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-artifact v7.0.1 and
    download-artifact v8.0.1; the v4 pins were the only node20 actions left in
    a repo pinned to node24 everywhere else, and node20 is removed from hosted
    runners on 2026-09-23.

  • The dead .github/actions/ghcr-publish composite is deleted. After this
    rewrite 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-arch
    push 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 the
    failure is diagnosable in isolation. merge's needs still refuses to build
    a 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 removed

It and the rewritten publish.yaml both fired on a v*.*.* tag and both
pushed 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-release depended on that job. It is replaced by publish-image, a
job that calls publish.yaml behind [validate, test, publish-dry-run] — the
same gates the container job had — and github-release needs both. So the
release 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.md described the container image as step 4 of the release
pipeline and publish.yaml as dispatch-only, both of which the rewrite
changes; the tag table gained latest and the first-publish note below.
examples/configs/{minimal,gateway}.yaml told users to run
ghcr.io/praxis-proxy/experimental:latest, a tag only a version tag produces
and none has been cut — they now name :main.

Deliberate scope

The matrix is inlined here rather than added to ghcr-publish. Teaching that
action 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 from
feat/publish-container-images. The workflow lowercases the repository name at
runtime, 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 pull from outside the org returns
denied. Noted in docs/release.md.

Known gap, not fixed here

On a push to main this workflow and container.yaml build the same amd64
image independently — this one publishes it, container.yaml smoke-tests it —
and nothing links them, so :main can be republished on a commit whose smoke
test fails. Closing it means running the smoke test against the built digest
before the merge job, which is worth doing on its own.

Verified

  • arm64 builds natively: the image builds with --build-arg FEATURES=otel on an
    M4 Max and produces a linux/arm64 image
  • all seven pinned action SHAs resolve to their stated version tags
  • actionlint with shellcheck clean on both workflows
  • exactly one workflow in the repo publishes an image

Ordering

Rebased onto main after #13 merged, so ARG FEATURES already exists and the
first :main image ships with OTel built in.

@Ladas
Ladas force-pushed the feat/publish-container-images branch 4 times, most recently from d4f1cbc to fe8d711 Compare September 4, 2026 03:46
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
Ladas force-pushed the feat/publish-container-images branch from fe8d711 to 1adb64d Compare September 4, 2026 13:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant