open atlas
↑ Back to track
CI/CD pipelines CICD · 01 · 02

Caching, matrix builds and artifacts

Cache keyed on a lockfile hash restores dependencies across runs; a matrix fans one job across versions and OSes in parallel; artifacts hand build output between jobs. A stale key is worse than no cache.

CICD Middle ◷ 16 min
Level
FoundationsJuniorMiddleSenior

Every CI run on a fresh runner reinstalls 1,200 npm packages from the network — three minutes, every push, before a single test runs. Someone adds actions/cache with key: deps, a constant. The first run saves node_modules; every run after restores it. Builds drop to forty seconds. Two weeks later a dependency bump lands, tests pass green in CI, and break in production — because the constant key kept restoring the old node_modules and the new lockfile was never installed. The cache was faster and wrong. A cache that never changes its key is a time bomb.

Caching: a lockfile hash is the only safe key

When you see a CI run spend three minutes just installing packages, that is a cache waiting to be configured — or, worse, a broken cache that never invalidates. Here is how to get it right.

Each CI job runs on a clean machine, so without help it redoes every expensive, deterministic step — chiefly the dependency install — from zero on every run. actions/cache fixes this by saving a directory after the first run and restoring it on later runs, keyed on a string you choose. The cardinal rule lives entirely in that key: it must change exactly when the cached contents should change, and not before. For dependencies, the input that determines node_modules is the lockfile, so you hash it.

- name: Cache node_modules
  uses: actions/cache@v4
  with:
    path: node_modules
    key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-

hashFiles('**/package-lock.json') produces a checksum that changes the instant any dependency changes. On a hit, the cache restores and the install is near-instant; on a miss — a new lockfile — the key is new, so the old entry is not reused, you install fresh, and the run saves a new entry under the new key. That is why a constant key: deps is dangerous: it can never miss, so it restores stale dependencies forever and your new lockfile is silently ignored. Always bind the key to the content.

restore-keys is the fallback ladder. When the exact key misses, GitHub tries each prefix in turn and restores the most recent partial match — so a lockfile bump still seeds the cache from the previous runner.os-npm- entry instead of starting empty, and the package manager only downloads the delta. It is a warm start, not a correctness shortcut: the exact key still governs whether the entry is treated as current.

Why this works

setup-node has caching built in: with: { node-version: 22, cache: 'npm' } hashes your lockfile and manages the key for you — prefer it over a hand-rolled actions/cache for the package-manager cache. Two scope facts matter at senior level. Caches are scoped per branch with read-down inheritance: a branch can restore from its base and from main, but not sideways from an unrelated branch — which is also why a cache written by an untrusted fork PR cannot poison main. And there is a repository eviction budget (10 GB); once exceeded, GitHub evicts least-recently-used entries, so unused caches quietly disappear.

Matrix: one job definition, many parallel runs

How do you prove your library works on Node 18, 20, and 22 without writing three nearly-identical jobs? That is the exact problem strategy.matrix solves.

A strategy.matrix runs the same job once per combination of the dimensions you list — the standard way to prove your code works across Node versions and operating systems without copy-pasting jobs. GitHub expands the cartesian product and schedules every combination in parallel.

test:
  strategy:
    fail-fast: false
    max-parallel: 4
    matrix:
      node: [18, 20, 22]
      os: [ubuntu-latest, macos-latest]
      include:
        - { node: 22, os: ubuntu-latest, coverage: true }
      exclude:
        - { node: 18, os: macos-latest }
  runs-on: ${{ matrix.os }}
  steps:
    - uses: actions/setup-node@v4
      with: { node-version: ${{ matrix.node }} }

That node × os grid is 3 × 2 = 6 jobs; the exclude drops one, the include adds an extra field to one existing combination (and, with a new value, could add a whole job). fail-fast: true (the default) cancels every sibling the moment one fails — fast feedback, but you lose the full picture; set it false when you need to see which combinations broke. max-parallel caps how many run at once, throttling concurrent runner usage. Mind the explosion: dimensions multiply, so adding a third 3-value axis turns 6 jobs into 18, each consuming runner minutes — a matrix is a cost dial, not a free coverage win. Together, these knobs let you get full cross-version signal in one workflow definition; without fail-fast: false, a single broken Node 18 cell can mask three other failing combinations hiding beneath it.

Artifacts: hand output from one job to the next

Because every job is a fresh machine, a build produced in one job does not exist in the next — the filesystem is gone when the job ends. The cache is the wrong tool here: it is a keyed, best-effort, possibly-evicted speedup, not a guaranteed handoff. Artifacts are the right tool — explicit, durable-for-the-run outputs you upload and later download, and that you can also publish as the run’s deliverables (a built bundle, test reports, coverage).

build:
  steps:
    - run: npm run build
    - uses: actions/upload-artifact@v4
      with: { name: dist, path: dist/ }
deploy:
  needs: build
  steps:
    - uses: actions/download-artifact@v4
      with: { name: dist }
    - run: ./deploy.sh dist/

Build once, deploy the exact same bytes you tested — no rebuild drift between jobs. Artifacts have a retention period (default 90 days, configurable) and count against storage, so name them deliberately and keep large ones short-lived.

DimensionCache (actions/cache)Artifact (upload/download-artifact)
PurposeSpeed up a reproducible step (re-derivable inputs)Pass / keep an output you cannot re-derive cheaply
LifetimeBest-effort; LRU-evicted under the 10 GB repo budgetRetained for a fixed period (default 90 days)
Keyed onA key string you design (e.g. lockfile hash)A plain name you choose (dist, coverage)
Cross-job?Yes, but as a hint — a miss is normal and safeYes, and guaranteed within the run via needs
Quiz

You set the cache key to a constant ('deps'). Builds are fast, but a dependency bump that passes CI breaks in prod. Why?

Quiz

A build job compiles dist/; a deploy job that 'needs: build' finds dist/ missing. What is the correct mechanism to pass it?

Pick the best fit

A library must verify it works on Node 18/20/22 across Ubuntu and macOS, give clear signal on failures, and not blow up CI minutes. Pick the matrix strategy.

Recall before you leave
  1. 01
    Why is a cache key bound to a lockfile hash safe while a constant key is dangerous, and what does restore-keys add?
  2. 02
    Cache and artifacts both move files between jobs. When do you use each, and why not cache for a build-to-deploy handoff?
Recap

Caching and matrices are the two big levers on a CI pipeline, and artifacts are how jobs talk to each other. Each job runs on a clean machine, so actions/cache saves and restores a directory keyed on a string you design — and the whole game is in that key: bind it to hashFiles(’**/package-lock.json’) so it invalidates the moment dependencies change, because a constant key never misses and serves stale node_modules forever, which is fast and wrong. restore-keys gives a prefix-fallback warm start after a real change, and setup-node’s built-in cache: ‘npm’ handles the key for you; remember caches are branch-scoped with read-down inheritance and LRU-evicted under a 10 GB budget. A strategy.matrix fans one job definition across versions and OSes as the cartesian product, run in parallel, with include/exclude to shape combinations, fail-fast to choose fast-stop versus full-failure visibility, and max-parallel to throttle — but dimensions multiply, so a matrix is a cost dial, not free coverage. Finally, because the build’s filesystem vanishes with the job, you hand output forward with upload-artifact/download-artifact: artifacts are durable, named, guaranteed-within-the-run outputs you keep or publish, whereas cache is a best-effort, evictable speedup. Use cache for reproducible work you can afford to lose, and artifacts for the exact bytes you must pass on. Now when you see a “fast” build that shipped the wrong dependencies, check the cache key first — a constant there is the most common culprit.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.