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:

YAML
# .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:

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

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

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

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

YAML
- 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. Including hashFiles('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 matches key (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

YAML
# .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 key that never changes (a fixed string instead of hashFiles(...)) — a stale cache gets reused forever, even after dependencies genuinely changed, and the pipeline silently tests against the wrong versions.
  • Leaving fail-fast at its default true when 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.