Monorepo CI with GitHub Actions: selective builds done right
Run only the CI jobs a change affects, using path filters, Turborepo and Nx affected builds, dynamic matrices, required checks and runner sizing.
Eddie Wang
Updated 17 min read
Monorepo CI tends to fail in one of two directions.
The first is over-building. Every push runs lint, test and build for every package, so a typo fix in a README waits on the full pipeline for packages nobody touched. It's correct, but slow and expensive.
The second is under-building. Someone scopes the API workflow to packages/api/**, and now it only runs when the API's own files change. The API imports a shared utils package, so when someone changes a validation function in utils, the API's tests don't run and the bug ships.
The fix is to let the dependency graph decide what runs. This guide covers GitHub Actions path filters and their edge cases, job-level filtering, affected builds with Turborepo and Nx, dynamic matrices, required checks when jobs are skipped, and sizing runners per package.
Why workflow-level path filters aren't enough
GitHub Actions can scope a whole workflow to the files a push or pull request changes:
on:
push:
branches: [main]
paths:
- "packages/api/**"
- "packages/utils/**"
pull_request:
paths:
- "packages/api/**"
- "packages/utils/**"
This has two limits. It works at the workflow level, so the entire workflow runs or none of it does. And it doesn't know your dependency graph. Take a typical layout:
packages/
api/ # depends on utils, db
web/ # depends on utils, ui
utils/ # no internal deps
db/ # depends on utils
ui/ # no internal deps
A change to utils has to test api, web and db. You can list every transitive dependency in each workflow's paths, but that list has to change every time someone adds an import between packages, and eventually someone forgets.
Path filter edge cases
If you use paths or paths-ignore, know how they behave. From GitHub's workflow syntax docs:
- You can't use
pathsandpaths-ignorefor the same event. To include some paths and exclude others, usepathswith!patterns. Order matters: a negative pattern after a positive match excludes the path, and a positive pattern after a negative match includes it again. - Path filters aren't evaluated for tag pushes, so a tag push runs the workflow whatever files it touches.
- Large diffs can skip the workflow. If the diff has more than 3,000 changed files and the files your filter matches aren't in the first 3,000, the workflow doesn't run. (GitHub Enterprise Server before 3.22 uses a 300-file limit.)
- Some pushes bypass the filter: a push with more than 1,000 commits, or one whose diff times out, always runs the workflow.
- Skipped workflows leave required checks pending. If path filtering skips a workflow, its checks stay "Pending", and a pull request that requires them can't merge. The fix is covered in Required checks when jobs are skipped.
Here's a filter that includes a package but excludes its docs and any Markdown file:
on:
push:
branches: [main]
paths:
- "packages/api/**"
- "!packages/api/docs/**"
- "packages/utils/**"
- "!**/*.md"
Job-level filtering with dorny/paths-filter
To choose jobs inside one workflow, move change detection into a job. dorny/paths-filter checks which files changed and sets one output per filter, which later jobs read in their if conditions:
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
api: ${{ steps.filter.outputs.api }}
web: ${{ steps.filter.outputs.web }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
api:
- 'packages/api/**'
- 'packages/utils/**'
- 'packages/db/**'
web:
- 'packages/web/**'
- 'packages/utils/**'
- 'packages/ui/**'
test-api:
needs: changes
if: needs.changes.outputs.api == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm ci
- run: npm test --workspace=packages/api
test-web:
needs: changes
if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm ci
- run: npm test --workspace=packages/web
The changes job doesn't install anything, so it finishes quickly. On pull requests it reads the changed files from the GitHub API, which is why it needs pull-requests: read. Jobs that don't match are skipped, and the Actions UI shows exactly which ones ran. The action also sets a changes output, a JSON array of the filters that matched, which you can use to build a matrix.
The dependency mapping is still manual, though: packages/utils/** appears under both api and web because both import it. That works for a few packages. Past that, let the build tool read the graph.
Affected builds with Turborepo
Turborepo reads your workspace configuration, so it already knows api depends on utils from package.json. With --affected, it runs tasks only in packages that changed and in the packages that depend on them.
In GitHub Actions, Turborepo picks the comparison base itself: GITHUB_BASE_REF on pull requests, and the push event payload on pushes (Turborepo docs).
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
filter: blob:none
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx turbo run lint test build --affected
A change in utils runs tasks for utils, api, web and db. A change in ui runs ui and web.
The checkout settings matter here. The default checkout is a single commit, which leaves nothing to compare against, and when the history between base and head is missing Turborepo treats every package as changed. That's safe, but it runs everything and defeats the purpose. fetch-depth: 0 fetches full history, and filter: blob:none skips file contents for old commits so the clone stays small.
Add remote caching
--affected decides which packages to consider. Remote caching skips work inside them: a package marked affected because a dependency changed can still get a cache hit if its task inputs hash to a value that's already cached.
- run: npx turbo run lint test build --affected
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
Used together, --affected also means fewer cache lookups over the network, since unaffected packages aren't queried at all.
Affected builds with Nx
nx affected works the same way, with one difference in the comparison point. The nrwl/nx-set-shas action finds the last successful workflow run on main and uses that commit as the base. When several PRs merge in a row, you test against what last passed CI rather than a branch point that may never have been green.
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
actions: read
contents: read
jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
filter: tree:0
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- uses: nrwl/nx-set-shas@v5
- run: npx nx affected -t lint test build
The actions: read permission lets nx-set-shas look up previous runs. If "affected" still means ten or more projects, Nx Cloud can distribute those tasks across several machines to cut wall-clock time.
Skip work before installing dependencies
Even with a warm cache, npm ci in a large monorepo takes a while. For a docs-only change, you don't want to install everything just so the build tool can report that nothing is affected.
Turborepo's turbo query affected (Turborepo 2.8.18 and later) answers the question without node_modules, because it only needs the lockfile and git history. It replaces turbo-ignore, which is deprecated. With --exit-code it exits 1 when the package is affected, 0 when it isn't, and 2 on an error:
deploy-api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
filter: blob:none
- name: Check whether api is affected
id: check
run: |
if npx -y turbo@^2.8.18 query affected --packages api --exit-code > /dev/null; then
echo "affected=false" >> "$GITHUB_OUTPUT"
else
echo "affected=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.check.outputs.affected == 'true'
run: npm ci
- if: steps.check.outputs.affected == 'true'
run: npx turbo run build deploy --filter=api
An error counts as affected here, so a broken check builds rather than silently skipping. This pattern is most useful for deploy jobs, where you don't want to redeploy a service that hasn't changed.
Dynamic matrices from changed packages
Hardcoding one job per package stops scaling after a handful. A dynamic matrix builds the job list at runtime from what changed.
With dorny/paths-filter, feed the changes output into the matrix:
jobs:
detect:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
packages: ${{ steps.filter.outputs.changes }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
api:
- 'packages/api/**'
web:
- 'packages/web/**'
ui:
- 'packages/ui/**'
test:
needs: detect
if: needs.detect.outputs.packages != '[]'
strategy:
fail-fast: false
matrix:
package: ${{ fromJson(needs.detect.outputs.packages) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test --workspace=packages/${{ matrix.package }}
Each changed package gets its own job, running in parallel. The if: needs.detect.outputs.packages != '[]' guard matters: a matrix built from an empty array is a workflow error, not a skipped job.
For a dependency-aware matrix, ask Turborepo instead. turbo query affected --packages with no package names returns every affected package, including dependents, as JSON:
- id: affected
run: |
packages=$(npx -y turbo@^2.8.18 query affected --packages \
| jq -c '[.data.affectedPackages.items[] | select(.name != "//") | .name]')
echo "packages=$packages" >> "$GITHUB_OUTPUT"
The select drops //, Turborepo's name for the repository root, which shows up when root files such as the workflow itself change. Use the same fetch-depth: 0 checkout as above. Swap this step in for the dorny/paths-filter step, set the job output to steps.affected.outputs.packages, and run the tests with npm test --workspace=${{ matrix.package }}, since the values are now package names instead of filter names.
When an upstream job is skipped
Selective jobs change how needs behaves. By default, a job only runs if every job it needs succeeded, and a skipped job doesn't count as success. So if deploy-api needs an optional migrate job that only runs when the database package changes, deploy-api is skipped on every API change that doesn't touch the database.
Override the default with a status function and check the upstream results yourself:
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
api: ${{ steps.filter.outputs.api }}
db: ${{ steps.filter.outputs.db }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
api:
- 'packages/api/**'
- 'packages/db/**'
db:
- 'packages/db/**'
migrate:
needs: changes
if: needs.changes.outputs.db == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: echo "Run database migrations"
deploy-api:
needs: [changes, migrate]
if: >-
!cancelled() &&
needs.changes.outputs.api == 'true' &&
(needs.migrate.result == 'success' || needs.migrate.result == 'skipped')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: echo "Deploy the API"
!cancelled() lets the job run after a skipped dependency but still stops it when someone cancels the workflow; always() would run it even then. The result check keeps a failed migration from being followed by a deploy.
Required checks when jobs are skipped
The complaint you hear most about path-filtered CI is a PR that only touches the frontend and can't merge because a required backend check is stuck in "Pending".
A job skipped by its if condition reports as "Success", so it doesn't block a merge. A workflow skipped by paths never reports anything. Instead of requiring every job, add one aggregator job to a workflow that has no workflow-level paths filter, and make it the only required check:
ci-ok:
if: always()
needs: [changes, test-api, test-web]
runs-on: ubuntu-latest
steps:
- if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1
- run: echo "Every required job passed or was skipped."
ci-ok always runs, always reports a status, and fails if any job it needs failed or was cancelled. List the change detection job in needs too. If detection fails, the jobs after it are skipped rather than failed, and an aggregator that only watched the test jobs would pass.
Caching across package jobs
A matrix with one job per package installs dependencies once per job. With hoisted node_modules, every leg installs the whole tree.
actions/setup-node with cache: npm handles this well enough for most repos. It caches npm's download cache keyed on the lockfile, so npm ci still does a clean install but reads packages locally. All matrix legs and PRs share the cache.
You can go further and cache node_modules directly, skipping npm ci on a hit. The tradeoff is that npm ci guarantees the install matches the lockfile, while a restored node_modules might not.
With Turborepo's remote cache, the duplicated install matters less: every leg still installs, but the build and test work is skipped on a cache hit.
On Tenki Runners, actions/cache and setup-node's cache: option work unchanged against Tenki's hosted Actions cache, and npm downloads go through a mirror on Tenki's network. See Caching for storage limits and eviction.
Size runners per package
Packages don't need the same hardware. A lint job is fine on 2 cores; a webpack build or a heavy integration suite may want 8. Running everything on one size either overpays for the small jobs or slows down the big ones.
On Tenki, pick a size by setting the job's runs-on to one label from Runner Sizes & Labels. The label has to be the whole runs-on value; arrays that add labels such as self-hosted or linux aren't supported.
jobs:
lint:
runs-on: tenki-standard-small-2c-4g
steps:
- uses: actions/checkout@v7
- run: npm ci
- run: npm run lint
test-api:
runs-on: tenki-standard-medium-4c-8g
steps:
- uses: actions/checkout@v7
- run: npm ci
- run: npm test --workspace=packages/api
build-web:
runs-on: tenki-standard-large-8c-16g
steps:
- uses: actions/checkout@v7
- run: npm ci
- run: npm run build --workspace=packages/web
Which runner should I use? has starting points by workload: small for linting and scripts, medium for unit and integration tests, large or large-plus for compilation, bundling and Docker builds. On the Starter plan, jobs can use up to 4 vCPU / 8 GB once a payment method is on file; the 8- and 16-core sizes need the Team plan (Limits & Concurrency).
Put the runner in the matrix
With a dynamic matrix, keep the package-to-runner mapping in one file, .github/ci-runners.json:
{
"api": "tenki-standard-medium-4c-8g",
"web": "tenki-standard-large-8c-16g"
}
Then have the detection step attach a runner to each affected package, falling back to the small size:
jobs:
detect:
runs-on: tenki-standard-small-2c-4g
outputs:
matrix: ${{ steps.affected.outputs.matrix }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
filter: blob:none
- id: affected
run: |
matrix=$(npx -y turbo@^2.8.18 query affected --packages \
| jq -c --slurpfile runners .github/ci-runners.json \
'[.data.affectedPackages.items[] | select(.name != "//")
| {name, runner: ($runners[0][.name] // "tenki-standard-small-2c-4g")}]')
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
test:
needs: detect
if: needs.detect.outputs.matrix != '[]'
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.detect.outputs.matrix) }}
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test --workspace=${{ matrix.name }} --if-present
Each matrix entry is an object, so matrix.name and matrix.runner come from the same row.
What it costs
Tenki bills Linux runners per core: $0.002 per core-minute, so $0.004/min for 2 cores, $0.008 for 4, $0.016 for 8 and $0.032 for 16 (Pricing). GitHub's current rates for Linux x64 are $0.006/min for 2 cores, $0.012 for 4, $0.022 for 8 and $0.042 for 16.
A worked example: four matrix jobs on tenki-standard-medium-4c-8g, seven minutes each, use 4 × 4 × 7 = 112 core-minutes, which is $0.224. The same jobs on GitHub's 4-core runner cost 4 × 7 × $0.012 = $0.336.
Per-core pricing also changes how to think about bigger runners. A job that takes 20 minutes on 2 cores costs $0.08 (40 core-minutes). If it scales cleanly to 5 minutes on 8 cores, it still costs $0.08 and you get the result 15 minutes sooner. If it only drops to 12 minutes, it costs $0.192, and you're paying for cores the job can't use. Measure before you move a job up a size, and move it back when the numbers don't change.
Full example: a five-package pipeline
This workflow puts the pieces together. Turborepo decides what's affected, one job runs quality checks on every push and PR, and on main only the affected deployable packages are built and deployed. ci-ok is the single required check.
name: Monorepo CI
on:
pull_request:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
detect:
runs-on: tenki-standard-small-2c-4g
timeout-minutes: 5
outputs:
deployables: ${{ steps.affected.outputs.deployables }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
filter: blob:none
- id: affected
run: |
deployables=$(npx -y turbo@^2.8.18 query affected --packages api web \
| jq -c '[.data.affectedPackages.items[].name]')
echo "deployables=$deployables" >> "$GITHUB_OUTPUT"
quality:
runs-on: tenki-standard-medium-4c-8g
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
filter: blob:none
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx turbo run lint check-types test --affected
build:
needs: [detect, quality]
if: github.ref == 'refs/heads/main' && needs.detect.outputs.deployables != '[]'
strategy:
matrix:
package: ${{ fromJson(needs.detect.outputs.deployables) }}
runs-on: tenki-standard-large-8c-16g
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx turbo run build --filter=${{ matrix.package }}
- uses: actions/upload-artifact@v7
with:
name: build-${{ matrix.package }}
path: packages/${{ matrix.package }}/dist
retention-days: 1
deploy:
needs: [detect, build]
if: needs.detect.outputs.deployables != '[]'
strategy:
matrix:
package: ${{ fromJson(needs.detect.outputs.deployables) }}
runs-on: tenki-standard-small-2c-4g
timeout-minutes: 15
environment: production
steps:
- uses: actions/download-artifact@v8
with:
name: build-${{ matrix.package }}
path: dist/
- run: echo "Deploying ${{ matrix.package }}"
ci-ok:
if: always()
needs: [detect, quality, build, deploy]
runs-on: tenki-standard-small-2c-4g
steps:
- if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1
- run: echo "Every required job passed or was skipped."
A few details in this workflow are easy to miss:
deploylistsdetectinneedsbecause it readsneeds.detect.outputs. A job can only read outputs from jobs it lists directly;builddepending ondetectisn't enough.detectdoesn't install dependencies, and it checks onlyapiandweb, the two packages that deploy. The artifact paths inbuildassume each package's directory underpackages/matches its name.qualityruns lint, type checks and tests for everything affected in one job, so Turborepo can parallelize and use the remote cache, and PRs get feedback quickly.deployruns only whenbuildsucceeded, which also means it never runs on pull requests.retention-days: 1keeps build artifacts that only pass data between jobs from sitting in storage for the 90-day default.concurrencycancels superseded runs on pull requests but lets every push tomainfinish, so a deploy isn't cut off halfway.- Every job has
timeout-minutes. GitHub's default is 6 hours, and a hung test suite bills for all of it.
If you're on GitHub-hosted runners, replace each Tenki label with ubuntu-latest or one of your larger runner labels; nothing else changes.
Choosing an approach
How far to go depends on how many packages you have and how tangled the graph is.
- For two or three packages with few cross-dependencies,
dorny/paths-filterwith the dependencies listed by hand is enough. - For five to fifteen packages with shared libraries, Turborepo's
--affectedandturbo query affectedgive you dependency-aware selection with little config, plus remote caching. - Larger or polyglot repos are where Nx fits: its project graph,
nx affectedand distributed task execution are built for that scale, at the cost of more configuration.
Whichever you pick, start with change detection and the ci-ok aggregator, since those fix wasted runs and stuck merges. Add runner sizing and artifact passing once the basic structure holds up in your repo.
Tenki Runners take one runs-on change per job; the quickstart walks through it, and Runners has the details.


