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-keyThree 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
pathgets saved underkey, 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:
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: bashCommit and push:
git add .github/workflows/ci.yml
git commit -m "Cache Ruff's own cache directory between runs"
git pushThe 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-44796e109f9e6e5fcfbb9baf75c84c07b67fa4a0395da76c459675aa33cee9c0This 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.