.png)
GitHub Actions Matrix Strategy on Tenki Runners
Your monorepo has 12 packages. Someone fixes a typo in the docs folder. Every package rebuilds, every test suite runs, and 45 minutes later you've burned through CI minutes for nothing. Or you've added path filters, but your shared utility package changed and the three packages that depend on it didn't rebuild because your filters don't understand your dependency graph.
Both of these are real problems, and most teams are stuck on one side or the other. This guide covers how to get monorepo CI right: path-based triggers that actually account for dependencies, runner routing that matches compute to workload, and artifact sharing that avoids redundant builds.
Before getting into the solution, it helps to name what's broken. Almost every monorepo CI setup falls into one of three failure modes.
Run everything, always. The simplest approach: every push triggers every workflow. It works when you have two packages and five-minute builds. At 10+ packages it becomes a tax on every commit. You're paying for compute and waiting for results that couldn't possibly have changed.
Naive path filters that ignore dependencies. You add paths: ['packages/frontend/**'] to the frontend workflow and call it done. But when packages/shared changes, the frontend doesn't rebuild even though it imports from shared. You've traded wasted minutes for silent correctness bugs.
Manual workflow dispatch as a workaround. Teams give up on automated filtering and rely on developers manually triggering the right workflows. This doesn't scale, people forget, and you lose the entire point of CI.
GitHub Actions provides two keywords for path-based filtering on push and pull_request events: paths (include list) and paths-ignore (exclude list). The rules are straightforward but the edge cases catch people.
A basic setup for a monorepo frontend workflow:
name: Frontend CI
on:
push:
branches: [main]
paths:
- 'packages/frontend/**'
- 'packages/shared/**'
pull_request:
paths:
- 'packages/frontend/**'
- 'packages/shared/**'This workflow fires when files in packages/frontend or packages/shared change. That's the dependency-aware part: you explicitly list the packages your workflow cares about, including upstream dependencies.
Here are the edge cases GitHub's docs mention but don't emphasize enough:
Here's a more realistic pattern that includes a package directory but excludes its docs:
on:
push:
branches: [main]
paths:
- 'packages/api/**'
- '!packages/api/docs/**'
- 'packages/shared/**'
- '!**/*.md'Workflow-level paths filters are all-or-nothing: the entire workflow runs or it doesn't. For monorepos with multiple packages in a single workflow, you need job-level granularity. That's where dorny/paths-filter comes in.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
changes:
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.filter.outputs.frontend }}
backend: ${{ steps.filter.outputs.backend }}
shared: ${{ steps.filter.outputs.shared }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
frontend:
- 'packages/frontend/**'
- 'packages/shared/**'
backend:
- 'packages/backend/**'
- 'packages/shared/**'
shared:
- 'packages/shared/**'
test-frontend:
needs: changes
if: ${{ needs.changes.outputs.frontend == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test --workspace=packages/frontend
test-backend:
needs: changes
if: ${{ needs.changes.outputs.backend == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test --workspace=packages/backendThe change detection job is fast (it just diffs file paths, no install or build step) and its outputs gate every downstream job. A change to packages/shared triggers both frontend and backend tests. A change to packages/frontend only triggers frontend tests. This is the dependency-aware filtering that most setups miss.
For Turborepo or Nx users, there's an alternative: their built-in change detection via --filter='...[origin/main]' (Turborepo) or nx affected (Nx) reads the package dependency graph directly. This approach is more accurate than maintaining path lists by hand, but it requires fetch-depth: 0 on checkout (so the runner has full git history) and it means your CI logic is coupled to your build tool.
# Turborepo approach: dependency-graph-aware filtering
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v3
- run: pnpm install
- run: pnpm turbo build --filter='...[origin/main]'
- run: pnpm turbo test --filter='...[origin/main]'Not every package in a monorepo needs the same hardware. A utility library that runs lint and unit tests needs a 2-core runner. A machine learning package that trains a small model or runs GPU inference tests needs something entirely different. Running both on ubuntu-latest either overpays for the utility package or underserves the ML package.
The fix is runner routing: assigning different runs-on values to different jobs based on what they're building.
jobs:
test-utils:
needs: changes
if: ${{ needs.changes.outputs.utils == 'true' }}
runs-on: ubuntu-latest # 2 cores, cheapest tier
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test --workspace=packages/utils
test-api:
needs: changes
if: ${{ needs.changes.outputs.api == 'true' }}
runs-on: ubuntu-latest-8-cores # needs more RAM for integration tests
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test --workspace=packages/api
test-ml:
needs: changes
if: ${{ needs.changes.outputs.ml == 'true' }}
runs-on: [self-hosted, gpu, linux] # GPU runner for model tests
steps:
- uses: actions/checkout@v4
- run: pip install -r packages/ml/requirements.txt
- run: pytest packages/ml/The cost difference is real. GitHub-hosted runners charge $0.008/min for a standard 2-core Ubuntu runner, $0.016/min for 4-core, and scale up from there. A macOS runner costs 10x what Ubuntu costs. Routing lightweight jobs to small runners and heavy jobs to large ones can cut your monthly bill by 30-50% without any change to build times.
There's a counterintuitive pattern here too: larger runners can be cheaper per build. If a job takes 20 minutes on 2 cores but 6 minutes on 8 cores, the 8-core run costs less despite the higher per-minute rate. Measure your actual build times before assuming the cheapest runner is the cheapest option.
For monorepos where packages have different runner needs and you want to keep the workflow DRY, you can use a dynamic matrix with runner labels included in the matrix data:
jobs:
detect:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- id: set-matrix
run: |
matrix=$(cat .github/package-matrix.json)
echo "matrix=$matrix" >> $GITHUB_OUTPUT
test:
needs: detect
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.detect.outputs.matrix) }}
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- run: ${{ matrix.test-command }}With a .github/package-matrix.json file that maps each package to its runner label, test command, and any other configuration. The matrix becomes a single source of truth for what runs where.
If you're using self-hosted runners or third-party runners like Tenki Runners, this approach works particularly well. Tenki's runners plug into your existing GitHub Actions setup and charge $0.015/min/core, which makes the per-job cost calculation straightforward when you're routing different packages to different runner sizes.
Once you've split your monorepo CI into separate jobs per package, you'll hit a new problem: downstream jobs need build outputs from upstream jobs. If your API package depends on a compiled shared library, the API test job needs that compiled output without rebuilding it.
GitHub Actions provides two mechanisms: artifacts (for passing build outputs between jobs) and caches (for reusing data across workflow runs). They're designed for different purposes and mixing them up causes problems.
The standard pattern for passing build outputs between jobs:
jobs:
build-shared:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build --workspace=packages/shared
- uses: actions/upload-artifact@v4
with:
name: shared-dist
path: packages/shared/dist/
retention-days: 1
test-api:
needs: build-shared
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: shared-dist
path: packages/shared/dist/
- run: npm ci
- run: npm test --workspace=packages/api
test-frontend:
needs: build-shared
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: shared-dist
path: packages/shared/dist/
- run: npm ci
- run: npm test --workspace=packages/frontendSet retention-days: 1 for build artifacts that only need to survive within a single workflow run. The default retention is 90 days, which wastes storage on throwaway build outputs. Artifacts count against your repository's storage quota, and at scale this adds up.
Artifacts are for passing data between jobs within a run. Caches are for speeding up repeated operations across runs: dependency installation, compiled outputs, Playwright browser binaries.
The key to effective caching in a monorepo is using cache keys that are scoped to the package, not the entire repo. If every job shares the same cache key, a change in any package invalidates the cache for all of them.
- uses: actions/cache@v4
with:
path: |
node_modules
packages/api/node_modules
key: deps-api-${{ runner.os }}-${{ hashFiles('packages/api/package-lock.json', 'package-lock.json') }}
restore-keys: |
deps-api-${{ runner.os }}-Note the restore-keys fallback. If the exact cache key doesn't match (because the lockfile changed), it'll restore the most recent cache with the matching prefix and then update it. This means dependency installs are incremental rather than starting from scratch.
If you're using Turborepo, its remote cache handles this automatically. With remote caching enabled, a build output that's already been computed (by a teammate or a previous CI run with the same inputs) is fetched from the cache instead of rebuilt. This cuts build times dramatically for packages that don't change often.
Here's a complete workflow that combines everything: path-aware change detection, dependency-aware job gating, selective runner routing, artifact sharing, and concurrency controls.
name: Monorepo CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
outputs:
shared: ${{ steps.filter.outputs.shared }}
api: ${{ steps.filter.outputs.api }}
frontend: ${{ steps.filter.outputs.frontend }}
ml: ${{ steps.filter.outputs.ml }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
shared:
- 'packages/shared/**'
api:
- 'packages/api/**'
- 'packages/shared/**'
frontend:
- 'packages/frontend/**'
- 'packages/shared/**'
ml:
- 'packages/ml/**'
build-shared:
needs: changes
if: ${{ needs.changes.outputs.shared == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- run: npm ci
- run: npm run build --workspace=packages/shared
- uses: actions/upload-artifact@v4
with:
name: shared-dist
path: packages/shared/dist/
retention-days: 1
test-api:
needs: [changes, build-shared]
if: |
always() &&
needs.changes.outputs.api == 'true' &&
(needs.build-shared.result == 'success' || needs.build-shared.result == 'skipped')
runs-on: ubuntu-latest-4-cores
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
if: ${{ needs.build-shared.result == 'success' }}
with:
name: shared-dist
path: packages/shared/dist/
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- run: npm ci
- run: npm test --workspace=packages/api
test-frontend:
needs: [changes, build-shared]
if: |
always() &&
needs.changes.outputs.frontend == 'true' &&
(needs.build-shared.result == 'success' || needs.build-shared.result == 'skipped')
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
if: ${{ needs.build-shared.result == 'success' }}
with:
name: shared-dist
path: packages/shared/dist/
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- run: npm ci
- run: npm test --workspace=packages/frontend
test-ml:
needs: changes
if: ${{ needs.changes.outputs.ml == 'true' }}
runs-on: [self-hosted, gpu, linux]
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- run: pip install -r packages/ml/requirements.txt
- run: pytest packages/ml/A few things to notice in this workflow:
concurrency block cancels in-progress runs when a new push lands on the same branch. This alone can save 20-30% of wasted minutes from rapid pushes during code review.if: always() with result checks on downstream jobs handles the case where build-shared was skipped (because shared didn't change) but API or frontend still need to run.timeout-minutes set. Runaway jobs (hung tests, infinite loops) are one of the biggest sources of unexpected CI bills.The most common complaint about path-filtered monorepo workflows: "My PR only touches the frontend but it can't merge because the backend check is stuck in Pending."
This happens because GitHub branch protection rules require specific checks to pass, and a skipped workflow never reports a status. There are two clean fixes.
Option 1: Use a status aggregator job. Add a final job that always runs and checks whether the required jobs succeeded or were skipped:
ci-ok:
if: always()
needs: [test-api, test-frontend, test-ml]
runs-on: ubuntu-latest
steps:
- run: |
results=("${{ needs.test-api.result }}" "${{ needs.test-frontend.result }}" "${{ needs.test-ml.result }}")
for r in "${results[@]}"; do
if [[ "$r" != "success" && "$r" != "skipped" ]]; then
echo "Job failed with result: $r"
exit 1
fi
done
echo "All required checks passed or were skipped."Then set ci-ok as the only required check in branch protection. It always runs, always reports a status, and it only passes if every non-skipped job succeeded.
Option 2: Use GitHub's merge queue. Merge queues run their own validation workflow on the merge group event, which can be configured separately from your PR checks. This sidesteps the Pending check problem entirely for repos that use merge queues.
Beyond path filtering and runner routing, there are a few more levers for keeping monorepo CI costs under control:
timeout-minutes: 20 caps your downside.For teams running high-volume monorepo CI, third-party runners can cut costs further. Tenki's runners advertise 30% faster builds at 50% lower cost compared to GitHub-hosted runners, and they drop into your existing workflow YAML with a runs-on label swap. That makes them a natural fit for the runner routing pattern described above: you can assign different Tenki runner sizes to different package jobs without changing anything else about the workflow.
A monorepo with 10 packages and the workflow structure above will typically see 60-80% reduction in CI minutes compared to the "run everything" approach. The exact savings depend on how often each package changes relative to the others. If most commits only touch 1-2 packages (which is the common pattern), most of your jobs get skipped on most pushes.
The harder win is correctness. Dependency-aware path filters mean you won't ship a broken shared library change because the downstream packages didn't rebuild. The artifact sharing pattern means you build once and test everywhere, which is both faster and more reliable than rebuilding the same code in every job.
Start with the change detection job and the status aggregator. Those two pieces solve the biggest pain points. Add runner routing and artifact sharing once you've confirmed the basic structure works for your repo.
Tags
Recommended for you
What's next in your stack.