---
title: "Keep Build Caches Between GitHub Actions Runs | StarSling"
description: "Find rm -rf, cargo clean, or make clean steps in GitHub Actions that destroy build state, and make them conditional on an actual cache miss."
url: https://starsling.dev/github-actions/optimizations/use-incremental-builds
canonicalUrl: https://starsling.dev/github-actions/optimizations/use-incremental-builds
---

# Keep build caches between GitHub Actions runs

[GitHub Actions](https://starsling.dev/github-actions) / [Optimizations](https://starsling.dev/github-actions/optimizations) / Keep build caches between GitHub Actions runs

- [How StarSling works](https://starsling.dev/)

Rule: ci.build.incremental. Detection mode: static. Last updated: 2026-08-21

A `rm -rf build`, `cargo clean`, or `make clean` step wired into a GitHub Actions workflow (or a build script it calls) deletes incremental build state before the compiler ever sees it; the fix is to condition the clean on an actual cache miss, or drop it entirely where the tool is incremental by default.

## Table of contents

- [Do this](#do-this)
- [Avoid this](#avoid-this)
- [How to detect it](#how-to-detect-it)
- [Tradeoffs and safety](#tradeoffs)
- [Make the clean step conditional, or drop it](#condition-the-clean-step)
- [Design the cache key for build state, not just the lockfile](#cache-key-design)
- [Verify it worked](#verify)
- [Related pages](#related-pages)
- [Sources](#sources)

<a id="do-this"></a>

## Do this

Compilers and bundlers that support incremental builds - tsc with project references, cargo, cmake, Gradle - compare the current source tree against state saved from a previous build (a .tsbuildinfo file, target/, CMakeCache.txt, .gradle/caches) and recompile only what changed. A clean step run unconditionally before the build throws that state away regardless of whether anything actually changed, so every run pays for a full rebuild the tool was designed to avoid. Where this bites depends on the runner: on a persistent workspace - a self-hosted runner, or any runner that reuses its disk between jobs - the clean step destroys build state that was genuinely still there, for no reason. On an ephemeral GitHub-hosted runner the workspace starts empty on every job anyway, so the clean step itself is not what is costing you - but once a workflow adds `actions/cache` to restore that incremental state (the second half of this page), a clean step left in place deletes exactly what was just restored, one step before the compiler could use it.

_.github/workflows/ci.yml_

```yaml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - name: Restore incremental build state
        uses: actions/cache@v4
        with:
          path: |
            **/*.tsbuildinfo
          key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('src/**/*.ts', 'tsconfig*.json') }}
          restore-keys: |
            tsbuildinfo-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-
            tsbuildinfo-${{ runner.os }}-
      # No clean step. tsc --build compares the restored .tsbuildinfo against
      # the checked-out source and recompiles only what changed - removing
      # the clean step is what makes the cache step above worth having.
      - run: npx tsc --build
```

<a id="avoid-this"></a>

## Avoid this

_.github/workflows/ci.yml_

```yaml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      # Runs on every build, unconditionally. On a persistent workspace this
      # deletes real incremental state for no reason; on an ephemeral runner
      # with a cache step added, this is the line that deletes exactly what
      # the cache step just restored, one step before tsc could use it.
      - run: rm -rf dist *.tsbuildinfo
      - run: npx tsc --build
```

<a id="how-to-detect-it"></a>

## How to detect it

1. Search workflow YAML for explicit clean commands: `grep -rn 'rm -rf build\|rm -rf target\|rm -rf dist\|rm -rf node_modules\|cargo clean\|make clean\|gradle clean' .github/workflows/`.
2. Trace through invoked build scripts, not just the workflow file: a step that runs `./scripts/build.sh` or `npm run build` can hide the same clean command inside the script or an npm `pre`/`post` hook. Read every script a build step calls before concluding there is no clean step.
3. For each clean command found, check whether it is unconditional or already gated on something - a bare `rm -rf build` before every build is the finding; a step already checking for a stale or corrupt cache before cleaning is not.
4. For cmake builds specifically, check whether the clean step runs regardless of `CMakeCache.txt` existing - cleaning a directory that has no prior cache is a no-op, but cleaning one that does is a wasted rebuild.
5. For cargo builds, check for `cargo clean` anywhere in the workflow or a called script - cargo's incremental compilation is on by default, so a `cargo clean` step needs a specific reason (a stale target/ directory from an incompatible toolchain version, for example), not routine hygiene.
6. Separately, confirm whether an incremental directory is cached at all: `grep -rn 'actions/cache' .github/workflows/` and check whether `path:` includes the tool's incremental state - `**/*.tsbuildinfo` for TypeScript project references, `.next/cache` for Next.js, `target/` for cargo (or confirm `Swatinem/rust-cache` is used instead), `.gradle/caches` and `.gradle/wrapper` for Gradle. No matching cache step means there is no restored state for a clean step to threaten yet, but it also means the build is cold every run regardless.

<a id="tradeoffs"></a>

## Tradeoffs and safety

- Some release or provenance builds legitimately want a from-scratch compile every time. If that is a deliberate requirement, keep the clean step, condition it explicitly (a workflow input, a label, a schedule), and comment why it is there; the finding is an undocumented or unconditional clean step, not the existence of clean builds in general.
- Making a clean conditional on a cache miss is only safe if the tool's own incremental algorithm is trustworthy. A custom build script that copies files without comparing content or timestamps can produce stale output from old state - only skip the clean for tools documented to validate their own incremental state (tsc, cargo, cmake's CMakeCache.txt check); when in doubt, keep cleaning until that is confirmed.
- A tool whose incremental directory holds native binaries (Rust's target/, some Gradle outputs) is not portable across operating systems or architectures. If build state is also being cached with actions/cache, segment the cache key on `runner.os` (and `runner.arch` for cross-compiled matrices), or the wrong platform's artifacts get restored and the build fails or silently produces the wrong output.
- Restore-keys trade an exact hit for a prefix match: a partial restore can carry over an older, larger `.next/cache` or `target/` than the run needs. This is a storage and eventual-eviction cost, not a correctness risk, because the tool re-validates the restored state against current sources before trusting any of it.
- On self-hosted runners with a persistent workspace, removing an unconditional clean step matters on its own, with no cache step involved at all - the workspace already holds real build state between runs, so an unconditional clean is pure waste there today, independent of anything actions/cache adds.

<a id="condition-the-clean-step"></a>

## Make the clean step conditional, or drop it

The fix is rarely 'delete the clean step and hope' - it is to make cleaning conditional on the thing that would actually make a clean necessary, which differs by tool. cmake's build state lives in CMakeCache.txt; a clean that runs whether or not that file exists wastes a rebuild on every run where it was already absent, so gate it on the file's presence. cargo's incremental compilation is on by default and its own dependency graph decides what needs recompiling, so a routine `cargo clean` step has no upside at all - remove it, and reach for a targeted `cargo clean -p <package>` only when a specific stale artifact is the actual problem. For a build with no such built-in check, condition the clean step on the outcome of the cache-restore step below (cache-hit: skip the clean; cache-miss: nothing to destroy, so cleaning is harmless but also unnecessary) rather than running it unconditionally before every build.

_.github/workflows/ci.yml_

```yaml
- name: Restore incremental build state
  id: cache
  uses: actions/cache@v4
  with:
    path: build/
    key: cmake-${{ runner.os }}-${{ hashFiles('CMakeLists.txt') }}

# CMake's own check: a clean only does something if CMakeCache.txt exists.
- name: Clean only if a stale cache is present
  if: steps.cache.outputs.cache-hit != 'true'
  run: |
    if [ -f build/CMakeCache.txt ]; then
      rm -rf build
    fi

- run: cmake -S . -B build && cmake --build build
```

<a id="cache-key-design"></a>

## Design the cache key for build state, not just the lockfile

A dependency cache and a build-state cache need different keys, and reusing the dependency key for both is the most common way this recipe goes wrong. A lockfile hash changes when dependencies change, but incremental build state goes stale on every source-file change - a lockfile-only key serves the exact same restored `.tsbuildinfo` or `.next/cache` on a commit that only touched application code, and either the tool trusts state it should not, or its own validation rejects the mismatch and falls back to a full build anyway, silently defeating the cache. The key for build state should include both: something that identifies the dependency set (the lockfile hash) and something that reflects the source tree (a hash of the source files themselves, or - more simply - the run's own commit SHA as the primary key). Because an exact key rarely matches two runs in a row, pair it with `restore-keys` prefixes that drop the more specific parts of the key in order, so a run with no exact hit still restores the closest prior state instead of nothing, and lets the compiler's own incremental algorithm compute the (usually small) delta from there.

_.github/workflows/ci.yml_

```yaml
- name: Restore incremental build state
  uses: actions/cache@v4
  with:
    path: .next/cache
    # Primary key: exact commit. Almost never hits on its own, which is fine -
    # restore-keys below is what actually serves state.
    key: nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ github.sha }}
    # Each fallback drops one level of specificity: same OS + lockfile first
    # (any recent build for this dependency set), then same OS alone.
    restore-keys: |
      nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-
      nextjs-${{ runner.os }}-
```

<a id="verify"></a>

## Verify it worked

Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to run this audit and open the fix as a reviewable PR:

```
Audit this repository's CI build for clean steps that destroy incremental build state, and fix what you find.

1. grep .github/workflows/ for explicit clean commands: rm -rf build/target/dist/node_modules,
   cargo clean, make clean, gradle clean. Then read every build script a workflow step
   invokes (./scripts/*.sh, package.json scripts including pre/post hooks) for the same
   commands - a clean step hidden inside a called script is still a finding.
2. For each clean command found, determine whether it runs unconditionally before every
   build. If it is already gated on something (a stale-cache check, an explicit input), it
   is not a finding; document why in the PR body and move on.
3. For an unconditional clean, fix it per tool: for cargo, remove the clean entirely -
   incremental compilation is on by default and cargo's own algorithm decides what needs
   recompiling. For cmake, make the clean conditional on CMakeCache.txt existing (cleaning
   a directory with no prior cache is a no-op; cleaning one that has state is a wasted
   rebuild). For other tools, condition the clean on an actual cache miss (see step 5) or
   add an --incremental flag to the build script if one exists.
4. If a clean step is genuinely required (a release build needing provenance, for
   example), leave it in place, make the condition explicit, and add a comment stating why
   - do not remove a deliberately documented clean step.
5. Check whether an actions/cache step already restores the tool's incremental directory
   (.tsbuildinfo for TypeScript project references, .next/cache for Next.js, target/ for
   cargo, .gradle/caches for Gradle). If not, and the clean step is being made conditional
   on a cache hit, add one with a key that includes runner.os plus a hash of both the
   lockfile and the source files that affect the build, and a restore-keys fallback.
6. Read the upstream docs linked on this page (GitHub Actions caching, and whichever of
   the Next.js, TypeScript, or Cargo docs matches this repo's build tool) before editing.
7. Show the full diff and open a pull request; do not apply changes blindly. In the PR
   body, list each finding with file and line, and state how to verify: re-run the same
   commit and confirm the build tool's own output reports reused state instead of a full
   recompile.
```

Confirm the change landed:

1. Re-run the workflow on the same commit and read the build tool's own output: tsc --build should report the project 'is up to date', Next.js should log that it found the previous build cache, and cargo/Gradle output should show a small set of recompiled units instead of the whole tree.
2. Open the actions/cache step's log for the run and confirm it reports a cache restore (a specific or restore-key hit), not 'Cache not found for input keys'.
3. Change one source file that should invalidate only part of the build and confirm the tool recompiles just the affected unit, not the entire project - this rules out a key that is too coarse (hit on stale state) or too narrow (miss on every commit).
4. Compare the build step's wall-clock time between a cold run (cache miss, e.g. a first run on a new branch) and a warm run on an unchanged commit; a working incremental cache turns a full compile into a state comparison plus a small delta.

<a id="related-pages"></a>

## Related pages

- [GitHub Actions cache: dependencies, keys, and cache hits](https://starsling.dev/best-practices/github-actions/cache-dependencies)
- [Build once and reuse it across GitHub Actions jobs](https://starsling.dev/github-actions/optimizations/avoid-duplicate-compilation)

<a id="sources"></a>

## Sources

- [GitHub Actions: caching dependencies to speed up workflows](https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows)
- [Next.js: configure CI build caching](https://nextjs.org/docs/pages/guides/ci-build-caching)
- [TypeScript: project references and build mode](https://www.typescriptlang.org/docs/handbook/project-references.html)
- [Cargo: profiles reference (incremental)](https://doc.rust-lang.org/cargo/reference/profiles.html)
