Zig Builds on GitHub Actions

Zig builds on GitHub Actions lose minutes to cold compiler and package caches. Cross-target matrix YAML on warp- labels, cache keys, sizing, and rates.

Last verified:

A zig build on GitHub Actions spends its minutes in three places: installing the compiler, fetching the packages named in build.zig.zon, and compiling every target in the matrix. Install the toolchain with the cache-enabled setup action, persist both Zig cache directories through a cache step, and point runs-on at a warp- label sized for the heaviest target.

Overview

Zig carries cross-compilation in the compiler. One host builds x86_64-linux-gnu, x86_64-linux-musl, aarch64-macos, and x86_64-windows-gnu from the same checkout, because the distribution ships the libc sources it needs and compiles them on demand rather than reaching for a per-target toolchain. The cross-compilation glossary entry covers the general shape of that tradeoff.

The compiler pays for that with caching. Zig keeps two directories, and both start empty on a GitHub Actions runner because runners are ephemeral virtual machines, allocated fresh per job and destroyed at the end.

DirectoryWhat it holdsWhere it comes from
Global cachePackages fetched from build.zig.zon under p/, plus compiled artifacts shared across projectsZIG_GLOBAL_CACHE_DIR, --global-cache-dir, or global_cache_dir from zig env
Local cacheThe build graph, manifests, and object files for this checkout, named .zig-cache in the project root on Zig 0.14 and laterZIG_LOCAL_CACHE_DIR or --cache-dir
Compiler archiveThe downloaded Zig tarball for the pinned versionCached by WarpBuilds/setup-zig by default

WarpBuild runners register under warp- labels, so the matrix below maps a Zig target triple onto a machine that can execute the binary it produces. Labels, machine shapes, and per-minute rates are in the cloud runners documentation.

Configuration

The workflow below builds and tests five targets, each on the label that runs its output natively, with both Zig cache directories persisted per target.

name: zig

on:
  push:
    branches: [main]
  pull_request:

env:
  ZIG_VERSION: 0.14.1
  ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache
  ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-global-cache

jobs:
  build:
    name: ${{ matrix.target }}
    runs-on: ${{ matrix.runner }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - target: x86_64-linux-gnu
            runner: warp-ubuntu-latest-x64-8x
          - target: x86_64-linux-musl
            runner: warp-ubuntu-latest-x64-8x
          - target: aarch64-linux-gnu
            runner: warp-ubuntu-latest-arm64-8x
          - target: aarch64-macos
            runner: warp-macos-15-arm64-6x
          - target: x86_64-windows-gnu
            runner: warp-windows-latest-x64-4x
    steps:
      - uses: actions/checkout@v5

      - uses: WarpBuilds/setup-zig@v2
        with:
          version: ${{ env.ZIG_VERSION }}

      - name: Restore Zig caches
        uses: WarpBuilds/cache@v1
        with:
          path: |
            ${{ github.workspace }}/.zig-cache
            ${{ github.workspace }}/.zig-global-cache
          key: zig-${{ env.ZIG_VERSION }}-${{ matrix.target }}-${{ hashFiles('build.zig', 'build.zig.zon') }}-${{ github.sha }}
          restore-keys: |
            zig-${{ env.ZIG_VERSION }}-${{ matrix.target }}-${{ hashFiles('build.zig', 'build.zig.zon') }}-
            zig-${{ env.ZIG_VERSION }}-${{ matrix.target }}-

      - name: Build
        run: zig build -Dtarget=${{ matrix.target }} -Doptimize=ReleaseSafe --summary all

      - name: Test
        run: zig build test -Dtarget=${{ matrix.target }} --summary all

      - uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.target }}
          path: zig-out/bin

Four details in that file carry the weight.

The setup action caches the toolchain. WarpBuilds/setup-zig installs the pinned compiler and caches the downloaded archives by default, so the download happens once per version rather than once per job. It is a drop-in replacement for the upstream setup action and takes the same inputs; the full list of cache-enabled forks is in the setup actions documentation. Pin the version explicitly, because Zig's language and standard library change between releases and an unpinned toolchain turns a red build into a mystery.

Both cache directories move into the workspace. Setting ZIG_LOCAL_CACHE_DIR and ZIG_GLOBAL_CACHE_DIR at the workflow level puts them where a cache action can address them with a single entry, and the same two paths can be passed as --cache-dir and --global-cache-dir on any zig build invocation. WarpBuilds/cache@v1 takes the same path, key, and restore-keys inputs as actions/cache@v4.

The key carries the target. Artifacts compiled for aarch64-macos do nothing for an x86_64-windows-gnu job, so a shared key means five jobs racing to overwrite one entry. The key ends in github.sha and never matches exactly, which is intended: the two restore-keys prefixes fall back first to the newest cache for the same dependency set and then to the newest cache for the target.

The runner column exists for test execution. Every target in that matrix cross-compiles fine on warp-ubuntu-latest-x64-8x. The native labels are there because zig build test runs the binary it just produced, and a foreign binary needs an emulator to run at all. When a pipeline only needs release artifacts, collapse the matrix onto one Linux x64 label and let the compiler do the work; the release builds guide covers packaging the outputs afterward.

Sizing

Rates below come from the pricing page, and the shapes from the runner catalog.

Runner labelvCPURAMStoragePrice per minute
warp-ubuntu-latest-x64-4x416 GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832 GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664 GB150GB SSD$0.032
warp-ubuntu-latest-arm64-8x832 GB150GB SSD$0.012
warp-macos-15-arm64-6x622 GB120GB SSD$0.08
warp-windows-latest-x64-4x416 GB256GB SSD$0.016

Compile-heavy Zig targets consume cores in two different ways, and they want different answers. A build graph with many independent artifacts, the shape a project reaches once it has several executables, a static library, and a test module, fans out across cores and rewards the 8x and 16x sizes. A single large module optimized with ReleaseFast or ReleaseSmall concentrates the work in codegen, where added cores stop helping and memory becomes the constraint instead. Every Linux size carries 4 GB per vCPU, so the 32 GB ceiling on the 8x label is the practical starting point and the 64 GB ceiling on the 16x label is the answer to an out-of-memory kill during optimization.

The matrix is the wider lever. Five targets running as five concurrent jobs put wall clock at the slowest target rather than the sum of all five, which is worth more on a cross-target project than any single size upgrade.

Applying the rates to a fixed pipeline makes the bill concrete. GitHub publishes its per-minute Actions rates in the GitHub Actions billing reference, checked on 2026-08-13.

SetupShapeRate per minute6 minute job3 x64 jobs, 400 runs per month
warp-ubuntu-latest-x64-8x8 vCPU, 32 GB$0.016$0.096$115.20
GitHub-hosted 8-core larger runner8 vCPU, 32 GB$0.022$0.132$158.40

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, GitHub list price checked on 2026-08-13. At the same shape and the same wall clock, the three x64 entries of this matrix come to $43.20 a month less.

The aarch64-linux-gnu entry runs natively on warp-ubuntu-latest-arm64-8x at $0.012 per minute, below the x64 rate at the same shape. The Linux ARM64 runner page lists the sizes and image details.

Bottlenecks

A cold global cache on every run. Without a persisted global cache, every job re-fetches and re-verifies each dependency in build.zig.zon and recompiles artifacts that no commit touched. The restore and save pair above fixes it, and keying on a hash of build.zig.zon means a dependency bump invalidates exactly the entry that went stale.

One cache entry shared across targets. Five jobs writing one key evict each other for the whole run, and the next pipeline starts cold anyway. Put matrix.target in the key, as the workflow above does.

Foreign tests under emulation. Building aarch64-linux-gnu on an x64 host is quick; running its test binary there means an emulator in the loop, and a suite that finishes in a minute natively can stretch well past that. Point the aarch64 entry at an ARM64 label and the emulator leaves the picture.

Debug-only matrices. A target that builds cleanly in Debug can fail in ReleaseFast, since the optimization pipeline exercises different code paths and undefined behavior stops being checked. Build the release modes you ship on every pull request rather than saving them for tags.

The observability Jobs report shows duration, queue time, CPU, and memory per job, which separates a target pinned by single-threaded codegen from one that saturates every core. For a target that fails only inside GitHub Actions, the Action Debugger pauses the workflow and opens an SSH session on the live runner, which beats bisecting through pushes. The same bottleneck analysis with different weights is on the C++ builds page, where the link step rather than codegen is the serial tail.

Proof

The rest is checkable in your own repository without taking anyone's word for it. Run the matrix unchanged on the labels you use today, then on the warp- labels above with both cache directories persisted, and compare job duration at P75 rather than a single run, since one run hides queue time and cache-miss variance. Every cost number on this page comes from a published rate with a source link and a checked-on date, and no build-time number is claimed for your project, because your cache hit rate and target count decide it.

FAQ

Which Zig cache directories should a GitHub Actions workflow persist?

Two. The global cache, reported as global_cache_dir by zig env and set by ZIG_GLOBAL_CACHE_DIR, holds fetched packages under p/ and artifacts shared across projects. The local cache, set by ZIG_LOCAL_CACHE_DIR and named .zig-cache in the project root on Zig 0.14 and later, holds the build graph and object files for that checkout. Restore and save both in one cache entry keyed on the Zig version, the target, and a hash of build.zig and build.zig.zon.

Do I need one runner per target to build Zig for several targets?

No. Zig cross-compiles every target in a matrix from a single x64 Linux host, so warp-ubuntu-latest-x64-8x can produce Linux gnu, Linux musl, macOS, and Windows artifacts in one job. Native labels earn their place when the job also runs zig build test, because that step executes the produced binary and a foreign binary otherwise needs an emulator.

Which runner size should a Zig project start with?

Start the build job on warp-ubuntu-latest-x64-8x at $0.016 per minute, which gives 8 vCPU and 32 GB. Move a target to warp-ubuntu-latest-x64-16x when the CPU chart shows every core saturated through codegen, and keep the matrix wide rather than the machine large, since separate targets are independent jobs.

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.