Skip to main content
GitHub Actions · lesson 3 of 7
GitHub Actions intermediate Lesson 3 of 7

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

github-actions matrix ci-cd act

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.

Frequently Asked Questions

How does a matrix strategy turn one job definition into several real jobs?
GitHub Actions expands strategy.matrix into one job per combination of values before scheduling anything — a single-axis matrix with 3 values becomes 3 independent jobs (test-1, test-2, test-3 in act's naming), each with matrix.version bound to a different one of the three values, all otherwise running the exact same steps.
Why did the matrix jobs' log lines interleave instead of running one after another?
By default, matrix jobs run concurrently, each in its own runner (its own container, under act) — there's no ordering guarantee between them, so their log lines arrive interleaved in whatever order the OS actually schedules the underlying processes, exactly the same non-determinism seen with OpenMP threads or MPI ranks elsewhere on this site.
What is fail-fast supposed to do, and what did it actually do here?
fail-fast: true (the default) is documented to cancel all other in-progress matrix jobs the moment any one of them fails, so a doomed matrix run doesn't waste time finishing the rest. Under act in this environment, when version 18 failed, versions 16 and 20 had already raced ahead and printed their own success output before 18's failure was even reported — act does not reliably enforce fail-fast's cancellation of already-scheduled jobs the way GitHub's real hosted runners do.
Should I trust fail-fast to work exactly this way on real GitHub Actions?
GitHub's own documented behavior is to cancel in-progress jobs on failure, and that's what a reader should expect on the real service — this lesson's finding is specifically about act's local emulation lagging that guarantee, not about GitHub Actions itself behaving unpredictably. Verify time-sensitive fail-fast behavior against a real GitHub-hosted run if it matters for a specific pipeline's cost or timing.
How do I set fail-fast: false and what changes?
It tells the matrix strategy to let every combination run to completion regardless of any other one's outcome, useful when you want a full picture of which versions actually pass rather than stopping at the first failure — the overall job still reports failed if any combination failed, but you see every result instead of a partial one.