
GitHub Actions Cost Optimization in 2026
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.
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 testThis 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-latestFive jobs instead of six. Small savings here, but it adds up when each leg costs real compute time.
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:
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 }}/4That'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.
Two settings control how matrix jobs behave when things go wrong or when you want to limit resource usage.
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: trueNode 20 and 22 failures kill the run. Node 23 can fail without affecting anything.
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.
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 testThe 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.
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:
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.
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
doneA few things worth noting about this setup:
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.
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
Recommended for you
What's next in your stack.