An ARM64 Migration Checklist

Migrate GitHub Actions jobs to ARM64 with a four category checklist: downloaded binaries, native modules, container base images, and hardcoded runner paths.

Last verified:

Migrating a GitHub Actions workflow to ARM64 is one label edit plus four categories of audit: binaries downloaded with an architecture token in the URL, native modules resolved from a lockfile, container base images, and absolute paths copied from the GitHub-hosted layout. Work through the four categories before the first flip and the label change is the only edit that reaches a pull request.

This checklist covers the audit, the fixes in order, and the arithmetic for the same pipeline at x64 and ARM64 rates. The label and size list sits on the Linux ARM64 runner catalog, the emulation case, where a job builds ARM64 artifacts on an x64 machine through QEMU, is covered in moving builds to native ARM64, and the short version of the four categories is on what changes when a workflow moves to ARM64.

Diagnosis

Run the audit against the whole repository first. Each category has a grep that finds it and an error string that identifies it once the job is already running, so a hit list built in ten minutes replaces a week of red runs.

CategoryFind it before the flipWhat it looks like after the flip
Downloaded binariesgrep -rn "amd64|x86_64|linux-x64" .github/workflows Makefile scripts/cannot execute binary file: Exec format error
Native modulesgrep -n "linux-x64|linux_x86_64|manylinux.*x86_64" package-lock.json requirements.txt poetry.lockAn install or import failure naming a platform package such as @esbuild/linux-x64
Container base imagesgrep -rhn "^FROM|image:" Dockerfile* .github/workflows then docker manifest inspect <image>no matching manifest for linux/arm64/v8 in the manifest list entries
Hardcoded pathsgrep -rn "/home/runner/work"A tool reads an empty directory, or a mount lands on a path that does not exist

The manifest check is worth scripting, because a base image is the category that cannot be fixed inside the workflow file.

for image in $(grep -rh "^FROM" Dockerfile | awk '{print $2}'); do
  archs=$(docker manifest inspect "$image" | grep -o '"architecture": "[^"]*"' | sort -u | tr '\n' ' ')
  echo "$image -> $archs"
done

An image that prints only amd64 blocks the job until the base image publishes an arm64 manifest or you build one. Official language images and the common distro images publish both; internal base images built once on an x64 machine usually do not, and that build is its own piece of work.

Rank the remaining jobs by billed minutes per month, which is runs per month multiplied by job duration. A nightly job with three audit hits is a poor first candidate. A pull request job that runs on every push with no audit hits is the one that pays for the migration while the rest of the fixes land.

Fix

Work the categories in this order. Each one is independent, so a category can be fixed and merged on x64 before any job changes architecture.

1. Downloaded binaries. Replace every literal architecture token with a value derived from the machine. One matrix entry then drives both the label and the URL.

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - runner: warp-ubuntu-latest-x64-8x
            arch: x86_64
          - runner: warp-ubuntu-latest-arm64-8x
            arch: aarch64
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v4
      - name: install the release binary for this machine
        run: |
          test "$(uname -m)" = "${{ matrix.arch }}"
          curl -fsSL "https://example.com/tool/v1.9.0/tool-linux-${{ matrix.arch }}.tar.gz" | tar xz

The test line fails loudly when a matrix entry and a label drift apart, which is the failure mode that otherwise shows up as a confusing runtime error three steps later.

2. Native modules. Lockfiles record platform-specific packages, so a lockfile generated on x86-64 alone leaves the aarch64 variant absent at install time. Regenerate the lockfile on a machine that sees both, keep optional dependencies enabled in the install step, and check the aarch64 package is present before the job runs. For Python, a package with no manylinux aarch64 wheel falls back to a source build, so the job needs a compiler and headers or a pinned version that publishes the wheel.

3. Container base images. Fix the images the audit flagged. Where the base image is yours, build it for both platforms and publish one manifest, so the workflow keeps naming a single tag.

4. Hardcoded paths. The Ubuntu 24.04 ARM64 image sets the work dir to /runner/_work, which differs from GitHub's /home/runner/work/ for the same instance (WarpBuild cloud runners documentation, checked on 2026-08-13). Replace each grep hit with the variable that already carries the right value: $GITHUB_WORKSPACE or ${{ github.workspace }} for the checkout, $RUNNER_TEMP or ${{ runner.temp }} for scratch files (GitHub variables reference). Config files checked into the repository carry these paths as often as workflow files do, so the grep covers Makefile, coverage configuration, and scanner properties as well. The path layout under the new prefix is unchanged, which the work directory answer shows row by row.

With the four categories closed, the flip is the label.

   test:
-    runs-on: warp-ubuntu-latest-x64-8x
+    runs-on: warp-ubuntu-latest-arm64-8x

Keep fail-fast: false while a matrix runs both architectures so one leg failing still reports the other. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so the jobs that stay on x86-64 keep their labels and nothing else in the workflow moves.

Configuration

Five sizes carry the Ubuntu 24.04 ARM64 image under the latest alias, and the same five exist for Ubuntu 26.04 under warp-ubuntu-2604-arm64-<size>. The ARM64 images for Ubuntu 22.04 were deprecated on March 31, 2025, so a migration that starts from a pinned 22.04 label picks a 24.04 or 26.04 target.

The Ubuntu 24.04 ARM64 runners are compatible with GitHub's Ubuntu 24.04 ARM64 runners, and the tooling list is published in the ARM Ubuntu 24 image reference. Read it for any language runtime the workflow assumes rather than assuming parity with the x86-64 image.

Two configuration items belong in the same pull request as the label change. Cache entries are keyed by a hash covering the runner OS and the cached paths, so an entry written on warp-ubuntu-latest-x64-8x does not restore on warp-ubuntu-latest-arm64-8x. Put ${{ runner.arch }} in the cache key so the two fleets stop invalidating each other, and expect one cold run per key. Second, jobs that need nested virtualization, such as Android emulator tests, stay on an x86-64 label (common issues).

When a migrated job fails in a way the logs do not explain, the Action Debugger pauses the workflow and opens an SSH session on the ARM64 machine, and CI observability correlates runner system metrics with the job logs, which separates a missing package from a memory ceiling.

Cost or Time Model

Price the pipeline at both rates before the migration, holding minutes constant, so the rate effect and the duration effect stay separate. Assumptions: 900 workflow runs per month, three jobs per run, 8 vCPU sizes throughout, warp-ubuntu-latest-x64-8x at $0.016 per minute and warp-ubuntu-latest-arm64-8x at $0.012 per minute, billed per minute.

JobMinutes per runMonthly minutesx64 at $0.016ARM64 at $0.012
Unit tests76,300$100.80$75.60
Integration tests119,900$158.40$118.80
Package and publish65,400$86.40$64.80
Total2421,600$345.60$259.20

The difference is $86.40 per month at identical minutes. Duration is the second input and it comes from your own run history, so keep the x86-64 leg in the matrix for a week after the flip and read both from the Actions run list. The rate gap absorbs a longer ARM64 run up to a known point: 900 runs at 32 minutes is 28,800 minutes, and 28,800 x $0.012 is $345.60, which matches the x86-64 total at 24 minutes.

Two list-price anchors put those rates against GitHub-hosted runners at the same shape, both checked on 2026-08-13 in the Actions minute multipliers reference. warp-ubuntu-latest-arm64-8x (8 vCPU, 32 GB) costs $0.012 per minute against $0.014 per minute for the 8-core Linux ARM64 larger runner (8 vCPU, 32 GB): 14 percent lower list price. On the architecture you are leaving, warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price.

The full rate sheet, including cache line items, is on the pricing page.

FAQ

In what order should jobs move to ARM64?

By billed minutes, highest first, with one exception. Audit the four categories across the whole repository before the first flip, then move the single job that consumes the most runner minutes and has the fewest audit hits. That job proves the rate arithmetic on real traffic while the harder jobs, usually the ones with a container base image or a native module, get their fixes prepared in parallel.

What breaks first on an ARM64 runner?

A step that downloads a release binary with an architecture token in the URL. The download succeeds because the file exists, and the first execution fails with cannot execute binary file: Exec format error. Drive that token from the same matrix variable that drives runs-on, or read it from uname -m inside the step, and the same workflow file works on both architectures.

Does the ARM64 work directory difference require a workflow change?

Only where a path is hardcoded. The Ubuntu 24.04 ARM64 image sets the work dir to /runner/_work rather than GitHub's /home/runner/work/, and every path variable still resolves, so a step that reads $GITHUB_WORKSPACE or ${{ github.workspace }} needs no edit. Grep the repository for /home/runner/work before the first ARM64 run and replace each hit with the variable.

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.