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

Monorepo CI with Path Triggers and Runner Routing

Eddie Wang
Eddie Wangengineering

Share Article:

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.

The three monorepo CI anti-patterns

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.

Path filtering done right: paths, paths-ignore, and edge cases

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:

  • You can't combine paths and paths-ignore for the same event. If you need to include some paths and exclude others, use the paths filter with the ! prefix for exclusions. Order matters: a negative pattern after a positive match excludes the path, and a positive pattern after a negative match re-includes it.
  • Path filters don't apply to tag pushes. If your workflow triggers on push with both branches and tags filters, the path filter only evaluates for branch pushes. Tag pushes always trigger the workflow regardless of what files changed.
  • The 300-file diff limit. GitHub only evaluates the first 300 changed files against your path filters. If your commit touches more than 300 files and the relevant changes fall outside that window, the workflow won't trigger. Large refactors or automated dependency bumps can hit this.
  • 1,000+ commits bypass filtering entirely. If you push more than 1,000 commits at once (or GitHub can't generate the diff due to a timeout), the workflow always runs. This is a safety mechanism, but it means long-lived branches merged back to main will ignore your filters.
  • Skipped workflows leave checks in "Pending" state. If a workflow is skipped due to path filtering, its associated status checks stay Pending. A PR that requires those checks will be blocked from merging. You need to handle this with branch protection rule configuration or a separate always-run status check.

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'

Job-level filtering with dorny/paths-filter

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/backend

The 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]'

Selective runner routing by package

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.

Dynamic runner selection with matrix strategy

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.

Shared build artifacts between jobs

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.

Artifact upload/download pattern

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/frontend

Set 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.

Cache restoration for repeated builds

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.

Putting it together: a production monorepo workflow

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:

  • The 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.
  • The 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.
  • Each job has a timeout-minutes set. Runaway jobs (hung tests, infinite loops) are one of the biggest sources of unexpected CI bills.
  • Runner sizes are matched to workload: standard for frontend, 4-core for API integration tests, self-hosted GPU for ML.

Handling the "Pending checks" problem

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.

Cost optimization for monorepo workloads

Beyond path filtering and runner routing, there are a few more levers for keeping monorepo CI costs under control:

  1. Cancel redundant runs. The concurrency block shown above is the single highest-impact optimization. When a developer pushes three commits in rapid succession, only the latest run completes.
  2. Set job timeouts. A hung test suite can run for 6 hours (the GitHub Actions default maximum) before it's killed. Setting timeout-minutes: 20 caps your downside.
  3. Schedule non-critical checks. Full end-to-end test suites, security scans, and coverage reports don't need to run on every PR push. Run them nightly or on merge to main.
  4. Minimize macOS and Windows usage. macOS runners cost 10x what Ubuntu runners cost. If you need cross-platform testing, run it on a schedule or only on main branch pushes, not on every PR update.
  5. Use composite actions for shared setup. If every job starts with the same checkout/setup-node/install sequence, extract it into a composite action. It won't save compute time directly, but it reduces workflow file size and makes it easier to optimize the setup in one place.

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.

What this gets you

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

#monorepo#path-filters#build-artifacts#ci-cd-architecture#platform-engineering#github-runners

Recommended for you

What's next in your stack.