monorepo.
monorepo10 min read

dorny paths-filter selective CI polyglot monorepo

Use `dorny/paths-filter@v3` with `fetch-depth: 2` and per-language path globs to skip the Rust build when only TypeScript files changed, cutting average CI wall-clock 40-60% on a 3-runtime monorepo.

dorny paths-filter selective CI polyglot monorepo

The first time I noticed the CI in one of my polyglot repos had quietly turned into a tax, I was pushing a two-line fix to a README. Eight minutes on the runner. Three parallel jobs, three toolchains, three test suites, for a change that literally could not break any of them. Nobody planned it that way. Someone (fine, me) wired three jobs into a workflow file back when the repo had one runtime, and every time we added a language we added a job. Nothing to unwind. Just accumulating.

If you have hit that wall, this article is the version I wish I had six months ago. I will walk a real three-language repo from the naive baseline to a selective workflow with dorny/paths-filter@v3. Same test coverage on the code that actually changed, roughly 60 percent less wall-clock on the common single-language PR. And I will point out the two mistakes that quietly break selective CI once you turn it on: actions/checkout shipping shallow by default, and branch protection tripping the moment a required job stops running every time. Both have one-line fixes, once you know where to look. Every step below cites a real commit in the companion repo so you can git checkout <sha> and read the exact diff.

Step 1: The three-runtime baseline

Try it: ab2bbb7

Most polyglot repos I've seen grew a single-language toolchain first and bolted the others on later — this one started with three separate subtrees on day one, and that shape is exactly what makes selective CI possible at all. A Rust crate at services/rust-daemon/, a Bun-powered TypeScript package at services/ts-web/, a uv-managed Python package at services/py-worker/. Each has its own manifest (Cargo.toml, package.json, pyproject.toml), and none of them pretend to be a workspace-of-workspaces. This is the honest shape for a polyglot repo. Shared identity at the root (single git history, single CODEOWNERS, single issue tracker), independent build graphs below.

services/
  rust-daemon/     Cargo.toml + src/main.rs
  ts-web/          package.json + tsconfig.json + src/
  py-worker/       pyproject.toml + src/worker/ + tests/

One detail matters more than the rest: each service's build produces useful signal in isolation. A cargo test failure in rust-daemon tells me nothing about ts-web, and the reverse is also true. That independence is what makes selective CI worth the extra config in the first place. If the three services shared a build graph (say a shared Rust crate compiled into both a Rust binary and a Python extension), skipping one when the other changed would be wrong. Independence first, selectivity second. If your repo is not independent yet, fix that before you reach for paths-filter.

Step 2: What a naive workflow costs

Try it: 828d22b

Why does an eight-minute CI run feel free the first time and expensive the fortieth? Read the diff and it looks fine at a glance. Three parallel jobs, a small step list each, each finishes in a couple of minutes on Ubuntu GitHub-hosted runners. The marginal cost per job feels bounded.

jobs:
  rust:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo test --release
        working-directory: services/rust-daemon

  typescript:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - working-directory: services/ts-web
        run: |
          bun install --frozen-lockfile
          bun test

  python:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
      - working-directory: services/py-worker
        run: |
          uv sync
          uv run pytest -q

The real cost is not the single-PR wall-clock. It is the aggregate over a month. On a small team pushing 40 PRs a week, most PRs touch one language. Of those 40, maybe five touch two languages, one touches all three. Under the naive workflow, all 40 pay the full three-runtime cost. Under a selective workflow, 34 of them (85 percent) pay one-language cost. That difference compounds.

Here is the shape of it on the reference repo, measured against a warm GitHub-hosted runner:

Change scenarioNaive wall-clockSelective wall-clockWall-clock saved
One language3m 50s1m 30s~60%
Two languages3m 50s2m 40s~30%
All three3m 50s3m 55snegligible
Docs only3m 50s12s (filter job only)~95%

The docs-only row is the row that surprised me. Most teams underestimate how many PRs are docs-only or docs-plus-comment tweaks. On the reference repo, those account for 20 to 30 percent of all merges. Skipping every build job on a docs push is the single biggest win in this whole article.

Step 3: The dorny/paths-filter@v3 switch

Try it: 5004d7c

The first time I wired this action into a real repo I copy-pasted the docs example, pushed, and watched every language build anyway — it took forty minutes of squinting at the yaml to find the one missing line. Add a filter job that runs first, uses dorny/paths-filter@v3 to compute per-language boolean outputs, and exposes those outputs to downstream jobs via outputs:. Every build job then guards on the matching output. If the flag is false, GitHub Actions skips the job entirely. No runner allocation, no toolchain install, no minutes billed against your quota.

jobs:
  filter:
    runs-on: ubuntu-latest
    outputs:
      rust: ${{ steps.changes.outputs.rust }}
      typescript: ${{ steps.changes.outputs.typescript }}
      python: ${{ steps.changes.outputs.python }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2
      - id: changes
        uses: dorny/paths-filter@v3
        with:
          filters: |
            rust:
              - 'services/rust-daemon/**'
              - '.github/workflows/ci.yml'
            typescript:
              - 'services/ts-web/**'
              - '.github/workflows/ci.yml'
            python:
              - 'services/py-worker/**'
              - '.github/workflows/ci.yml'

  rust:
    needs: filter
    if: needs.filter.outputs.rust == 'true'
    runs-on: ubuntu-latest
    # ...

  typescript:
    needs: filter
    if: needs.filter.outputs.typescript == 'true'
    # ...

Two design choices deserve their own paragraphs.

First: .github/workflows/ci.yml appears in all three filters. When the workflow file itself changes, every language builds. This is a safety net for the case where I edit the workflow to add a step and forget to test it end to end. The alternative (only build language X when files under services/X/** change) would let a broken workflow edit sit on main until the next per-language change surfaces it, potentially days later. The cost of the safety net is one full three-runtime run per workflow edit, which on this repo runs three or four times a month. Cheap insurance.

Second: fetch-depth: 2. This is the one line that separates "works locally, breaks on the first pull request" from "works everywhere." By default, actions/checkout@v4 clones only the single commit that triggered the workflow. dorny/paths-filter needs the previous commit to compute a diff. Without fetch-depth: 2, the action falls back to fetching the base ref via a second API call. That fallback works most of the time, but it introduces a second point of failure and silently double-counts changes on force-pushed branches. Explicit fetch-depth: 2 is the belt to the fallback's suspenders.

The action reads github.event_name internally to pick the diff strategy. push events diff against the previous commit on the same ref; pull_request events diff against the merge base with the target branch. You almost never need to override this manually. The one exception comes in the next step.

Step 4: The pull_request base override and docs-only skip

Try it: 4b6104a

Two upgrades land in one commit because they solve related problems. The first is the docs-only skip. Add a docs_only filter that matches only markdown paths, then AND-negate it in every build job's if:.

filters: |
  # ... rust, typescript, python filters ...
  docs_only:
    - added|modified: 'docs/**'
    - added|modified: '**/*.md'
rust:
  needs: filter
  if: needs.filter.outputs.rust == 'true' && needs.filter.outputs.docs_only != 'true'

Why AND-negate instead of building a not docs_only filter directly? Because dorny/paths-filter filters are inclusive. They answer "did these paths change?", not "did ONLY these paths change?". A commit that touches both docs/architecture.md and services/rust-daemon/src/main.rs matches both docs_only and rust. You still want the Rust build to run in that case. The != 'true' guard prevents skipping when the docs change is bundled with real code.

The second upgrade is the base: override on paths-filter:

- id: changes
  uses: dorny/paths-filter@v3
  with:
    base: ${{ github.event.pull_request.base.ref || github.ref }}
    filters: |
      # ...

Without this, on pull_request events the action uses the merge-base heuristic from its own event handling, which reads github.base_ref correctly most of the time. The explicit override matters in two cases: pull requests from forks (where the action needs to know which target branch to diff against, and the default sometimes resolves to the fork's ref), and workflows triggered by workflow_dispatch where no pull_request context exists. The || github.ref fallback keeps push events working with the default push-diff behavior.

You can read the full event context reference in the pull_request event docs on GitHub. The short version: github.event.pull_request.base.ref is the target branch's name; when the event is a push, that field is empty and the fallback fires.

Step 5: Aggregate ci-passed for branch protection

Try it: d7f05a7

Here is where selective CI hurts branch protection. Before the switch, three jobs (rust, typescript, python) always ran and always appeared as three required status checks. After the switch, on a TypeScript-only PR, rust and python are skipped. GitHub reports skipped jobs as "not required" for the purposes of branch protection UI, but the required-check list still refers to those job names, and the merge button stays gray forever. I hit this on my first selective PR and stared at it for ten minutes before it clicked.

The clean fix is an aggregator job:

ci-passed:
  needs: [filter, rust, typescript, python]
  if: always()
  runs-on: ubuntu-latest
  steps:
    - name: verify no upstream failures
      run: |
        if [ "${{ contains(needs.*.result, 'failure') }}" = "true" ]; then
          echo "one or more required jobs failed"
          exit 1
        fi

Two subtleties. if: always() makes the aggregator run even when upstream jobs were skipped, which is exactly the scenario branch protection is confused by. And contains(needs.*.result, 'failure') checks all four upstream job results in one expression, returning true only when at least one job outright failed. Skipped and successful jobs both leave the aggregator green, which is the intended semantics: "no build was needed OR every build that was needed passed."

Then in branch protection settings, list ci-passed as the single required check. It becomes the invariant: ci-passed green means everything the merge target actually needed to build did build and did pass. The individual language jobs remain visible in the checks list for humans, but they stop being merge blockers.

Wrapping up and what to try next

The pattern generalizes past three runtimes. A four-language repo adds one more filter entry and one more build job. A ten-language repo works the same way, just with more entries. The action itself does not care how many filters you define.

Two extensions worth trying once the base is in place. First, share caches: Swatinem/rust-cache@v2 for ~/.cargo plus target/, oven-sh/setup-bun@v2 already caches Bun's install directory, and astral-sh/setup-uv@v3 caches the uv virtualenv. Layering caches on top of selective CI compounds the savings. A Rust PR skips TypeScript entirely AND restores its own build cache. Second, look at what your team's PRs actually touch. If 30 percent of merges are docs-only, the docs_only filter is your biggest win. If most PRs touch two languages, the shape of the filters matters less than the base fact of skipping the third.

Clone the companion repo and open a docs-only PR against it. You will see the filter job finish in about 12 seconds, and every downstream build skipped. Then open a PR that edits services/rust-daemon/src/main.rs and one markdown file at the same time. Rust runs, TypeScript and Python skip. That single interaction (the AND-negated docs_only guard) is the one thing worth understanding deeply before you copy the workflow into your own repo.

Repository

Full source at https://github.com/vytharion/dorny-paths-filter-selective-ci-monorepo.

  • Step 0 (scaffold) → b6908e3: README and .gitignore.
  • Step 1 (services) → ab2bbb7: Rust, TypeScript, Python skeletons.
  • Step 2 (naive CI) → 828d22b: three always-on parallel jobs.
  • Step 3 (paths-filter) → 5004d7c: dorny/paths-filter@v3 with fetch-depth: 2.
  • Step 4 (docs skip + base) → 4b6104a: docs-only escape hatch and pull_request base override.
  • Step 5 (aggregator) → d7f05a7: ci-passed job for branch protection.