Objective
By the end of this lab you will have authored a GitHub Actions
workflow that demonstrates the three primitives of multi-job
orchestration: needs: for ordering, matrix for fan-out, and
if: for conditional execution. The workflow builds a Terraform
binary on three operating systems and three Terraform versions
(a 3×3 = 9-way fan-out), runs a single integration test that joins
on all nine builds, and skips the entire pipeline on
documentation-only changes.
The point is not the YAML — it is the DAG. The pipeline as a graph is the mental model that lets you reason about cost, failure propagation, and the trade-offs between fan-out (more compute, faster wall time) and fan-in (more coordination, more brittle joins).
Architecture
A pipeline DAG with three layers: a build matrix (9 jobs), a fan-in test job (1 job, depends on all 9 builds), and a conditional publish job (1 job, depends on the test job, runs only on tagged releases).
flowchart TB
L[lint]
B1["build\nubuntu · 1.9.x"]
B2["build\nubuntu · 1.10.x"]
B3["build\nubuntu · 1.11.x"]
B4["build\nmacos · 1.9.x"]
B5["build\nmacos · 1.10.x"]
B6["build\nmacos · 1.11.x"]
B7["build\nwindows · 1.9.x"]
B8["build\nwindows · 1.10.x"]
B9["build\nwindows · 1.11.x"]
T["test\nneeds: build.*"]
P["publish\nneeds: test\nif: startsWith(github.ref, 'refs/tags/')"]
L --> T
B1 --> T
B2 --> T
B3 --> T
B4 --> T
B5 --> T
B6 --> T
B7 --> T
B8 --> T
B9 --> T
T --> P
The 9 build jobs run in parallel, the test job joins on all of them, and the publish job is conditional on the test passing and the run being a tag push.
Requirements
- Git 2.55.x on Linux or macOS.
- A GitHub repository. The workflow file is the deliverable; the run is not exercised in the lab.
- No network access required to author the workflow. The runner fleet is GitHub-hosted; the matrix dimensions are limited by what GitHub Actions offers on the team’s tier.
Scenario
A platform team supports Terraform across Linux, macOS, and Windows, and across the current and two previous minor versions. They want CI to catch regressions in any of those combinations before they ship a release. The mitigation is a matrix build that produces a binary for each combination, a fan-in test that exercises every binary, and a publish step that only runs when the test passes and the run is a tagged release.
The lab builds the workflow and the documentation that explains why each design choice was made.
Tasks
Task 1 — Build the empty repository
LAB="$HOME/multi-job-lab"
rm -rf "$LAB"
mkdir -p "$LAB"
cd "$LAB"
git init -b main
git config user.email 'ops@example.com'
git config user.name 'Ops'
mkdir -p src
# A trivial "build target" — the workflow's build job compiles a
# single Go file into a binary and uploads it as an artefact. The
# point is the workflow shape, not the build target.
cat > src/main.go <<'EOF'
package main
import "fmt"
func main() {
fmt.Println("runbook build")
}
EOF
cat > go.mod <<'EOF'
module runbook/build
go 1.22
EOF
git add src/ go.mod
git commit -m 'initial: trivial go module as the build target'
The repository has a single Go file that the build job compiles. The “Terraform version” dimension is simulated by passing the version string into the binary as a build flag, which the workflow demonstrates.
Task 2 — Author the multi-job workflow
# check-shell-blocks: allow-invalid
cd "$HOME/multi-job-lab"
mkdir -p .github/workflows
cat > .github/workflows/multi-job.yml <<'EOF'
name: multi-job
on:
push:
branches: [main]
tags: ['v*.*.*']
pull_request:
branches: [main]
# Concurrency: cancel any in-flight run for the same ref when a new
# push arrives. Tag pushes get a separate concurrency group so a
# release is not cancelled by an unrelated push.
concurrency:
group: multi-job-${ github.workflow }-${ github.ref }
cancel-in-progress: ${ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') }
permissions:
contents: read
jobs:
# ─────────────────────────────────────────────────────────────────
# Layer 1: lint (no dependencies)
# ─────────────────────────────────────────────────────────────────
lint:
name: lint
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-go@0a5cd549ddc19ed5d6e3a91e9e4380152814fbb5 # v5.0.0
with:
go-version: '1.22'
- run: go vet ./...
# ─────────────────────────────────────────────────────────────────
# Layer 2: build matrix (3 OS × 3 terraform versions = 9 jobs)
# ─────────────────────────────────────────────────────────────────
build:
name: build (${ matrix.os } · terraform ${ matrix.terraform })
runs-on: ${ matrix.os }
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, macos-14, windows-2022]
terraform: ['1.9.x', '1.10.x', '1.11.x']
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-go@0a5cd549ddc19ed5d6e3a91e9e4380152814fbb5 # v5.0.0
with:
go-version: '1.22'
- name: build
env:
TF_VERSION: ${ matrix.terraform }
GOOS: ${ matrix.os == 'windows-2022' && 'windows' || matrix.os == 'macos-14' && 'darwin' || 'linux' }
run: |
mkdir -p out
go build -ldflags "-X main.tfVersion=$TF_VERSION" -o out/runbook-$GOOS-$TF_VERSION ./src
- name: upload artefact
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.0.0
with:
name: build-${ matrix.os }-${ matrix.terraform }
path: out/
# ─────────────────────────────────────────────────────────────────
# Layer 3: integration test (depends on the lint job and ALL 9
# build jobs; runs even on docs-only changes because docs do not
# affect the test — but skips when the change is entirely outside
# the build/test surface)
# ─────────────────────────────────────────────────────────────────
test:
name: integration test
runs-on: ubuntu-24.04
needs: [lint, build]
if: |
always() &&
needs.lint.result == 'success' &&
!contains(needs.*.result, 'failure') &&
!contains(needs.*.result, 'cancelled')
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.0.0
with:
path: artifacts/
merge-multiple: true
- name: list artefacts
run: ls -la artifacts/
- name: smoke test every artefact
run: |
set -eu
for f in artifacts/*; do
echo "--- $f"
"$f" | head -3
done
# ─────────────────────────────────────────────────────────────────
# Layer 4: publish (depends on the test job; runs only on tags)
# ─────────────────────────────────────────────────────────────────
publish:
name: publish
runs-on: ubuntu-24.04
needs: [test]
if: startsWith(github.ref, 'refs/tags/v')
environment:
name: release
url: https://github.com/${ github.repository }/releases/tag/${ github.ref_name }
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.0.0
with:
path: artifacts/
merge-multiple: true
- name: publish artefacts
run: |
echo "would publish:"
ls -la artifacts/
EOF
git add .github/workflows/multi-job.yml
git commit -m 'ci: multi-job workflow with matrix, needs, and conditional execution'
The workflow has four jobs in three layers. The matrix produces
nine build jobs, the test job joins on all of them, and the
publish job is gated on startsWith(github.ref, 'refs/tags/v').
Task 3 — Document the fan-out and fan-in
# check-shell-blocks: allow-invalid
cd "$HOME/multi-job-lab"
cat > fan-out-explanation.md <<'EOF'
# Pipeline fan-out and fan-in
The `multi-job` workflow has four jobs arranged in three layers:
## Layer 1 — `lint`
A single job that runs `go vet`. No dependencies. Runs in parallel
with the build matrix.
## Layer 2 — `build` matrix
A 3×3 matrix that produces nine jobs:
| Operating system | Terraform version |
|------------------|-------------------|
| `ubuntu-24.04` | `1.9.x` |
| `ubuntu-24.04` | `1.10.x` |
| `ubuntu-24.04` | `1.11.x` |
| `macos-14` | `1.9.x` |
| `macos-14` | `1.10.x` |
| `macos-14` | `1.11.x` |
| `windows-2022` | `1.9.x` |
| `windows-2022` | `1.10.x` |
| `windows-2022` | `1.11.x` |
The matrix is declared with `strategy.matrix` and `fail-fast:
false`. Each job uploads its binary as a uniquely-named artefact.
## Layer 3 — `test`
A single fan-in job that depends on `lint` and all nine `build.*`
jobs:
```yaml
test:
needs: [lint, build]
The needs: [lint, build] reference uses the build shorthand:
when a matrix job has the same id as a non-matrix job, needs: build means “all jobs in the matrix”. To depend on a single
matrix value, the syntax is needs: build.<matrix-value> (the
“matrix object” syntax).
The if: block guards the test against running on failed matrix
parents:
if: |
always() &&
needs.lint.result == 'success' &&
!contains(needs.*.result, 'failure') &&
!contains(needs.*.result, 'cancelled')
always() runs the step even when an upstream job failed, but the
!contains(...) filters out the failure cases. Without this
guard, a matrix failure would either skip the test (because
needs.test requires success) or report the test as cancelled
(which is what if: always() does, with no further filtering).
Layer 4 — publish
A single fan-out job that depends on the test job and runs only on tag pushes:
publish:
needs: [test]
if: startsWith(github.ref, 'refs/tags/v')
The environment: release block attaches the job to the
release GitHub Environment, which can have its own protection
rules (required reviewers, wait timer, branch restriction). The
job is therefore doubly gated: by the if: expression and by the
environment’s protection rules.
Failure propagation
The rule is: a job that depends on a failed upstream job is
skipped unless its if: block overrides that behaviour. The
override patterns are:
if: always()— run regardless of upstream state.if: failure()— run only when an upstream failed.if: success()— the default; run only when all upstreams succeeded.if: cancelled()— run only when the run was cancelled.
The test job’s if: block combines always() with explicit
checks against the needs.*.result array. This is the pattern to
follow when you want a job to run only on a specific subset of
upstream outcomes, not on the default success-only behaviour.
EOF
git add fan-out-explanation.md git commit -m ‘docs: fan-out and fan-in explanation’
The fan-out document is what a new contributor reads to
understand why the matrix has nine jobs and not three or twenty.
It is also the place the team updates when the matrix dimensions
change.
### Task 4 — Document the paths-filter behaviour
```bash
# check-shell-blocks: allow-invalid
cd "$HOME/multi-job-lab"
cat > paths-filter-table.md <<'EOF'
# Paths-filter behaviour
The `multi-job` workflow does not have an explicit `paths:` filter
on its `on:` trigger, so it runs on every push and pull request.
This is the conservative default for a small repository. The
table below describes what each job would do under each of three
candidate paths-filter strategies; the team should choose one
based on the cost estimate in `cost-estimate.md`.
## Strategy A — no filter (current)
The workflow runs on every push and PR, regardless of which paths
changed.
| Change | Lint | Build | Test | Publish |
|----------------------------------------------|:----:|:-----:|:----:|:-------:|
| `src/main.go` | ✓ | ✓ | ✓ | tag only |
| `docs/policy-note.md` | ✓ | ✓ | ✓ | tag only |
| `README.md` | ✓ | ✓ | ✓ | tag only |
| `.github/workflows/multi-job.yml` | ✓ | ✓ | ✓ | tag only |
## Strategy B — paths filter on the build/test jobs
The build matrix and test job get an explicit `if:` filter that
skips them when the change is documentation-only.
```yaml
build:
if: |
!contains(github.event.pull_request.changes.paths_from_origin, 'docs/**') ||
contains(github.event.pull_request.changes.paths_from_origin, 'src/**') ||
contains(github.event.pull_request.changes.paths_from_origin, '.github/**')
| Change | Lint | Build | Test | Publish |
|---|---|---|---|---|
src/main.go | ✓ | ✓ | ✓ | tag only |
docs/policy-note.md | ✓ | ⊘ | ⊘ | tag only |
README.md | ✓ | ⊘ | ⊘ | tag only |
.github/workflows/multi-job.yml | ✓ | ✓ | ✓ | tag only |
The third row (README-only) skips the build and test jobs because neither depends on the README. The fourth row (workflow change) still runs the build and test jobs because a workflow change can introduce regressions that the build matrix would catch.
Strategy C — dorny/paths-filter action
The dorny/paths-filter action provides a richer filter that
returns named outputs based on which paths changed. The lab does
not include this strategy in the workflow but documents it as the
recommended evolution when the team has more than a handful of
documentation paths.
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1b284cd68cea36 # v3.0.1
id: filter
with:
filters: |
src:
- 'src/**'
docs:
- 'docs/**'
workflow:
- '.github/**'
The filter step’s outputs (steps.filter.outputs.src,
steps.filter.outputs.docs, steps.filter.outputs.workflow) are
then used in downstream if: expressions.
Recommendation
Start with Strategy A (no filter). Move to Strategy B when the
team has more than five documentation-only PRs per week. Move to
Strategy C when the documentation surface grows past what an
explicit if: block can comfortably enumerate.
EOF
git add paths-filter-table.md git commit -m ‘docs: paths-filter strategies for the multi-job workflow’
The paths-filter table is the team's reference for "do I need to
run CI on this PR?". The table is updated when the workflow is
restructured; the cost estimate is updated when the matrix
dimensions change.
### Task 5 — Estimate the cost savings
```bash
# check-shell-blocks: allow-invalid
cd "$HOME/multi-job-lab"
cat > cost-estimate.md <<'EOF'
# Cost estimate for the multi-job workflow
The matrix dimension is 3 OS × 3 Terraform versions = 9 build jobs.
With `fail-fast: false`, every matrix job runs to completion even
on failure. The estimate below uses GitHub-hosted runner rates
(August 2026) for the standard tiers.
## Per-job cost
| Job | Runner | Avg duration | Rate (per minute) |
|--------------|-----------------------|--------------|-------------------|
| `lint` | `ubuntu-24.04` | 30 s | $0.008 |
| `build.*` | `ubuntu-24.04` | 4 min | $0.008 |
| `build.*` | `macos-14` | 6 min | $0.080 |
| `build.*` | `windows-2022` | 6 min | $0.016 |
| `test` | `ubuntu-24.04` | 2 min | $0.008 |
| `publish` | `ubuntu-24.04` | 1 min | $0.008 |
## Per-run cost (no paths filter)
Total = 1 lint + 3 ubuntu + 3 macos + 3 windows + 1 test + 0 publish
= $0.004 + $0.096 + $1.440 + $0.288 + $0.016
= $1.844 per push / pull-request run
For 100 PRs per week, the weekly cost is approximately **$184.40**.
## Per-run cost (with Strategy B paths filter, 50% of PRs docs-only)
For the 50 docs-only PRs:
Total = 1 lint + 0 build + 0 test
= $0.004
For the 50 code-touching PRs:
Total = $1.844 (as above)
Weekly cost = 50 × $0.004 + 50 × $1.844 = **$92.20**
Savings: $184.40 − $92.20 = **$92.20 per week**, or **50%**.
## Caveats
1. The estimate uses runner-minute costs for the public linux
runner tier. Self-hosted runners are billed differently, and
the cost saving calculation changes.
2. The 4-minute and 6-minute build durations are placeholders.
Real builds of a Terraform binary take longer (15-30 minutes);
the proportion saved is the same, but the absolute number is
higher.
3. The `fail-fast: false` setting is the cost-vs-speed trade-off;
switching to `fail-fast: true` would save compute at the cost
of not knowing the full state of the matrix on a failure.
4. The `concurrency.cancel-in-progress` setting cancels
in-progress runs on the same ref when a new push arrives,
which can save additional compute on PRs that see rapid
pushes. The estimate does not include this saving.
## Recommendation
If the team has more than 50 documentation-only PRs per week,
adopt Strategy B (the explicit `if:` filter). Below that
threshold, the engineering cost of maintaining the filter is
likely higher than the compute saved.
EOF
git add cost-estimate.md
git commit -m 'docs: cost estimate for the multi-job workflow'
The cost estimate is what an engineering manager reads to decide whether the multi-job workflow is worth the spend. The numbers are illustrative — replace them with the team’s actual runner rates and average build durations before applying the workflow to production.
Task 6 — Capture the deliverables
cd "$HOME/multi-job-lab"
# The workflow file is already in the repository.
cp .github/workflows/multi-job.yml "$HOME/multi-job.yml"
ls -l .github/workflows/multi-job.yml \
fan-out-explanation.md \
paths-filter-table.md \
cost-estimate.md
The deliverable workflow file is in $HOME/multi-job.yml, and the
three documentation files are in the repository for team review.
Task 7 — Validate the YAML structure
cd "$HOME/multi-job-lab"
# YAML parse check.
python3 -c "
import yaml
with open('.github/workflows/multi-job.yml') as f:
doc = yaml.safe_load(f)
jobs = doc['jobs']
print('jobs:', list(jobs.keys()))
print('matrix:', jobs['build']['strategy']['matrix'])
print('needs of test:', jobs['test']['needs'])
print('if of publish:', jobs['publish']['if'])
"
The output must list four jobs (lint, build, test,
publish), with build having a matrix of three OS values and
three Terraform versions, test having needs: [lint, build],
and publish having the if: startsWith(...) expression.
Validation
.github/workflows/multi-job.ymlparses as valid YAML and has exactly four jobs:lint,build,test,publish.jobs.build.strategy.matrixhas 3 OS values and 3 Terraform version values, producing 9 matrix combinations.jobs.build.strategy.fail-fastisfalse.jobs.test.needsis[lint, build]and theif:block usesalways()plus the!contains(needs.*.result, ...)filter.jobs.publish.ifisstartsWith(github.ref, 'refs/tags/v').- Every
uses:reference is a pinned commit SHA. fan-out-explanation.md,paths-filter-table.md, andcost-estimate.mdexist and are non-empty.- The deliverables in
$HOMEand the repository match the expected file set.
Expected Outcome
A workflow file that produces a 9-way matrix, a fan-in test, and a conditional publish, with three documents that explain the design choices.
$HOME/multi-job-lab/
├── .github/workflows/multi-job.yml # the workflow file, pinned to SHAs
├── fan-out-explanation.md # DAG explanation
├── paths-filter-table.md # paths-filter strategies
├── cost-estimate.md # weekly cost estimate
├── go.mod
├── src/main.go
└── README.md
The workflow is the configuration; the three documents are the durable explanation of why the configuration exists. A new contributor reads the documents on day one; the workflow file is exercised by the platform on every push.
Troubleshooting
A matrix job fails and the test job is skipped. This is the
default behaviour when needs: build is specified and a matrix
job fails. The if: block on the test job is what makes it run
despite the failure; verify the if: expression matches the
example. The expression !contains(needs.*.result, 'failure') is
the key guard.
A matrix job is cancelled because the run was cancelled. The
!contains(needs.*.result, 'cancelled') guard in the test job’s
if: is what prevents a cancelled matrix from cancelling the
test. Without it, a cancelled run produces no test output, which
is unhelpful for debugging.
needs: build does not depend on all matrix jobs. That is
correct: needs: build (without an index) does depend on all
matrix jobs. If you want to depend on a single matrix value, the
syntax is needs: build.<matrix-key> (the “needs object” syntax).
The lab uses the shorthand because the test depends on all
matrix jobs.
The concurrency group cancels a tagged release. The
cancel-in-progress expression excludes tag pushes:
${ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') }. If a tag push is being cancelled, the
expression has been edited; restore it to the original form.
The matrix dimension exceeds the plan limit. GitHub Actions has a per-matrix limit on the number of jobs. The default is 256 jobs; the team can request an increase. The lab’s 3×3 matrix is well within the default and is not affected.
Cleanup
LAB="$HOME/multi-job-lab"
# Keep the deliverables.
mv "$LAB"/fan-out-explanation.md "$LAB"/paths-filter-table.md \
"$LAB"/cost-estimate.md "$HOME"/ 2>/dev/null
mv "$LAB/.github/workflows/multi-job.yml" "$HOME/multi-job.yml" 2>/dev/null
rm -rf "$LAB"
find "$HOME" -maxdepth 1 -name 'multi-job-lab' -print
# expected: (no output)
If you applied the workflow to a real GitHub repository during the lab, disable or delete it through the GitHub UI or the API:
gh api --method DELETE \
/repos/$OWNER/$REPO/actions/workflows/multi-job.yml
What You Learned
- A pipeline is a DAG. Jobs are nodes,
needs:declarations are edges, and the graph is what the runner platform executes. Mental models that treat CI as “a list of commands” lose this. needs: build(without an index) depends on all matrix jobs. To depend on a single matrix value, use the “needs object” syntax:needs: build.<matrix-key>.fail-fast: falseis the cost-vs-speed trade-off. It is the right setting for compatibility testing, the wrong setting for cost. Document the choice explicitly so a future contributor knows it was deliberate.if: always()plus explicitneeds.*.resultchecks is the pattern for fan-in jobs that must run despite upstream failures. The defaultif: success()skips the test when an upstream fails, which is usually the wrong behaviour for an integration test.concurrency.cancel-in-progressis conditional on the ref. Tag pushes and main pushes should not be cancelled by an unrelated event; PR pushes should be.- The cost estimate is the durable artefact. The workflow file is configuration; the cost estimate is the answer to “is this worth the spend?”. A team that does not maintain the estimate will over-spend on the matrix without realising it.