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.
Available ARM runner labels
Section titled “Available ARM runner labels”GitHub-hosted ARM runners are selected by label like any other runner:
| Label | Platform |
|---|---|
ubuntu-24.04-arm | Linux, ARM64 |
ubuntu-22.04-arm | Linux, ARM64 |
ubuntu-26.04-arm | Linux, ARM64 (preview at time of writing) |
windows-11-arm | Windows, ARM64 |
windows-11-vs2026-arm | Windows, 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.
Native versus emulated
Section titled “Native versus emulated”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 | |
|---|---|---|
| Setup | A binfmt setup action | A runner label |
| Compile speed | Several times slower | Native |
| Test reliability | Timing-sensitive tests may behave differently | Representative |
| Debugging | Failures may be emulation artefacts | Real 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.
A cross-architecture test matrix
Section titled “A cross-architecture test matrix”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.
Multi-architecture container images
Section titled “Multi-architecture container images”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:
-
Build per architecture on its native runner, pushing by digest rather than by tag.
-
Collect the digests as job outputs or as artifacts.
-
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.
Things that are missing on ARM
Section titled “Things that are missing on ARM”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.archbelongs 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.
Exercise
Section titled “Exercise”-
Run a job on
ubuntu-24.04-armthat printsuname -m. Confirm it reportsaarch64. -
Build a cross-architecture matrix with
include, running your test suite on both. Compare durations. -
Add a cache step whose key omits
runner.arch. Run both legs and observe the collision — the second leg restores the first’s binaries. -
Fix the key with
${{ runner.os }}-${{ runner.arch }}and confirm two distinct caches. -
If you build containers, convert an emulated multi-platform build to the native build-and-merge pattern and compare total wall-clock time.