Keep build caches between GitHub Actions runs

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.

ci.build.incrementalstatic · checkable from the repo
How StarSling works

AI agents open the PR

StarSling agents run this exact audit on your workflows, apply the fix, and open a reviewable PR automatically.

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
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

Avoid this

.github/workflows/ci.yml
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

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.

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.

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
- 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

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
- 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 }}-

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.

Prompt for your coding agent
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.

Go further

One fix, all of them, or forever.

You have the prompt for this one practice. Here is how much further you can take it, each step doing more for you than the last.

  1. Fix this one thing

    Copy the prompt above

    Hand OPT62 to your coding agent and fix it in your repo today.

  2. Fix everything, once

    Install the ci-speedup skill

    One prompt audits your whole repo against all 73 ci-speedup patterns (this one plus 72 more) and hands your agent every fix at once. Open source, MIT, runs locally.

  3. Keep it fixed, forever

    Install the StarSling GitHub App

    Connect GitHub and the fixes stay applied as your CI evolves, with agents that keep inspecting your workflows and opening optimization PRs you review.

Sources

1GitHub Actions: caching dependencies to speed up workflows (opens in new tab)

2Next.js: configure CI build caching (opens in new tab)

3TypeScript: project references and build mode (opens in new tab)

4Cargo: profiles reference (incremental) (opens in new tab)

Last updated 2026-08-21