How Do I Push a Manifest List from GitHub Actions?

Build each architecture on its own native runner, push both images by digest, then run docker buildx imagetools create in a final job to publish one tag.

Build each architecture on a runner of that architecture, push both images by digest with no tag attached, then run docker buildx imagetools create in a final job to publish one manifest list that points at both digests. That final job is the only step in the workflow that writes a tag, so the tag your users pull always resolves to an index rather than to a single architecture image (Docker multi-platform image guide for GitHub Actions, checked on 2026-08-13).

Answer

The shape is three jobs: one build job per architecture running in parallel, and one small merge job that runs after both.

JobRunner labelWhat it pushesPer minute
build (linux/amd64)warp-ubuntu-latest-x64-8ximage manifest, addressed by digest, no tag$0.016
build (linux/arm64)warp-ubuntu-latest-arm64-8ximage manifest, addressed by digest, no tag$0.012
mergewarp-ubuntu-latest-arm64-2xone manifest list carrying every tag$0.003

Every rate comes from the WarpBuild cloud runners documentation and the pricing page, checked on 2026-08-13. Both halves of this workflow run on native hardware.

name: publish-image

on:
  push:
    tags: ["v*"]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - platform: linux/amd64
            runner: warp-ubuntu-latest-x64-8x
          - platform: linux/arm64
            runner: warp-ubuntu-latest-arm64-8x
    runs-on: ${{ matrix.runner }}
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - name: Name this leg
        run: |
          platform="${{ matrix.platform }}"
          echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      - id: build
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: ${{ matrix.platform }}
          labels: ${{ steps.meta.outputs.labels }}
          outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true

      - name: Record the digest
        run: |
          mkdir -p /tmp/digests
          digest="${{ steps.build.outputs.digest }}"
          touch "/tmp/digests/${digest#sha256:}"

      - uses: actions/upload-artifact@v4
        with:
          name: digests-${{ env.PLATFORM_PAIR }}
          path: /tmp/digests/*
          if-no-files-found: error
          retention-days: 1

  merge:
    needs: [build]
    runs-on: warp-ubuntu-latest-arm64-2x
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          path: /tmp/digests
          pattern: digests-*
          merge-multiple: true

      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha,format=long
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Create the manifest list
        working-directory: /tmp/digests
        run: |
          docker buildx imagetools create \
            $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
            $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)

      - name: Inspect what was published
        run: |
          docker buildx imagetools inspect \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}

The handoff between the two stages is a file name. Each build job writes an empty file named after its digest into /tmp/digests and uploads it as an artifact, and the merge job downloads every digests-* artifact into one directory with merge-multiple: true. The printf '...@sha256:%s ' * line then turns that directory listing back into one fully qualified reference per architecture (Docker multi-platform image guide for GitHub Actions, checked on 2026-08-13).

Detail

Why pushing by digest and merging beats one emulated build

The alternative is a single job that passes both platforms to one buildx invocation. On an x64 runner, the linux/arm64 half of that build needs an aarch64 execution environment, and the usual way a workflow gets one is docker/setup-qemu-action, which registers binfmt handlers so the kernel routes aarch64 binaries through a QEMU user mode interpreter. QEMU translates every guest instruction into host instructions at run time before the host CPU executes it, and Docker's own multi-platform documentation names emulation as the slowest of the three build strategies it lists (Docker multi-platform builds, checked on 2026-08-13).

That translation lands on exactly the steps that dominate an image build: compilers and linkers, native module builds under npm, wheels that pip builds from source, and anything that generates machine code at run time. It also produces failure modes that read like Dockerfile bugs, including exec format error when a handler was never registered in that job and Illegal instruction from libraries probing for CPU features the interpreter does not implement (Docker multi-platform builds, checked on 2026-08-13).

The split workflow removes the interpreter from the picture. The aarch64 layers are compiled by an aarch64 kernel and userspace on warp-ubuntu-latest-arm64-8x, the amd64 layers on warp-ubuntu-latest-x64-8x, and the two jobs run in parallel, so the wall clock of the build stage is the slower leg rather than the sum of both. The merge job moves no layer bytes at all: docker buildx imagetools create writes a small JSON index that references manifests already in the registry (docker buildx imagetools create reference, checked on 2026-08-13).

Assume 400 tagged builds a month, 6 minutes for the amd64 leg and 8 minutes for the arm64 leg, plus a 1 minute merge job. At the rates above that is 400 x 6 x $0.016 = $38.40 for the amd64 leg, 400 x 8 x $0.012 = $38.40 for the arm64 leg, and 400 x 1 x $0.003 = $1.20 for the merge, so $78.00 a month with the merge job accounting for $1.20 of it.

The registry authentication step

Three jobs means three machines, so each one authenticates on its own. docker/login-action@v3 against ghcr.io with ${{ github.actor }} and ${{ secrets.GITHUB_TOKEN }} works once the job grants packages: write, because the automatic token is scoped per job and defaults to read-only permissions on repositories configured that way (GitHub automatic token authentication, checked on 2026-08-13).

Two details are easy to miss. The merge job needs the same login as the build jobs, since imagetools create reads the source manifests and writes the index through the registry API. And every architecture must be pushed to the same repository, because an index entry is a digest inside that repository; digests that live in a different registry force a copy instead of a reference (docker buildx imagetools create reference, checked on 2026-08-13).

For Amazon ECR, replace the login step with aws-actions/configure-aws-credentials using an OIDC role and aws-actions/amazon-ecr-login, and keep the rest of the workflow unchanged. For Docker Hub, use a repository secret holding an access token rather than an account password.

The tag strategy that keeps the manifest list stable

The build jobs deliberately push no tag. push-by-digest=true with name-canonical=true stores each architecture manifest under its content address and writes no tag at all, so there is never a moment where :latest or :1.4.0 resolves to an amd64-only image while the arm64 job is still running (Docker multi-platform image guide for GitHub Actions, checked on 2026-08-13).

All tags are then written once, in the merge job, from a single docker/metadata-action block. That matters because docker buildx imagetools create accepts many -t flags in one invocation, so 1.4.0, 1.4, the long commit SHA, and latest are all created against the same index in the same command (docker/metadata-action, checked on 2026-08-13).

Keep two habits alongside it. Deploy from the immutable tag or from the index digest that imagetools inspect prints, and treat latest as a convenience pointer only. And read the inspect output before you trust the result: a healthy index lists one entry per architecture, and BuildKit also stores provenance and SBOM attestations as extra entries with the platform set to unknown/unknown, which is normal and is explained in the multi arch image glossary entry.

When a merge job is the wrong shape

The three job workflow is the right default when the Dockerfile is cheap to rebuild and the registry cache carries most of the work between runs. A dependency heavy Dockerfile that recompiles the same base layers on every pull request wants the other shape: one job that hands the build to a remote Docker builder profile with both architectures enabled, keeping a persistent layer cache on the builder between runs. Each architecture runs on a separate builder instance, so a multi architecture build opens one session per architecture (Docker builders documentation, checked on 2026-08-13). Remote Docker builders sit in the same product surface as snapshot runners, CI observability, an MCP server, and the Action Debugger. Sizes and rates for those profiles are on the multi platform Docker builders page.

Do I still need docker/setup-qemu-action once the build is split?

No. Each build job compiles only its own architecture on a runner of that architecture, so no binfmt handler and no QEMU interpreter are involved. Keep setup-qemu-action only on workflows where one machine still has to produce a platform it cannot execute natively. The ARM64 labels and rates for the native leg are in the Linux ARM64 runner catalog.

Why do the build jobs push without a tag?

Because a tag written by a build job would point at a single architecture image until the other job finished. push-by-digest=true stores the manifest under its digest and attaches no tag, so the merge job is the only step that ever writes a tag and that tag is created pointing at the manifest list. The full diagnosis of the single job alternative is in multi platform Docker builds on native runners.

Can I use docker manifest create instead of docker buildx imagetools create?

Both write an index. docker manifest create is an experimental CLI feature that needs DOCKER_CLI_EXPERIMENTAL=enabled and a separate docker manifest push step, and it reads platform metadata from the referenced manifests (docker manifest reference, checked on 2026-08-13). docker buildx imagetools create takes every tag in one invocation and needs no experimental flag, which is why the merge job above uses it.

Can I skip the merge job entirely?

Yes, by handing the whole build to a remote Docker builder profile with both architectures enabled and setting platforms: linux/amd64,linux/arm64 on one job. Each architecture runs on a separate builder instance and bills as one session per architecture, and the builder publishes the index for you (Docker builders documentation). Profile sizes and per-minute rates are on the multi platform Docker builders page.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.