GitHub Actions Matrix Builds for Multi-Platform Testing
The Pipeline First
Here's the complete matrix build. Then I'll break it down.
name: Matrix CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
exclude:
- os: macos-latest
node: 18
include:
- os: ubuntu-latest
node: 22
coverage: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm test
- if: matrix.coverage
run: npm run test:coverage
- if: matrix.coverage
uses: codecov/codecov-action@v4
That's 8 parallel jobs (3 OS × 3 Node versions - 1 exclusion) running simultaneously. Your entire test suite across every combination finishes in the time it takes to run ONE.
Why Matrix Builds
If you're running tests on a single OS with a single runtime version, stop. You're shipping blind.
Matrix builds let you:
- Catch platform-specific bugs before users find them
- Validate compatibility across Node 18, 20, and 22 simultaneously
- Run everything in parallel — no sequential waiting
The execution time difference is dramatic. A sequential approach running 8 configurations takes 8×T minutes. A matrix runs them all in ~T minutes.
Breaking Down the Strategy
The Matrix Object
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
This generates every combination: ubuntu+18, ubuntu+20, ubuntu+22, macos+18, and so on. 3 × 3 = 9 jobs.
fail-fast: false is critical. Without it, one failing job cancels ALL other jobs. You lose visibility into which combinations actually pass. Set this to false unless you're paying per-minute and need to save costs.
Exclude and Include
exclude:
- os: macos-latest
node: 18
include:
- os: ubuntu-latest
node: 22
coverage: true
Exclude removes combinations you don't care about. Node 18 on macOS? Not worth the minutes.
Include adds extra variables to specific combinations. Here, we run coverage ONLY on Ubuntu + Node 22. No need to run coverage 8 times — once gives you the data.
Make It Faster
Caching Dependencies
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
This single line saves 30-60 seconds per job by caching node_modules. Across 8 matrix jobs, that's 4-8 minutes saved per run.
Conditional Steps
- if: matrix.coverage
run: npm run test:coverage
Only run expensive operations where they matter. Coverage collection adds 20-40% overhead — don't pay that cost 8 times.
Real-World Matrix Patterns
Python Multi-Version Testing
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
python: ["3.10", "3.11", "3.12"]
include:
- os: ubuntu-latest
python: "3.12"
lint: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
cache: pip
- run: pip install -r requirements.txt
- if: matrix.lint
run: |
pip install ruff mypy
ruff check .
mypy src/
- run: pytest tests/ -v --tb=short
Pin Python versions as strings ("3.10" not 3.10). YAML interprets 3.10 as the float 3.1. This is one of the most common matrix build bugs.
Docker Multi-Platform Builds
Matrix builds aren't just for testing. Use them to build container images for multiple architectures:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
platform:
- linux/amd64
- linux/arm64
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
push: false
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
tags: myapp:${{ github.sha }}
Each platform builds in parallel. On merge to main, combine them into a multi-arch manifest with docker buildx imagetools create.
Database Compatibility Testing
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
database:
- engine: postgres
version: "15"
port: 5432
- engine: postgres
version: "16"
port: 5432
- engine: mysql
version: "8.0"
port: 3306
services:
db:
image: ${{ matrix.database.engine }}:${{ matrix.database.version }}
env:
POSTGRES_PASSWORD: testpass
MYSQL_ROOT_PASSWORD: testpass
POSTGRES_DB: testdb
MYSQL_DATABASE: testdb
ports:
- ${{ matrix.database.port }}:${{ matrix.database.port }}
options: >-
--health-cmd="pg_isready || mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
env:
DB_ENGINE: ${{ matrix.database.engine }}
DB_PORT: ${{ matrix.database.port }}
DB_PASSWORD: testpass
This tests your application against every supported database version in parallel. Catching an incompatibility in CI is dramatically cheaper than catching it in production.
Dynamic Matrix Generation
For monorepos or when the matrix changes based on which files were modified:
jobs:
detect:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.detect.outputs.matrix }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- id: detect
run: |
CHANGED=$(git diff --name-only HEAD~1 HEAD | grep '^packages/' | cut -d/ -f2 | sort -u | jq -R . | jq -s .)
echo "matrix={\"package\":$CHANGED}" >> $GITHUB_OUTPUT
test:
needs: detect
if: needs.detect.outputs.matrix != '{"package":[]}'
runs-on: ubuntu-latest
strategy:
matrix: ${{ fromJson(needs.detect.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- run: cd packages/${{ matrix.package }} && npm ci && npm test
Only changed packages get tested. In a monorepo with 20 packages, this can cut CI time from 40 minutes to 5 when you change a single package.
Cost Control
Matrix builds consume runner minutes fast. 8 matrix jobs running for 5 minutes each costs 40 runner minutes per push. On a busy repo with 50 pushes per day, that's 2,000 minutes daily.
strategy:
max-parallel: 4
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
max-parallel: 4 throttles concurrent jobs. Use it to stay within your plan's concurrency limit.
Other cost-saving strategies:
- Run the full matrix only on PRs to main. Feature branch pushes get a single configuration.
- Use
pathsfilters to skip CI when only docs change. - Use
ubuntu-latestfor most jobs. macOS runners cost 10x more. Windows runners cost 2x more. Only test on expensive runners when platform-specific behavior matters.
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
pull_request:
paths-ignore:
- '**.md'
- 'docs/**'
Troubleshooting
Problem: Matrix jobs fail on Windows but pass on Linux.
Fix: Check for path separator issues (/ vs \) and case-sensitive file imports. Use path.join() or path.resolve() in your code instead of string concatenation with /.
Problem: Cache misses on every run.
Fix: Ensure your package-lock.json is committed. The cache key is based on the lockfile hash. Also check that the cache key isn't accidentally including a matrix variable that varies across jobs.
Problem: Matrix generates too many jobs, hitting concurrency limits.
Fix: Use max-parallel to throttle:
strategy:
max-parallel: 4
matrix:
# ...
Problem: Matrix job names are unreadable in the Actions UI.
Fix: Add a descriptive name field:
jobs:
test:
name: Test (${{ matrix.os }}, Node ${{ matrix.node }})
runs-on: ${{ matrix.os }}
Problem: One slow matrix combination blocks the pipeline. Fix: Identify the slow combination with the Actions timing UI and check if it needs optimization. Consider moving it to a separate non-blocking job if it's consistently slow.
Problem: Environment variables differ across OS in the matrix.
Fix: Use $GITHUB_ENV for cross-platform variables. For OS-specific behavior, use conditional steps:
- if: runner.os == 'Windows'
run: choco install some-tool
shell: pwsh
- if: runner.os == 'Linux'
run: sudo apt-get install -y some-tool
shell: bash
- if: runner.os == 'macOS'
run: brew install some-tool
shell: bash
Problem: Matrix job artifacts overwrite each other. Fix: Include matrix variables in artifact names:
- uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.os }}-node${{ matrix.node }}
path: test-results/
Matrix Build Reporting
When 8 matrix jobs run in parallel, understanding failures requires good reporting. Add status badges and summary tables.
Job Summary with Markdown
- name: Test Summary
if: always()
run: |
echo "## Test Results (${{ matrix.os }}, Node ${{ matrix.node }})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| OS | ${{ matrix.os }} |" >> $GITHUB_STEP_SUMMARY
echo "| Node | ${{ matrix.node }} |" >> $GITHUB_STEP_SUMMARY
echo "| Status | ${{ job.status }} |" >> $GITHUB_STEP_SUMMARY
Aggregate Results Across Matrix Jobs
report:
needs: test
if: always()
runs-on: ubuntu-latest
steps:
- name: Check Matrix Results
run: |
if [ "${{ needs.test.result }}" == "failure" ]; then
echo "One or more matrix jobs failed"
exit 1
fi
echo "All matrix jobs passed"
The report job runs after all matrix jobs complete and provides a single pass/fail status. This is cleaner than checking 8 individual job statuses in a branch protection rule.
Conclusion
Matrix builds are the fastest path to confidence in your code. Set fail-fast: false, cache aggressively, and use include/exclude to keep the matrix focused. Use dynamic matrices in monorepos, test against real databases, and build multi-platform images. Control costs with max-parallel and path filters. Report results with job summaries. Your CI should be faster than your coffee — matrix builds make that possible.
Related Articles
CI/CD Engineering Lead
Automation evangelist who believes no deployment should require a human. I write pipelines, break pipelines, and write about both. Code-first, always.
Related Articles
The Complete Guide to GitHub Actions CI/CD: From Zero to Production-Ready Pipelines
Build production-grade GitHub Actions CI/CD pipelines — from first workflow to reusable workflows, matrix builds, and deployment gates.
GitHub Actions Reusable Workflows and Composite Actions for DRY Pipelines
Eliminate duplicated CI/CD logic across repositories using GitHub Actions reusable workflows and composite actions with real-world examples.
Hardening GitHub Actions: Permissions, OIDC, and Pinned Actions
Harden GitHub Actions security with least-privilege permissions, OIDC federation, SHA-pinned actions, and secrets management best practices.