Skip to content

ARM Runners in GitHub Actions

Lesson 7 of 11Intermediate4 min readGitHub Actions & CI/CD · Advanced ActionsVerified: GitHub-hosted runner labels, August 2026

ARM stopped being a niche target some time ago. Apple Silicon is the default developer machine, AWS Graviton and equivalents are the default cost-efficient server, and a Raspberry Pi is an ARM box. If you ship binaries or container images, you probably need ARM builds — and until recently the only way to produce them in CI was emulation.

GitHub-hosted ARM runners are selected by label like any other runner:

LabelPlatform
ubuntu-24.04-armLinux, ARM64
ubuntu-22.04-armLinux, ARM64
ubuntu-26.04-armLinux, ARM64 (preview at time of writing)
windows-11-armWindows, ARM64
windows-11-vs2026-armWindows, ARM64 with the newer toolchain

macOS runners on Apple Silicon are selected through the ordinary macos-latest and versioned macos-14/macos-15/macos-26 labels rather than an -arm suffix; the Intel variants carry -intel in their names.

The old approach ran an ARM build on an x86 runner through QEMU binfmt emulation. It works and it is slow — every instruction is translated, and a compile-heavy build can take several times longer. Test suites fare worse, because they are dominated by the very code paths emulation penalises.

Native ARM runners remove the translation entirely.

Emulated (QEMU on x86)Native ARM runner
SetupA binfmt setup actionA runner label
Compile speedSeveral times slowerNative
Test reliabilityTiming-sensitive tests may behave differentlyRepresentative
DebuggingFailures may be emulation artefactsReal failures

That last row is the underrated one. Time to stop reaching for emulation is when you cannot tell whether a failing test found a real ARM bug or an emulation quirk.

jobs:
test:
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
arch: amd64
- runner: ubuntu-24.04-arm
arch: arm64
runs-on: ${{ matrix.runner }}
name: Test (${{ matrix.arch }})
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: "1.25"
- run: go test -race ./...

include without axes gives exactly two jobs rather than a product. runs-on reads the runner from the matrix, which is what makes one job definition target two architectures.

Architecture-specific bugs are real and mostly fall into recognisable families: unaligned memory access, assumptions about char signedness, differences in floating-point intermediate precision, and — most commonly in practice — memory ordering. ARM’s memory model is weaker than x86’s, so code with a missing memory barrier can work reliably on x86 and fail intermittently on ARM. A racy program is not equally racy on both.

The fast pattern builds each architecture natively and then merges the results into one manifest list, so a single tag serves both architectures and clients pull the right one automatically:

  1. Build per architecture on its native runner, pushing by digest rather than by tag.

  2. Collect the digests as job outputs or as artifacts.

  3. Create the manifest list in a final job that references both digests under one tag.

merge:
needs: [build-amd64, build-arm64]
runs-on: ubuntu-latest
steps:
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create the manifest list
run: |
docker buildx imagetools create \
--tag "ghcr.io/${GITHUB_REPOSITORY}:${GITHUB_SHA}" \
"ghcr.io/${GITHUB_REPOSITORY}@${AMD64_DIGEST}" \
"ghcr.io/${GITHUB_REPOSITORY}@${ARM64_DIGEST}"
env:
AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }}
ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }}

This is more workflow than a single emulated platforms: linux/amd64,linux/arm64 build, and for a large image it is dramatically faster. For a small image where the build takes a minute either way, the simpler emulated version is a perfectly reasonable choice — the complexity only pays for itself when the build is long.

Expect to hit a few gaps:

  • Prebuilt binaries. Some tools publish x86-only releases, so an install step that downloads a binary needs an architecture-aware URL — or a build from source.
  • Native npm and Python packages. Most popular ones ship ARM wheels and prebuilt binaries now, but a long-tail package may compile from source on ARM and not on x86, which shows up as a much slower install rather than a failure.
  • Docker base images. Verify the base image has an arm64 variant. Without it, the build fails or silently falls back to emulation depending on configuration.
  • Cache keys. runner.arch belongs in any cache key on a cross-architecture matrix, or the arm64 leg restores x86 binaries. See caching.

That last one is worth stating as a rule: a cache key without runner.os and runner.arch on a cross-platform matrix is a bug waiting for the day the caches collide.

  1. Run a job on ubuntu-24.04-arm that prints uname -m. Confirm it reports aarch64.

  2. Build a cross-architecture matrix with include, running your test suite on both. Compare durations.

  3. Add a cache step whose key omits runner.arch. Run both legs and observe the collision — the second leg restores the first’s binaries.

  4. Fix the key with ${{ runner.os }}-${{ runner.arch }} and confirm two distinct caches.

  5. If you build containers, convert an emulated multi-platform build to the native build-and-merge pattern and compare total wall-clock time.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Want production-ready workflow templates? The Professional Toolkit has five, with permissions set correctly.