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.
chardefaults 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
mmaparithmetic 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' . | headEmpty 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
- Does the build link native code? A yes moves the build toward native, because assembling and maintaining a cross toolchain is ongoing work.
- Must the tests execute on aarch64? A yes forces at least one native job, whatever the build strategy is.
- Does the toolchain publish a supported target triple with a maintained sysroot? A no removes cross compiling from the options.
The toolchain table
| Toolchain | Cross compiling to linux/arm64 | Tests on the target architecture | Build shape and time cost |
|---|---|---|---|
Go, CGO_ENABLED=0 | Built 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=1 | Needs 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 graph | rustup 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.rs | Needs 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 CMake | Needs 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-arm64The 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 --releaseBoth 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
| Label | vCPU | RAM | Storage | Per minute |
|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 |
| warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 |
| warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 |
| warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 |
| warp-ubuntu-latest-x64-32x | 32 | 128GB | 150GB SSD | $0.064 |
| warp-ubuntu-latest-arm64-2x | 2 | 8GB | 150GB SSD | $0.003 |
| warp-ubuntu-latest-arm64-4x | 4 | 16GB | 150GB SSD | $0.006 |
| warp-ubuntu-latest-arm64-8x | 8 | 32GB | 150GB SSD | $0.012 |
| warp-ubuntu-latest-arm64-16x | 16 | 64GB | 150GB SSD | $0.024 |
| warp-ubuntu-latest-arm64-32x | 32 | 128GB | 150GB 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-8xat $0.016 per minute, forCminutes. - Native path:
warp-ubuntu-latest-arm64-8xat $0.012 per minute, forNminutes. - Billing is per minute of runner time.
CandNcome 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 C | Cross monthly cost | Native minutes N | Native monthly cost | Same N on the GitHub-hosted 8-core Linux ARM64 runner at $0.014 |
|---|---|---|---|---|
| 4 | $64.00 | 5 | $60.00 | $70.00 |
| 6 | $96.00 | 7 | $84.00 | $98.00 |
| 10 | $160.00 | 12 | $144.00 | $168.00 |
| 18 | $288.00 | 24 | $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.
| Size | WarpBuild x64 | GitHub-hosted x64 same core count | WarpBuild ARM64 | GitHub-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.