DevOpsil
CI/CD
92%
Fresh
Part 2 of 6 in CI/CD Mastery

GitHub Actions Matrix Builds for Multi-Platform Testing

Sarah ChenSarah Chen8 min read

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 paths filters to skip CI when only docs change.
  • Use ubuntu-latest for 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.

Share:
Sarah Chen
Sarah Chen

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