GitHub Actions: Matrix Builds and Parallelism

TT
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.

yaml
# .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@v4

This 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.

yaml
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: true

Result: 9 original combinations minus 2 exclusions plus 2 additions = 9 jobs.

yaml
# 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

yaml
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 version

Java Matrix with Gradle

yaml
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 test

Cross-Platform Build Matrix

yaml
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.

yaml
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 }}
yaml
# 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_OUTPUT

Fan-Out and Fan-In Patterns

Some workflows need to run parallel jobs and then aggregate results.

yaml
# 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@v4

Jest Shard Configuration

typescript
// 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.

yaml
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.

yaml
- 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

text
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
yaml
# 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.