Every pipeline covered so far assumes one repository builds one thing. A repository holding a dozen services or packages breaks that assumption completely — running the full pipeline on every single change means a one-line fix to an unrelated package waits behind tests for eleven services it never touched.
3 min read
A monorepo holds multiple, often independently-deployable projects (services, packages, apps) in one repository. The naive pipeline — run every project's build and test suite on every push, regardless of what actually changed — technically works, but wastes enormous CI time the moment the repo has more than a few projects: a change to one service's README triggers a full rebuild and test run of every other unrelated service in the repo, every single time.
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
api-changed: ${{ steps.filter.outputs.api }}
frontend-changed: ${{ steps.filter.outputs.frontend }}
steps:
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
api:
- 'services/api/**'
frontend:
- 'apps/frontend/**'
test-api:
needs: detect-changes
if: needs.detect-changes.outputs.api-changed == 'true'
runs-on: ubuntu-latest
steps: [...]
test-frontend:
needs: detect-changes
if: needs.detect-changes.outputs.frontend-changed == 'true'
runs-on: ubuntu-latest
steps: [...]A path-based filter job runs first, cheaply, and every downstream job
declares which paths it actually cares about — a change confined to
services/api/ never triggers test-frontend at all, rather than running
it and having it trivially pass against unchanged code.
Simple path filtering breaks the moment one project depends on another —
a shared internal library used by both api and frontend changing
means both actually need re-testing, even though neither services/api/
nor apps/frontend/ themselves were directly touched. Purpose-built
monorepo tools (Nx, Turborepo, Bazel) solve this properly by understanding
the actual dependency graph between projects, not just which file paths
changed:
# Turborepo: only runs `test` for projects affected by
# what changed since the last shared commit with main
turbo run test --filter=...[origin/main]--filter=...[origin/main] resolves to "every project changed since
diverging from main, plus everything that depends on those projects" —
the shared-library case above is correctly included automatically,
something a hand-rolled paths-filter config can't express without
manually maintaining the dependency relationships itself.
The build-caching lesson's dependency and layer caching applies per project here too, but monorepo tools add another layer: caching a project's test results keyed on that project's own content hash, so an unchanged project doesn't just skip its build — it skips re-running tests that would produce an identical result to the last run:
turbo run test
# Cache hit for 9 of 11 packages — replaying cached output
# Actually running tests for 2 changed packagesThis is the same underlying idea as actions/cache's lockfile-hash key,
applied to an entire project's test outcome rather than just its
installed dependencies.
Affected-project detection (whether hand-rolled path filters or a tool like Nx/Turborepo) is genuinely more infrastructure than "run everything every time," and gets it wrong in ways that are hard to notice — a missed dependency edge in a hand-rolled filter config means a project silently doesn't get retested when it should, a much worse failure mode than the slow-but-correct baseline it's replacing. This complexity only pays for itself once a monorepo is large enough that the naive "test everything" approach has become genuinely too slow to work with — introducing it prematurely, for a small repo where full-pipeline runs are already fast, adds real risk for no actual benefit yet.
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. Why does running every project's full pipeline on every push become a real problem in a monorepo?
2. Why does simple path-based filtering break down for a shared internal library used by multiple projects?
3. What does a tool like Turborepo's affected-project filtering resolve automatically that a hand-rolled path filter struggles with?
4. Why might introducing affected-project detection prematurely, in a small monorepo, add more risk than it removes?
CI/CD & Deployment Pipelines