Advanced Pipeline Patterns
Matrix builds across multiple versions and operating systems, and dependency caching for faster builds.
Beyond a single job
The pipelines built so far in this track run one version of one runtime against one operating system. Real projects often need more: confirming a library works across several language versions at once, testing on both Linux and Windows, and keeping pipeline runtimes fast as the codebase and dependency tree grow. This page covers two patterns that solve those problems — matrix builds and dependency caching — with a complete GitHub Actions workflow combining both.
Matrix builds
A matrix build runs the same job multiple times, once for each combination of values you define, instead of writing a nearly-identical job block per combination by hand. This is the standard way to test a library against multiple language versions, multiple operating systems, or both at once:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node-version: ['18', '20', '22']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
With two operating systems and three Node.js versions defined, this single job definition expands into six actual jobs — every combination of os × node-version — all running in parallel by default:
ubuntu-latest / node 18
ubuntu-latest / node 20
ubuntu-latest / node 22
windows-latest / node 18
windows-latest / node 20
windows-latest / node 22
Each combination reports its own separate status check, so a PR shows exactly which OS/version pairing broke rather than one opaque pass/fail for "tests." matrix.os and matrix.node-version are referenced with the same ${{ }} expression syntax used for github.ref and secrets elsewhere in this track.
Narrowing a matrix with exclude and include
Sometimes one specific combination doesn't make sense (an old runtime version that's unsupported on one OS) or you need one extra combination beyond the full cross-product:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node-version: ['18', '20', '22']
exclude:
- os: windows-latest
node-version: '18'
include:
- os: macos-latest
node-version: '20'
exclude removes specific combinations from the generated matrix; include adds specific extra ones beyond the standard cross-product — here, a single macOS/Node 20 job added on top of the six Linux/Windows combinations, minus the one excluded pairing.
fail-fast
By default, if one matrix combination fails, GitHub Actions cancels all the other still-running combinations in the same matrix:
strategy:
fail-fast: false
matrix:
node-version: ['18', '20', '22']
Setting fail-fast: false lets every combination run to completion regardless of whether another one already failed — worth it specifically when you want to see the full picture of which versions pass and which don't in one run, rather than stopping at the first failure and re-running later to find out about the rest.
Dependency caching
Without caching, every single pipeline run reinstalls every dependency from scratch — for a project with a large node_modules, vendor, or dependency tree, this can dominate total pipeline time. actions/cache (and the built-in cache option on setup actions like actions/setup-node) persists a directory between runs, keyed so a cache is only reused when it's genuinely still valid:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # caches ~/.npm, keyed on package-lock.json's hash
That one line handles the common case for npm projects entirely. For anything the built-in cache: option doesn't cover, actions/cache does the same job explicitly:
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: composer-${{ runner.os }}-${{ hashFiles('composer.lock') }}
restore-keys: |
composer-${{ runner.os }}-
key— the exact cache to look for. IncludinghashFiles('composer.lock')means the key changes automatically the moment any dependency version changes — an outdated cache is never restored and silently used against a lockfile it no longer matches.restore-keys— a fallback prefix. If no cache exactly matcheskey(the lockfile changed since the last run), GitHub Actions falls back to the most recent cache matching this prefix instead of starting from nothing — most dependencies are still shared even after a small lockfile change, so a near-miss cache is still a large speedup over none at all.
A complete example combining both
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
node-version: ['18', '20', '22']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
Six matrix combinations, each with dependencies cached and keyed to package-lock.json per the runner's OS — a full cross-platform, cross-version compatibility check that stays fast on every subsequent run because dependency installation only does real work when the lockfile actually changes.
Common mistakes
- Enabling a large matrix (many OS/version combinations) without caching — multiplying an already-slow uncached install step across a dozen combinations turns a 2-minute pipeline into a 20-minute one.
- Using a cache
keythat never changes (a fixed string instead ofhashFiles(...)) — a stale cache gets reused forever, even after dependencies genuinely changed, and the pipeline silently tests against the wrong versions. - Leaving
fail-fastat its defaulttruewhen you specifically want to see every matrix combination's result in one run — the pipeline cancels the remaining jobs the moment any single one fails. - Adding OS/version combinations to a matrix that nothing in the project actually needs to support — every combination costs real compute minutes and slows down the feedback loop for every single push.