Skip to content

Caching Dependencies for Faster Builds


You’ve been using caching since the dependency-installation chapter, without seeing how it actually works. The cache: "pip" input on setup-python has been quietly saving you time on every run where requirements.txt hasn’t changed. This chapter opens that up — the general-purpose mechanism underneath it, and when you’d reach for it directly instead of relying on a wrapper.

What setup-python’s cache Input Is Actually Doing

cache: "pip" isn’t a special pip-only feature baked into setup-python. It’s a convenience wrapper around a separate, general-purpose action: actions/cache. When you set that input, setup-python is doing, on your behalf, roughly what you’d write by hand if you called actions/cache yourself — save pip’s download cache after a successful install, restore it before the next one, and invalidate it automatically when your dependency file changes.

That’s a good default for the common case. It stops being enough the day you need to cache something setup-python was never built to know about — a downloaded external tool, a linter’s own cache directory, a compiled build artifact. That’s when you need the underlying primitive directly.

Meet actions/cache Directly

The general form looks like this:

- name: Cache Something
  uses: actions/cache@v6
  with:
    path: path/to/cache
    key: some-descriptive-key

Three ideas make this work:

  • path — what directory (or file) on the runner’s disk to save and restore.
  • key — a string that identifies this specific cache. If a cache already exists under this exact key, it’s restored before your steps run, and none of your later cache-writing work happens again for nothing.
  • What gets saved — after the job finishes successfully, whatever’s at path gets saved under key, ready for a future run to restore.

Anatomy Of A Cache Key

A cache key is only useful if it changes exactly when the cached content should be invalidated — no sooner, no later. The near-universal pattern is to build the key from a hash of whatever file determines the cache’s contents:

key: ${{ runner.os }}-mytool-${{ hashFiles('**/some-lockfile.txt') }}

hashFiles computes a hash of the given file (or files matching a glob). As long as that file’s contents don’t change, the key stays the same, and the cache from last time gets restored. The moment the file changes, the hash changes, the key changes, and actions/cache correctly treats it as a cache miss — you get a fresh dependency install instead of a stale one masquerading as current.

Including runner.os in the key matters too: a cache built on Ubuntu isn’t guaranteed to be valid on Windows or macOS, since compiled binaries and paths can differ by platform. Keying by OS keeps each platform’s cache separate.

restore-keys is the fallback mechanism — a list of key prefixes to fall back to if there’s no exact match on the primary key:

restore-keys: |
  ${{ runner.os }}-mytool-

If nothing matches the full key (because your lockfile changed since the last run), actions/cache will still restore the most recent cache matching that prefix, if one exists. It won’t be a perfect match, but for many tools, a slightly-stale cache is still faster to build on top of than starting from nothing.

When You’d Reach For actions/cache Yourself

Anything that’s slow to produce and safe to reuse between runs is a candidate. A few real examples: a downloaded compiler or CLI tool that isn’t covered by a setup-* action, a linter or type-checker’s own on-disk cache (many tools, including Ruff, keep one to skip re-analyzing unchanged files), or compiled build output that’s expensive to regenerate from scratch every time.

Hands-On: Caching Ruff’s Own Cache

Ruff keeps a cache of its own — by default in a .ruff_cache directory — that lets it skip re-analyzing files that haven’t changed since the last run. Right now, every CI run starts that cache from empty, since the runner is a fresh VM each time. Give it something to restore.

Update the Lint (ruff) step in .github/workflows/ci.yml, adding a cache step immediately before it:

.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        python-version: ["3.12", "3.13", "3.14"]
        os: [windows-latest, ubuntu-latest, macos-latest]
    steps:
      - name: Checkout
        uses: actions/checkout@v7

      - name: Setup Python
        uses: actions/setup-python@v7
        with:
          python-version: ${{ matrix.python-version }}
          cache: "pip"
          cache-dependency-path: requirements.txt

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Cache Ruff Cache
        uses: actions/cache@v6
        with:
          path: .ruff_cache
          key: ruff-${{ runner.os }}-${{ hashFiles('**/*.py') }}
          restore-keys: |
            ruff-${{ runner.os }}-

      - name: Lint (ruff)
        uses: astral-sh/[email protected]

      - name: Run Tests
        run: python -m pytest --junitxml=test-report.xml

      - name: Upload Test Report
        uses: actions/upload-artifact@v7
        with:
          name: test-report-${{ matrix.os }}-${{ matrix.python-version }}
          path: test-report.xml

  summarize:
    needs: build
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        python-version: ["3.12", "3.13", "3.14"]
        os: [windows-latest, ubuntu-latest, macos-latest]
    steps:
      - name: Checkout
        uses: actions/checkout@v7

      - name: Setup Python
        uses: actions/setup-python@v7
        with:
          python-version: ${{ matrix.python-version }}

      - name: Download Test Report
        uses: actions/download-artifact@v8
        with:
          name: test-report-${{ matrix.os }}-${{ matrix.python-version }}

      - name: Parse Report And Write Job Summary
        env:
          PYTHONIOENCODING: utf-8
        run: python scripts/summarize_report.py >> "$GITHUB_STEP_SUMMARY"
        shell: bash

Commit and push:

git add .github/workflows/ci.yml
git commit -m "Cache Ruff's own cache directory between runs"
git push

The first run will show a cache miss for the Cache Ruff Cache step — there’s nothing to restore yet, and .ruff_cache gets built and saved fresh. You can inspect Cache Ruff Cache for any job and you may see:

Cache not found for input keys: ruff-Linux-44796e109f9e6e5fcfbb9baf75c84c07b67fa4a0395da76c459675aa33cee9c0, ruff-Linux-

Push again with no .py file changes, and the second run’s cache step reports a hit: .ruff_cache from the previous run is restored before Ruff even starts, so it only has to check files that changed since then.

git commit --allow-empty -m "Empty to trigger Ruff cache Hit"

Cache Hit output:

Cache hit for: ruff-Linux-44796e109f9e6e5fcfbb9baf75c84c07b67fa4a0395da76c459675aa33cee9c0
Received 759 of 759 (100.0%), 0.0 MBs/sec
Cache Size: ~0 MB (759 B)
/usr/bin/tar -xf /home/runner/work/_temp/9db0e6cf-591b-4da6-a3bc-cb7b13de7695/cache.tzst -P -C
/home/runner/work/devstuffs-building-real-ci-pipelines/devstuffs-building-real-ci-pipelines
--use-compress-program unzstd
Cache restored successfully
Cache restored from key: ruff-Linux-44796e109f9e6e5fcfbb9baf75c84c07b67fa4a0395da76c459675aa33cee9c0

This isn’t a dramatic speedup on a project this size which has just a handful of files, and Ruff was already fast. The value here is understanding the mechanism itself: the same path / key / restore-keys pattern scales to genuinely expensive things to rebuild, on any future project, whether or not there’s a convenient setup-* wrapper doing it for you.

There’s still that dangling issue from two chapters ago, waiting to bite you the first time a test actually fails. Time to go fix it.

Last updated on