# Monorepo CI with GitHub Actions: selective builds done right (https://tenki.cloud/blog/monorepo-ci-github-actions-selective-builds)

> For the complete documentation index, see [llms.txt](https://tenki.cloud/llms.txt)

- Author: Eddie Wang (Engineering)
- Published: Mar 6, 2026
- Updated: Sep 22, 2026
- Category: [CI/CD](https://tenki.cloud/blog/category/ci-cd)
- Reading time: 17 min

Run only the CI jobs a change affects, using path filters, Turborepo and Nx affected builds, dynamic matrices, required checks and runner sizing.

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:

```yaml
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:

```text
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](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#onpushpull_requestpull_request_targetpathspaths-ignore):

* You can't use `paths` and `paths-ignore` for the same event. To include some paths and exclude others, use `paths` with `!` 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](#required-checks-when-jobs-are-skipped).

Here's a filter that includes a package but excludes its docs and any Markdown file:

```yaml
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`](https://github.com/dorny/paths-filter) checks which files changed and sets one output per filter, which later jobs read in their `if` conditions:

```yaml
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](https://turborepo.dev/docs/crafting-your-repository/constructing-ci)).

```yaml
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.

```yaml
- 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`](https://github.com/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.

```yaml
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](https://turborepo.dev/docs/reference/turbo-ignore). With `--exit-code` it exits `1` when the package is affected, `0` when it isn't, and `2` on an error:

```yaml
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:

```yaml
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:

```yaml
- 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:

```yaml
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"](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-jobs-with-conditions), 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:

```yaml
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](https://tenki.cloud/docs/runners/caching.md) 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](https://tenki.cloud/docs/runners/sizes.md). The label has to be the whole `runs-on` value; arrays that add labels such as `self-hosted` or `linux` aren't supported.

```yaml
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?](https://tenki.cloud/docs/runners/which-runner-should-i-use.md) 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](https://tenki.cloud/docs/runners/limits-and-concurrency.md)).

### Put the runner in the matrix

With a dynamic matrix, keep the package-to-runner mapping in one file, `.github/ci-runners.json`:

```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:

```yaml
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](https://tenki.cloud/pricing)). GitHub's [current rates](https://docs.github.com/en/billing/reference/actions-runner-pricing) 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.

```yaml
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:

* `deploy` lists `detect` in `needs` because it reads `needs.detect.outputs`. A job can only read outputs from jobs it lists directly; `build` depending on `detect` isn't enough.
* `detect` doesn't install dependencies, and it checks only `api` and `web`, the two packages that deploy. The artifact paths in `build` assume each package's directory under `packages/` matches its name.
* `quality` runs 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.
* `deploy` runs only when `build` succeeded, which also means it never runs on pull requests.
* `retention-days: 1` keeps build artifacts that only pass data between jobs from sitting in storage for the 90-day default.
* `concurrency` cancels superseded runs on pull requests but lets every push to `main` finish, 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-filter` with the dependencies listed by hand is enough.
* For five to fifteen packages with shared libraries, Turborepo's `--affected` and `turbo query affected` give you dependency-aware selection with little config, plus remote caching.
* Larger or polyglot repos are where Nx fits: its project graph, `nx affected` and 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](https://tenki.cloud/docs/runners/quickstart.md) walks through it, and [Runners](https://tenki.cloud/products/runners) has the details.