Monorepo CI Patterns
Monorepo CI patterns avoid running every test on every commit by detecting affected projects, using build graphs (Bazel, Turborepo, Nx), and path filters — while keeping trunk i…
Introduction
Monorepo CI patterns avoid running every test on every commit by detecting affected projects, using build graphs (Bazel, Turborepo, Nx), and path filters — while keeping trunk integration trustworthy.
The story
A 400K-line monorepo ran full CI on every PR: 52 minutes, 800 tests, $12/run. Engineers pushed less and merged stale PRs. Platform team added Nx affected: npx nx affected -t test,build --base=origin/main — average PR CI dropped to 8 minutes. One outage: a shared library change skipped downstream apps because path filter missed transitive dependency. Fix: dependency graph in turbo.json / nx.json + explicit dependsOn: ["^build"] and weekly full-ci on main cron.
Understanding the topic
Monorepo CI optimizes signal vs spend using graph-aware affected detection and path filters without sacrificing integration guarantees.
- Affected detection: compare git diff to dependency graph — only test/build packages that changed or depend on changed code.
- Bazel: hermetic build graph;
bazel test //...orbazel test //pkg/...with query; remote execution scales horizontally. - Turborepo: task pipeline in turbo.json;
turbo run build --filter=[origin/main...HEAD]; remote cache shares artifacts across CI and dev. - Nx: project graph +
nx affected; implicit deps via static analysis; distributed task execution (Nx Cloud). - Path filters: GitHub Actions
paths:/paths-ignore:; coarse but cheap — pair with graph tools for accuracy.
Internal architecture
Monorepo CI topology — from git diff to selective pipeline:
PR changes: libs/auth/src/token.ts↓Git diff vs merge-base (origin/main)↓Dependency graph (nx.json / turbo.json / Bazel BUILD)├─ libs/auth → test, build├─ apps/api → depends on auth → test, build├─ apps/web → depends on auth → test, build└─ apps/billing → no dep edge → SKIP↓CI matrix (parallel shards)├─ job: affected-test-api├─ job: affected-test-web└─ job: affected-build-auth↓Merge queue / main: nightly full //... or --all
Visual explanation
Two diagrams show where Monorepo CI Patterns lives in the delivery path and how teams implement it in production.
Step-by-step explanation
- Declare project boundaries: packages/, apps/ with explicit dependency edges in package.json or BUILD files.
- Generate or maintain dependency graph —
nx graph,turbo run build --dry-run,bazel query 'deps(//apps/api:all)'. - Configure CI: compute affected on PR with merge-base fetch-depth: 0.
- Add path filters for docs-only skips but never for shared infra (Dockerfile, root package.json, CI config).
- Schedule full graph CI on main nightly and before release tags — catches filter/graph gaps.
Production implementation
GitHub Actions + Turborepo affected filter with path filter safety:
- Nx equivalent:
npx nx affected -t test,build --base=origin/main --head=HEAD. - Bazel:
bazel test $(bazel query --keep_going "rdeps(//..., set($CHANGED))")with changed file script. - Always run CI when root lockfile or shared config changes — path filter include list.
# .github/workflows/ci.ymlname: Monorepo CIon:pull_request:push:branches: [main]jobs:changes:runs-on: ubuntu-latestoutputs:turbo: ${{ steps.filter.outputs.turbo }}steps:- uses: actions/checkout@v4- uses: dorny/paths-filter@v3id: filterwith:filters: |turbo:- 'apps/**'- 'packages/**'- 'turbo.json'- 'package-lock.json'ci-config:- '.github/**'- 'Dockerfile*'affected:needs: changesif: needs.changes.outputs.turbo == 'true' || needs.changes.outputs.ci-config == 'true'runs-on: ubuntu-lateststeps:- uses: actions/checkout@v4with: { fetch-depth: 0 }- uses: actions/setup-node@v4with: { node-version: 20, cache: npm }- run: npm ci- name: Turbo affectedrun: |npx turbo run lint test build \--filter="[origin/main...HEAD]" \--concurrency=10env:TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}TURBO_TEAM: ${{ vars.TURBO_TEAM }}full-main:if: github.ref == 'refs/heads/main' && github.event_name == 'push'runs-on: ubuntu-lateststeps:- uses: actions/checkout@v4- run: npm ci && npx turbo run test build --force # weekly cron or every main push
Execution workflow
Map project graph
Explicit deps per app/lib in nx/turbo/bazel config.
Real-world use
Turborepo and Nx grew from JavaScript monorepo pain; Bazel from Google's scale. GitHub Actions path filters are simplest but miss transitive deps — 2021 Notion blog and Nx docs document "affected" as industry standard for monorepos >50 packages.
Enterprise use cases
Google's monorepo uses Bazel + TAP — every change runs affected tests at scale via remote execution. Meta uses internal systems on single large repo. Vercel dogfoods Turborepo on Next.js monorepo. Microsoft Windows repo uses custom graph — lesson: graph accuracy beats folder guessing.
- Polyglot monorepo: Bazel unifies Java, Go, TS; one graph, one CI entrypoint.
- Frontend monorepo: Nx/Turbo with Storybook and e2e as separate affected targets.
- CI budget cap: dynamic parallelism — more shards when affected set large.
Production case study
E-commerce platform monorepo (12 apps, 38 packages) migrated from path-only filters to Nx affected + nightly full build. Missed dependency incident on shared UI kit prompted dependsOn: ["^build"] enforcement in CI lint.
- Before: path filter skipped apps when libs/ui changed — false negative.
- After: nx affected with enforce-buildable-lib dependency; PR comment lists affected projects.
- Cost: CI minutes down 68%; nightly full run catches graph drift.
- DX:
nx affected:graphin PR bot visualization for reviewers.
Trade-offs
- Affected-only CI: fast feedback; risk of missed deps if graph incomplete.
- Full CI every PR: safest; doesn't scale past ~15 min wall clock.
- Path filters alone: zero graph setup; frequent false negatives on shared libs.
- Bazel migration: ultimate graph + cache; high upfront BUILD file cost.
Security implications
Monorepo CI must not skip security scans on "unaffected" paths when root supply-chain files change.
- Changes to lockfile, .github/workflows, Dockerfile trigger full scan regardless of affected graph.
- CODEOWNERS on shared libs — auth change requires security team review even if one app affected.
- Remote cache poisoning: authenticate Turbo/Nx remote cache; don't accept unverified cache writes.
- Path filter bypass: malicious PR touches only docs but embeds payload in shared config — include deny paths.
Scalability analysis
Monorepos hit CI quadratic cost without graph discipline — 100 packages × 100 PRs/day overwhelms naive pipelines.
- Distributed task execution (Nx Cloud, Bazel RBE) required beyond ~30 min affected wall clock.
- Merge queue + affected: queue runs affected against synthetic merge — graph must include merge-base correctly.
- Shard tests within large affected projects — one app with 10K tests still needs splitting.
- Graph maintenance: new package without declared dep edges → silent skip until full CI catches it.
Staff engineer insights
- Path filters are a pre-filter, not a substitute for dependency graphs — use both.
- Any change to CI config, root lockfile, or base Docker image should run full or expanded affected set.
- Invest in graph lint: fail PR if new import crosses boundary without project.json dependency entry.
- Bazel is worth it at 500+ engineers; Nx/Turbo wins below that for Node-heavy repos.
Best practices
- fetch-depth: 0 on checkout for accurate merge-base affected calculation.
- Comment affected project list on PR for reviewer context.
- Version and cache turbo.json/nx.json — graph definition is CI input.
- Treat root package.json and lockfile changes as global affected.
Common mistakes
- Shallow clone (fetch-depth: 1) breaks affected — silent wrong skip set.
- Implicit dependencies (dynamic import, string path) invisible to graph — use integration tests or explicit deps.
- Docs-only path filter skipping .github workflow change — security hole.
Advanced interview questions
Interview Prep
Practice concise answers, then expand each card for the explanation.
1AdvancedQuestionCompare path filters vs Nx affected vs Bazel query for monorepo CI.+
Answer
Follow-up
2AdvancedQuestionDesign CI for 60-package monorepo, 15 min SLA on PR.+
Answer
Follow-up
3AdvancedQuestionAffected CI passed but main broke after merge — causes?+
Answer
Follow-up
4AdvancedQuestionHow does Turborepo remote cache work in CI?+
Answer
Follow-up
5AdvancedQuestionBazel in CI vs vanilla npm monorepo?+
Answer
Follow-up
Hands-on exercise
In a sample turborepo or nx workspace, change one shared lib and list affected apps vs path-filter-only prediction.
- Document apps path filter would miss vs graph includes.
- Add intentional missing dep edge — confirm full CI catches integration failure.
- Draft turbo.json pipeline with dependsOn: ["^build"].
# Turborepogit fetch origin mainnpx turbo run build --filter="[origin/main...HEAD]" --dry-run# Nxnpx nx affected:apps --base=origin/main --head=HEADnpx nx graph --affected# Compare to naive path filter:git diff --name-only origin/main...HEAD | grep '^packages/shared'
Summary
You can design monorepo CI with affected detection, Bazel/Turborepo/Nx patterns, and path filters — balancing speed and correctness through dependency graphs, remote cache, and nightly full runs.