GitHub Actions Matrix — Parallel Builds and Multi-Environment Testing
Use GitHub Actions matrix strategy to run jobs across multiple .NET versions, operating systems, and test configurations in parallel — reducing total CI time for multi-target libraries and clinical platform modules.
When to Use Matrix Builds
Matrix builds run the same job configuration with different parameters in parallel.
Use matrix for:
→ Testing a NuGet library against multiple .NET versions (6, 8, 9)
→ Testing across multiple OSes (Linux, Windows, macOS — for cross-platform libs)
→ Running tests for each module independently in parallel
→ Multi-region deployment (deploy to UK South and UK West simultaneously)
→ Smoke testing against multiple browser configurations
When NOT to use matrix:
→ Sequential dependencies (deploy to staging before production)
→ Simple single-target applications (.NET 8, Linux only — matrix adds overhead for no benefit)
→ When parallel jobs would compete for the same resource (one DB per job is better)Basic Matrix — Multiple .NET Versions
# Test a shared library or NuGet package against multiple .NET versions
name: Library CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
dotnet-version: ['6.0.x', '8.0.x', '9.0.x']
fail-fast: false # run all matrix jobs even if one fails
steps:
- uses: actions/checkout@v4
- name: Setup .NET ${{ matrix.dotnet-version }}
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ matrix.dotnet-version }}
- name: Restore
run: dotnet restore SharedKernel/SharedKernel.csproj
- name: Test
run: |
dotnet test SharedKernel.Tests/SharedKernel.Tests.csproj \
--framework net${{ matrix.dotnet-version | split('.') | first }}.0 \
--logger "trx;LogFileName=results-${{ matrix.dotnet-version }}.trx"Matrix Across Multiple Dimensions
# Test across OS × .NET version combinations
jobs:
test-cross-platform:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
dotnet-version: ['8.0.x', '9.0.x']
exclude:
# Don't test .NET 6 on Windows — not required
- os: windows-latest
dotnet-version: '6.0.x'
include:
# Extra combination: macOS with .NET 8 only
- os: macos-latest
dotnet-version: '8.0.x'
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ matrix.dotnet-version }}
- name: Test
run: dotnet test Clinical.sln --configuration ReleaseParallel Module Tests
# Run each module's tests in parallel — fast feedback for large codebases
jobs:
test-modules:
strategy:
matrix:
module:
- name: Patients
path: Modules/Patients/Patients.Tests
- name: Prescriptions
path: Modules/Prescriptions/Prescriptions.Tests
- name: LabResults
path: Modules/LabResults/LabResults.Tests
- name: Billing
path: Modules/Billing/Billing.Tests
fail-fast: false # don't stop all tests if one module fails
runs-on: ubuntu-latest
name: "Tests: ${{ matrix.module.name }}"
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.x'
- name: Restore
run: dotnet restore ${{ matrix.module.path }}/${{ matrix.module.name }}.Tests.csproj
- name: Test ${{ matrix.module.name }}
run: |
dotnet test ${{ matrix.module.path }}/${{ matrix.module.name }}.Tests.csproj \
--configuration Release \
--logger "trx;LogFileName=${{ matrix.module.name }}-results.trx" \
--collect:"XPlat Code Coverage"
- name: Upload results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results-${{ matrix.module.name }}
path: "**/${{ matrix.module.name }}-results.trx"Matrix with Database Per Job
# Integration tests: each matrix job gets its own SQL Server
# Prevents tests from interfering with each other across modules
jobs:
integration-tests:
strategy:
matrix:
module: [Patients, Prescriptions, LabResults]
runs-on: ubuntu-latest
services:
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
env:
ACCEPT_EULA: Y
SA_PASSWORD: YourStrong!Passw0rd
ports:
- 1433:1433
options: >-
--health-cmd "/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P YourStrong!Passw0rd -Q 'SELECT 1'"
--health-interval 10s
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.x'
- name: Run ${{ matrix.module }} integration tests
run: |
dotnet test Modules/${{ matrix.module }}/${{ matrix.module }}.IntegrationTests \
--configuration Release \
--filter "Category=Integration"
env:
ConnectionStrings__Clinical: "Server=localhost,1433;Database=${{ matrix.module }}Test;..."
# Each matrix job has its own SQL Server service container
# Tests for Patients, Prescriptions, and LabResults run in parallel
# Total time: max(individual module test time), not sum of all modulesMerging Coverage Reports from Matrix Jobs
# Collect coverage from all parallel jobs and merge into one report
merge-coverage:
runs-on: ubuntu-latest
needs: test-modules # wait for all matrix jobs
steps:
- uses: actions/checkout@v4
- name: Download all coverage artifacts
uses: actions/download-artifact@v4
with:
pattern: coverage-*
merge-multiple: true
path: ./coverage
- name: Install ReportGenerator
run: dotnet tool install --global dotnet-reportgenerator-globaltool
- name: Merge and generate report
run: |
reportgenerator \
-reports:"./coverage/**/coverage.cobertura.xml" \
-targetdir:"./coverage/merged" \
-reporttypes:"Cobertura;HtmlSummary"
- name: Upload merged coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage/merged/Cobertura.xmlProduction issue I've seen: A team's CI pipeline ran all tests sequentially — unit tests for 5 modules, then integration tests for each module, one after another. Total CI time: 22 minutes. Every PR waited 22 minutes for feedback. Developers would start a PR, forget about it, and come back later. When they returned, the context switch cost made it harder to act on feedback. Converting the tests to a matrix build (5 modules in parallel, each with their own database) reduced total CI time to 6 minutes. Developer feedback loops improved measurably — PRs were reviewed and merged the same day instead of the next day.
Key Takeaway
Matrix builds run the same job with different parameters in parallel — useful for multi-.NET-version libraries, cross-platform testing, and per-module parallel test execution. Use
fail-fast: falseto see all failures rather than stopping at the first. Each matrix job gets its own service containers (database, Redis) — tests run in complete isolation. Merge coverage reports from parallel jobs with ReportGenerator before uploading to Codecov. Don't use matrix for single-version, single-platform applications — the overhead outweighs the benefit.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.