Tenki’s startup program is live: up to $50K in credits and grants.Apply
GitHub Actions

GitHub Actions Matrix Strategy on Tenki Runners

Eddie Wang
Eddie Wangengineering

Share Article:

A single-job CI pipeline that runs every test sequentially is simple. It's also slow. For a Node.js monorepo with 2,000 tests, you're looking at 25-30 minutes of wall time on a standard GitHub-hosted runner. Double that if you also need to test against multiple Node versions.

GitHub Actions' strategy.matrix solves this by fanning a single job definition into multiple parallel runs. Each combination of your matrix variables gets its own runner, its own environment, and its own pass/fail status. On Tenki Runners, those parallel legs spin up on bare-metal infrastructure at $0.002/core/minute, which changes the economics of heavy matrix usage significantly.

This guide covers static and dynamic matrix configurations, runner sizing decisions for each leg, the tradeoffs around fail-fast and concurrency limits, and how Tenki's per-job review output gives you specific quality signals when a matrix leg fails.

Static Matrix Basics

A static matrix is the most common pattern. You define one or more variables with fixed arrays of values, and GitHub Actions generates a job for every combination. Here's a typical setup for a Node.js project testing across three versions and two operating systems:

jobs:
  test:
    strategy:
      matrix:
        node-version: [18, 20, 22]
        os: [ubuntu-latest, macos-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

This produces six jobs (3 Node versions x 2 OS options). Each job reports independently in the Actions UI, labeled with its matrix values: test (18, ubuntu-latest), test (20, macos-latest), and so on.

You can also use include to add specific combinations that don't fit the grid, and exclude to remove ones you don't need. If you support Node 18 only on Linux (because your macOS users are all on 20+), that's one exclude entry:

strategy:
  matrix:
    node-version: [18, 20, 22]
    os: [ubuntu-latest, macos-latest]
    exclude:
      - node-version: 18
        os: macos-latest

Five jobs instead of six. Small savings here, but it adds up when each leg costs real compute time.

Sizing Tenki Runners Per Matrix Leg

Matrix strategy multiplies your job count. That's the whole point. But it also means your runner sizing decision compounds across every leg.

Tenki offers five runner configurations, and the right choice depends on what each matrix leg actually does:

  • tenki-standard-small-2c-4g (2 vCPU, 4 GB) — Good for lightweight test shards: unit tests, linting jobs, or any leg where the test runner itself isn't CPU-bound. When you're splitting 2,000 tests across 4 matrix legs, each shard runs maybe 500 tests. A 2-core runner handles that fine.
  • tenki-standard-medium-4c-8g (4 vCPU, 8 GB) — The sweet spot for most matrix jobs. Integration tests, build steps, and anything that benefits from parallel test execution within the shard itself (Jest workers, for instance). This is what I'd recommend as your default.
  • tenki-standard-large-8c-16g (8 vCPU, 16 GB) — Reach for this when matrix legs involve compilation (Rust, C++), Docker builds, or end-to-end browser tests with headless Chrome. The extra cores pay for themselves in shorter wall time.
  • tenki-standard-large-plus-16c-32g (16 vCPU, 32 GB) — Heavy compilation workloads and large monorepo builds. You probably don't need 16 cores per matrix leg unless each leg is itself doing substantial parallel work.

The general principle: prefer more legs on smaller runners over fewer legs on bigger runners. Four legs on 2-core runners cost the same in core-minutes as two legs on 4-core runners, but they finish in half the wall time because parallelism is doing the work instead of raw compute power.

To use Tenki runners in a matrix, you just swap out the runs-on value:

jobs:
  test:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    runs-on: tenki-standard-medium-4c-8g
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx jest --shard=${{ matrix.shard }}/4

That's it. One line change from ubuntu-latest to a Tenki runner label. Your test suite now runs across four parallel shards on 4-core bare-metal runners, each finishing in roughly a quarter of the original time.

fail-fast and max-parallel: The Cost vs. Speed Tradeoff

Two settings control how matrix jobs behave when things go wrong or when you want to limit resource usage.

fail-fast

By default, fail-fast is true. When any matrix leg fails, GitHub cancels all other in-progress legs. This saves compute minutes but costs you diagnostic information. If shard 2 fails and shards 1, 3, and 4 get cancelled, you don't know whether those other shards had their own problems.

Set fail-fast: false when you want to see the full picture:

strategy:
  fail-fast: false
  matrix:
    shard: [1, 2, 3, 4]

On Tenki runners, the cost of letting all legs complete is lower than on GitHub-hosted runners because Tenki's per-core pricing runs up to 60% cheaper. That changes the math: the extra minutes you spend to see all failures often cost less than the time you waste on a second CI run to find the next broken test.

There's a middle ground, too. You can use continue-on-error with a matrix variable to mark experimental legs that shouldn't cancel the rest:

jobs:
  test:
    runs-on: tenki-standard-medium-4c-8g
    continue-on-error: ${{ matrix.experimental }}
    strategy:
      fail-fast: true
      matrix:
        node-version: [20, 22]
        experimental: [false]
        include:
          - node-version: 23
            experimental: true

Node 20 and 22 failures kill the run. Node 23 can fail without affecting anything.

max-parallel

max-parallel caps how many matrix legs run simultaneously. Without it, GitHub Actions launches all legs at once (subject to your account's concurrency limits).

strategy:
  max-parallel: 4
  matrix:
    shard: [1, 2, 3, 4, 5, 6, 7, 8]

This runs 8 shards but only 4 at a time. The first batch finishes, then the next 4 start. Useful when you have a shared resource (a test database, an external API with rate limits) that can't handle 8 concurrent connections. You trade some wall time for predictable resource usage.

On Tenki, your concurrency limits are visible in the Limits, Concurrency & Cold Start docs. If you're running a large matrix and hitting the concurrency ceiling, set max-parallel to match rather than queueing jobs that wait for a runner.

Dynamic Matrix Generation

Static matrices work when you know the combinations ahead of time. But what if the number of test shards depends on how many tests you actually have? Or you want to generate a list of changed packages in a monorepo and only test those?

Dynamic matrices solve this. A prior job outputs a JSON array, and a downstream job consumes it with fromJSON(). Here's a practical example that detects changed packages and tests only those:

jobs:
  detect-changes:
    runs-on: tenki-standard-small-2c-4g
    outputs:
      packages: ${{ steps.changed.outputs.packages }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Find changed packages
        id: changed
        run: |
          CHANGED=$(git diff --name-only origin/main...HEAD \
            | grep '^packages/' \
            | cut -d'/' -f2 \
            | sort -u \
            | jq -R -s -c 'split("\n") | map(select(length > 0))')
          echo "packages=$CHANGED" >> "$GITHUB_OUTPUT"

  test:
    needs: detect-changes
    if: needs.detect-changes.outputs.packages != '[]'
    strategy:
      fail-fast: false
      matrix:
        package: ${{ fromJSON(needs.detect-changes.outputs.packages) }}
    runs-on: tenki-standard-medium-4c-8g
    steps:
      - uses: actions/checkout@v4
      - run: cd packages/${{ matrix.package }} && npm ci && npm test

The detect-changes job runs on a small 2-core runner because it just diffs files and produces JSON. The test job fans out across 4-core runners, one per changed package. If you change 3 packages, you get 3 parallel test jobs. Change 1 package, you get 1 job. No wasted compute.

You can also generate numeric shard indexes dynamically based on test count. Say you want each shard to handle roughly 200 tests:

- name: Calculate shards
  id: shards
  run: |
    TEST_COUNT=$(find tests/ -name '*.test.ts' | wc -l)
    SHARD_COUNT=$(( (TEST_COUNT + 199) / 200 ))
    SHARDS=$(seq 1 $SHARD_COUNT | jq -R -s -c 'split("\n") | map(select(length > 0) | tonumber)')
    echo "shards=$SHARDS" >> "$GITHUB_OUTPUT"
    echo "total=$SHARD_COUNT" >> "$GITHUB_OUTPUT"

This approach scales your parallelism with your test suite. As you add tests, the matrix grows automatically.

Tenki Code Reviewer and Matrix Failures

When a matrix leg fails, GitHub shows a red X next to that specific combination in the Actions UI. That tells you something broke, but not why without digging into logs.

Tenki Code Reviewer adds a layer on top of this. It reviews the PR itself and understands the codebase context. So when your matrix run has 4 legs and one of them fails, you're not just seeing a red badge. Tenki's review comments on the PR with severity-tagged findings, telling you whether the change introduced a logic error, a type mismatch, or a security issue.

This distinction matters more with matrix builds than single-job pipelines. In a matrix context, a failure could mean:

  • A genuine bug in the code change (fails on all legs or specific Node versions)
  • A flaky test that only surfaces under certain timing conditions on one shard
  • An environment problem (dependency install failure, network timeout)
  • A compatibility issue with a specific OS or runtime version

Tenki Code Reviewer focuses on the first category. Because it reviews the actual code diff rather than CI output, its findings tell you whether the change is the problem versus the environment. If the reviewer flags a null reference in your PR and shard 3 fails with a null pointer exception, you've got correlated signals pointing at the same root cause. That's a faster path to a fix than reading through four sets of test logs.

The review is priced at $1.00 per PR review, separate from the runner cost per matrix leg. So you get one code review for the entire PR, regardless of how many matrix legs you run. The matrix runs and the code review complement each other: the matrix tells you which environments break, and the review tells you why.

Full Example: 4-Shard Node.js Pipeline on Tenki

Here's a complete workflow that puts everything together. It dynamically generates test shards, runs them in parallel on Tenki 4-core runners, and collects results:

name: CI
on:
  pull_request:
    branches: [main]

jobs:
  prepare:
    runs-on: tenki-standard-small-2c-4g
    outputs:
      shards: ${{ steps.calc.outputs.shards }}
      total: ${{ steps.calc.outputs.total }}
    steps:
      - uses: actions/checkout@v4
      - name: Calculate shard count
        id: calc
        run: |
          TEST_COUNT=$(find src/ -name '*.test.ts' | wc -l)
          TOTAL=$(( (TEST_COUNT + 149) / 150 ))
          # Minimum 2 shards, maximum 8
          TOTAL=$(( TOTAL < 2 ? 2 : TOTAL > 8 ? 8 : TOTAL ))
          SHARDS=$(seq 1 $TOTAL | jq -R -s -c 'split("\n") | map(select(length > 0) | tonumber)')
          echo "shards=$SHARDS" >> "$GITHUB_OUTPUT"
          echo "total=$TOTAL" >> "$GITHUB_OUTPUT"

  test:
    needs: prepare
    strategy:
      fail-fast: false
      matrix:
        shard: ${{ fromJSON(needs.prepare.outputs.shards) }}
    runs-on: tenki-standard-medium-4c-8g
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - name: Run test shard
        run: npx jest --shard=${{ matrix.shard }}/${{ needs.prepare.outputs.total }} --ci
      - name: Upload results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results-shard-${{ matrix.shard }}
          path: coverage/

  report:
    needs: test
    if: always()
    runs-on: tenki-standard-small-2c-4g
    steps:
      - uses: actions/download-artifact@v5
        with:
          pattern: test-results-shard-*
          merge-multiple: true
      - name: Summarize results
        run: |
          echo "## Test Results" >> $GITHUB_STEP_SUMMARY
          for dir in test-results-shard-*; do
            echo "- $dir: $(cat $dir/status 2>/dev/null || echo 'unknown')" >> $GITHUB_STEP_SUMMARY
          done

A few things worth noting about this setup:

  1. The prepare job uses a 2-core runner. It counts files and produces JSON. No need for a bigger machine.
  2. fail-fast is off. Every shard finishes, so you see all failures in one run.
  3. Shard count adapts to your test suite. 300 tests produce 2 shards. 1,200 tests produce 8. You don't touch the workflow file when the test suite grows.
  4. The report job always runs. Even if shards fail, you still get a summary. The if: always() on the upload step ensures artifacts exist for failed shards too.

At Tenki's pricing of $0.002/core/minute, four 4-core shards running for 7 minutes each costs about $0.224 in total compute. The same test suite on a single ubuntu-latest runner would take 25-28 minutes of wall time. The matrix approach finishes in under 8 minutes (including the prepare and report jobs) and gives you per-shard failure isolation.

When to Use Each Pattern

Static matrix when you're testing across a fixed set of environments (OS versions, language versions, configuration flags). The matrix definition lives in YAML and changes only when you add or drop a supported version.

Dynamic matrix when the dimensions change between runs. Monorepo package lists, test shard counts based on suite size, or deployment targets generated from infrastructure-as-code.

Hybrid when you want static environment dimensions (Node 20, Node 22) combined with dynamic test sharding. You can nest a fromJSON() value alongside static arrays in the same matrix.

The matrix strategy is one of those GitHub Actions features that's easy to use at a basic level but rewards careful thinking about runner sizing, failure semantics, and dynamic generation. Pair it with Tenki's bare-metal runners and per-review code analysis, and you've got a CI pipeline that's both fast and informative.

Tags

#parallel-testing#github-actions-runners#runner-sizing#cicd-optimization#github-actions-parallel-steps

Recommended for you

What's next in your stack.