Jobs, matrix, and reuse: the DAG, fan-out limits, and reusable-workflow nesting
Jobs form a DAG via `needs`; a matrix fans one job into many legs with `max-parallel` and `fail-fast` governing cost. Reusable workflows (`workflow_call`) factor pipelines into callable units — bounded at 4 levels of nesting and 20 unique called files.
The CI bill tripled overnight and nobody had merged a feature. The cause was one well-meaning matrix. A platform team had a test job covering 4 OSes and 5 language versions — a tidy 20 legs — and someone added a third dimension, database: [postgres, mysql, sqlite, mssql], to “be thorough.” That is a Cartesian product: 4 × 5 × 4 = 80 jobs, each spinning a fresh runner, each pulling the same dependencies. Worse, fail-fast had been turned off for flakiness reasons, so a single failing leg no longer short-circuited the other 79 — every run paid for all 80 to completion. The fix wasn’t to delete coverage; it was to understand that a matrix multiplies, that include/exclude let you cover the combinations that matter without the full product, and that max-parallel caps concurrent burn. They cut 80 legs to a curated 14 with include, set max-parallel: 6, and the bill came back down — with better-chosen coverage than the blind grid had given.
Jobs are a DAG, not a script
Most CI cost and latency problems trace back to not modeling the job graph deliberately — either everything runs sequentially when it didn’t have to, or everything runs in parallel when it needed ordering. Understanding the DAG is understanding where to fix both.
Jobs run in parallel by default; needs: draws the dependency edges that serialize them into a DAG:
jobs:
build:
runs-on: ubuntu-latest
outputs:
artifact: ${{ steps.pack.outputs.name }}
steps: [ ... ]
test:
needs: build # waits for build, reads its outputs
runs-on: ubuntu-latest
deploy:
needs: [build, test] # waits for both
if: ${{ github.ref == 'refs/heads/main' }}
timeout-minutes: 15 # kill a hung deploy, don't burn 6hEach job runs on a fresh runner with no shared filesystem — that is why data moves between jobs through outputs (small strings) or artifacts (files), never through disk. A job inherits nothing from another’s working directory. Two production controls live at this level: timeout-minutes (default is a generous 360 minutes / 6 hours per job — far too long for most work, so set a real ceiling or a hung step burns hours of runner time) and if: conditions that gate whole jobs. needs also propagates failure: if build fails, test and deploy are skipped — unless a downstream job opts into running anyway with if: ${{ always() }} or if: ${{ !cancelled() }}.
Matrix: one job definition, many legs
strategy.matrix expands a single job into one leg per combination — the Cartesian product of every dimension:
strategy:
fail-fast: false # don't cancel siblings when one leg fails
max-parallel: 6 # at most 6 legs run concurrently
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
include:
- { os: ubuntu-latest, node: 22, coverage: true } # add/augment a leg
exclude:
- { os: macos-latest, node: 18 } # drop a combinationThe numbers that govern cost and behavior:
- It multiplies.
3 os × 3 node = 9legs; adding any third dimension multiplies again. This is the Hook’s blow-up — a matrix is the easiest place in Actions to 10× your bill by accident. fail-fast(defaulttrue) cancels all in-progress and queued legs the moment one fails — fast feedback but you lose the full failure picture. Setfalseto see every failing combination, at the cost of paying for them all.max-parallelcaps how many legs run at once; without it, legs run as concurrently as your runner concurrency limit allows (and a matrix is capped at 256 legs per workflow run).include/excludeare the precision tools:excluderemoves a generated combination;includeadds a one-off leg or augments an existing one with extra keys — the way to get curated coverage instead of a blind grid.
Together these four knobs mean you can always recover from a matrix that blew up: cap the burn with max-parallel, trim the product with exclude/include, and decide whether you want fast-fail feedback or the full picture with fail-fast. Without max-parallel on a matrix you haven’t thought through, a single new dimension can turn a manageable 9-leg suite into an 80-leg runner fire overnight.
A matrix has os: [ubuntu, windows, macos] and node: [18, 20, 22] with fail-fast: false and no max-parallel. The node-18 leg on windows fails fast. How many legs total run to completion, and why?
▸Why this works
Why does a matrix multiply instead of zipping dimensions pairwise? Because the common need is true coverage — every OS against every runtime — and the product expresses that directly. Pairwise “zip” semantics would silently skip combinations and hide compatibility bugs that only appear at a specific intersection. The trade is that the product grows multiplicatively, so the language gives you exclude to carve out combinations you don’t need and include to add back the few extra ones you do — letting you start from full coverage and trim, or start sparse and augment. The discipline a senior brings is treating each new dimension as a multiplier on the bill, not a free axis.
Reusable workflows: factor the pipeline
When the same build-test-scan sequence appears in twenty repos, copy-paste rots. A reusable workflow is a workflow with on: workflow_call that other workflows invoke as a single job:
# .github/workflows/build.yml (the reusable workflow)
on:
workflow_call:
inputs:
node: { type: string, required: true }
secrets:
token: { required: true }
# caller
jobs:
ci:
uses: my-org/ci/.github/workflows/build.yml@v2 # pin to a tag/SHA
with: { node: "20" }
secrets: { token: ${{ secrets.NPM_TOKEN }} }The hard limits worth memorizing: a call chain can nest up to 4 levels deep (a reusable workflow calling another, and so on — the 4th-level workflow may not itself call a reusable workflow), and a single workflow file may reference at most 20 unique reusable workflows across the whole tree. Pin the uses: reference to a tag or SHA, never a moving branch, for the same supply-chain reason you pin actions — @main lets the called workflow change under you. Secrets are not inherited automatically; you either pass them explicitly under secrets: or forward all with secrets: inherit. This is how large orgs keep one audited pipeline definition and call it everywhere, instead of twenty drifting copies.
An org factors its pipeline into reusable workflows: a top caller uses 'ci.yml', which uses 'build.yml', which uses 'scan.yml', which uses 'sign.yml'. Someone adds a fifth level where sign.yml calls 'notify.yml'. What happens?
- 01Explain how a matrix computes its legs and the three knobs that govern its cost and feedback.
- 02What is a reusable workflow, and what limits and supply-chain rules apply to it?
Jobs are the unit of parallelism: they run concurrently until needs: edges serialize them into a DAG, each on a fresh runner with no shared disk, so data crosses job boundaries only through outputs (small strings) or artifacts (files). Two controls live here and both matter at senior scale: timeout-minutes defaults to a profligate six hours, so set a real ceiling or a hung step burns runner hours, and if: gates whole jobs while needs propagates failure downstream unless a job opts back in with always() or !cancelled(). A strategy.matrix expands one job into the Cartesian product of its dimensions — the single easiest way to multiply your CI bill by accident, as a third axis turns nine legs into eighty — and you govern it with fail-fast (cancel siblings on first failure for speed, or false to see every failure and pay for all of them), max-parallel (the concurrency cap, with 256 legs the hard maximum), and include/exclude for curated rather than blind coverage. Reusable workflows declared with on: workflow_call factor the pipeline into callable, version-pinned units so one audited definition serves many repos; they nest at most four levels deep, a file may reference at most twenty unique called workflows, the uses: reference must be pinned to a tag or SHA for the same supply-chain reasons you pin actions, and secrets pass explicitly or via secrets: inherit — never silently. The throughline: jobs compose a graph, a matrix multiplies it, and reuse factors it — each a lever on both correctness and cost. Now when you see a CI bill that tripled overnight or a reusable workflow that stopped working after someone bumped a dependency, you’ll know which knob to check first: the matrix dimension that crept in, the missing max-parallel, or the @main pin that drifted.
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.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.