How would you design a GitHub Actions matrix strategy with dynamic matrices, fail-fast control, and conditional includes for a polyglot microservices build?
Quick Answer
Matrix strategies run a job across multiple configurations by defining arrays of parameters (OS, language version, service name) that GitHub expands into parallel job instances. Dynamic matrices use a prior job's JSON output to define the matrix at runtime. fail-fast controls whether one failure cancels sibling jobs, and include/exclude add or remove specific combinations.
Detailed Answer
Think of a matrix strategy like a restaurant kitchen preparing the same dish with variations for a banquet. The base recipe (job steps) is the same, but you need it in ten configurations: vegetarian and non-vegetarian (OS), with three spice levels (language versions), for two dining halls (services). Instead of writing ten separate recipes, you define the variations as a matrix and the kitchen (GitHub Actions) prepares all combinations in parallel, each on its own station (runner). If one station burns a dish (job failure) and fail-fast is on, the head chef stops all stations. If fail-fast is off, the other stations keep cooking so you know which combinations succeed.
A static matrix is defined directly in the workflow YAML under strategy.matrix. You specify one or more variables as arrays, and GitHub expands the Cartesian product into individual job instances. For example, os: [ubuntu-latest, windows-latest] combined with node: [18, 20, 22] produces six parallel jobs. The include directive adds extra combinations or appends variables to existing ones: include: [{os: ubuntu-latest, node: 22, experimental: true}] adds the experimental flag only to that specific combination. The exclude directive removes unwanted combinations from the product. The max-parallel setting limits concurrency to avoid exhausting your runner pool. The fail-fast setting (true by default) cancels all remaining matrix jobs when any one fails, which saves runner minutes but hides failures in other combinations.
Dynamic matrices take this further by computing the matrix values at runtime. A preceding job runs a script that outputs a JSON array, and the matrix job consumes it via fromJSON(). This enables powerful patterns: scanning a monorepo for services that changed (using git diff), outputting their names as a JSON array, and having the build matrix spawn jobs only for changed services. The output is passed between jobs using the jobs.<id>.outputs mechanism and the $GITHUB_OUTPUT environment file. Dynamic matrices are essential for monorepos where building all fifty services on every push wastes hours of compute, but building only the three that changed takes minutes.
In production, Acme Corp's platform monorepo uses a two-job pattern for their polyglot build. The first job, called detect-changes, runs a script that checks git diff against the base branch, identifies which service directories have changes, and outputs a JSON matrix including each service's name, language runtime, build command, and test command. The second job, build-and-test, uses strategy.matrix.service: ${{ fromJSON(needs.detect-changes.outputs.matrix) }} to spawn parallel builds only for changed services. The matrix includes a Node.js checkout-service, a Python payments-api, and a Go inventory-worker, each with their specific build toolchain. fail-fast is set to false so that a failure in the checkout build does not cancel the payments build, since they are independent services. The max-parallel is set to five to respect the organization's runner concurrency limit.
A key gotcha with dynamic matrices is that if the output JSON is an empty array, the matrix job fails with an error because GitHub cannot expand an empty matrix. You must handle the empty case explicitly, either by skipping the matrix job with an if conditional (if: needs.detect-changes.outputs.has_changes == 'true') or by always including at least one dummy entry. Another pitfall is that matrix variable values are always strings in the YAML context, even if you define them as numbers. This can cause issues when using them in version comparisons or arithmetic. Use the fromJSON() trick to preserve types: node-version: ${{ fromJSON('[18, 20, 22]') }} keeps the values as numbers. Finally, matrix jobs share no state: each instance gets a fresh runner with no access to artifacts or outputs from sibling instances. Use the upload-artifact and download-artifact actions to pass data between matrix jobs and a subsequent aggregation job.