Building a GitHub Actions Matrix That Scales

A GitHub Actions build matrix turns one job into one job per combination. Size the fan-out to runner capacity, shard the suite, and price every cell.

Last verified:

A GitHub Actions build matrix expands one job definition into one job per combination of the values listed under strategy.matrix, so a suite written once runs as 24 independent jobs on 24 machines. The matrix keeps paying while every cell does distinct work and the fan-out fits the runner capacity behind your runs-on labels, and it starts costing more than it returns the moment either of those two conditions breaks.

This guide covers the three ways a matrix stops helping, the edits that fix each one, a workflow that routes cells of different weights to different runner sizes, and the arithmetic that sets the shard count. For the semantics of include, exclude, fail-fast, and the matrix context, see the build matrix glossary entry.

Diagnosis

A matrix that has stopped helping still looks healthy in the run view. The grid is green, the job count is high, and the wall clock has quietly stopped tracking the work. Three patterns account for most of it.

Cells that duplicate work

Every cell pays for checkout, dependency install, and cache restore before it runs anything specific to that combination. A dimension earns those repeated minutes only when its cells can disagree.

The common case is an operating system dimension on a suite with no operating system specific code. Three operating systems multiplied by three language versions is nine jobs, nine installs, and nine cache restores to answer a question that three jobs plus one targeted test case answers.

Read duration P75 and P90 per job in the Jobs report, which reports both percentiles for every unique repository, workflow, and job name combination (reports documentation). Cells whose percentiles sit within noise of each other and whose logs contain the same steps are buying no information. Collapsing that dimension divides the job count by the length of its list.

fail-fast cancelling useful signal

fail-fast defaults to true. The first cell that reports a failure cancels the in-progress cells from the same matrix, and cells still waiting in the queue never start.

For a merge gate that default is correct. For a compatibility grid it removes the answer the run existed to produce, and the rerun pays for the entire grid a second time. The symptom is a run holding one red cell, a row of cancelled cells, and a comment asking someone to rerun it.

Fan-out wider than available capacity

Expansion and execution are separate events. The matrix creates every cell at once, then each cell waits for an online runner that advertises every label in its runs-on key. Twenty-four cells facing six eligible runners run in four waves, so wall clock is four cell durations plus queue wait, and doubling the matrix width without adding capacity doubles the wall clock.

Measure before widening anything. The Queue Timings report gives queue wait at P75 and P90 per runner label and stack, with CSV export, so a label whose P90 climbs while other labels start on time is a capacity signal rather than a workflow bug. On GitHub-hosted runners, subtract each job's created_at from its started_at and read the spread yourself, then check the account ceilings covered in the guide to GitHub Actions concurrency limits.

One hard stop sits above all of this: a matrix generates a maximum of 256 jobs per workflow run, and a product larger than that fails the run instead of truncating it (GitHub Actions usage limits, checked on 2026-08-13).

SymptomWhat it meansWhere to look
Every cell logs the same steps and finishes within a minute of the othersA dimension that produces no distinct informationJobs report, duration P75 and P90
One red cell beside a row of cancelled cellsfail-fast cancelling the rest of the gridRun view, conclusion on sibling jobs
Wall clock is a whole multiple of one cell durationFan-out wider than the eligible runnersQueue Timings, P75 and P90 per label
One cell pending while every other cell startsLabel typo in one dimensionruns-on values against the runner catalog
The run fails before any job startsProduct above the 256 job capLength of each matrix dimension

Fix

Work the list in order. The first two edits shrink the grid, the third restores the signal, and the last two give what remains somewhere to run.

Cut dimensions that repeat work. Keep a dimension when its values change the build product or the runtime under test: architecture, operating system for code with platform specific paths, and the language versions your users actually run. Drop the rest to a single value and cover the edge case with one test rather than one axis.

Shard instead of copying. Replace a duplicated dimension with a shard index and pass that index into the test runner, so each cell executes a distinct slice of the same suite. Most runners take a shard argument directly, and the ones that do not can partition by test file list. Sharding converts matrix width into wall clock reduction, which is the only thing a wide grid is good for.

Set fail-fast by purpose. True for merge gates, false for compatibility grids. Write the reason in a comment next to the key so the next person keeps the choice.

Size cells by what binds them. A unit shard bound by a single process and a browser shard running parallel workers want different machines. Put the runner label in a matrix key so one grid can hold both, as the configuration below does.

Give the fan-out somewhere to land.. A fresh runner is provisioned per job. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. Relabeling runs-on is the whole change, since the plan level ceiling follows the runner pool rather than the workflow. The answer on how many GitHub Actions jobs can run at once covers which ceiling applies where.

Then measure again. The queue and duration percentiles above come from the same reports. If wall clock stays flat after a widening, the constraint moved to queue wait, which the guide to GitHub Actions queue times takes apart.

Configuration

This workflow runs three suites of different weights. exclude trims the browser suite to three shards, and include merges a runner label and a shard total into each suite without changing the job count.

name: test
on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    name: ${{ matrix.suite }}-${{ matrix.shard }}
    strategy:
      # this grid is a compatibility report; a merge gate would keep the default
      fail-fast: false
      matrix:
        suite: [unit, integration, browser]
        shard: [1, 2, 3, 4]
        exclude:
          - suite: browser
            shard: 4
        include:
          - suite: unit
            runner: warp-ubuntu-latest-x64-4x
            shard_total: 4
          - suite: integration
            runner: warp-ubuntu-latest-x64-8x
            shard_total: 4
          - suite: browser
            runner: warp-ubuntu-latest-x64-16x
            shard_total: 3
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm run test:${{ matrix.suite }} -- --shard=${{ matrix.shard }}/${{ matrix.shard_total }}

What each piece does:

  • The product of suite and shard is 12 cells. GitHub applies exclude first, dropping browser-4 and leaving 11, then applies include.
  • Each include object names a suite value that already exists, so it merges runner and shard_total into the matching cells rather than appending jobs. The count stays at 11.
  • shard_total keeps the shard argument honest. Browser cells receive --shard=1/3 through --shard=3/3 while unit and integration cells receive n/4.
  • runs-on: ${{ matrix.runner }} reads a matrix value like any other expression, so the machine choice lives in the matrix and the steps stay untouched.
  • The name key puts the suite and shard in the job name, which keeps required status checks readable. Renaming a matrix value renames the check, so update branch protection when you change the shard count.
  • Add max-parallel under strategy when something outside the runner fleet sets the ceiling, such as a rate limited API or a shared test database.

The three labels resolve to these machines, from the WarpBuild cloud runners documentation, checked on 2026-08-13.

runs-on labelOSvCPURAMStorageUSD per minute
warp-ubuntu-latest-x64-2xUbuntu 24.0428 GB150GB SSD$0.004
warp-ubuntu-latest-x64-4xUbuntu 24.04416 GB150GB SSD$0.008
warp-ubuntu-latest-x64-8xUbuntu 24.04832 GB150GB SSD$0.016
warp-ubuntu-latest-x64-16xUbuntu 24.041664 GB150GB SSD$0.032
warp-ubuntu-latest-x64-32xUbuntu 24.0432128 GB150GB SSD$0.064

Sizing each cell type

Pick the size from what binds the cell, then confirm with CPU and memory P75 and P90 in the Jobs report after one day of runs.

Cell typeWhat binds itStarting labelRate per minute
Lint, type check, unit shardsOne process, low memorywarp-ubuntu-latest-x64-2x$0.004
Unit shards with parallel workersWorker count, so vCPUwarp-ubuntu-latest-x64-4x$0.008
Integration shards with service containersMemory held by databases and brokerswarp-ubuntu-latest-x64-8x$0.016
Browser and end to end shardsParallel browser instances, memory per instancewarp-ubuntu-latest-x64-16x$0.032
Native compile and link cellsParallel compile jobs, so vCPU and diskwarp-ubuntu-latest-x64-32x$0.064

Two catalog facts change matrix shape. Nested virtualization for Android emulator workloads is available on Linux x64 through the nested-virtualization.enabled=true label, so an emulator cell belongs on an x64 label rather than an ARM64 one. macOS runners cannot run Docker, so a Docker based integration cell stays on Linux even when the rest of the grid is a platform dimension.

Cost or Time Model

Sharding trades billed minutes for wall clock, and the exchange rate is the per-job setup time. Two formulas cover it. Billed minutes are T + N * s, and wall clock per run is T / N + s + queue, where T is total suite execution time, N is the shard count, and s is the fixed setup a cell pays before its first test.

Assumptions for the model: a suite with T of 96 minutes of test execution, s of 2 minutes for checkout, npm ci, and cache restore, uniform shards, no retries, and per minute billing. Rates are the catalog rates above.

ShardsTest minutes per cellCell durationWall clock, queue excludedBilled minutesCost per run at $0.008
1 (serialized)9698 min98 min98$0.78
61618 min18 min108$0.86
12810 min10 min120$0.96
2446 min6 min144$1.15
4824 min4 min192$1.54

Read the last two rows together. Going from 24 shards to 48 shards buys 2 minutes of wall clock and costs 48 more billed minutes, because every extra shard pays the same 2 minute setup. The useful stopping point is where per-cell test time approaches s, which is 24 shards for this suite.

Now add queue wait, which is the part the table cannot assume. Pull your own numbers from the Queue Timings row for the label. Suppose that row reports P75 of 0.4 minutes and P90 of 1.5 minutes for warp-ubuntu-latest-x64-4x. The 24 shard run then finishes in 6.4 minutes at P75 and 7.5 minutes at P90, against 98.4 and 99.5 minutes for the serialized version. Where a pool runs its cells in waves, multiply the queue figure by the number of waves before comparing, since each wave queues again.

Those 144 billed minutes carry a list price. GitHub's per minute prices below are from the GitHub Actions billing reference, checked on 2026-08-13.

ShapeGitHub-hosted runner and rateWarpBuild label and rate144 minutes on GitHub-hosted144 minutes on WarpBuild
2 vCPU, 8 GBubuntu-latest, private repos, $0.006warp-ubuntu-latest-x64-2x, $0.004$0.86$0.58
4 vCPU, 16 GB4-core larger runner, $0.012warp-ubuntu-latest-x64-4x, $0.008$1.73$1.15
8 vCPU, 32 GB8-core larger runner, $0.022warp-ubuntu-latest-x64-8x, $0.016$3.17$2.30
16 vCPU, 64 GB16-core larger runner, $0.042warp-ubuntu-latest-x64-16x, $0.032$6.05$4.61
32 vCPU, 128 GB32-core larger runner, $0.082warp-ubuntu-latest-x64-32x, $0.064$11.81$9.22

Matched shape for shape, that is 33 percent lower list price at 2 and 4 vCPU, 27 percent at 8 vCPU, 24 percent at 16 vCPU, and 22 percent at 32 vCPU, with GitHub list prices checked on 2026-08-13.

Scale it to a working month. At 30 runs a day, the 24 shard configuration bills 4,320 minutes a day, which is $34.56 on warp-ubuntu-latest-x64-4x and $51.84 on the GitHub-hosted 4-core larger runner. The same change returns 92 minutes of wall clock per run, or 2,760 minutes of engineer waiting a day.

Full rates by runner type are on the WarpBuild pricing page.

FAQ

How many shards should a GitHub Actions matrix have?

Billed minutes for a sharded suite are the suite time plus the per-job setup time multiplied by the shard count, and wall clock is the suite time divided by the shard count plus that same setup time. Keep adding shards while the per-cell test time stays well above the setup time, and stop once they are close, because past that point each extra shard buys seconds of wall clock and costs a full setup.

Can different cells in one matrix use different runner sizes?

Yes. Put the runner label in a matrix key and set runs-on to that key. An include entry whose keys match an existing combination merges the label into that combination without changing the job count, so unit cells can land on a 4 vCPU label while browser cells land on a 16 vCPU label in the same matrix.

Should fail-fast be true or false in a matrix?

Set fail-fast true when the matrix is a merge gate, because one broken combination is enough to block the merge and the remaining cells only spend minutes confirming it. Set it false when the matrix is a compatibility report, because the default cancels in-progress siblings and leaves you rerunning the whole grid to learn what else failed.

Does a wider matrix cost more in billed minutes?

Yes, by the fixed setup work each cell repeats. Checkout, dependency install, and cache restore are paid once per job, so a 24 shard matrix over a 96 minute suite bills 144 minutes against 98 for the same suite in one job. The wall clock drops from 98 minutes to 6 minutes for that extra $0.37 per run at $0.008 per minute.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.