Incremental Builds on GitHub Actions
Incremental builds fail on GitHub Actions because each job gets a clean VM. Boot from a WarpBuild snapshot so build outputs and compiler state survive.
Last verified:
Incremental builds fail on GitHub Actions because every job is provisioned on a clean VM that is destroyed when the job ends, so the build outputs and the fingerprint database an incremental compiler compares against are gone before the next run starts. The fix is to give the job a disk that already holds that state: a WarpBuild snapshot runner captures the runner VM mid-workflow and later jobs boot from that image with the build tree, its timestamps, and the compiler caches in place.
This guide covers why the failure happens, the exact runs-on and snapshot-save configuration that fixes it, the boundaries of the mechanism, and a time model comparing a full rebuild against an incremental run at a stated change size.
Diagnosis
An incremental build is a comparison. The build system reads the inputs, reads a record of what it produced last time, and rebuilds the difference. Three separate things have to be present on disk for that comparison to work, and a stateless runner supplies none of them.
The record of the last build does not exist
Every incremental tool keeps a local database next to its outputs. TypeScript writes .tsbuildinfo. Cargo writes target/*/.fingerprint. Gradle keeps a file-hash cache under ~/.gradle. Bazel keeps an action cache in its output base. ccache and sccache keep an object store keyed on hashed preprocessed source. CMake with Ninja keeps .ninja_deps and .ninja_log in the build directory.
A GitHub Actions job starts from a base image with an empty workspace. The build system finds no database, so it does the only correct thing available to it and builds every target from scratch. Nothing is broken. The input it needs was deleted with the previous machine.
A fresh checkout rewrites every timestamp
This is the failure that survives a cache action, so it is worth separating out. actions/checkout writes a new working tree, and git sets file mtimes to the moment of the write. Every source file is now newer than every object file you restored.
Tools that compare timestamps rebuild the whole tree on that basis: make, ninja, and Cargo all fall in this group. Tools that hash content survive better, which is why a restored ccache directory still produces object-cache hits and why Gradle and Bazel can reuse work after a restore. The split is worth knowing before you spend a week tuning cache keys, because on a timestamp-driven build no cache key change will help.
Machine state has no path into a cache action
The rest of the state is not a set of files you can name in a path: list.
- A Gradle or Bazel daemon with a warm JVM, a populated in-memory file hash map, and a loaded project model. It dies with the machine.
- The Docker daemon's local image store, holding base images and toolchain containers a build pulled during an earlier run.
- Absolute paths baked into debug info, sccache configuration, and generated build files, which only line up when the tree sits back at the path it was written to.
- Inode identity, which hardlink-based output stores rely on.
The restore itself is billed work
The final term is transfer. A cache action compresses the build tree, uploads it, and downloads it again on the next run, and all of that time is charged as runner minutes. The transfer scales with the size of the tree while the change scales with the pull request, so a one-file change on a 6.5 GB build tree pays the same restore as a rewrite of the whole module. The same asymmetry drives checkout time, which the guide to large repositories on GitHub Actions covers separately.
Measure the split before choosing a fix. From a recent run, take three numbers: seconds in the restore step, seconds between the end of the restore and the first line of compiler output, and the count of targets the build system decided to rebuild. If that last number is close to your total target count on a small pull request, the problem is the comparison, and no amount of cache tuning will move it.
Fix
Stop restoring state into a machine and boot the machine that already has it.
The pattern that the snapshot runner documentation recommends splits the work by branch. Pushes to the default branch boot from the base image with snapshot.enabled=true, run a full build, clean the machine, and write a snapshot under an alias. Pull requests boot from that alias with snapshot.key=<alias> and compile only what their diff invalidated.
The build tree comes back on the disk it was written to, at the same absolute path, with its original mtimes and inode identity, and with the fingerprint database sitting next to the outputs it describes. That is the full set of conditions the comparison needs.
| At the first compile step | Stateless runner plus a cache action | Snapshot runner |
|---|---|---|
| Source mtimes | Rewritten by checkout | Preserved from the run that produced the outputs |
| Object files and generated headers | Restored when the key hits | Already on disk |
| Fingerprint database | Restored, sensitive to path | In place at its original path |
| Build daemon | Cold, rescans the tree | Cold, and its on-disk caches are intact |
| Pulled container images | Pulled again | In the local image store |
| Cost at job start | Grows with tree size | Flat 45 to 60 second boot plus $0.04 |
The boundaries
Four limits decide whether this pattern fits a given job.
/tmp does not persist. The directory is cleaned on reboot and a snapshot boot is a reboot, so a build that writes intermediates to /tmp gets nothing back. Point the build directory under $HOME or inside the workspace.
Snapshots are deleted after 15 days. An alias that stops being refreshed expires, and the next job carrying that key boots from the base image and pays a full build once. Refresh the alias on a schedule if the branch that writes it goes quiet.
Boot takes 45 to 60 seconds, which is slower than a base image boot. On a job whose setup is under a minute, the boot plus the $0.04 restore fee costs more than the state is worth, and the guide to GitHub Actions cold starts is the better starting point for that shape of job.
Stateless runners remain the better choice for release builds. A snapshot carries whatever earlier runs left on disk, which is exactly what you want on a pull request and exactly what you do not want on a tagged artifact. Keep release workflows on a plain warp-ubuntu-latest-x64-16x label with no snapshot directive so they build from a clean tree.
Platform placement is the last check. Snapshots are supported on WarpBuild Cloud Ubuntu runners only. BYOC runs on AWS, GCP, and Azure, and snapshots are unsupported there too. A snapshot label on a BYOC, Windows, or macOS runner is silently ignored, so the job runs normally with no warning and no improvement in duration. Grep your workflows for that mistake before debugging anything else.
Configuration
A CMake and Ninja project, with a full build on main and incremental builds on pull requests:
name: build
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: >-
${{ github.ref == 'refs/heads/main'
&& 'warp-ubuntu-latest-x64-16x;snapshot.enabled=true'
|| 'warp-ubuntu-latest-x64-16x;snapshot.key=service-build-main' }}
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Configure
run: cmake -S . -B $HOME/build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
- name: Build
run: cmake --build $HOME/build --parallel 16
- name: Test
run: ctest --test-dir $HOME/build --output-on-failure
- name: Cleanup credentials
if: github.ref == 'refs/heads/main'
run: |
rm -rf $HOME/.ssh $HOME/.aws
git clean -ffdx
- name: Save snapshot
if: github.ref == 'refs/heads/main'
uses: WarpBuilds/snapshot-save@v1
with:
alias: "service-build-main"
fail-on-error: true
wait-timeout-minutes: 60Two details in that file carry the incremental behavior. The build directory sits under $HOME rather than /tmp, so it survives the boot. And snapshots are only ever written from main, so a pull request cannot poison the image that other pull requests boot from, and the alias is rebuilt from the base image on every push to the default branch, which bounds drift.
The snapshot-save action takes three inputs. alias is required and names the image later jobs boot from. fail-on-error defaults to true and turns a failed capture into a failed job. wait-timeout-minutes defaults to 30 and bounds the wait for capture to finish; large build trees are the reason to raise it to 60.
Branch on the boot source
A job booted from a snapshot carries WARPBUILD_SNAPSHOT_KEY set to the alias. Use it for work that only a cold machine needs:
- name: Install system packages
if: env.WARPBUILD_SNAPSHOT_KEY == ''
run: sudo apt-get update && sudo apt-get install -y ninja-build ccacheKey the alias to the things that invalidate everything
One alias per build configuration keeps the image honest. A toolchain bump, a compiler flag change, or a switch of build type invalidates the entire tree anyway, so encoding those in the alias avoids a run that boots warm and then rebuilds from scratch without telling you:
runs-on: warp-ubuntu-latest-x64-16x;snapshot.key=service-gcc14-relwithdebinfoKeep lockfile-keyed dependencies in a cache action
Package directories belong in a cache action rather than the image, so a lockfile bump invalidates one small entry instead of forcing a fresh full build. WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4, documented on the WarpBuild caching page:
- name: Restore vcpkg binary cache
uses: WarpBuilds/cache@v1
with:
path: ~/.cache/vcpkg/archives
key: ${{ runner.os }}-vcpkg-${{ hashFiles('vcpkg.json') }}
restore-keys: |
${{ runner.os }}-vcpkg-Two operational notes. And run as many jobs as your workflows need, because generally available Linux and Windows runners do not have plan-level concurrency caps; a hundred pull request jobs can boot from one alias at the same time. Snapshot runners sit alongside remote Docker builders, CI observability, an MCP server, and the Action Debugger in the WarpBuild product surface.
Cost or Time Model
Assumptions, stated so you can substitute numbers from your own step timings:
- A C++ service repository with 4,200 translation units and a 6.5 GB build tree, including objects, generated headers, and a compiler cache.
- Runner
warp-ubuntu-latest-x64-16x, 16 vCPU and 64 GB, at $0.032 per minute from the pricing page. - Change size: a pull request touching 12 sources and 2 shared headers, which the build graph resolves to 180 translation units, or 4.3 percent of the tree.
- 600 pull request runs per month, and 22 pushes to
mainthat each write a fresh snapshot.
Start with the compile step alone, which is what the change size controls:
| Compile step | Translation units built | Minutes |
|---|---|---|
| Full rebuild from an empty tree | 4,200 | 24.00 |
| Clean VM with a restored compiler cache | 4,200 driver invocations, mostly object-cache hits | 7.20 |
| Snapshot boot, incremental | 180 | 2.50 |
The middle row is the one teams are surprised by. The files are back, so the compiler cache answers most invocations without doing real work, and the build system still walks all 4,200 units because the checkout rewrote their timestamps.
Now the whole job:
| Stage | Clean VM plus cache action | Snapshot runner |
|---|---|---|
| Boot | 0.25 min | 1.00 min |
| Checkout | 0.50 min | 0.20 min |
| Restore build tree | 2.60 min | 0.00 min |
| Reconcile and rehash | 1.40 min | 0.30 min |
| Compile | 7.20 min | 2.50 min |
| Save state, amortized | 0.80 min | 0.18 min |
| Total per run | 12.75 min | 4.18 min |
The difference is 8.57 minutes per run. Across 600 runs that is 5,142 minutes per month, or about 86 hours, and those minutes sit at the front of a job where somebody is waiting on a check.
Priced out
| Line | Clean VM plus cache action | Snapshot runner |
|---|---|---|
| Runner minutes | 7,650 at $0.032 = $244.80 | 2,508 at $0.032 = $80.26 |
| Cache storage | 6.5 GB at $0.20 per GB-month = $1.30 | $0.00 |
| Cache operations | 720 at $0.0001 = $0.07 | $0.00 |
| Snapshot restores | $0.00 | 600 at $0.04 = $24.00 |
| Snapshot storage | $0.00 | 720 snapshot-hours at $0.025 = $18.00 |
| Monthly total | $246.17 | $122.26 |
Two sensitivities decide whether that holds for you. Runner size moves the top line and leaves the $42.00 of snapshot fees fixed, so the same workload on warp-ubuntu-latest-x64-4x at $0.008 per minute removes only $41.14 of runner cost and the trade goes flat. Run count moves the runner line while snapshot storage stays at $18.00 per alias per month, so a repository running 60 jobs against an alias pays $0.30 per job for storage while one running 6,000 pays $0.003.
Against GitHub-hosted list prices
GitHub-hosted runners have no snapshot mechanism, so the clean VM column is the only column available there. Rates below are from the GitHub Actions billing reference, checked on 2026-08-13, next to the WarpBuild rate for the same shape:
| Shape | GitHub-hosted per minute | WarpBuild label | WarpBuild per minute |
|---|---|---|---|
| 8 vCPU, 32 GB | $0.022 | warp-ubuntu-latest-x64-8x | $0.016 |
| 16 vCPU, 64 GB | $0.042 | warp-ubuntu-latest-x64-16x | $0.032 |
| 32 vCPU, 128 GB | $0.082 | warp-ubuntu-latest-x64-32x | $0.064 |
At the 16 vCPU shape, the same 7,650 monthly minutes of the clean VM path cost $321.30 on the GitHub-hosted larger runner against $244.80 on warp-ubuntu-latest-x64-16x, and against $122.26 for the snapshot path including its restore and storage fees.
The model holds step timings identical across every column so that only the mechanism and the list price move.
FAQ
Why do incremental builds not work on GitHub Actions?
Every job is provisioned on a clean VM and the VM is destroyed when the job ends. The record an incremental build compares against, meaning object files, generated headers, and the fingerprint database the build system writes next to them, never survives to the next run, so the build system correctly decides that everything is out of date and compiles the whole tree.
Does a cache action make a build incremental again?
Partly. A cache action restores the files, and tools that key on hashed content, such as ccache and sccache, reuse them. Tools that compare timestamps, such as make, ninja, and Cargo, see a working tree whose mtimes were rewritten by actions/checkout and treat every source file as newer than its output, so they rebuild anyway.
Is it safe to use snapshot runners on a public repository?
Only with a cleanup step. On a public repository anyone who can open a pull request can reference your alias and boot a runner from that image, and on a private repository WarpBuild provisions runners at the organization level, so a runner built for a snapshot job can be handed to another job in the organization. Run rm -rf $HOME/.ssh $HOME/.aws and git clean -ffdx before snapshot-save on every workflow that captures an image.
When should a build stay on a stateless runner?
Release and tagged builds that have to be reproducible from source. A snapshot accumulates whatever earlier runs left on disk, so a release job should boot from the base image and rebuild from a clean tree. Snapshots also carry a 45 to 60 second boot, are deleted after 15 days, and are supported on WarpBuild Cloud Ubuntu runners only.
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.