GitHub Actions · lesson 3 of 7
Beginner
2Intermediate
4Also part of GitHub Actions
5- + Bash Error Handling from Bash
- + CI/CD Pipeline Patterns: Sequential, Parallel, and Staged from DevOps
- + Terraform CI/CD with GitHub Actions (plan/apply) from Terraform
- + A CI Security Gate: Gitleaks, Bandit and pip-audit in One Workflow from DevSecOps (AppSec in the pipeline)
- + Migrating a Jenkins Pipeline to GitHub Actions from Jenkins
Matrix Builds: Real Parallel Expansion, and a fail-fast Limitation Under act
A 3-value matrix genuinely runs as three separate, interleaved, concurrent jobs — but fail-fast's promise to cancel the others the instant one fails doesn't hold up the same way under act as it does on GitHub's real runners.
By SkillByExample · Updated
strategy.matrix turns one job definition into several real, independently running jobs — and
one of its safety guarantees doesn’t hold up quite the way its name promises, at least locally.
A matrix expands into real parallel jobs
# .github/workflows/matrix.yml
name: Matrix
on: [push]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
version: [16, 18, 20]
steps:
- name: Show version
run: echo "testing with version ${{ matrix.version }}"
act push -W .github/workflows/matrix.yml -P ubuntu-latest=catthehacker/ubuntu:act-latest
[Matrix/test-1] ⭐ Run Set up job
[Matrix/test-2] ⭐ Run Set up job
[Matrix/test-3] ⭐ Run Set up job
[Matrix/test-3] 🧪 Matrix: map[version:20]
[Matrix/test-2] 🧪 Matrix: map[version:18]
[Matrix/test-1] 🧪 Matrix: map[version:16]
[Matrix/test-2] | testing with version 18
[Matrix/test-3] | testing with version 20
[Matrix/test-1] | testing with version 16
[Matrix/test-3] 🏁 Job succeeded
[Matrix/test-2] 🏁 Job succeeded
[Matrix/test-1] 🏁 Job succeeded
Three separate jobs — test-1, test-2, test-3 — each bound to a different matrix.version
value, running truly concurrently: notice test-2’s “testing with version 18” line appears
before test-1’s “16” line, even though 16 comes first in the matrix array. Nothing about
matrix order guarantees execution order.
The limitation: fail-fast didn’t cancel the others in time
# .github/workflows/matrix_failfast.yml
name: MatrixFailFast
on: [push]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
version: [16, 18, 20]
steps:
- name: Fail only on 18
run: |
if [ "${{ matrix.version }}" = "18" ]; then
exit 1
fi
echo "version ${{ matrix.version }} ok"
act push -W .github/workflows/matrix_failfast.yml -P ubuntu-latest=catthehacker/ubuntu:act-latest
[MatrixFailFast/test-3] | version 20 ok
[MatrixFailFast/test-1] | version 16 ok
[MatrixFailFast/test-3] ✅ Success - Main Fail only on 18 [150.02056ms]
[MatrixFailFast/test-1] ✅ Success - Main Fail only on 18 [129.522703ms]
[MatrixFailFast/test-2] ❌ Failure - Main Fail only on 18 [120.766155ms]
[MatrixFailFast/test-3] ✅ Success - Complete job
[MatrixFailFast/test-3] 🏁 Job failed
[MatrixFailFast/test-1] ✅ Success - Complete job
[MatrixFailFast/test-1] 🏁 Job failed
[MatrixFailFast/test-2] 🏁 Job failed
Error: Job 'test' failed
fail-fast: true is documented to cancel the other in-progress matrix jobs the instant one
fails — but here, versions 16 and 20 both finished and printed their success line before
version 18’s failure was even reported. All three jobs simply ran to completion; the overall
result correctly reports failed (since 18 did fail), but nothing was actually cancelled
early. This is a real, observed limitation of act’s local emulation, not of GitHub Actions
itself — GitHub’s real hosted runners are documented to cancel in-flight jobs on a fail-fast
trigger, and that’s the behavior to expect and test against on the real service if the timing
matters for a pipeline’s cost.
Practice
1. Set fail-fast: false and confirm every combination still runs to completion.
strategy:
fail-fast: false
matrix:
version: [16, 18, 20]
[MatrixFailFastFalse/test-3] | version 20 ok
[MatrixFailFastFalse/test-1] | version 16 ok
[MatrixFailFastFalse/test-1] 🏁 Job succeeded
[MatrixFailFastFalse/test-3] 🏁 Job succeeded
[MatrixFailFastFalse/test-2] ❌ Failure - Main Fail only on 18 [116.54946ms]
[MatrixFailFastFalse/test-2] 🏁 Job failed
Error: Job 'test' failed
With fail-fast: false, 16 and 20 report succeeded individually (not failed like they did
under the fail-fast: true run) — the overall workflow still exits non-zero because one
combination failed, but each job’s own result now accurately reflects only its own outcome.
2. Add a second matrix axis and confirm it multiplies the job count.
strategy:
matrix:
version: [16, 18]
os: [ubuntu, alpine]
[Matrix2Axis/test-4] 🧪 Matrix: map[os:alpine version:18]
[Matrix2Axis/test-3] 🧪 Matrix: map[os:ubuntu version:18]
[Matrix2Axis/test-1] 🧪 Matrix: map[os:ubuntu version:16]
[Matrix2Axis/test-2] 🧪 Matrix: map[os:alpine version:16]
Two versions times two OS values produces all 4 combinations, confirmed by the four distinct
🧪 Matrix: lines — a matrix with N axes multiplies out to the full Cartesian product of
every axis’s values, not just the sum of them. (act --list doesn’t expand these into
separate rows the way GitHub’s real UI does — it shows one test row regardless of matrix
size; the actual run is where the real per-combination expansion shows up.)
3. Use exclude to remove one specific combination from the matrix.
strategy:
matrix:
version: [16, 18]
os: [ubuntu, alpine]
exclude:
- version: 16
os: alpine
[Matrix2AxisExclude/test-2] 🧪 Matrix: map[os:ubuntu version:18]
[Matrix2AxisExclude/test-3] 🧪 Matrix: map[os:alpine version:18]
[Matrix2AxisExclude/test-1] 🧪 Matrix: map[os:ubuntu version:16]
Only 3 🧪 Matrix: lines now, not 4 — exclude removed exactly the named combination
(version: 16 with os: alpine), leaving the other 3 of the original 4 untouched.
Next: reusable workflows — calling one workflow from another with real inputs.