Do GitHub Actions Caches Work Across Architectures?

No. An x64 cache entry restored into an ARM64 job hands that job binaries it cannot run. Put runner.arch in the key and in every restore-keys prefix.

No. A cache entry written by an x64 job should not be restored into an ARM64 job, because the compiled objects, native modules, and linked binaries inside it were produced for the architecture that wrote them. Nothing in GitHub Actions blocks that restore, so the guard belongs in the key: add ${{ runner.arch }} next to ${{ runner.os }} and carry the same segment into every restore-keys prefix (GitHub contexts reference).

WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so a matrix that spans architectures is one label per leg: warp-ubuntu-latest-x64-4x at $0.008 per minute and warp-ubuntu-latest-arm64-4x at $0.006 per minute (cloud runners documentation, checked on 2026-08-13).

Answer

runner.os reports Linux on an x86-64 runner and Linux on an aarch64 runner. The common key shape ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} therefore renders to the same string on both legs of a matrix, and the store has no second signal to tell them apart. A cache version is a hash of the compression tool and the path list, and two entries with different versions never match, so two Linux jobs caching the same paths land on the same version and the same entry (caching documentation).

That version rule is what keeps operating systems apart. A cache saved on warp-macos-14-arm64-6x cannot be restored on warp-ubuntu-latest-x64-4x because the versions differ, and the enableCrossOsArchive input defaults to false (caching documentation). Inside one operating system there is no equivalent guard, so you write it into the key yourself.

name: test

on: [push, pull_request]

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        runner:
          - warp-ubuntu-latest-x64-4x
          - warp-ubuntu-latest-arm64-4x
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v4

      - uses: WarpBuilds/cache@v1
        with:
          path: |
            ~/.npm
            node_modules
          key: ${{ runner.os }}-${{ runner.arch }}-node22-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-${{ runner.arch }}-node22-
            ${{ runner.os }}-${{ runner.arch }}-

      - run: npm ci

      - run: npm test

runner.arch resolves to X64 on the first leg and ARM64 on the second, so the two legs address different entries and neither can read the other's tree. Both fallback prefixes keep the architecture segment. A prefix truncated to ${{ runner.os }}- reopens the hole the primary key just closed, because a miss on the exact key would then fall back to whatever the other architecture wrote.

Cache storage is $0.20 per GB-month and each cache write, restore, or list operation is $0.0001 on hosted runners, with both free on BYOC (caching documentation and the pricing page, checked on 2026-08-13).

Detail

What travels across architectures and what does not

The dividing line is simple: contents that were downloaded stay valid, contents that were built do not.

Cached pathWhat it holdsPortable across architectures
~/.npm/_cacacheregistry tarballs and their metadataYes for the tarballs. Optional dependencies still resolve per platform, so the installed tree differs
node_modulesthe installed tree, including .node addons built by node-gypNo
~/go/pkg/modmodule source zips fetched from the proxyYes
~/.cache/go-buildcompiled package objectsNo
~/.cargo/registrycrate sources and the registry indexYes
target/rlibs, object files, and linked binariesNo
~/.cache/pipwheels tagged with the platform, such as manylinux_2_28_x86_64Downloads only. An x64 wheel is skipped on aarch64 and the install rebuilds from source
~/.m2/repository, ~/.gradle/cachesjars of bytecodeYes, apart from artifacts that bundle native libraries
~/.cache/sccache, ~/.ccachecompiled object files keyed by compiler inputNo

That split argues for two entries instead of one. Keep the download caches under a key carrying the operating system and the lockfile hash, and put the build output under a key that also carries ${{ runner.arch }}. The x64 and ARM64 legs then share one copy of the tarballs and keep their own compiled trees. The cache key glossary entry covers segment ordering, and how to write a good cache key covers choosing the files to hash.

What goes wrong without the architecture segment

The job fails on the first binary it touches. An x64 node_modules restored into an aarch64 job raises invalid ELF header from the first require of a native addon, and a restored target/ directory sends aarch64 rustc into a tree of x86-64 objects that the linker rejects.

The leg that finishes first owns the entry. Cache entries are immutable, so the second leg's save step finds the key taken and uploads nothing. The architecture that lost the race keeps reading the other architecture's tree on every later run, and the entry survives until it expires after 7 days without use (caching documentation).

A green run can still ship the wrong artifact. Steps that copy a prebuilt binary out of the restored cache into a release archive do not always execute it first, so the mismatch reaches the registry instead of the log.

Fallback prefixes bleed. A primary key with the architecture and a restore-keys prefix without it behaves correctly until the exact key misses, which is the run where the lockfile changed and the diff already looks large.

Sizing the duplicate entry

Splitting one entry into two costs storage and buys back build minutes. A 2 GB dependency tree held for both architectures occupies 4 GB, which is $0.80 a month at $0.20 per GB-month. At 1,500 workflow runs a month with one restore and one write on each leg, the 6,000 operations add $0.60 at $0.0001 each, for $1.40 a month.

The rebuild it replaces is 4 minutes of npm ci and native module compilation on warp-ubuntu-latest-arm64-4x at $0.006 per minute: 1,500 x 4 x $0.006 = $36.00 a month on the ARM64 leg alone. Every rate here comes from the pricing page and the caching documentation, checked on 2026-08-13.

The setup actions already do this

actions/setup-node builds its own key rather than using yours. The prefix is node-cache-${platform}-${arch}-${packageManager}, where platform comes from RUNNER_OS and arch from os.arch() on the runner, and the lockfile hash is appended to it (actions/setup-node source). A matrix that caches only through cache: true on the official setup actions is separated by architecture already. Hand-written keys are the exposure, together with any extra path entries layered on top of a setup action.

Layer caches and the platforms that opt out

Docker layer caches are indexed per platform, so a multi-platform build does not mix linux/amd64 and linux/arm64 layers inside one image. The exposure moves to the backend key: two matrix legs writing to the GitHub Actions cache backend under the same scope overwrite each other, so give each platform its own scope (Docker cache backend documentation).

A remote Docker builder holds its layer cache on the builder virtual machine and runs one session per architecture, so the per-architecture split happens without a cache key at all (Docker builders documentation).

Two platforms retire the question. macOS runners are ARM64 across the catalog, so a macOS entry has no second architecture to collide with. The Windows catalog is x86-64 only, and WarpBuild caching is not supported on Windows runners (caching documentation).

Does runner.os already separate x64 from ARM64?

No. runner.os reports Linux on both, so a key built from runner.os and a lockfile hash collides across a matrix. Add ${{ runner.arch }}, which resolves to X64 or ARM64 (GitHub contexts reference). The cache key glossary entry shows where the segment belongs in the string, and the Linux ARM64 runner catalog lists every label that resolves to ARM64.

Can a cache saved on macOS restore on a Linux runner?

No. Cache versions differ across those runners, and entries with different versions never match, so the two stay apart without any work from you. The enableCrossOsArchive input covers the operating system boundary and defaults to false, and it has no bearing on x64 against ARM64 inside Linux (caching documentation).

Which parts of a cache can both architectures share?

Anything downloaded rather than built: registry tarballs, Go module zips, crate sources, and bytecode jars. Compiled objects, linked binaries, and native node addons belong to the architecture that produced them. Give each class its own entry, as described in how to write a good cache key.

Pick the label for the ARM64 leg from the Linux ARM64 runner catalog, price the two-entry split against your own job minutes on the pricing page, and wire the drop-in cache action from the caching reference.

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.