Cross Compiling or Native ARM64 Builds

Cross compile ARM64 artifacts on an x64 runner when the build links no native code and tests can stay on x64. Build natively when cgo or ARM64 tests run.

Last verified:

Cross compiling produces an ARM64 artifact on an x64 runner, while a native build runs the same compile on an ARM64 runner. Cross compiling is enough when the build links no native code and the test suite exercises no architecture-specific behaviour; a native ARM64 runner is required once cgo, native extensions, or tests that must execute on aarch64 enter the pipeline.

WarpBuild carries both shapes as runner labels. The cross-compiling job runs on warp-ubuntu-latest-x64-8x at $0.016 per minute and the native job runs on warp-ubuntu-latest-arm64-8x at $0.012 per minute. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so a repository can hold both legs without leaving GitHub Actions.

This page owns the compile-strategy decision. Container emulation is covered in QEMU compared with native ARM64 builds, the full label and price list sits on the Linux ARM64 runner catalog, and running the same jobs on your own AWS account is covered in Graviton builds in GitHub Actions.

Diagnosis

What a cross compiler actually proves

A cross compiler emits machine code for a target triple such as aarch64-unknown-linux-gnu while executing on the x64 host. A green cross build proves that the source compiled and linked for that triple. Nothing on the machine executed the output, so every property that appears at execution time stays unverified until something runs the binary on ARM64 hardware.

Three classes of problem live on the execution side.

cgo and native extensions

The moment the build links C or C++, cross compiling stops being a compiler flag and becomes a toolchain assembly job. Go with CGO_ENABLED=1 needs aarch64-linux-gnu-gcc and headers for every linked library. Rust crates with a build.rs that shells out to a C compiler need the same, plus a linker entry in .cargo/config.toml. Python packages without an aarch64 wheel fall back to building from source, and Node addons compiled through node-gyp pick up the host compiler unless you override it.

The failure modes are specific. ld: cannot find -lssl means the sysroot lacks the target library. wrong ELF class: ELFCLASS64 means the build grabbed a host object file. undefined reference to __aarch64_ldadd4_acq_rel means the compiler and the target libgcc disagree on atomics support.

Architecture-specific tests

An x64 host cannot execute an aarch64 test binary, so go test, cargo test, and ctest all stop at the compile step in a cross build. Skipping the tests is the quiet part of the decision, because aarch64 semantics differ from x86-64 in ways that only execution reveals.

  • aarch64 uses a weaker memory model than x86-64 store ordering. Missing barriers and benign-looking data races surface as flaky failures on ARM64 and stay invisible on x64.
  • char defaults to unsigned on AArch64 Linux and signed on x86-64, so byte comparisons that assume a negative value change branch on the target.
  • Fused multiply-add contraction changes last-bit float results, which breaks exact-equality assertions in numeric suites.
  • SSE intrinsics and __builtin_ia32_* calls compile only behind an #ifdef, so the ARM64 path is often the branch nobody has executed.
  • Page size assumptions baked into mmap arithmetic differ between host and target.

Toolchain drift

Cross toolchains age separately from the code they build. The sysroot pins a glibc version, and a sysroot newer than the deployment target yields a binary that refuses to start with GLIBC_2.38 not found, which the build log never shows. Autoconf scripts that probe with uname -m or nproc read the host. A -march=native flag left in a release build targets the x64 host CPU and produces an ARM64 binary tuned for nothing. Conditional compilation guarded by #ifdef __aarch64__ or cfg(target_arch = "aarch64") compiles only on the target path, so that code gets its first compiler pass the day you cross compile and its first execution much later.

Classify the repository in one step

Run this in a scratch job before choosing a strategy.

      - name: classify the build
        run: |
          go list -f '{{.ImportPath}} {{len .CgoFiles}}' ./... | grep -v ' 0$' || echo "no cgo packages"
          find . -name build.rs -not -path './target/*' | head
          grep -rn "march=native\|__aarch64__\|target_arch" --include='*.c' --include='*.cc' --include='*.rs' . | head
          grep -rn "CMAKE_TOOLCHAIN_FILE\|--host=" --include='*.yml' --include='*.cmake' . | head

Empty output across all four lines points at cross compiling. Any hit on the first two lines pulls the build toward a native ARM64 runner, and hits on the last two mean the cross path needs a maintained sysroot that somebody owns.

Fix

Three questions decide it

  1. Does the build link native code? A yes moves the build toward native, because assembling and maintaining a cross toolchain is ongoing work.
  2. Must the tests execute on aarch64? A yes forces at least one native job, whatever the build strategy is.
  3. Does the toolchain publish a supported target triple with a maintained sysroot? A no removes cross compiling from the options.

The toolchain table

ToolchainCross compiling to linux/arm64Tests on the target architectureBuild shape and time cost
Go, CGO_ENABLED=0Built into the standard toolchain through GOOS=linux GOARCH=arm64. No extra packages.go test compiles the test binary and stops. No ARM64 execution.One compile pass on the x64 host. No toolchain install step.
Go, CGO_ENABLED=1Needs gcc-aarch64-linux-gnu and CC=aarch64-linux-gnu-gcc, plus target headers for every linked library.Same block: the test binary builds and cannot run.Adds an apt install step per run unless the packages are cached.
Rust, pure crate graphrustup target add aarch64-unknown-linux-gnu plus a linker entry in .cargo/config.toml. Proc macros still build for the host.cargo test --target aarch64-unknown-linux-gnu requires a runner binary to execute the tests.Adds a target download step. The dependency graph compiles twice when host and target both need it.
Rust with -sys crates or build.rsNeeds the cross C toolchain and PKG_CONFIG_SYSROOT_DIR pointed at the target sysroot.Same as above, with the added risk that the linked C library version differs from the target.Adds toolchain install plus sysroot setup per run.
C++ with CMakeNeeds a toolchain file, a sysroot, and find_package paths that resolve inside it.ctest cannot execute the cross-built tests on the host.Adds sysroot fetch and configure time. The configure step reruns whenever the toolchain file changes.

The three workable shapes

Cross only. One job on an x64 label produces the artifact. Correct for a pure Go or pure Rust binary whose behaviour carries no architecture dependence, where the tests already run on x64 and prove the logic.

Native only. One job on an ARM64 label builds and tests on the architecture you ship to. Correct when cgo, native extensions, or aarch64 semantics are in play, and it removes the cross toolchain from the repository.

Split. Cross compile the release artifact on x64, then run the test suite on a native ARM64 runner. Correct when the release build is heavily parallel and the test suite is short, or when a cross toolchain already exists and works.

Pick one per artifact rather than per repository. A service binary and a native Python extension in the same repository often land on different answers.

Configuration

Cross-compiling job on an x64 label

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

jobs:
  cross-build-arm64:
    # warp-ubuntu-latest-x64-8x: 8 vCPU, 32GB, $0.016 per minute
    runs-on: warp-ubuntu-latest-x64-8x
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: "1.24"
      - name: install the aarch64 cross toolchain
        run: |
          sudo apt-get update
          sudo apt-get install -y gcc-aarch64-linux-gnu
      - name: build the arm64 binary
        env:
          GOOS: linux
          GOARCH: arm64
          CGO_ENABLED: "1"
          CC: aarch64-linux-gnu-gcc
        run: go build -o dist/app-linux-arm64 ./cmd/app
      - name: assert the artifact targets aarch64
        run: file dist/app-linux-arm64 | grep -q "ARM aarch64"
      - uses: actions/upload-artifact@v4
        with:
          name: app-linux-arm64
          path: dist/app-linux-arm64

The file assertion earns its place. A misconfigured CC or a stale GOARCH yields an x86-64 binary with the right filename, and the assertion fails the job instead of shipping it.

Native job on an ARM64 label

  native-build-arm64:
    # warp-ubuntu-latest-arm64-8x: 8 vCPU, 32GB, $0.012 per minute
    runs-on: warp-ubuntu-latest-arm64-8x
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: "1.24"
      - uses: WarpBuilds/cache@v1
        with:
          path: |
            ~/.cache/go-build
            ~/go/pkg/mod
          key: go-${{ runner.arch }}-${{ hashFiles('**/go.sum') }}
      - run: go build -o dist/app-linux-arm64 ./cmd/app
      - run: go test -race ./...

The environment block disappears because the host is the target. go test -race now executes on aarch64, which is where the race detector has something to say about the weak memory model.

Rust cross linker configuration

# .cargo/config.toml
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"

[env]
PKG_CONFIG_ALLOW_CROSS = "1"

Without the linker entry, cargo build --target aarch64-unknown-linux-gnu calls the host cc and fails at link time with a stream of unrecognized object file errors. Sizing, caching, and toolchain setup for the rest of a Rust pipeline are covered on the Rust on GitHub Actions solution page.

Split shape as a matrix

jobs:
  build:
    strategy:
      matrix:
        include:
          - runner: warp-ubuntu-latest-x64-8x
            job: cross-build
          - runner: warp-ubuntu-latest-arm64-4x
            job: native-test
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v4
      - uses: WarpBuilds/cache@v1
        with:
          path: target
          key: cargo-${{ runner.arch }}-${{ hashFiles('**/Cargo.lock') }}
      - if: matrix.job == 'cross-build'
        run: |
          rustup target add aarch64-unknown-linux-gnu
          cargo build --release --target aarch64-unknown-linux-gnu
      - if: matrix.job == 'native-test'
        run: cargo test --release

Both legs start together. Generally available Linux and Windows runners do not have plan-level concurrency caps, so the matrix width is a scheduling choice rather than a quota question.

Labels, sizes, and rates

LabelvCPURAMStoragePer minute
warp-ubuntu-latest-x64-2x28GB150GB SSD$0.004
warp-ubuntu-latest-x64-4x416GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664GB150GB SSD$0.032
warp-ubuntu-latest-x64-32x32128GB150GB SSD$0.064
warp-ubuntu-latest-arm64-2x28GB150GB SSD$0.003
warp-ubuntu-latest-arm64-4x416GB150GB SSD$0.006
warp-ubuntu-latest-arm64-8x832GB150GB SSD$0.012
warp-ubuntu-latest-arm64-16x1664GB150GB SSD$0.024
warp-ubuntu-latest-arm64-32x32128GB150GB SSD$0.048

The Ubuntu 24.04 ARM64 image sets the work dir to /runner/_work, which differs from GitHub's /home/runner/work/, so read $GITHUB_WORKSPACE in any script that currently hardcodes a path. Ubuntu 26.04 labels use the same sizes and rates under warp-ubuntu-2604-arm64-<size>. Full platform coverage sits in the cloud runners documentation, and the drop-in cache action used above is documented in the caching documentation.

Cache keys carry the architecture through runner.arch for a reason. Cache entries are keyed by a hash covering the runner OS compression tool and the cached paths, so an entry written on an x64 label does not restore on an ARM64 label. Without the key split, the two legs invalidate each other on every run.

When a test passes on the cross-built path and fails on the native one, the Action Debugger pauses the workflow and opens an SSH session on the ARM64 machine so you can run the binary by hand, and CI observability correlates runner system metrics with the job logs.

Cost or Time Model

Assumptions

  • 1,000 workflow runs per month, which is 50 runs per weekday across 20 weekdays.
  • One ARM64 artifact per run.
  • Cross path: warp-ubuntu-latest-x64-8x at $0.016 per minute, for C minutes.
  • Native path: warp-ubuntu-latest-arm64-8x at $0.012 per minute, for N minutes.
  • Billing is per minute of runner time.
  • C and N come from your own run history. The values below show the arithmetic.

Monthly cost at 1,000 runs

Monthly cost on the cross path is 1000 * C * $0.016, which is $16.00 * C. Monthly cost on the native path is 1000 * N * $0.012, which is $12.00 * N.

Cross minutes CCross monthly costNative minutes NNative monthly costSame N on the GitHub-hosted 8-core Linux ARM64 runner at $0.014
4$64.005$60.00$70.00
6$96.007$84.00$98.00
10$160.0012$144.00$168.00
18$288.0024$288.00$336.00

The last row is the break-even. Setting 0.016 * C equal to 0.012 * N gives N = 1.33 * C, so the native path costs less on the invoice unless the native job needs more than four minutes for every three cross-compiled minutes. That margin absorbs a native build that lands slower than the cross build on a specific workload.

The split shape

The split shape adds a native test job to the cross build. At 1,000 runs with C of 6 on warp-ubuntu-latest-x64-8x and a 5 minute test job on warp-ubuntu-latest-arm64-4x at $0.006 per minute, the monthly total is 1000 * (6 * 0.016 + 5 * 0.006), which is $126.00. The native-only shape at N of 7 costs $84.00. The split shape buys the cross toolchain nothing over native-only here, which is the usual result once a native runner is on the table.

List-price anchors

GitHub publishes its own per-minute rates on the Actions minute multipliers reference, checked on 2026-08-13.

SizeWarpBuild x64GitHub-hosted x64 same core countWarpBuild ARM64GitHub-hosted Linux ARM64 same core count
2x$0.004$0.006$0.003$0.005
4x$0.008$0.012$0.006$0.008
8x$0.016$0.022$0.012$0.014
16x$0.032$0.042$0.024$0.026
32x$0.064$0.082$0.048$0.050

At the 8 vCPU size the cross-compiling job is $0.016 per minute against $0.022 for the GitHub-hosted 8-core Linux runner, a 27 percent lower list price, and the native job is $0.012 against $0.014, a 14 percent lower list price (GitHub pricing, checked on 2026-08-13).

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

FAQ

When is cross compiling to ARM64 good enough?

When the build links no native code, the test suite exercises no architecture-specific behaviour, and the toolchain publishes a supported target triple. A pure Go binary with CGO_ENABLED=0 and a pure Rust crate graph both fit. The compiler emits aarch64 machine code on the x64 host and the artifact ships.

What does cross compiling hide that a native ARM64 job catches?

Everything that only appears at execution time. Weak memory ordering on aarch64 surfaces races that x86-64 store ordering hides, char defaults to unsigned on ARM64, fused multiply-add changes last-bit float results, and a sysroot glibc newer than the deployment target produces a binary that fails to start. None of these stop the cross build.

Can I cross compile the artifact and still test on ARM64?

Yes, and it is the usual middle path. Build on warp-ubuntu-latest-x64-8x at $0.016 per minute, upload the binary as an artifact, then download and run the test suite on warp-ubuntu-latest-arm64-4x at $0.006 per minute. The test job proves the artifact runs on the architecture you ship to.

Does a native ARM64 runner cost more than cross compiling on x64?

The ARM64 label carries a lower per-minute rate at every size. warp-ubuntu-latest-arm64-8x is $0.012 per minute against $0.016 for warp-ubuntu-latest-x64-8x, so the native path costs less on the invoice unless the native job needs more than four minutes for every three cross-compiled minutes.

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.