CI/CD Automation Tutorial 0/46 lessons ~6 min read Lesson 12

    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…

    Course progress0%
    Focus
    20 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    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 //... or bazel 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:

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

    Monorepo CI Patterns — system view
    Git diff
    Changed files
    Dep graph
    Nx/Turbo/Bazel
    Affected set
    Transitive closure
    CI matrix
    Parallel jobs
    Where this topic sits in the delivery path.
    Monorepo CI Patterns — execution flow
    Path filter
    Coarse gate
    Graph refine
    Accurate set
    Remote cache
    Skip rebuild
    Full CI cron
    Safety net
    Follow this loop when designing or reviewing pipelines.

    Step-by-step explanation

    1. Declare project boundaries: packages/, apps/ with explicit dependency edges in package.json or BUILD files.
    2. Generate or maintain dependency graph — nx graph, turbo run build --dry-run, bazel query 'deps(//apps/api:all)'.
    3. Configure CI: compute affected on PR with merge-base fetch-depth: 0.
    4. Add path filters for docs-only skips but never for shared infra (Dockerfile, root package.json, CI config).
    5. 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.
    yaml
    # .github/workflows/ci.yml
    name: Monorepo CI
    on:
    pull_request:
    push:
    branches: [main]
    jobs:
    changes:
    runs-on: ubuntu-latest
    outputs:
    turbo: ${{ steps.filter.outputs.turbo }}
    steps:
    - uses: actions/checkout@v4
    - uses: dorny/paths-filter@v3
    id: filter
    with:
    filters: |
    turbo:
    - 'apps/**'
    - 'packages/**'
    - 'turbo.json'
    - 'package-lock.json'
    ci-config:
    - '.github/**'
    - 'Dockerfile*'
    affected:
    needs: changes
    if: needs.changes.outputs.turbo == 'true' || needs.changes.outputs.ci-config == 'true'
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    with: { fetch-depth: 0 }
    - uses: actions/setup-node@v4
    with: { node-version: 20, cache: npm }
    - run: npm ci
    - name: Turbo affected
    run: |
    npx turbo run lint test build \
    --filter="[origin/main...HEAD]" \
    --concurrency=10
    env:
    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-latest
    steps:
    - uses: actions/checkout@v4
    - run: npm ci && npx turbo run test build --force # weekly cron or every main push

    Execution workflow

    1Design monorepo CI with affected detection
    1 / 5

    Map project graph

    Explicit deps per app/lib in nx/turbo/bazel config.

    Run graph visualizer in onboarding docs.

    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:graph in 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.

    5 questions
    1AdvancedQuestionCompare path filters vs Nx affected vs Bazel query for monorepo CI.+

    Answer

    Path filters: file glob, no transitive deps, fast setup, error-prone. Nx affected: project graph, transitive closure, good for JS. Bazel query: language-agnostic hermetic graph, rdeps/deps queries, best scale, highest setup cost.

    Follow-up

    When is path filter enough?
    2AdvancedQuestionDesign CI for 60-package monorepo, 15 min SLA on PR.+

    Answer

    Affected via turbo/nx, remote cache, matrix parallelization, merge-base diff, skip docs with explicit filter list, full CI nightly. Shared lib change fans out via graph. Cap with dynamic concurrency.

    Follow-up

    Merge queue interaction?
    3AdvancedQuestionAffected CI passed but main broke after merge — causes?+

    Answer

    Incomplete graph (missing dep edge), merge skew without queue, flaky test not in affected set, environment diff, two PRs each green alone but conflict at integration. Fix: merge queue, graph lint, periodic full CI.

    Follow-up

    Integration test placement?
    4AdvancedQuestionHow does Turborepo remote cache work in CI?+

    Answer

    Tasks hash inputs (source, deps, env vars); cache hit skips execution, restores outputs. TURBO_TOKEN authenticates team cache. Same cache dev/CI if hashes match — ensure env vars declared in turbo.json globalPassThroughEnv.

    Follow-up

    Cache poisoning mitigation?
    5AdvancedQuestionBazel in CI vs vanilla npm monorepo?+

    Answer

    Bazel: hermetic, reproducible, RBE scales, steep learning. npm+Turbo: faster adoption, good enough to ~200 devs. Choose Bazel when polyglot, strict reproducibility, or Google-scale graph required.

    Follow-up

    Migration strategy?

    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"].
    bash
    # Turborepo
    git fetch origin main
    npx turbo run build --filter="[origin/main...HEAD]" --dry-run
    # Nx
    npx nx affected:apps --base=origin/main --head=HEAD
    npx 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.

    Ready to mark this lesson complete?Track your journey across the entire course.