Incremental Build
An incremental build reuses outputs from the previous build and recompiles only what changed. How the record works, and why a clean job rebuilds everything.
An incremental build is a build that reuses the outputs of the previous build and recompiles only the units whose inputs changed. It works by keeping a record of what produced each output, then comparing that record against the current sources so every unchanged unit can be skipped.
The record is the whole mechanism. On a developer machine it survives because the build directory stays on disk between builds. In GitHub Actions the job starts on a machine that is provisioned for that job and deleted when the job ends (GitHub-hosted runners reference, checked on 2026-08-13), so the next run has nothing to compare against and compiles the whole tree.
Definition
An incremental build has three parts, and losing any one of them turns the next build into a full build.
- Outputs from an earlier build, still on disk: object files, generated headers, compiled classes, linked binaries.
- A record of the inputs that produced each output. Depending on the tool this is the modification time of the output file itself, a separate fingerprint file written alongside it, or a hash stored in a cache directory.
- A staleness rule that reads the record and decides, per unit, whether to reuse the output or rebuild it.
Deleting the outputs forces a full compile. Deleting the record forces a full compile even when the outputs are all present, because the build system has no way to prove they are current and treats an unprovable output as stale.
The two staleness rules
Build systems answer the staleness question in one of two ways, and the difference decides how a build behaves after a fresh checkout.
Timestamp rules compare modification times. An output is current when every input that feeds it has a modification time older than the output. The comparison is cheap because the filesystem already stores the numbers, and it is exact only while the timestamps mean what the tool assumes they mean.
Content rules compare hashes. The tool hashes the source text, the compiler identity, and the flag set, then looks for an output stored under that hash. Modification times are irrelevant, so a file whose timestamp moved but whose bytes did not still resolves to the same entry.
The table below shows where each build system keeps its record and which rule it applies. Documentation links are the upstream project references, checked on 2026-08-13.
| Build system | Where the record lives | Staleness rule |
|---|---|---|
| GNU make | the output files themselves | A target is rebuilt when any prerequisite has a newer modification time. |
| Ninja | .ninja_log and .ninja_deps in the build directory | Modification times plus the exact command line that produced the output, so a changed flag rebuilds. |
| Cargo | target/<profile>/.fingerprint | One fingerprint file per unit, holding source modification times, flags, and dependency versions. |
| Go | the build cache directory reported by go env GOCACHE | A hash of the inputs, with modification times ignored entirely. |
| ccache | the cache directory reported by ccache --show-config | A hash of the preprocessed source, the compiler, and the flags. |
| MSBuild | the Inputs and Outputs attributes declared on each target | The newest input modification time against the oldest output modification time. |
Why a fresh workspace breaks the timestamp rule
A checkout writes every file it creates with the current time, because the commit does not carry modification times and the tool has nothing else to write. A restored archive behaves the opposite way: tar sets each extracted file back to the modification time it had when the archive was written.
Put those two together in one job and the ordering inverts. Every source file is stamped with the moment the job started, while every restored object file carries a stamp from an earlier day. A timestamp rule reads that and marks the entire tree out of date, which is the correct answer to the question it was asked. Content rules are unaffected, which is why a Go build or a ccache-backed C build recovers from a restore while a make or Cargo build does not.
Example
This workflow builds a Rust crate on a clean job. Cargo writes its outputs and its fingerprint database into target/, and both live only as long as the machine does.
name: build
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cargo build --releaseEvery push runs the same three operations: a fresh machine, a fresh clone, an empty target/. Cargo finds no fingerprint files, so it compiles every crate in the dependency graph and the workspace itself, then the machine is deleted along with everything it produced.
Now add a cache step so target/ survives, and trace what changes.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-cargo-target-${{ hashFiles('Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-target-
- run: cargo build --releaseThe three runs below start from an empty store and change one source file between run 2 and run 3.
| Run | target/ at the start | Source modification times | What Cargo does |
|---|---|---|---|
| 1 | empty | all set to the checkout time | Compiles every unit and writes a fingerprint per unit. The cache step saves target/. |
| 2 | restored, fingerprints intact | all set to this run's checkout time | Every source is newer than the fingerprint recorded for it, so every unit is rebuilt despite the outputs being present. |
| 3 | restored, one source edited | all set to this run's checkout time | Same result as run 2. The edit is invisible, because the other files already looked edited. |
Run 2 is the shape that surprises people. The outputs are on disk, the fingerprints are on disk, and the build still compiles everything, because the checkout moved the source timestamps forward past the record. A build directory that persists on the machine between runs avoids the inversion entirely: the sources keep the timestamps they had when they were last written, the outputs keep theirs, and the ordering the build system depends on holds.
Related Terms
- Making incremental builds work on GitHub Actions: the workspace shapes that keep a build directory and its timestamps intact across runs, and which build systems recover from a plain cache restore.
- Compiler cache and how it differs from an incremental build: the content-keyed store of compiled objects that survives a timestamp reset, and where the two mechanisms overlap.
- Snapshot runner labels and what the saved image keeps: the label suffixes that boot a job from a saved machine image instead of a base image.
- WarpBuild snapshot runner documentation: the
snapshot.enabledandsnapshot.keylabel options, the save action inputs, and the documented limitations. - GitHub-hosted runners reference: how a hosted runner is provisioned for one job and deleted afterwards.
- WarpBuild pricing: per minute rates by runner type.
FAQ
What is the difference between an incremental build and a cached build?
An incremental build reuses the outputs of the previous build in place, using a record the build system wrote next to them. A cached build copies files from a remote store into a fresh workspace. The second one restores the outputs, and it restores the record only when the cache path covers the directory holding it, which is why a restored tree can still compile from scratch.
Why does my build recompile everything even after a cache restore?
Most likely the staleness rule reads timestamps. A checkout writes every source file with the current time, while a restored output keeps the modification time it had when the archive was created. Every source then looks newer than the object built from it, so the build system correctly marks the whole tree out of date.
Is an incremental build safe for a release artifact?
Treat it as unsafe unless the build system keys on content rather than timestamps. Incremental state accumulates whatever earlier builds left behind, including outputs from source that no longer exists in the tree. Release and tagged builds are usually run from a clean tree so the artifact is reproducible from the commit alone.
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.