Why Does Cargo Rebuild Everything in GitHub Actions?
Cargo rebuilds because a fresh runner has no target directory, and a changed toolchain, feature set, or RUSTFLAGS invalidates a restored one. Keys and costs.
Cargo rebuilds everything because each GitHub Actions job starts on a clean machine with no target directory, so there are no fingerprints on disk to compare against and every crate in the graph compiles from source. When target does get restored from a cache and Cargo still rebuilds it, the fingerprints came back but no longer match: the toolchain, the enabled feature set, a profile setting, or RUSTFLAGS moved between the run that wrote the entry and the run reading it.
Answer
Cargo keeps compiled artifacts in target and, next to each one, a fingerprint recording the inputs that produced it. The build cache reference describes the directory, and Cargo's contributor guide on fingerprints lists what goes into the comparison. A rebuild happens when a fingerprint is absent or when any recorded input differs.
That gives three distinct situations, and they need different fixes.
The first is an empty target. GitHub-hosted runners give each job a fresh virtual machine, and self-hosted runners configured as ephemeral behave the same way. Nothing survives, so the fix is a cache or a prepared machine.
The second is a restored target that Cargo discards anyway. Something in the fingerprint changed, and Cargo cannot reuse artifacts built under different inputs.
The third looks like a full rebuild and is not one: the dependency tree restores cleanly and your workspace crates recompile on every job. That is timestamps, covered below.
Here is what sits in the fingerprint and what moves it inside a workflow.
| Fingerprint input | What changes it in a GitHub Actions job | What rebuilds |
|---|---|---|
The rustc version, down to the commit hash | rustup update, a rust-toolchain.toml bump, a runner image shipping a newer preinstalled toolchain | Every crate in the graph |
| The enabled feature set for each unit | --all-features in one job and default features in another, or a new dependency turning on a feature of a shared crate | The crate whose features changed and everything compiled against it |
Profile settings: opt-level, debug, lto, codegen-units, panic, overflow-checks | Switching to --release, editing a [profile.*] table, adding a profile only the workflow uses | Everything built under that profile |
RUSTFLAGS and RUSTDOCFLAGS | A lint job exporting -D warnings while the test job exports nothing | Everything for that flag set |
| The target triple | --target x86_64-unknown-linux-musl on one job and the host triple on another | Everything for that triple |
| Fingerprints of dependencies | A Cargo.lock update touching one low-level crate | That crate and every crate above it |
| Source file timestamps for path and workspace crates | actions/checkout writing every file with the current timestamp | Your workspace crates, on every job |
Environment variables read through env! or option_env! | A workflow exporting a build identifier or version string per run | The crates that read them |
Two rows explain most confused reports. Registry dependencies are fingerprinted from the package checksum rather than from timestamps, so a restored entry keeps them valid while the workspace crates in the same entry rebuild. And feature sets unify across the graph, so a single new dependency can change the hash of a crate that nobody edited.
Detail
Put the toolchain and the feature set in the key
Cargo.lock is the obvious key input and it covers none of the top four rows in that table. A key built from the lockfile alone restores several gigabytes after a toolchain bump and then throws all of it away.
Name the compiler and the flag set in the key directly. Hashing rust-toolchain.toml records the intent; hashing the output of rustc -vV records what actually ran, which matters when the file says stable and the runner image moved.
name: rust
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUSTFLAGS: "-D warnings"
jobs:
test:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v5
- name: Record the exact toolchain
run: rustc -vV | tee rustc-version.txt
- name: Restore cargo cache
id: cargo
uses: WarpBuilds/cache/restore@v1
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
key: cargo-allfeatures-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('rustc-version.txt') }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
cargo-allfeatures-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('rustc-version.txt') }}-
- run: cargo test --workspace --all-features --locked
- name: Save cargo cache
if: success() && github.ref == 'refs/heads/main'
uses: WarpBuilds/cache/save@v1
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
key: ${{ steps.cargo.outputs.cache-primary-key }}The allfeatures segment is written by hand because the feature set and RUSTFLAGS appear in no file the key hashes. Change either one and change that segment, or the job restores artifacts Cargo will discard. Setting RUSTFLAGS at workflow level rather than per job keeps every job on one flag set, which keeps them on one set of artifacts. The WarpBuild cache documentation has the action inputs, and how to cache Cargo dependencies in GitHub Actions covers which Cargo home paths belong in path.
One feature set per job
A job that runs cargo build, then cargo clippy, then cargo test --all-features compiles the dependency graph more than once inside a single run, because each distinct feature set and flag set gets its own artifacts. The job log reads as a rebuild loop and the cache entry grows to hold every variant.
Pick one feature set per job and let separate jobs carry separate cache keys. CARGO_INCREMENTAL: 0 belongs in the same decision: incremental state pays off on a developer machine that recompiles the same tree many times, and a runner sees each commit once. Rust incremental compilation on GitHub Actions works through what incremental state does and does not survive.
When the target directory is too large to move
Past a certain workspace size the restore step costs more than the compile it removes, and no key fixes that. A snapshot runner takes the machine itself instead: snapshot runners capture the runner disk after a warm build and boot later jobs from it, so target is already on local disk and nothing crosses the network.
jobs:
test:
runs-on: >-
${{ github.ref == 'refs/heads/main'
&& 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true'
|| 'warp-ubuntu-latest-x64-8x;snapshot.key=rust-target' }}
steps:
- uses: actions/checkout@v5
- run: cargo test --workspace --all-features --locked
- name: Clear credentials before snapshotting
if: github.ref == 'refs/heads/main'
run: |
rm -rf $HOME/.ssh $HOME/.aws
git clean -ffd -e target
- name: Save snapshot
if: github.ref == 'refs/heads/main'
uses: WarpBuilds/snapshot-save@v1
with:
alias: rust-target
wait-timeout-minutes: 60Four constraints are worth knowing before you commit to this shape. The documented cleanup command is git clean -ffdx, and -x deletes gitignored paths, which includes target, so this workflow drops -x and excludes the directory it is trying to keep. Snapshots are deleted after 15 days, so an alias needs a job that refreshes it. /tmp does not persist across the reboot. Snapshot runners are supported on WarpBuild Cloud Ubuntu runners only, with labels on other runner types silently ignored. A macOS Rust job stays on the cache path.
A snapshot does not fix the timestamp row either. actions/checkout still rewrites your workspace sources, so your own crates recompile and the dependency tree stays warm.
What the rebuild costs
Rates come from the pricing page, checked on 2026-08-13. Durations below are illustrative, and the arithmetic is what you should rerun with numbers from your own job log.
Take a workspace where cargo test --workspace --all-features takes 12 minutes cold and 4 minutes against a valid target, on warp-ubuntu-latest-x64-8x at $0.016 per minute, across 880 runs a month.
- Rebuilding every run: 12 x $0.016 = $0.192 per job, so 880 jobs cost $168.96.
- Restoring a valid entry: 4 x $0.016 = $0.064 per job, so 880 jobs cost $56.32, plus a 12 GB entry at $0.20 per GB-month for $2.40 and about 1,760 cache operations at $0.0001 for $0.18.
- Restoring an invalid entry: the download plus the full 12 minute compile, which is the worst of the three and the case a toolchain-aware key removes.
For the snapshot path, restore bills $0.04 per job and storage $0.025 per snapshot-hour, which is $0.60 per day for one live alias. At $0.016 per minute the restore fee equals 2.5 minutes of build time, so a snapshot pays once it removes more than that, before counting a boot that takes 45 to 60 seconds. On warp-ubuntu-latest-x64-16x at $0.032 per minute the same fee equals 1.25 minutes.
To find which steps the minutes actually go to, the Action Debugger opens an SSH session on a paused runner. Run cargo build --timings against the restored target there to see what Cargo decided was stale.
Rust builds on GitHub Actions covers runner sizing for the same workloads.
Related Questions
Why do my own crates rebuild even when the cache restores?
Cargo checks source file timestamps for path and workspace crates, and actions/checkout writes every file with the timestamp of the checkout, so the restored artifacts are older than the sources that produced them and every workspace crate recompiles. Registry dependencies are fingerprinted from their package checksum rather than from timestamps, so they stay warm. That is why a restored run compiles your crates and skips the dependency tree, which is the expected result rather than a broken cache.
Why does adding one feature flag rebuild the whole graph?
The enabled feature set is part of the hash Cargo computes for every unit, and features unify across the dependency graph, so turning on a feature of a low-level crate changes the hash of that crate and of everything compiled against it. The same rule explains a job that runs cargo build with default features and then cargo test --all-features: those are two feature sets, so the graph compiles twice. Give each feature set its own job and its own cache key.
When should I use a snapshot runner instead of caching the target directory?
When target has grown past the point where moving it costs more than the compile it removes. A snapshot runner restores the whole runner disk in place, so nothing crosses the network, and it suits workspaces whose build cache is measured in tens of gigabytes. Snapshot restore bills $0.04 per job against $0.016 per minute on warp-ubuntu-latest-x64-8x, so it pays once it removes more than 2.5 minutes of build, before counting the 45 to 60 second boot. Keep the cache path for smaller workspaces and for macOS or Windows jobs, where snapshots are unavailable.
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.