How do you implement matrix builds for multi-platform testing?
Quick Answer
Matrix builds use the 'strategy.matrix' key to define variables that GitHub Actions expands into multiple parallel job runs. You define dimensions like OS, language version, or architecture, and GitHub creates a Cartesian product of all combinations, running each as a separate job with its own runner. Include/exclude rules let you fine-tune which combinations actually execute.
Detailed Answer
Think of matrix builds like a quality assurance lab testing a new phone case. You need to verify it fits multiple phone models (iPhone 15, Pixel 8, Galaxy S24) in multiple colors (black, clear, blue), and the lab runs all nine combinations simultaneously on different workbenches. Each workbench independently verifies one specific combination. If the clear Galaxy S24 case fails, you know exactly which combination broke without halting the other eight tests. The matrix strategy in GitHub Actions works identically — defining dimensions and letting the system expand them into parallel test runs.
The matrix strategy is configured under 'strategy.matrix' within a job definition. You declare one or more variables, each with a list of values. GitHub computes the Cartesian product: if you specify 3 operating systems and 4 Node.js versions, you get 12 parallel jobs. Each job receives the matrix values via the 'matrix' context — for example, ${{ matrix.os }} and ${{ matrix.node-version }}. You can use 'include' to add specific combinations with extra variables (like a particular compiler flag only needed on Windows), and 'exclude' to remove unwanted combinations (like skipping Node.js 14 on the ARM runner because it is unsupported).
Internally, the GitHub Actions orchestrator evaluates the matrix definition before any jobs run. It expands the Cartesian product, applies excludes, merges includes, and creates individual job entries in the workflow run. Each matrix job is a fully independent job with its own runner — they do not share filesystems or state. The 'fail-fast' flag (true by default) tells the orchestrator to cancel all in-flight matrix jobs if any single job fails, which saves compute minutes but can hide failures in other combinations. Setting 'fail-fast: false' ensures every combination runs to completion, giving you a complete picture. The 'max-parallel' key throttles how many matrix jobs run concurrently, which is essential when you have limited self-hosted runners or are hitting API rate limits from parallel integration tests.
In production, matrix builds are indispensable for libraries and services that must work across multiple environments. A payment processing library might test against Node.js 18, 20, and 22 on Ubuntu, Windows, and macOS — that is 9 combinations catching platform-specific bugs like path separator issues or missing native dependencies. For Docker-based services like order-service, you might matrix across CPU architectures (amd64, arm64) to produce multi-arch images. Teams often combine matrix builds with reusable workflows: the reusable workflow defines the standard test pipeline, and each calling repository provides its own matrix values as inputs. This pattern ensures consistent testing methodology while allowing project-specific customization.
The biggest gotcha with matrix builds is cost explosion. A matrix of 3 operating systems, 4 language versions, and 3 database versions yields 36 parallel jobs — each consuming runner minutes. On GitHub-hosted runners, macOS minutes are billed at 10x the Linux rate, so a 36-job matrix with macOS can be shockingly expensive. Another common mistake is not realizing that 'include' entries do not just add combinations — they can also extend existing combinations with additional variables. If an include entry matches an existing matrix combination on all specified keys, it adds the extra variables to that combination rather than creating a new one. Finally, artifact naming in matrix builds requires attention: if multiple matrix jobs upload artifacts with the same name, they overwrite each other. Always include matrix variables in artifact names like 'build-${{ matrix.os }}-${{ matrix.node }}'.