GitHub Actions: Matrix Builds and Parallelism

GitHub Actions: Matrix Builds and Parallelism
Running your test suite against a single Node version and OS is fine for getting started, but it creates a false sense of confidence. Libraries break on Windows because of path separators; code that works on Node 18 silently fails on Node 20 due to changed defaults; Python 3.10 and 3.12 have incompatible type annotation syntax. Matrix builds run your workflow against every meaningful combination automatically—without duplicating YAML.
Basic Matrix Strategy
A matrix defines a set of variables. GitHub Actions runs one job per combination.
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: ['18', '20', '22']
# Runs 3 × 3 = 9 parallel jobs
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
- name: Upload coverage (only on ubuntu + Node 20)
if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20'
uses: codecov/codecov-action@v4This creates 9 parallel jobs. All 9 run simultaneously (subject to your concurrent job limit—20 for free accounts, more for paid plans).
Matrix Exclusions and Additions
Not every combination makes sense. Some combinations are invalid, redundant, or too expensive.
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: ['18', '20', '22']
experimental: [false]
exclude:
# Skip Node 18 on macOS (not tested in production)
- os: macos-latest
node-version: '18'
# Skip Node 18 on Windows (EOL in our support matrix)
- os: windows-latest
node-version: '18'
include:
# Add a special experimental combination
- os: ubuntu-latest
node-version: '23'
experimental: true
# Add extra env variable to a specific combination
- os: ubuntu-latest
node-version: '20'
coverage: trueResult: 9 original combinations minus 2 exclusions plus 2 additions = 9 jobs.
# Use the include properties in steps
- name: Run tests with coverage
run: npm run test:coverage
if: matrix.coverage == true
- name: Regular test run
run: npm test
if: matrix.coverage != true
# Handle experimental failures gracefully
- name: Continue on error for experimental
continue-on-error: ${{ matrix.experimental }}Multi-Language and Multi-Version Patterns
Python Version Matrix
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- run: pip install -r requirements-test.txt
- run: pytest --tb=short
- run: mypy src/ # type checking against each versionJava Matrix with Gradle
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
java-version: ['17', '21', '23']
distribution: [temurin, zulu]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: ${{ matrix.java-version }}
distribution: ${{ matrix.distribution }}
cache: gradle
- run: ./gradlew testCross-Platform Build Matrix
jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact: myapp-linux-x64
- os: macos-latest
target: aarch64-apple-darwin
artifact: myapp-macos-arm64
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact: myapp-windows-x64.exe
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --release --target ${{ matrix.target }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: target/${{ matrix.target }}/release/myapp*Dynamic Matrix Generation
The matrix values don't have to be static—you can generate them from a script or API call.
jobs:
# First job: compute the matrix
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- name: Generate matrix from changed packages
id: set-matrix
run: |
# Find packages with changes (monorepo use case)
CHANGED=$(git diff --name-only origin/main...HEAD | \
grep "^packages/" | \
cut -d/ -f2 | \
sort -u | \
jq -R -s 'split("\n") | map(select(length > 0))')
echo "matrix={\"package\":$CHANGED}" >> $GITHUB_OUTPUT
# Second job: use the generated matrix
test:
needs: setup
if: ${{ needs.setup.outputs.matrix != '{"package":[]}' }}
runs-on: ubuntu-latest
strategy:
matrix: ${{ fromJson(needs.setup.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- run: npm test --workspace packages/${{ matrix.package }}# Generate matrix from an API or file
- name: Read versions from JSON file
id: set-matrix
run: |
MATRIX=$(cat .github/supported-versions.json)
echo "matrix=$MATRIX" >> $GITHUB_OUTPUTFan-Out and Fan-In Patterns
Some workflows need to run parallel jobs and then aggregate results.
# Fan-out: run test shards in parallel
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4] # split test suite into 4 parallel shards
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npx jest --shard=${{ matrix.shard }}/4 --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage-shard-${{ matrix.shard }}
path: coverage/
# Fan-in: merge coverage from all shards
coverage:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: coverage-shard-*
merge-multiple: true
path: coverage/
- run: npx nyc merge coverage/ merged-coverage.json
- uses: codecov/codecov-action@v4Jest Shard Configuration
// jest.config.ts
export default {
testPathPattern: '.*\\.test\\.ts$',
// Shard is passed via CLI: --shard=1/4
// Jest automatically distributes tests across shards
coverageDirectory: 'coverage',
coverageReporters: ['json', 'lcov', 'text'],
};Fail Fast vs. Full Matrix
By default, GitHub Actions cancels all pending matrix jobs when one fails. Use fail-fast: false to run all combinations regardless.
strategy:
fail-fast: false # continue all jobs even if one fails
max-parallel: 4 # limit concurrent jobs (useful to avoid rate limits)
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: ['18', '20', '22']When to use each:
fail-fast: true(default): fast feedback, saves minutes. Use when all combinations should pass.fail-fast: false: full visibility. Use when you're debugging compatibility—you want to see which exact combinations fail.
Caching in Matrix Builds
Each matrix combination runs in a separate runner. Cache keys must include the matrix variables to avoid cache collisions.
- uses: actions/cache@v4
with:
path: ~/.npm
# Include OS and Node version in the cache key
key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.node-version }}-
${{ runner.os }}-node-
- uses: actions/cache@v4
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ matrix.java-version }}-${{ hashFiles('**/*.gradle*') }}Matrix Build Metrics and Cost
Example: 3 OS × 3 Node = 9 jobs × 5 minutes each = 45 minutes billed
But: all 9 run in parallel, so wall-clock time ≈ 5 minutes
GitHub-hosted runner pricing (approximate, 2025):
ubuntu-latest: $0.008/minute
windows-latest: $0.016/minute (2×)
macos-latest: $0.08/minute (10×)
9-job matrix with all OS:
ubuntu (3 jobs): 3 × 5min × $0.008 = $0.12
windows (3 jobs): 3 × 5min × $0.016 = $0.24
macos (3 jobs): 3 × 5min × $0.080 = $1.20
Total per run: $1.56
Cost reduction strategies:
- Test on ubuntu only for PR checks; full matrix only on main push
- Exclude macOS for non-macOS-specific code
- Use self-hosted runners for expensive jobs# Run full matrix only on push to main
strategy:
matrix:
os: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main'
&& fromJson('["ubuntu-latest","windows-latest","macos-latest"]')
|| fromJson('["ubuntu-latest"]') }}
node: ['20']Frequently Asked Questions
Q: How many parallel matrix jobs can GitHub Actions run simultaneously?
For GitHub-hosted runners, free accounts get 20 concurrent jobs (Linux), 5 (macOS). GitHub Team gets 60 concurrent Linux jobs. GitHub Enterprise can configure higher limits. If your matrix generates more jobs than the concurrent limit, the extra jobs queue and start as slots open. You can also set max-parallel in your matrix strategy to explicitly cap concurrency (useful to avoid hitting API rate limits on downstream services during tests).
Q: My matrix job passes on Ubuntu but fails on Windows. How do I debug it?
Enable fail-fast: false so all jobs complete. Use if: failure() steps to upload logs or screenshots only on failure. For path-related issues, Windows uses backslashes by default—use path.join() in Node.js or os.path.join() in Python instead of hardcoded slashes. For line ending issues, add a .gitattributes file with * text=auto eol=lf. For shell script issues, use bash shell explicitly in steps (shell: bash) rather than relying on the default PowerShell on Windows.
Q: Can I run different commands for different matrix entries?
Yes, using if conditions on steps or by using the include strategy to add per-entry variables. For complex branching, consider using a composite action or reusable workflow that accepts parameters. A common pattern is adding a boolean run-e2e or lint field to specific matrix entries and then conditioning steps on those values.
Q: Should I use matrix builds or separate workflow files for different languages?
Separate workflow files win for clarity when the steps are fundamentally different (a Python workflow and a TypeScript workflow share almost nothing). Matrix builds win when the difference is only the version or OS variable and the steps are identical. A hybrid is common: one workflow file per language, but that file uses a matrix for multi-version testing within the language.
