# Automation, CI/CD & GitOps — Part 12: Monorepo CI/CD Strategies

> **Series:** Automation, CI/CD & GitOps (12 of 14)
> **Part 1:** `01-cicd-fundamentals.md` — CI/CD Fundamentals
> **Part 2:** `02-infrastructure-as-code.md` — Infrastructure as Code
> **Part 3:** `03-gitops.md` — GitOps
> **Part 4:** `04-github-actions.md` — GitHub & GitHub Actions
> **Part 5:** `05-github-security-governance.md` — GitHub Security & Governance
> **Part 6:** `06-gitlab-cicd.md` — GitLab & GitLab CI/CD
> **Part 7:** `07-bitbucket-pipelines.md` — Bitbucket & Bitbucket Pipelines
> **Part 8:** `08-azure-devops.md` — Azure DevOps
> **Part 9:** `09-jenkins.md` — Jenkins
> **Part 10:** `10-circleci.md` — CircleCI
> **Part 11:** `11-tekton.md` — Tekton
> **Part 12:** This file — Monorepo CI/CD Strategies
> **Part 13:** `13-progressive-delivery.md` — Progressive Delivery with Argo Rollouts & Flagger
> **Part 14:** `14-self-hosted-runner-scaling.md` — Self-Hosted Runner Scaling & Cost Optimization
> **Questions:** `questions.md`

## Table of Contents

1. [Why Monorepo CI/CD Is a Genuinely Different Problem](#why-monorepo-cicd-is-a-genuinely-different-problem)
2. [Monorepo vs. Polyrepo — the Tradeoff This Chapter Assumes](#monorepo-vs-polyrepo--the-tradeoff-this-chapter-assumes)
3. [Path-Based Filtering — the First, Simplest Lever](#path-based-filtering--the-first-simplest-lever)
4. [Path Filtering's Ceiling — Why It's Not Enough Alone](#path-filterings-ceiling--why-its-not-enough-alone)
5. [Workspaces (pnpm/Yarn/npm) — the Layer Underneath Nx/Turborepo](#workspaces-pnpmyarnnpm--the-layer-underneath-nxturborepo)
6. [Affected-Only Builds — Dependency-Graph Awareness](#affected-only-builds--dependency-graph-awareness)
7. [Nx — Project Graph and `nx affected`](#nx--project-graph-and-nx-affected)
8. [Turborepo — Task Pipelines and Remote Caching](#turborepo--task-pipelines-and-remote-caching)
9. [Bazel — Hermetic, Reproducible Builds at the Largest Scale](#bazel--hermetic-reproducible-builds-at-the-largest-scale)
10. [Choosing Between Nx, Turborepo, and Bazel — a Decision Framework](#choosing-between-nx-turborepo-and-bazel--a-decision-framework)
11. [Polyglot Monorepos — When Services Span Multiple Languages](#polyglot-monorepos--when-services-span-multiple-languages)
12. [Test Impact Analysis — Finer-Grained Than Project-Level Affected Detection](#test-impact-analysis--finer-grained-than-project-level-affected-detection)
13. [Independent Deploys — Decoupling Build Scope from Deploy Scope](#independent-deploys--decoupling-build-scope-from-deploy-scope)
14. [Remote Caching — the Real Force Multiplier](#remote-caching--the-real-force-multiplier)
15. [Distributed Build Execution — Beyond Caching](#distributed-build-execution--beyond-caching)
16. [Trunk-Based Development and Monorepo Merge Cadence](#trunk-based-development-and-monorepo-merge-cadence)
17. [Dynamic Pipeline Generation, Platform by Platform](#dynamic-pipeline-generation-platform-by-platform)
18. [Git Scaling Techniques — Sparse Checkout, Partial Clone, Shallow Clone](#git-scaling-techniques--sparse-checkout-partial-clone-shallow-clone)
19. [Large and Binary Files — Git LFS in a Monorepo](#large-and-binary-files--git-lfs-in-a-monorepo)
20. [Merge Queues at Monorepo Scale](#merge-queues-at-monorepo-scale)
21. [Path-Scoped Ownership at Scale](#path-scoped-ownership-at-scale)
22. [Monorepo-Specific Security Considerations](#monorepo-specific-security-considerations)
23. [Case Study: Scaling CI/CD from 10 to 500 Services](#case-study-scaling-cicd-from-10-to-500-services)
24. [CI Cost Attribution in a Monorepo](#ci-cost-attribution-in-a-monorepo)
25. [Visualizing the Full Monorepo CI Decision Stack](#visualizing-the-full-monorepo-ci-decision-stack)
26. [A Full Worked Example: GitHub Actions + Nx Affected](#a-full-worked-example-github-actions--nx-affected)
27. [A Second Worked Example: GitLab Dynamic Child Pipelines + Turborepo](#a-second-worked-example-gitlab-dynamic-child-pipelines--turborepo)
28. [Common Mistakes](#common-mistakes)
29. [Worked Practice Problems](#worked-practice-problems)
30. [Summary and What's Next](#summary-and-whats-next)

---

## Why Monorepo CI/CD Is a Genuinely Different Problem

Every platform chapter in this series (Parts 4-11) assumed, implicitly, a repository small enough that "build and test everything on every push" is a perfectly reasonable default. A **monorepo** — a single repository holding many genuinely independent projects/services/packages, common at organizations from mid-size up through the largest tech companies — breaks that assumption at a scale that becomes impossible to ignore.

```mermaid
graph TD
    Small["Small repo, ONE service:<br/>'test everything on every<br/>push' = a few minutes,<br/>totally reasonable"] --> SmallOk["✅ No special handling<br/>needed at all"]

    Mono["MONOREPO, 200<br/>independent services:<br/>'test everything on every<br/>push' = HOURS, even<br/>though a single commit<br/>only touched ONE service"] --> MonoProb["❌ Directly violates Part 1's<br/>'fail fast, cheap checks first'<br/>principle at massive scale —<br/>199 services' tests run for<br/>NO reason on every commit"]
```

**The core problem, stated precisely:** a monorepo's CI/CD needs to answer "given this specific commit, which of the many projects in this repository were *actually, possibly* affected by it?" and build/test only those — anything less either wastes enormous compute time and money re-testing untouched code on every single commit, or (the worse failure mode) skips testing something that genuinely was affected because a naive, purely path-based heuristic missed an indirect dependency. This chapter is entirely about the tooling and techniques that answer that question correctly and efficiently, layered on top of every platform already covered in this series — every technique here is something a team bolts onto GitHub Actions, GitLab CI/CD, or any other platform's pipeline, not a replacement for any of them.

Notice both failure directions named here are genuinely costly in different currencies — wasted compute is a direct, easily-measured financial cost (this chapter's later cost-attribution section returns to it explicitly), while a missed test is a correctness cost that may not surface until well after the fact, in production, at a point where root-causing it back to "CI never actually validated this" is its own investigation. A mature monorepo CI/CD setup treats both as real, comparably serious risks — not just the more visible, more easily budgeted compute-cost side of the tradeoff.

**Why this chapter earns its place as a standalone topic rather than a paragraph inside an existing platform chapter, worth stating explicitly:** every technique covered here is genuinely platform-agnostic — `nx affected` produces the same correct, dependency-graph-aware result regardless of whether the surrounding pipeline YAML is GitHub's, GitLab's, or Azure's. Bundling this content into any one platform chapter would have implied a false coupling between "how to scale CI/CD in a monorepo" and "which specific CI/CD vendor a team happens to use," when in reality the two are almost entirely independent decisions — a team can and commonly does change CI/CD platforms without changing its monorepo build-orchestration tooling at all, and vice versa.

Read this chapter as an overlay on top of everything already covered in Parts 4-11, not a replacement for any of it — every mechanism this chapter references (path filters, dynamic config, environments, secrets scoping) is the exact same mechanism its originating platform chapter already covered in full; this chapter's job is showing how those mechanisms compose with genuinely new, monorepo-specific tooling (Nx, Turborepo, Bazel) that none of those platform chapters needed to cover on their own.

---

## Monorepo vs. Polyrepo — the Tradeoff This Chapter Assumes

Worth a brief, honest note before diving into technique: this chapter doesn't argue a monorepo is the "correct" choice over many separate repositories (a polyrepo) — that's a genuine, debated architectural decision with real tradeoffs on both sides (monorepos ease cross-project refactoring and dependency-version consistency at the cost of exactly the CI/CD scaling problem this chapter addresses; polyrepos sidestep that CI/CD problem entirely but push cross-project coordination costs elsewhere). This chapter assumes an organization has already made the monorepo choice — for whatever combination of reasons — and focuses entirely on making CI/CD work well within it, since that's the concrete, common, and often underestimated cost of the decision once it's made.

It's also worth naming that "monorepo" itself spans a real spectrum, not one fixed shape — a monorepo holding a handful of tightly-related services owned by one team looks and behaves very differently from one holding hundreds of largely-independent services owned by dozens of teams across an entire organization. The techniques in this chapter apply at every point on that spectrum, but their urgency scales with it: a small, single-team monorepo may never need more than path filtering, while the largest, most fragmented monorepos genuinely need most of what this chapter covers, in roughly the order the later case-study section walks through.

A brief comparison table worth having on hand, since it's the single most common question raised whenever this topic comes up in a real team discussion:

| | Monorepo | Polyrepo |
|---|---|---|
| **Cross-project refactoring** | A single atomic commit/PR can update a shared library and every consumer together | Requires coordinating changes across multiple repos, often with a temporary compatibility window |
| **Dependency version consistency** | Trivially enforceable — everyone shares one version by construction | Requires active governance (a shared dependency-update bot, a policy) to avoid drift |
| **CI/CD scaling** | Genuinely hard — the entire subject of this chapter | Naturally bounded — each repo's CI stays small regardless of how many repos exist |
| **Security boundary** | Requires active, deliberate scoping (this chapter's security section) | Natural, by construction — each repo's secrets are inherently separate |
| **Onboarding a new engineer** | One clone gets the whole codebase, but with genuine "which of these 500 folders do I actually work in" orientation cost | Each repo is small and orientable, but overall system understanding is spread across many places |

**Neither column is unconditionally better — this is a genuine architectural tradeoff, not a solved question with one right answer**, and different parts of even one organization sometimes reasonably land on different choices for different parts of their system. This chapter is deliberately agnostic on which side of that tradeoff is correct for any given organization; its entire scope is making the monorepo side of that tradeoff work well once chosen.

A hybrid worth naming, since it's genuinely common in practice rather than a purely theoretical middle ground: an organization can, and often does, run several monorepos rather than one single repository for the entire company — a "monorepo per business unit" or "monorepo per closely-related product family" pattern that captures most of a full monorepo's cross-project refactoring benefit within each grouping, while keeping each individual monorepo's own CI/CD scaling problem meaningfully smaller than one company-wide repository would present. This is worth considering explicitly as a third option alongside "one giant monorepo" and "fully separate polyrepos," not just as a compromise but as a genuinely deliberate architectural choice in its own right.

The boundary lines for such a split are worth choosing deliberately rather than arbitrarily — grouping by genuine coupling (services that frequently change together, share significant internal libraries, or are owned by one cohesive team) rather than by superficial similarity (e.g. "all our Python services" when those services don't actually depend on each other) keeps each resulting monorepo's own affected-detection graph meaningfully smaller and more precise, which is, after all, the entire point of the split in the first place.

---

## Path-Based Filtering — the First, Simplest Lever

The most basic technique, already introduced piecemeal across this series (GitHub's `paths:` filter in Part 4, GitLab's `rules: changes:` in Part 6), deserves a dedicated, unified treatment here: only run a given job/pipeline if the commit actually touched files under a relevant path.

```yaml
# GitHub Actions
on:
  push:
    paths: ['services/checkout/**']

# GitLab CI/CD
deploy-checkout:
  rules:
    - changes: [services/checkout/**/*]

# Azure Pipelines
trigger:
  paths:
    include: [services/checkout]
```

```mermaid
graph TD
    Commit["Commit touches ONLY<br/>services/checkout/**"] --> Filter{"Path filter per service"}
    Filter -->|"services/checkout/** matched"| RunCheckout["Run checkout service's<br/>pipeline"]
    Filter -->|"services/payments/** NOT matched"| SkipPayments["Skip payments service's<br/>pipeline entirely"]
```

**This is the right first lever to reach for precisely because it's cheap, simple, and requires no additional tooling beyond what every platform already ships natively** — a team with a handful of clearly-separated top-level directories (`services/checkout/`, `services/payments/`) gets real, immediate CI time savings from path filtering alone, with zero new dependencies to adopt or learn.

Bitbucket's own gap here, already flagged in Part 7 as one of its genuine capability limitations, is worth a direct callout in this specific context: with no native path-filtering primitive, a Bitbucket-hosted monorepo has to assemble the equivalent behavior from a hand-written `git diff`-based script inside a pipeline step — functionally achievable, but without the declarative, built-in convenience every other platform in this series offers natively, making Bitbucket a genuinely weaker starting point specifically for a monorepo-heavy organization, independent of its other strengths covered in Part 7.

Jenkins, by contrast, achieves path filtering via a Shared Library helper checking `changeset` conditions (Part 9) — genuinely closer to Bitbucket's hand-rolled-script approach than to GitHub's or GitLab's native, declarative syntax, worth remembering when comparing self-hosted-vs-SaaS tradeoffs specifically through a monorepo lens.


---

## Path Filtering's Ceiling — Why It's Not Enough Alone

Worth being precise about exactly where path filtering stops being sufficient, since this is the single most common mistake a team makes when scaling monorepo CI/CD purely with path filters and no further tooling.

```mermaid
graph TD
    SharedLib["shared-lib/ changes<br/>(a package used by<br/>BOTH checkout AND<br/>payments services)"] --> Q{"Path filter for<br/>services/checkout/**?"}
    Q -->|"shared-lib/ does NOT<br/>match this pattern"| Skipped["❌ checkout's tests are<br/>SKIPPED — even though<br/>shared-lib IS a real<br/>dependency of checkout,<br/>and this change could<br/>genuinely break it"]
```

**The failure mode path filtering alone cannot catch: a change to a shared dependency (a common library, a shared type definition, a proto schema) doesn't live under any individual service's own path, so a naive path filter never triggers the tests of the services that actually, genuinely depend on it.** This is a correctness gap, not just an efficiency one — a team relying purely on path filtering for a monorepo with real shared internal dependencies is at genuine risk of merging a breaking change to a shared library while every downstream consumer's tests were silently never run at all. This is exactly the gap the dependency-graph-aware tools covered next (Nx, Turborepo, Bazel) exist to close — they understand the *actual* dependency graph between projects, not just directory boundaries, and can correctly determine that a `shared-lib` change means `checkout` and `payments` are both genuinely affected, even though neither one's own files changed.

**A second, subtler failure mode worth naming, since it cuts in the opposite direction from the shared-dependency gap:** path filtering can also be *too broad* rather than too narrow, if a service's own path filter is scoped more loosely than its actual boundaries — a filter on `services/checkout/**` that inadvertently also matches an unrelated `services/checkout-analytics-dashboard/` directory (because the glob pattern wasn't precise enough) triggers unnecessary work in the opposite direction, the same efficiency cost this whole chapter exists to eliminate, just introduced by an imprecise filter rather than a missing one. Both failure modes point at the same underlying lesson: path filtering's correctness is only as good as how precisely and comprehensively its patterns are maintained, which is exactly the maintenance burden that motivates moving to a dependency-graph-aware tool that derives the relationship from real code structure rather than a hand-maintained glob pattern at all.

---

## Workspaces (pnpm/Yarn/npm) — the Layer Underneath Nx/Turborepo

Worth a dedicated, clarifying section, since it's a genuinely common point of confusion for teams new to the JS/TS monorepo ecosystem: **package manager workspaces** (npm workspaces, Yarn workspaces, pnpm workspaces) and **build orchestration tools** (Nx, Turborepo) solve two different, complementary problems, not competing ones.

```mermaid
graph TD
    Workspaces["Package manager<br/>WORKSPACES (npm/Yarn/pnpm):<br/>solves DEPENDENCY<br/>INSTALLATION — one shared<br/>node_modules, packages can<br/>depend on EACH OTHER by<br/>name without publishing to<br/>a registry first"] --> Orchestration["Nx / Turborepo:<br/>solves TASK ORCHESTRATION<br/>on TOP of that —<br/>affected-detection, task<br/>graphs, caching, running<br/>build/test/lint across<br/>many packages efficiently"]
```

```json
// package.json at the repo root — npm/Yarn/pnpm workspaces config
{
  "workspaces": ["services/*", "shared-lib"]
}
```

```bash
# This is what WORKSPACES give you, on their own, with NO Nx/Turborepo at all:
# "checkout" can depend on "shared-lib" by name, resolved LOCALLY, with
# changes to shared-lib immediately visible to checkout with no publish step
cd services/checkout && npm install shared-lib   # resolves to the LOCAL workspace package
```

**Workspaces alone give you the *ability* to structure a monorepo with genuinely interdependent local packages at all — they say nothing whatsoever about *how CI should decide what to build/test given a specific change*, which is entirely Nx/Turborepo's job.** A team can absolutely use plain npm/Yarn/pnpm workspaces with zero Nx or Turborepo, hand-writing their own affected-detection logic (a `git diff`-based shell script, commonly the very first, most primitive version of this chapter's whole subject before a team adopts real tooling) — and many smaller monorepos genuinely do exactly this for a while before the maintenance cost of a hand-rolled script exceeds the cost of adopting Nx or Turborepo properly. **The practical guidance worth taking from this distinction:** workspaces are close to a prerequisite (or at minimum, the natural default) for a JS/TS monorepo's dependency management regardless of which orchestration tool sits on top; the choice of Nx vs. Turborepo vs. a hand-rolled script is a genuinely separate decision layered above it.

Both Nx and Turborepo, in fact, build directly on top of whichever workspace tool (npm, Yarn, or pnpm) a project already uses, rather than replacing it — adopting either tool is additive to an existing workspaces setup, not a migration away from it, which is worth knowing when evaluating the real adoption cost: a team already using workspaces is adopting only the orchestration layer, not re-architecting how packages depend on each other at all.

---

## Affected-Only Builds — Dependency-Graph Awareness

The general pattern every tool in this section implements, worth understanding independent of any specific tool's syntax:

```mermaid
flowchart TD
    Commit["Git diff: which files<br/>actually changed?"] --> Graph["Build/consult the FULL<br/>project dependency graph<br/>(which projects depend<br/>on which OTHERS)"]
    Graph --> Walk["Walk the graph FORWARD<br/>from every changed file's<br/>owning project, to every<br/>project that transitively<br/>DEPENDS ON it"]
    Walk --> Affected["The complete, CORRECT<br/>set of 'affected' projects<br/>— build/test ONLY these"]
```

**The critical distinction from path filtering, worth stating as precisely as possible: path filtering answers "which files changed"; affected-only tooling answers "which files changed, AND everything that transitively depends on them, walked through a real dependency graph the tool actually understands."** This is a strictly more correct (and more complex to implement) technique — every tool covered in the next three sections solves this same underlying graph-walking problem, with different scope, different ecosystem focus, and different maturity tradeoffs.

Two genuinely distinct sources feed a tool's understanding of "the dependency graph" worth distinguishing precisely: **inferred** graphs (Nx's default mode, and Turborepo's, derive dependencies automatically by statically analyzing actual import/require statements in the source code — no manual declaration needed, but only as accurate as the tool's static analysis can determine) versus **declared** graphs (Bazel requires every dependency explicitly stated in a `BUILD` file, more upfront authoring effort, but with no possibility of a missed or misinferred dependency, since nothing is inferred at all). This distinction is the real, underlying reason Bazel's setup cost is higher and its correctness guarantee is stronger — the two properties are directly linked, not independent tradeoffs.

A concrete edge case worth knowing, since it's the specific way inferred graphs can go subtly wrong: a dynamic import, a runtime-resolved plugin path, or any dependency established outside a statically-analyzable import statement is invisible to an inferred-graph tool's static analysis by construction — Nx and Turborepo both offer manual "implicit dependency" declarations specifically to patch this gap when it's known to exist, but a team relying purely on automatic inference should be aware this class of dependency requires deliberate, manual handling rather than being caught automatically.

---

## Nx — Project Graph and `nx affected`

**Nx** (primarily, though not exclusively, a JavaScript/TypeScript ecosystem tool) builds an explicit **project graph** — a dependency graph between every package/app in the monorepo, derived from actual import statements and declared project dependencies — and exposes it directly via the `nx affected` command family.

Nx computes this graph once and caches it, incrementally updating it as files change rather than rebuilding it from scratch on every invocation — a genuinely important performance property in its own right, since a naive from-scratch graph computation on every single CI run would itself become a meaningful fixed cost at real monorepo scale, working against the very efficiency this tooling exists to provide.

```bash
# Determine exactly which projects are affected by the changes
# between the current branch and main
nx show projects --affected --base=main

# Run tests ONLY for affected projects
nx affected --target=test --base=main

# Run builds ONLY for affected projects
nx affected --target=build --base=main
```

```yaml
# GitHub Actions, using Nx's affected commands directly
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }   # Nx needs real git history to compute the diff against main
      - run: npx nx affected --target=test --base=origin/main
```

**Nx's project graph is explicitly visualizable and inspectable** (`nx graph` opens an interactive dependency-graph visualization), which is a genuinely useful debugging tool in its own right when a team needs to understand *why* a given project was marked affected by a specific change — a direct answer to "why did my unrelated-looking commit trigger this other team's service tests," rather than an opaque, unexplainable result. Nx also layers in its own remote caching (Nx Cloud, or a self-hosted equivalent), covered in the dedicated caching section below.

Nx also ships **generators** — scaffolding tools that create a new project (a new service, a new shared library) pre-wired into the project graph correctly from the start, with the right dependency declarations and the right task configuration already in place. This is worth a brief mention as a genuine adoption-friction reducer: a common failure mode in less mature monorepo setups is a new project added by hand, with its dependencies on shared packages declared informally (an import statement Nx's graph inference picks up automatically) or, worse, left undeclared entirely in a tool requiring more explicit configuration — generators exist specifically to make "add a new project correctly, the first time" the path of least resistance rather than something a team has to remember to get right manually.

---

## Turborepo — Task Pipelines and Remote Caching

**Turborepo** solves largely the same problem as Nx, with a lighter-weight, more minimal design philosophy and (per current guidance) a gentler learning curve for teams under roughly 100 packages — worth knowing the actual distinguishing tradeoff rather than treating the two as interchangeable.

```json
// turbo.json — declares the TASK graph (not quite the same as Nx's full project graph,
// but serving the same underlying purpose)
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": []
    }
  }
}
```

```bash
# Run tests only for packages affected by changes since main,
# using Turborepo's own filtering syntax
turbo run test --filter="...[main]"
```

| | Nx | Turborepo |
|---|---|---|
| **Design philosophy** | Deep project-graph understanding, more configuration, more capability | Lighter-weight, simpler mental model, faster to adopt |
| **Ecosystem scope** | Broadest — plugins for many languages/frameworks beyond JS | Primarily JS/TS-focused |
| **Typical scale sweet spot** | Up to several hundred packages | Comfortably handles smaller-to-mid monorepos |
| **Owned by** | Nx (Nrwl) | Vercel |

**`^build` in the `dependsOn` array is worth explaining precisely, since the caret syntax is easy to gloss over:** it means "this package's `build` task depends on the `build` task of every package *this package itself depends on*" — Turborepo's declarative way of encoding the same dependency-graph-walking logic Nx implements via its explicit project graph, expressed here as a per-task dependency rule rather than a separately-visualized graph object.

The `--filter="...[main]"` syntax used in the affected-detection command deserves its own brief explanation too: the leading `...` means "include this package's own dependents" (everything that depends on whatever changed), and `[main]` scopes the comparison to changes since that ref — together, the same "changed files, plus everything that transitively depends on them" affected-set computation this chapter has described generally, expressed in Turborepo's own compact filter-syntax dialect.

---

## Bazel — Hermetic, Reproducible Builds at the Largest Scale

**Bazel** (originated at Google, open-sourced from their internal Blaze build system) targets a genuinely different scale and guarantee level than Nx or Turborepo — the standard choice for organizations with 1,000+ engineers and genuinely massive, multi-language monorepos, trading significantly more upfront configuration complexity for **hermetic, fully reproducible builds**.

```python
# BUILD.bazel — declares exactly what a target depends on, explicitly,
# down to the individual file level
java_library(
    name = "checkout_lib",
    srcs = glob(["src/main/java/**/*.java"]),
    deps = [
        "//shared-lib:common",
        "@maven//:com_google_guava_guava",
    ],
)
```

```bash
# Build/test only what's affected by a given set of changed files —
# Bazel computes this from its OWN exact, declared dependency graph
bazel query "rdeps(//..., set(services/checkout/BUILD.bazel))"
bazel test $(bazel query "rdeps(//..., set(...))")
```

**"Hermetic" is the specific term worth understanding precisely, since it's the core property distinguishing Bazel from Nx/Turborepo's more lightweight approach:** a hermetic build declares *every* input explicitly (every source file, every dependency, every tool version) with no reliance on ambient system state (a globally-installed compiler version, an environment variable, network access during the build) — meaning the exact same build, given the exact same inputs, produces a byte-for-byte identical output regardless of which machine or when it runs. This lets Bazel's own caching be extremely aggressive and correct (a cached result is provably still valid, not just probably valid), at the real cost of significantly more upfront work precisely declaring every dependency explicitly, rather than letting a tool infer them from import statements the way Nx does. **The honest guidance on when this cost is worth paying:** Bazel's complexity is justified at genuinely large scale (many thousands of build targets, multiple languages sharing one build graph) where its correctness and caching guarantees compound into major real savings; for a smaller monorepo, Nx or Turborepo's lighter-weight inference-based approach is very likely the better cost/benefit tradeoff.

**The `rdeps` query used in the affected-detection example above is worth a brief, direct explanation, since it's the clearest illustration of Bazel's explicit-graph philosophy in action:** `rdeps(//..., set(...))` literally means "every target, anywhere in the entire repository (`//...`), that has a reverse dependency on (i.e. depends on, directly or transitively) any target in this given set" — the exact same "walk the dependency graph forward from what changed" logic this chapter has described generally, here expressed as a genuine, general-purpose graph query language capable of answering arbitrary questions about the dependency graph, not just the one specific "what's affected" question a narrower tool's dedicated `affected` command is purpose-built for.

---

## Choosing Between Nx, Turborepo, and Bazel — a Decision Framework

The three tables and sections above each covered one tool in isolation; worth pulling them together into an actual decision framework, since "which one should we adopt" is the single most common practical question a team asks once it's convinced affected-only builds are worth adopting at all.

```mermaid
graph TD
    Start["Choosing a monorepo<br/>build tool"] --> Q1{"Single language<br/>(mostly JS/TS)?"}
    Q1 -->|Yes| Q2{"Under ~100 packages,<br/>want the SIMPLEST<br/>adoption path?"}
    Q2 -->|Yes| Turbo["Turborepo"]
    Q2 -->|No - want deeper<br/>graph tooling,<br/>more plugins| NxChoice["Nx"]
    Q1 -->|"No - multiple languages<br/>(Java, Go, Python,<br/>C++, etc.)"| Q3{"1,000+ engineers,<br/>need HERMETIC,<br/>fully reproducible<br/>builds?"}
    Q3 -->|Yes| BazelChoice["Bazel"]
    Q3 -->|"No - smaller scale,<br/>multi-language but<br/>not Bazel-scale"| NxChoice
```

| Criterion | Turborepo | Nx | Bazel |
|---|---|---|---|
| **Setup complexity** | Lowest — a single `turbo.json`, works with existing `package.json` scripts | Moderate — deeper configuration, more concepts (generators, executors) | Highest — every target's dependencies declared explicitly, a genuine paradigm shift for teams new to it |
| **Multi-language support** | JS/TS-centric | Broad, via community and official plugins | Native, first-class — the primary reason organizations choose it |
| **Reproducibility guarantee** | Best-effort, not formally hermetic | Best-effort, not formally hermetic | Fully hermetic by design |
| **Migration cost from an existing repo** | Low — commonly adoptable incrementally, package by package | Moderate | High — often requires substantial `BUILD` file authoring across the whole repo |
| **Typical adopter profile** | Startups/mid-size teams, JS/TS-heavy | Mid-to-large teams, especially with plugin/generator needs | Very large, often multi-language organizations (Google-scale) |

**The single most common real-world mistake in this decision, worth naming explicitly since it recurs across organizations of every size:** choosing Bazel because of its reputation and the caliber of companies known to use it, without the actual scale or multi-language breadth that justifies its steep migration and ongoing-maintenance cost. A 15-engineer, all-TypeScript startup adopting Bazel "to do it right from the start" very commonly spends significantly more total engineering time on `BUILD` file authoring and Bazel-specific troubleshooting than the CI time savings could possibly be worth at that scale — Turborepo or Nx would very likely deliver 80-90% of the practical affected-build and caching benefit at a small fraction of the adoption cost. The right default, absent a genuinely compelling scale or multi-language reason otherwise, is to start with the lighter-weight tool and only reach for Bazel once its specific guarantees (hermeticity, first-class multi-language builds at very large scale) are actually the binding constraint, not a hypothetical future one.

A last practical note on this decision: whichever tool is chosen, budget real time for the team's own learning curve alongside the tool's own setup cost — even Turborepo's comparatively gentle adoption path still requires engineers to internalize the affected-detection mental model this chapter has spent several sections building up, which is itself a genuine, if smaller, cost distinct from the raw configuration effort.

Migrating between these tools later, should scale eventually demand it, is a real but bounded cost, not a permanent lock-in — a team that outgrows Turborepo's lighter-weight model can migrate to Nx's deeper graph tooling, or eventually to Bazel, incrementally, module by module, rather than needing a single, all-at-once rewrite. This is worth knowing specifically because it lowers the stakes of the initial choice: starting with the lighter-weight tool and being "wrong" about eventual scale is a recoverable, incremental cost, whereas starting with Bazel and being "wrong" about needing it is a much larger sunk cost to walk back.

---

## Polyglot Monorepos — When Services Span Multiple Languages

Worth a dedicated note on a real complication every tool covered so far handles with genuinely different levels of maturity: a monorepo containing a TypeScript frontend, a Go backend service, and a Python data pipeline, all in one repository, needing one coherent affected-detection story spanning all three.

```mermaid
graph TD
    Poly["Polyglot monorepo:<br/>TypeScript frontend +<br/>Go backend + Python<br/>data pipeline"] --> NxPoly["Nx: JS/TS-native graph<br/>inference, PLUS community/<br/>official plugins for<br/>other languages — genuine<br/>but uneven depth across<br/>ecosystems"]
    Poly --> TurboPoly["Turborepo: primarily<br/>JS/TS-oriented — non-JS<br/>packages typically need<br/>hand-authored task<br/>definitions rather than<br/>automatic graph inference"]
    Poly --> BazelPoly["Bazel: genuinely FIRST-<br/>CLASS multi-language —<br/>the SAME explicit<br/>dependency-declaration<br/>model applies uniformly<br/>across every language,<br/>no per-language tier of<br/>support"]
```

**This is worth returning to as a concrete addition to the earlier decision framework, not just a passing caveat:** a genuinely polyglot monorepo, spanning several languages each with real interdependencies (not just several languages that happen to coexist without depending on each other), tips the calculus meaningfully toward Bazel even at a scale that might otherwise favor Nx or Turborepo's lighter adoption cost — because Nx and Turborepo's affected-detection quality for a *non*-JS/TS project is genuinely less mature and less automatic than for JS/TS, often requiring more manual task/dependency declaration to get right, partially eroding the "lighter-weight, less configuration" advantage that favors them in a single-language monorepo. A team with a real, interdependent multi-language monorepo should weigh this specifically, rather than assuming the earlier decision framework's single-language guidance transfers unchanged.

A common, pragmatic middle ground worth naming for a team not yet ready for a full Bazel migration: run Nx or Turborepo for the JS/TS portion of the monorepo, and a lighter-weight, hand-rolled affected-detection script (a `git diff` against `main`, scoped to the non-JS directories, feeding into that language's own native build tool) for the non-JS portions — genuinely less elegant than one unified graph, but a real, working incremental step that doesn't require the full Bazel migration cost up front, consistent with this chapter's broader theme of matching tooling investment to actual current pain rather than a theoretically ideal end state.

The genuine risk with this split-tooling middle ground, worth naming honestly rather than presenting it as a free lunch: a cross-language dependency (a TypeScript frontend calling a Go backend's API, where the API contract itself changes) sits exactly at the seam between the two separately-managed affected-detection systems, and neither one alone can express "this Go API change should also mark the TypeScript frontend as affected." This is precisely the gap Bazel's unified, cross-language graph closes — worth remembering as the split-tooling approach's own real ceiling, not a permanently acceptable state, if that specific kind of cross-language coupling turns out to be common.

---

## Test Impact Analysis — Finer-Grained Than Project-Level Affected Detection

Everything covered so far determines affected-ness at the **project/package** level — "this project's tests should run." A more advanced, finer-grained technique worth knowing about is **Test Impact Analysis (TIA)**: using actual code-coverage data (which specific lines of code a given test exercises, collected from prior runs) to determine which *individual tests*, not just which *projects*, are actually impacted by a given change.

```mermaid
graph TD
    Change["A single function in<br/>shared-lib/formatDate.ts<br/>changes"] --> Coverage["Historical coverage data:<br/>WHICH tests, across the<br/>ENTIRE monorepo, actually<br/>exercise this specific<br/>function?"]
    Coverage --> Select["Run ONLY those specific<br/>tests — not every test in<br/>every project that merely<br/>DEPENDS ON shared-lib,<br/>but the actual subset that<br/>exercises the changed code"]
```

**The distinction from project-level affected detection is genuinely meaningful at real scale, worth stating precisely:** project-level affected detection (Nx/Turborepo/Bazel, covered earlier) answers "which *projects* depend on what changed" — correctly, but coarsely, since it still runs *every* test within each affected project, even tests that have nothing to do with the specific function that actually changed. Test Impact Analysis narrows this further, to the individual-test level, using real historical coverage data rather than the dependency graph alone. **This is a genuinely more advanced, more operationally demanding technique** — it requires reliably collecting and maintaining per-test coverage data over time, and carries real risk of a false negative (skipping a test that should have run, because the coverage data was stale or incomplete) that project-level affected detection's coarser, dependency-graph-based approach is structurally less prone to. Most organizations get the large majority of the available CI-time benefit from project-level affected detection alone, and reach for test-impact analysis specifically only once that coarser technique's own ceiling (running every test in a very large affected project, even one only lightly touched) becomes the next real bottleneck.

**The false-negative risk is worth taking seriously enough to name a concrete mitigation, not just a caveat:** a common, prudent pattern is running TIA-narrowed test selection on every ordinary PR for speed, but periodically (nightly, or on every merge to `main`) running the *full*, unnarrowed test suite for every affected project regardless of coverage-based selection — a safety net that catches anything TIA's narrower selection might have incorrectly skipped, trading a small amount of redundant nightly compute for a meaningful correctness backstop against the technique's own known failure mode.

---

## Independent Deploys — Decoupling Build Scope from Deploy Scope

Worth a final, important clarification, since it's easy to conflate two genuinely separate concerns this chapter has otherwise treated together: **"which projects need to be built/tested" (affected-only builds) is a distinct question from "which services actually get independently deployed."**

```mermaid
graph TD
    Repo["ONE monorepo"] --> Checkout["services/checkout —<br/>its OWN deployment<br/>pipeline, its OWN<br/>release cadence"]
    Repo --> Payments["services/payments —<br/>its OWN INDEPENDENT<br/>deployment pipeline,<br/>DIFFERENT release<br/>cadence, deployed<br/>SEPARATELY"]
    Checkout -.co-located source, NOT co-deployed.-> Payments
```

**A monorepo does not imply a monolithic deployment** — this is a genuinely common point of confusion for teams new to the pattern. The affected-only build/test techniques covered throughout this chapter determine what needs *validating* for a given change; a completely separate deployment pipeline (per service, following the same environment/approval-gate patterns already covered per-platform in Parts 4-11) determines what actually gets *released*, and on what cadence. A change to `shared-lib/` correctly triggers both `checkout`'s and `payments`' tests (per the affected-detection logic covered earlier in this chapter) — but that doesn't mean both services need to deploy together, or even at the same time; each retains its own independent deployment pipeline, its own environment approval gates, and its own release cadence, exactly as if each lived in its own separate repository. **The monorepo/polyrepo choice is fundamentally a source-control and build-tooling decision, not a deployment-architecture one** — conflating the two is a common source of unnecessary coupling (e.g. forcing a coordinated, all-services-at-once release train purely because the source happens to live in one repository) that this chapter's techniques neither require nor encourage.

A deployment orchestrator (this course's GitOps chapter, Part 3, and the upcoming Part 13 on progressive delivery) can still coordinate *when* several services' independent pipelines happen to release, without collapsing them into one monolithic release process — the distinction is between voluntary coordination for a genuine business reason (a synchronized marketing launch, say) and structural coupling forced purely by shared source-control location, which this chapter argues against.

**One genuine, worth-naming exception to this general independence:** a change to `shared-lib/` that both `checkout` and `payments` depend on does create a real *ordering* consideration at deploy time, even though it doesn't require simultaneous deployment — if `shared-lib`'s change includes a breaking API change, both consuming services' own deploy pipelines need to have picked up the corresponding update before either is safe to release independently, which is a version-compatibility concern each service's own deployment pipeline needs to account for (commonly via the same semantic-versioning and compatibility-window discipline any polyrepo consuming a shared library from a package registry would already need), not something the monorepo's mere co-location resolves automatically.

---

## Remote Caching — the Real Force Multiplier

Every tool covered in the previous three sections layers a **remote cache** on top of affected-detection — worth covering as its own concept, since it compounds with affected-only builds rather than replacing them.

```mermaid
sequenceDiagram
    participant Dev as Developer's laptop
    participant Remote as Remote Cache
    participant CI as CI pipeline

    Dev->>Dev: Runs 'build' locally -<br/>result cached with a hash<br/>of its exact inputs
    Dev->>Remote: Pushes the cached result<br/>(same hash key)
    Note over CI: LATER - a different engineer's<br/>CI run needs the SAME build
    CI->>Remote: Checks: has this EXACT<br/>input hash been built before,<br/>by ANYONE, ANYWHERE?
    Remote-->>CI: Cache HIT - instant result,<br/>ZERO actual build time
```

**The genuinely powerful property worth stating explicitly:** a remote cache is shared across every developer's laptop *and* every CI run, keyed by a hash of a task's actual inputs — meaning if *any* engineer, anywhere, already built or tested a given set of inputs (even locally, on their own machine, before ever pushing), every other engineer's and every CI run's identical request for that same input hash is an instant cache hit, never re-executing the actual work at all. This is a different, complementary lever from affected-only builds: affected-detection decides *what* needs to run at all; remote caching decides whether something that's determined to need running has *already effectively been run* by someone else and can be skipped entirely. **The combined effect compounds multiplicatively, not just additively** — a monorepo with both affected-only detection and remote caching commonly sees CI times measured in single-digit minutes for typical, small changes, even in a codebase where "test literally everything" would take hours.

**Cache correctness deserves a specific, direct callout, since a subtly wrong cache key is a genuinely hard-to-debug failure mode:** the hash a remote cache keys on must capture every input that could affect the output — source files, dependency versions, environment/tool versions, and any relevant configuration — and missing even one genuine input from that hash means a cache can serve a stale, incorrect result for a change that *did* actually matter but wasn't reflected in the key. This is precisely the property Bazel's hermetic build model (covered earlier) makes provably correct by construction; Nx's and Turborepo's lighter-weight, inference-based input tracking is generally reliable in practice but carries a small, real residual risk of an incorrectly-scoped cache key that a team should be aware of, not assume away entirely, especially around less obvious inputs like environment variables or external tool versions that aren't declared as explicit file-based dependencies.

---

## Distributed Build Execution — Beyond Caching

Remote caching (covered above) answers "has this exact work already been done by someone else?" A related but genuinely distinct technique — most mature in Bazel's ecosystem via **Remote Execution (RBE)** — answers a different question entirely: "can this work, which does need to happen, be spread across many machines simultaneously rather than run on one CI runner sequentially?"

```mermaid
graph TD
    Sequential["ONE CI runner, building<br/>1,000 targets ONE AT A<br/>TIME (even with internal<br/>parallelism across a<br/>handful of CPU cores)"] --> SeqSlow["Bounded by ONE<br/>machine's total capacity,<br/>regardless of how many<br/>targets are independently<br/>buildable"]

    RBE["Remote Execution:<br/>the SAME 1,000 targets,<br/>dispatched across a POOL<br/>of many remote worker<br/>machines simultaneously"] --> RBEFast["Bounded by the POOL's<br/>total capacity — genuinely<br/>parallel across machines,<br/>not just across cores on<br/>one machine"]
```

**The distinction from remote caching is worth stating with precision, since the two are easy to conflate given they're often adopted together:** remote caching skips work that's provably already been done; remote execution genuinely distributes work that has *not* been done, and must actually run, across a pool of machines rather than one runner's own limited core count. A monorepo build with 1,000 independent, uncached targets to compile benefits enormously from remote execution even though none of that work is cacheable (imagine the very first build after a from-scratch clone, or a change genuinely touching a huge swath of the codebase) — remote caching alone provides zero benefit in that specific scenario, since there's nothing cached yet to hit. **This is a genuinely more advanced, more infrastructure-intensive technique than anything else covered in this chapter** — it requires operating (or subscribing to a managed) pool of remote build workers, and is realistically only justified at the scale where Bazel itself is already the right choice (per the earlier decision framework) — Nx and Turborepo's ecosystems have historically leaned more heavily on remote caching alone rather than full remote execution, reflecting their lighter-weight, smaller-scale design center.

A useful mental shorthand for keeping the two concepts straight going forward: caching answers "do I need to do this at all," execution answers "given that I do need to do this, how many machines can share the load." A monorepo genuinely large enough to need both gets a build pipeline where most work is skipped outright via cache hits, and whatever genuinely novel work remains is itself spread across many workers rather than serialized on one — the two techniques compounding rather than substituting for each other.

Remote execution's operational cost is worth stating honestly, since it's easy to undersell relative to caching's more obviously bounded infrastructure footprint: a worker pool needs its own capacity planning, its own scaling policy, and its own security posture (every worker executes arbitrary build-defined code, the same class of concern already covered for self-hosted runners across this series) — real, ongoing infrastructure a team takes on, not a one-time setup cost.

---

## Trunk-Based Development and Monorepo Merge Cadence

Worth a closing structural note tying this chapter back to Part 1's opening CI principles: monorepos, and the merge-queue/affected-detection machinery covered throughout this chapter, are most effective when paired with **trunk-based development** — short-lived feature branches merged frequently (often multiple times a day per active contributor) directly to `main`, rather than long-lived feature branches that diverge from `main` for days or weeks before merging.

```mermaid
graph TD
    LongLived["Long-lived feature<br/>branches, diverging from<br/>main for WEEKS"] --> LongProb["❌ Each merge is a large,<br/>high-risk batch of changes<br/>— exactly the 'big-bang<br/>merge' problem Part 1<br/>opened this entire series<br/>by arguing against"]

    TrunkBased["Trunk-based: SHORT-lived<br/>branches, merged multiple<br/>times a DAY, feature flags<br/>(Part 1) hiding incomplete<br/>work from users"] --> TrunkGood["✅ Small, frequent,<br/>LOW-risk merges — affected-<br/>detection and merge queues<br/>from this chapter work BEST<br/>against exactly this shape<br/>of change"]
```

**Why this connection matters specifically for everything covered in this chapter, not just as generic good practice:** every technique in this chapter — affected-only detection, merge queues, remote caching — performs *best* against small, frequent changes, and degrades in usefulness against large, infrequent ones. A single enormous, long-lived branch merging after three weeks of divergence touches so much of the codebase that affected-detection's precision advantage shrinks toward "most things are affected anyway," and a merge queue's "test against the simulated future state" value is largest precisely when many *other* small changes are also landing frequently in that same window — against a world of rare, giant merges, there's comparatively little queue contention to protect against in the first place. **This is the same throughline as Part 1's very first CI argument, now closing the loop at monorepo scale:** small, frequent integration was already the right default for a single-service repository; at monorepo scale, it's not just still the right default, it's the specific precondition that makes this entire chapter's tooling investment pay off as designed.

Feature flags (also covered conceptually in Part 1) are the specific mechanism that makes trunk-based development practical for genuinely incomplete work in a monorepo context — a half-finished feature spanning `checkout` and a shared UI component can merge to `main` frequently, in small increments, hidden behind a flag, without either service's affected-detection or deployment pipeline needing to treat "not yet feature-complete" as a reason to hold back the merge itself. The alternative — a long-lived branch held open until the feature is fully complete — is precisely the pattern this section argues degrades every technique in this chapter, making feature flags not just a deployment-risk-reduction tool (their original framing in Part 1) but a direct enabler of the monorepo CI/CD efficiency this entire chapter is about.

This is worth internalizing as the chapter's own closing argument in miniature: monorepo CI/CD tooling and trunk-based development aren't two independent best practices that happen to both be good ideas — they're mutually reinforcing, each making the other meaningfully more valuable than it would be alone.

---

## Dynamic Pipeline Generation, Platform by Platform

Every platform covered in Parts 4-11 has arrived at its own mechanism for the underlying "compute which jobs should even exist in this pipeline run, based on what changed" problem — worth a unified summary connecting back to each platform's own specific chapter, since the terminology genuinely differs even though the underlying need is identical:

| Platform | Mechanism | Covered in |
|---|---|---|
| **GitHub Actions** | `paths:` filtering, or `dorny/paths-filter` action to output changed-path booleans consumed by later job `if:` conditions | Part 4 |
| **GitLab CI/CD** | `rules: changes:`, or full parent-child dynamic pipelines generating a second-stage config | Part 6 |
| **Bitbucket Pipelines** | No native path filtering — commonly assembled via a custom script checking `git diff` and conditionally invoking `pipe`s | Part 7 |
| **Azure DevOps** | `trigger: paths:`, or a template-generation script producing dynamic stage lists | Part 8 |
| **CircleCI** | Dynamic config via setup workflows + the `path-filtering` orb — computes the entire second-stage pipeline programmatically | Part 10 |
| **Jenkins** | Multibranch/Organization Folder discovery plus a Shared Library helper computing `changeset`-based conditionals | Part 9 |
| **Tekton** | No native equivalent — a platform team implements this as custom logic inside a Task, commonly invoking the same Nx/Turborepo/Bazel commands covered in this chapter directly | Part 11 |

**The unifying lesson worth taking from this table:** none of these platform-specific mechanisms actually understand a monorepo's *dependency graph* — every one of them is either simple path matching (the ceiling covered earlier in this chapter) or a generic "generate config dynamically" capability that a team then fills in with real dependency-graph-aware logic. **In every real, mature monorepo setup, the platform-specific mechanism in this table is the delivery vehicle, and Nx/Turborepo/Bazel's affected-detection is the actual brain deciding what to run** — the two layers are complementary, not competing, and nearly every production monorepo CI/CD setup genuinely combines both: a platform-native trigger/dynamic-config mechanism invoking an `nx affected`/`turbo run --filter`/`bazel query` command to determine the real, graph-aware set of work.

**Worth stating as a direct, practical consequence of this table for anyone choosing a CI/CD platform specifically for a monorepo-heavy organization:** the platform choice from Parts 4-10 matters less for monorepo scalability than it might first appear, precisely because the real intelligence lives in the build-orchestration tool layered on top, not in the platform's own native triggering mechanism. A team already committed to GitLab for other reasons (its integrated security scanning, say, per Part 6) doesn't need to weigh "but can it handle our monorepo" as a first-order platform-selection criterion — dynamic child pipelines plus Nx or Turborepo get it there just as effectively as GitHub Actions or CircleCI's own equivalent mechanisms would, since the actual affected-detection intelligence is identical regardless of which platform delivers its output.

---

## Git Scaling Techniques — Sparse Checkout, Partial Clone, Shallow Clone

A large monorepo creates a genuinely separate scaling problem worth its own section: even *checking out* the repository can become slow at real scale (tens of gigabytes, deep history), independent of anything covered so far about which jobs actually run.

```mermaid
graph TD
    Full["Full git clone:<br/>every file, every commit,<br/>the entire history —<br/>can be tens of GB and<br/>take minutes on its own,<br/>on EVERY single CI run"]
    Techniques["Three COMPOSABLE<br/>techniques, addressing<br/>three DIFFERENT axes"] --> Shallow["Shallow clone<br/>(--depth=1):<br/>fewer COMMITS —<br/>skip history you don't need"]
    Techniques --> Partial["Partial clone<br/>(--filter=blob:none):<br/>fewer BLOBS —<br/>fetch file CONTENTS<br/>lazily, on demand"]
    Techniques --> Sparse["Sparse checkout<br/>(cone mode):<br/>fewer WORKING-TREE<br/>FILES — only populate<br/>the directories this<br/>specific job actually<br/>needs"]
```

```bash
# Shallow clone — CI almost never needs full history for a build
git clone --depth=1 https://github.com/my-org/monorepo.git

# Partial clone — fetch commit/tree metadata eagerly, file CONTENTS lazily
git clone --filter=blob:none https://github.com/my-org/monorepo.git

# Sparse checkout (cone mode) — only populate the working tree with
# the specific directories THIS job actually needs
git sparse-checkout init --cone
git sparse-checkout set services/checkout shared-lib
```

**These three techniques address genuinely different axes of the same overall problem, and are explicitly designed to compose together** — a CI job for the `checkout` service can combine a shallow clone (skip history depth) with a partial clone (skip fetching blob contents for files outside what's needed) and a sparse checkout (only materialize `services/checkout/` and `shared-lib/` into the actual working tree), turning what could be a multi-gigabyte, multi-minute checkout into something closer to what a small, single-service repo's checkout would cost — directly addressing the "even before any build/test logic runs, just getting the code onto the runner is slow" problem that affected-only build detection alone doesn't solve.

**One genuine tension worth naming explicitly, since it directly affects how sparse checkout is actually applied in practice:** sparse checkout needs to know *which* directories a given job needs *before* checkout happens, but affected-detection (Nx/Turborepo/Bazel) typically needs the actual git history and diff to determine what's affected in the first place — a real chicken-and-egg ordering constraint. The practical resolution most real setups use: perform a lightweight, cheap operation first (a shallow/partial clone of just enough metadata to compute the diff and run affected-detection), then, once the actual affected set is known, either populate a fuller sparse checkout scoped to exactly the affected projects' own dependencies, or simply proceed with what's already available if the initial fetch was generous enough — the exact sequencing is a genuine, project-specific tuning exercise rather than a one-size-fits-all recipe.

---

## Large and Binary Files — Git LFS in a Monorepo

Worth a brief, practical note extending the git-scaling techniques above: a monorepo consolidating many projects commonly also consolidates each project's own large/binary assets (design files, ML model weights, test fixture media, compiled third-party binaries) into one repository — and plain Git handles large binary files poorly by design, since every version of every binary is stored in full in history, with no meaningful diffing possible the way Git's delta compression works well for text.

```mermaid
graph TD
    PlainGit["Plain Git, binary files:<br/>EVERY version of EVERY<br/>binary stored in FULL,<br/>forever, in history —<br/>a repo with years of<br/>binary churn can become<br/>enormous"] --> PlainProb["❌ Every clone/checkout<br/>downloads the FULL<br/>history of every binary<br/>ever committed, even<br/>ones no longer referenced<br/>by the current commit"]

    LFS["Git LFS: the repo stores<br/>only a small TEXT POINTER<br/>to each binary; the actual<br/>binary content lives in a<br/>SEPARATE LFS store"] --> LFSGood["✅ Git history itself stays<br/>small; binary content is<br/>fetched on demand — and<br/>composes with sparse<br/>checkout/partial clone<br/>from earlier in this chapter"]
```

```bash
git lfs install
git lfs track "*.psd" "*.model" "assets/**/*.png"
git add .gitattributes
```

**Why this belongs in a monorepo-specific chapter rather than a generic Git tutorial:** the scaling pain Git LFS addresses is disproportionately a *monorepo* problem — a small, single-purpose repo with a handful of binary assets rarely hits this ceiling in practice, while a monorepo consolidating many projects' worth of binary assets over years of history hits it far sooner and far harder. It's worth budgeting for explicitly in the same incremental-adoption spirit as this chapter's case study — a team migrating many projects' binary assets into one monorepo should plan for Git LFS from the start, rather than discovering the problem only once clone times have already become painful.

A related, practical detail worth knowing: LFS-tracked file *content* is fetched separately from ordinary git blobs, meaning it composes naturally with the partial-clone technique covered earlier — `git lfs install --skip-smudge` (or the equivalent CI-oriented flag) can defer even LFS content download until a specific job's `sparse-checkout` scope is known, extending the same "only fetch what this specific job actually needs" discipline to binary assets, not just ordinary tracked source files.

Worth a final, practical caution: `.gitattributes` (where LFS tracking patterns live) is itself a tracked file, meaning a change to which file types are LFS-tracked needs the same review scrutiny as any other shared, repository-wide configuration — an accidental narrowing that stops tracking a genuinely large file type can silently reintroduce the exact bloat problem LFS exists to prevent.

Git LFS itself requires its own storage backend (many git hosts, including several platforms covered earlier in this series, provide one natively, with their own separate storage quota and pricing worth checking) — a detail easy to overlook until an LFS-heavy monorepo's storage bill arrives as its own, separate line item from the platform's ordinary CI/CD billing already covered per-platform in Parts 4-10.

---

## Merge Queues at Monorepo Scale

Part 6 covered GitLab's merge trains as a solution to a specific high-merge-volume correctness problem — worth returning to here because monorepos are exactly where that problem is most acute, and most platforms have since converged on some form of merge queue.

```mermaid
graph TD
    Problem["Monorepo, 200 engineers,<br/>50+ merges/day to main —<br/>EACH one individually<br/>tested against main's<br/>state at PR-approval time"] --> Risk["❌ By actual merge time,<br/>several OTHER merges may<br/>have already landed —<br/>the tested state and the<br/>merged state have<br/>silently diverged"]
    Queue["Merge queue: every<br/>approved PR is tested<br/>against a SIMULATED<br/>state including every<br/>PR ahead of it in the<br/>queue"] --> QueueGood["✅ main NEVER receives<br/>an untested combination,<br/>at ANY merge volume"]
```

**This is the exact same underlying mechanism already covered in depth as GitLab's merge trains in Part 6, and GitHub, Bitbucket, and Azure DevOps have each since shipped their own equivalent (GitHub's native Merge Queue, effectively identical in concept)** — worth restating here specifically because monorepo-scale merge volume is precisely the condition under which this mechanism moves from "nice to have" to "actually necessary for `main` to stay reliably green." A monorepo without a merge queue, combined with affected-only builds that (correctly) only test what a *given* PR touches, has a genuine gap: two PRs, each individually passing because they only touched their own affected projects, can still conflict or break something when combined — the merge queue's "test against the simulated future state" property is what specifically catches this, independent of and complementary to the affected-only build optimization covered throughout this chapter.

**A monorepo-specific wrinkle worth flagging: a merge queue re-tests each queued PR against an evolving simulated state, which means it re-runs affected-detection itself for each position in the queue** — the "affected set" for the PR at queue position 5 needs to be computed against a hypothetical state including PRs 1-4 already merged, not just against `main`'s actual current HEAD. This is a real, compounding computational cost at high queue depth (each position potentially needing its own affected-detection pass), and is part of why the case study earlier in this chapter treats merge queues as a technique adopted only once merge volume genuinely demands it — the queue-depth-driven cost of correctly maintaining this simulated-state affected detection is itself a real resource the earlier, lower-volume stages of adoption don't need to pay.

---

## Path-Scoped Ownership at Scale

A monorepo also strains the CODEOWNERS/approval-ownership mechanisms already covered per-platform in this series (GitHub's CODEOWNERS in Part 5, GitLab's approval rules in Part 6) — worth a brief, dedicated note on how these scale to hundreds of independently-owned projects in one repository.

```
# .github/CODEOWNERS in a monorepo — genuinely large, one entry per project
/services/checkout/       @checkout-team
/services/payments/       @payments-team
/shared-lib/              @platform-team @checkout-team @payments-team   # shared code, multiple stakeholders
/services/*/  @platform-team    # a fallback default for any service not explicitly listed above
```

**The practical operational discipline this demands at real monorepo scale, worth naming explicitly:** a CODEOWNERS file with hundreds of entries needs its own maintenance process (who updates it when a new service is added, who audits it for staleness as teams reorganize) — treating it as a living, actively-maintained artifact rather than a write-once file, since a stale entry (routing review to a team that no longer owns that code) silently degrades the exact review-ownership guarantee CODEOWNERS exists to provide. Combined with the shared-dependency problem from earlier in this chapter, a change to `shared-lib/` correctly routes review to every genuinely affected team (`checkout-team` and `payments-team` both, in the example above) — the same "don't let a shared dependency's blast radius go unreviewed by its real downstream consumers" concern that motivated affected-only build detection, applied here to human review instead of automated testing.

**A practical technique worth naming for keeping a large CODEOWNERS file honest over time:** generate it, or at least validate it, from the same project-graph metadata Nx/Turborepo/Bazel already maintain for affected-detection — since that graph already has an authoritative, machine-readable record of which projects exist and (via a lightweight, separately-maintained ownership annotation per project) who owns each one, a small script can regenerate or lint CODEOWNERS against that source of truth as part of CI itself, catching drift (a new project added without a corresponding CODEOWNERS entry, an entry pointing at a team that no longer exists) automatically rather than relying purely on manual audits.

---

## Monorepo-Specific Security Considerations

Worth a dedicated callout, extending this series' recurring supply-chain-security thread (Parts 5, 8, and this course's DevSecOps series) with the specific way a monorepo changes the underlying risk calculus.

```mermaid
graph TD
    Polyrepo["POLYREPO: a compromised<br/>CI credential in ONE repo's<br/>pipeline is scoped to<br/>THAT repo's own secrets<br/>and access, by construction"] --> PolyGood["Blast radius naturally<br/>bounded by repo boundaries"]

    Mono["MONOREPO: ONE repository's<br/>CI configuration, if not<br/>carefully scoped, can mean<br/>ONE compromised dependency<br/>or pipeline step has a<br/>MUCH larger potential reach —<br/>every service's secrets/deploy<br/>credentials live in the<br/>SAME repository's CI config"] --> MonoRisk["❌ Blast radius bounded<br/>only by however carefully<br/>secrets/permissions were<br/>scoped WITHIN the repo —<br/>not by the repo boundary<br/>itself, which no longer<br/>exists as a natural barrier"]
```

**The practical mitigation, worth stating precisely since it's the direct monorepo-specific application of every least-privilege principle already established throughout this series:** every technique this chapter has covered for *scoping build/test work* (path filtering, affected-only detection, path-scoped CODEOWNERS) has a direct security analogue in *scoping secrets and deploy credentials* — a `checkout` service's deploy pipeline should hold only `checkout`'s own deploy credentials, scoped via the same Environment/Protected-variable/Context mechanisms already covered per-platform in Parts 4-10, never a broad, repository-wide credential usable by any pipeline anywhere in the monorepo. **A monorepo without this discipline effectively flattens what should be many independent security boundaries into one** — a compromised dependency affecting a low-stakes internal tool's pipeline should never, by construction, be able to reach the production payments service's deploy credentials just because both happen to share one repository's CI configuration. This is not a new principle this chapter is introducing — it's the same least-privilege scoping discipline from every prior platform chapter, restated here specifically because a monorepo's shared CI configuration surface makes it easier to accidentally violate at scale than a polyrepo's naturally-separated pipelines would.

The same reasoning extends to which Hub Tasks, orbs, or Actions a given service's pipeline is permitted to reference at all — a low-stakes internal tool's build pipeline pulling in an unvetted, low-trust third-party Action carries a supply-chain risk (per Part 5's own detailed treatment) that, in a monorepo with insufficiently scoped credentials, can reach far beyond that one tool's own blast radius. Treating every pipeline in a monorepo with the same scrutiny as if it individually held production-level access — rather than assuming a "less important" service's pipeline is inherently lower-risk — is the correct default until credential scoping has actually been verified to bound its real reach.

---

## Case Study: Scaling CI/CD from 10 to 500 Services

A brief, concrete narrative worth walking through, since it ties every technique in this chapter together in the rough order an organization typically actually adopts them, rather than as an assumed-complete system from day one.

```mermaid
graph TD
    S1["~10 services:<br/>'test everything on<br/>every push' — a few<br/>minutes, totally fine,<br/>NO special tooling needed"] --> S2["~50 services:<br/>full-suite CI creeps to<br/>15-20 minutes — path<br/>filtering adopted, cuts<br/>most unrelated work"]
    S2 --> S3["~150 services: path<br/>filtering's ceiling is hit —<br/>a shared-lib change starts<br/>causing missed test runs.<br/>Nx or Turborepo adopted<br/>for real dependency-graph<br/>awareness"]
    S3 --> S4["~300 services: CI queue<br/>times (not just per-run<br/>duration) become the<br/>bottleneck — remote<br/>caching adopted, plus<br/>git-level scaling<br/>(sparse checkout, partial<br/>clone) as checkout itself<br/>becomes noticeably slow"]
    S4 --> S5["~500 services: merge<br/>volume high enough that<br/>individually-tested PRs<br/>start conflicting when<br/>combined — merge queue<br/>adopted; CODEOWNERS<br/>maintenance becomes its<br/>own dedicated, staffed<br/>process"]
```

**The lesson worth taking from this progression, more than any single stage's specific tooling choice:** no real organization adopts every technique in this chapter simultaneously, and trying to would be its own mistake (premature complexity for a scale that doesn't yet need it, echoing this chapter's earlier caution about adopting Bazel too early). Each technique earns its adoption at the point where the *previous* technique's own ceiling has genuinely been reached and is causing real, measured pain — path filtering first (cheap, built into every platform already), affected-only detection once path filtering's shared-dependency blind spot bites, remote caching once affected-detection alone still leaves meaningful redundant work, git-scaling once checkout time itself becomes the bottleneck, and a merge queue once merge volume alone (independent of any single PR's own correctness) becomes the risk. Treating this as a progression to adopt incrementally, matched to actual measured pain at each stage, is a more honest and more cost-effective strategy than front-loading every technique this chapter covers into a brand-new, still-small monorepo.

**The practical signal worth watching for at each transition, rather than a fixed service-count threshold:** the specific numbers in this progression (10, 50, 150, 300, 500 services) are illustrative, not prescriptive — the actual trigger for adopting the next technique is a measured pain point (CI duration creeping past what the team considers acceptable, a missed-test incident tracing back to an untracked shared dependency, checkout time becoming a visible complaint, a conflicting-merge incident), not an arbitrary service count reached. Two organizations at the identical service count can reasonably be at different points in this progression, depending on how interdependent their services genuinely are, how frequently they merge, and how large their individual codebases are — the progression is a rough sequencing of *which problem tends to bite first*, not a scale-based checklist to apply mechanically.

Treat the entire progression as a diagnostic starting point for a real conversation with a platform team, not a rigid prescription: the honest question to ask at any given monorepo's current state is simply "what's the single biggest CI/CD pain point our engineers actually complain about today," and the answer to that question — not this chapter's illustrative service counts — is what should determine which technique gets adopted next.

---

## CI Cost Attribution in a Monorepo

A genuinely practical, easy-to-overlook operational problem worth its own section: once every team's CI runs share one repository's pipeline configuration and one platform account's billing (per-minute/credits, per Parts 4-10's various pricing models), **which team's changes are actually driving the CI bill** becomes meaningfully harder to answer than in a polyrepo, where each repository's CI spend is naturally, trivially attributable to whichever team owns it.

```mermaid
graph TD
    Bill["ONE monthly CI bill,<br/>ONE shared platform account"] --> Q{"Which team is<br/>ACTUALLY driving this cost?"}
    Q --> Opaque["❌ Without attribution:<br/>a genuine mystery — is<br/>it one team's slow test<br/>suite, one team's<br/>oversized resource class<br/>choices, or broad usage<br/>evenly spread?"]
```

**The practical mitigation leans on exactly the affected-detection machinery already covered throughout this chapter, repurposed for cost visibility rather than correctness:** since affected-only detection already determines *which* projects a given CI run actually built/tested, that same data — tagged with the owning team per the CODEOWNERS mapping covered earlier — can be aggregated into a genuine per-team cost breakdown, commonly via the CI platform's own cost/usage API (several platforms in this series — CircleCI's Insights from Part 10, GitHub's own usage reporting — expose exactly this kind of per-job cost data) cross-referenced against the affected-project-to-team mapping. **Without this discipline, a monorepo's CI cost conversation tends to default to either "let's just accept it as one shared platform cost" (which removes any team's incentive to actually optimize their own slow tests or oversized resource-class choices) or an unproductive, evidence-free argument about whose fault the bill is** — neither of which a polyrepo's naturally-attributed, per-repository billing would ever force a team into. Establishing per-team cost attribution, even approximately, restores the same accountability a polyrepo gets essentially for free, without giving up any of this chapter's monorepo-specific CI efficiency techniques.

This same attribution data doubles as a genuinely useful prioritization signal for exactly the incremental-adoption case study covered next in this chapter — a team can point at concrete, real numbers ("Team X's slow integration-test suite accounts for 40% of total monorepo CI spend") to justify investing in remote caching or test-impact analysis specifically where the data shows the actual bottleneck lives, rather than guessing at which team's pipeline most needs attention.

A lighter-weight starting point worth naming for a team not ready to build full per-job cost tagging: even a simple monthly report cross-referencing total CI minutes by top-level directory (available from most platforms' own usage APIs, per Parts 4-10) against the CODEOWNERS mapping gets most of the same visibility with a fraction of the tooling investment — full per-job attribution is worth building once the rough picture from this cheaper approach justifies the additional precision.

---

## Visualizing the Full Monorepo CI Decision Stack

Worth a closing, unifying diagram — every technique covered across this chapter, laid out as the actual sequence of decisions a single CI run passes through, from a raw git push to a completed, correctly-scoped result:

```mermaid
flowchart TD
    Push["Developer pushes a<br/>small, trunk-based commit"] --> Checkout["Git-level scaling:<br/>shallow + partial clone,<br/>sparse checkout of only<br/>the relevant directories"]
    Checkout --> PathFilter["Platform-native path<br/>filtering: coarse first pass,<br/>rules out obviously<br/>unrelated top-level areas"]
    PathFilter --> Affected["Dependency-graph-aware<br/>affected detection<br/>(Nx / Turborepo / Bazel):<br/>the REAL, correct scope,<br/>including indirect<br/>shared-dependency impact"]
    Affected --> Cache{"Remote cache: has this<br/>EXACT work already been<br/>done, by anyone, anywhere?"}
    Cache -->|"Hit"| Skip["Skip actual execution —<br/>instant result"]
    Cache -->|"Miss"| Execute["Execute — locally parallel,<br/>or distributed via remote<br/>execution at Bazel scale"]
    Skip --> Result["Correctly-scoped,<br/>fast result"]
    Execute --> Result
    Result --> Queue["Merge queue: validated<br/>against the simulated<br/>FUTURE combined state,<br/>not just main's current HEAD"]
    Queue --> Merged["Merged to main —<br/>cost attributed to the<br/>owning team; deploy<br/>pipelines for each<br/>AFFECTED service proceed<br/>fully independently"]
```

**Reading this diagram top to bottom is the single best way to internalize how this chapter's many individually-covered techniques actually compose in a mature, real production monorepo setup** — no single layer in this stack is optional in the sense that skipping it breaks everything else, but each layer genuinely narrows, speeds up, or de-risks what the layers below it have to do. A team missing the affected-detection layer entirely still functions (falling back to path filtering's coarser, occasionally-incorrect approximation); a team missing remote caching still functions (just slower, redoing work others have already done); a team missing a merge queue still functions most of the time (just with occasional, otherwise-avoidable "two individually-passing PRs broke main when combined" incidents). The case study earlier in this chapter described roughly the order these layers get adopted in practice — this diagram is the same information, reorganized as the shape of one single CI run once every layer is actually in place.

Worth using this diagram directly as a debugging tool, not just a teaching aid: when a real monorepo's CI is behaving unexpectedly (too slow, or — worse — silently skipping something that should have run), walking through each layer in this exact top-to-bottom order and asking "is this specific layer behaving correctly, in isolation" is a far more tractable diagnostic approach than treating the whole pipeline as one opaque black box. A checkout that's slower than expected points at the git-scaling layer; a test that should have run but didn't points at the affected-detection layer specifically, not at "CI is broken" generically; a merge that broke `main` despite every individual PR passing points at the merge-queue layer. This diagram is, in effect, the chapter's entire content compressed into one ordered checklist for exactly that kind of triage.

---

## A Full Worked Example: GitHub Actions + Nx Affected

Tying every technique from this chapter together into one realistic pipeline:

```yaml
name: Monorepo CI
on:
  pull_request:
    branches: [main]

jobs:
  affected:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0              # Nx needs real history to diff against main
          filter: blob:none           # partial clone - skip blob content until needed

      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci

      - name: Run affected tests only
        run: npx nx affected --target=test --base=origin/main --parallel=4

      - name: Run affected builds only
        run: npx nx affected --target=build --base=origin/main --parallel=4

      - name: Security scan affected projects only
        run: npx nx affected --target=security-scan --base=origin/main
```

```mermaid
flowchart TD
    PR["PR opened - touches ONLY<br/>shared-lib/ and services/checkout/"] --> Checkout["Partial + shallow checkout"]
    Checkout --> NxGraph["nx affected computes:<br/>shared-lib changed →<br/>checkout AND payments<br/>both depend on it →<br/>BOTH marked affected"]
    NxGraph --> Run["Test/build/scan run<br/>ONLY for checkout + payments —<br/>197 other untouched services<br/>SKIPPED entirely"]
    Run --> Queue["Merge queue tests the<br/>FINAL combined state<br/>before actually merging"]
```

**Every layer from this chapter is visible in this one pipeline:** the checkout itself is optimized (shallow + partial clone), the actual work is scoped correctly by a real dependency graph (Nx affected, correctly catching the indirect `shared-lib` → `checkout`/`payments` dependency that pure path filtering would have missed), and — implicitly, configured at the GitHub repository level rather than in this YAML — a merge queue ensures the final merged state is still validated even at high merge volume. This is what a mature, real-world monorepo CI/CD setup actually looks like: not one single clever trick, but several complementary layers, each addressing a distinct part of the underlying scaling problem.

Notice, too, what's deliberately absent from this example, consistent with the independent-deploys section earlier in this chapter: there's no deploy step shown here at all, because deployment is each affected service's own separate pipeline's responsibility, triggered independently (commonly by this same CI workflow, once it succeeds, calling into each service's own deploy pipeline via the platform's native cross-workflow triggering) rather than folded into this shared build/test workflow.


---

## A Second Worked Example: GitLab Dynamic Child Pipelines + Turborepo

Worth a second full example using a genuinely different platform-native delivery mechanism, to reinforce that this chapter's techniques are platform-agnostic even though the specific YAML differs — here using GitLab's dynamic child pipelines (Part 6) instead of GitHub's static path filtering, combined with Turborepo instead of Nx.

```yaml
# .gitlab-ci.yml — the parent pipeline, generates the real job list dynamically
stages: [generate, build-test]

generate-pipeline:
  stage: generate
  image: node:20
  script:
    - npx turbo run build test --filter="...[origin/main]" --dry=json > affected.json
    - node ./scripts/generate-gitlab-config.js affected.json > generated-config.yml
  artifacts:
    paths: [generated-config.yml]

trigger-affected-jobs:
  stage: build-test
  trigger:
    include:
      - artifact: generated-config.yml
        job: generate-pipeline
    strategy: depend
```

```mermaid
flowchart TD
    Parent["Parent pipeline:<br/>generate-pipeline job runs<br/>turbo run --filter=...[main]<br/>--dry=json"] --> JSON["Turborepo outputs WHICH<br/>packages are affected,<br/>as structured JSON"]
    JSON --> Script["A small script translates<br/>that JSON into a genuine<br/>generated-config.yml —<br/>one job per AFFECTED<br/>package only"]
    Script --> Child["Child pipeline triggered,<br/>containing ONLY the jobs<br/>for genuinely affected<br/>packages — everything<br/>else was never even<br/>defined in this run"]
```

**The structural difference from the earlier GitHub+Nx example is worth naming precisely, since it illustrates a genuine platform capability difference already covered in Part 6:** GitHub's static `paths:` filtering can gate whether an *already-defined* job runs, but cannot change which jobs exist in the pipeline at all — the earlier example handled this by having every job internally re-check `nx affected` and no-op if its own project wasn't in the affected set. GitLab's dynamic child pipelines, by contrast, let the *set of jobs itself* be generated programmatically from Turborepo's affected output — a genuinely more precise mechanism (the CI system's own UI shows only the jobs that actually needed to exist for this specific run, not a full job list with most entries showing as skipped). This is the same distinction already drawn in Part 10 between CircleCI's dynamic config and simpler path-based `filters:` — worth recognizing as the same underlying capability difference recurring a third time across this series, now applied specifically to monorepo affected-detection.

**A third, worth-knowing option not shown in either full example: several teams achieve a similar effect on any platform simply by having each affected-detection invocation write its result to a small JSON/text artifact early in the pipeline, then having every subsequent job read that artifact and self-determine whether to proceed — functionally similar to the GitHub example's no-op pattern, but slightly more explicit about the affected-set computation happening exactly once rather than being silently re-run inside every individual job.

Both examples are equally valid, correct approaches, worth stating explicitly so neither reads as "the better one":** the no-op-per-job pattern (GitHub) is simpler to set up and debug, at the cost of a pipeline UI showing more entries than strictly necessary; the dynamically-generated job list (GitLab) is more precise and produces a cleaner UI, at the cost of an extra generation step and script to maintain. A team's actual platform (established for other reasons, per Parts 4-10's own comparisons) determines which pattern is available and idiomatic, not a strict technical superiority of one over the other.

---

## Common Mistakes

| Mistake | Why it's a problem | Fix |
|---|---|---|
| Relying on path filtering alone in a monorepo with real shared dependencies | Silently skips testing genuine downstream consumers of a changed shared library — a correctness gap, not just an efficiency one | Adopt a dependency-graph-aware tool (Nx, Turborepo, or Bazel) for anything with real internal cross-project dependencies |
| Adopting Bazel for a small-to-mid monorepo "because it's what Google uses" | Bazel's hermetic-build discipline carries real, substantial upfront configuration cost, justified mainly at very large scale | Default to Nx or Turborepo unless genuinely operating at the scale/multi-language-breadth where Bazel's guarantees pay for themselves |
| No remote caching configured, even with affected-only detection in place | Every CI run rebuilds/retests everything the affected set determines is needed, even if it was already built by another engineer minutes ago | Enable remote caching (Nx Cloud, Turborepo Remote Cache, or a self-hosted Bazel remote cache) to compound with affected-detection |
| A full, unfiltered `git clone` on every CI run of a very large monorepo | Wastes real time on checkout alone, before any actual build/test logic even begins | Combine shallow clone, partial clone, and sparse checkout for CI jobs that don't need the full repository |
| No merge queue in a high-merge-volume monorepo, relying only on affected-only PR checks | Two individually-passing PRs can still conflict when both land, since each was only tested against main's state at approval time, not the actual final combined state | Adopt a merge queue (or GitLab merge trains) so `main` is validated against a simulated future state, not just each PR in isolation |
| A stale, unmaintained CODEOWNERS file in a monorepo that's reorganized since it was written | Review routes to teams that no longer actually own the affected code | Treat CODEOWNERS as a living artifact with its own maintenance process, audited as teams and ownership boundaries change |
| Sharing one broad, repository-wide deploy credential across every service's pipeline | Flattens what should be many independent security boundaries into one — a compromised low-stakes pipeline can reach high-stakes production credentials | Scope every service's deploy credentials independently via Environment/Protected-variable/Context mechanisms, exactly as if each lived in its own repo |
| Assuming a monorepo implies coordinated, all-services-at-once deployment | Unnecessarily couples independent services' release cadences purely because their source happens to be co-located | Keep deployment pipelines, environments, and approval gates fully independent per service, regardless of shared source-control location |
| No per-team CI cost attribution in a monorepo with a shared billing account | Removes any individual team's incentive to optimize their own slow tests or oversized resource-class choices; disputes over the bill become evidence-free | Tag affected-project cost data by owning team, using the same CODEOWNERS mapping already maintained for review routing |
| Front-loading every technique in this chapter into a brand-new, still-small monorepo | Premature complexity and adoption cost for a scale that doesn't yet need it | Adopt techniques incrementally, matched to actual measured pain at each stage, per this chapter's case study |
| Conflating package-manager workspaces with build-orchestration tooling | Assuming npm/Yarn/pnpm workspaces alone give you affected-detection, caching, or task graphs — they don't | Recognize workspaces as the dependency-resolution layer only; Nx/Turborepo/a hand-rolled script is a separate, additional decision |
| Consolidating many projects' binary assets into a monorepo with no Git LFS plan | Clone/checkout times balloon as binary history accumulates, undermining every other git-scaling technique in this chapter | Adopt Git LFS from the start of a binary-heavy monorepo migration, not reactively once clones are already painful |
| Confusing remote caching with distributed remote execution | Assumes caching alone will speed up a from-scratch build with nothing yet cached, when it provides zero benefit in that specific scenario | Recognize caching skips already-done work; remote execution distributes genuinely new work — the two are complementary, not interchangeable |
| A cache key that doesn't capture every genuine input (e.g. an untracked environment variable or tool version) | Serves stale, incorrect cached results for changes that should have invalidated the cache — a silent correctness bug, not just a missed optimization | Audit what a task's cache key actually covers; prefer Bazel's hermetic guarantee when this risk is genuinely unacceptable |
| Long-lived feature branches in a monorepo relying on this chapter's tooling | Every technique here performs worse against large, infrequent merges — affected-detection's precision and merge-queue value both shrink | Pair monorepo CI/CD tooling with trunk-based development and feature flags for small, frequent merges |

---

## Worked Practice Problems

**Problem 1:** A team's monorepo CI relies purely on `paths:` filtering per service directory. A change to a shared `proto/` directory (containing gRPC schema definitions used by 12 different services) merges with only the `proto/` directory's own (minimal) tests running — none of the 12 consuming services' tests ran at all, and one of them breaks in production days later due to an incompatible schema change. Diagnose the root cause and the fix.

*Answer:* This is precisely the path-filtering ceiling covered in this chapter — a change to `proto/` doesn't match any of the 12 consuming services' own path filters, so each service's own path-scoped pipeline correctly (from path filtering's own limited logic) determined "my directory didn't change" and skipped its tests, even though every one of those services has a genuine, real dependency on the changed schema. The root cause is architectural, not a one-off mistake: path filtering alone cannot express "also run X's tests when Y (something X depends on) changes," because it has no concept of a dependency graph at all. The fix: adopt a dependency-graph-aware affected-detection tool (Nx, Turborepo, or Bazel, depending on the monorepo's language mix and scale) that understands the real dependency relationship between `proto/` and its 12 consumers, so a `proto/` change correctly marks all 12 as affected and runs their tests before the change can merge.

**Problem 2:** An engineering leader observes that CI times have crept up over 18 months as the monorepo grew, and asks whether adopting affected-only builds or remote caching would have more impact, given a fixed amount of engineering time to implement one first. How would you reason about the answer?

*Answer:* Affected-only builds should almost always come first, because it changes *what* runs at all — for a typical small change touching one or two projects out of hundreds, affected detection alone can produce the majority of the total time savings (running 2 projects' worth of work instead of 200's), and remote caching compounds on top of whatever affected detection determines actually needs running. Remote caching alone, without affected-only detection, still means computing which of 200 projects' cache keys to even check — some tools' caching does partially subsume this (a cache miss for an untouched project's unchanged inputs is itself fast), but the clean, correct sequencing is: get affected-detection right first (the correctness-and-scope layer), then layer remote caching on top (the "don't even redo affected work that's already been done elsewhere" layer) — reversing the order risks spending the caching implementation effort on a system that's still needlessly considering the full 200-project set for every change.

**Problem 3:** A monorepo has both a well-configured `nx affected` setup and GitHub's native path filtering, layered together — a change is made purely to a markdown README file inside `services/checkout/docs/`. Walk through what happens in both layers, and whether this is correctly optimized.

*Answer:* GitHub's `paths:` filtering (if scoped broadly to `services/checkout/**`) would trigger `checkout`'s pipeline, since the changed file does live under that path — but `nx affected`, computing from the *actual* project dependency graph and typically configured to ignore non-code files like documentation via its own input-file configuration, would correctly determine that no genuine build/test-relevant input changed, and report zero affected projects needing tests or builds. This is a real, if minor, inefficiency in the layering: the platform-native path filter is coarser than the affected-detection tool underneath it, so the pipeline still spins up and runs `nx affected` (a fast operation) even though it correctly does nothing further — the fix, if this pattern is common enough to matter, is tuning Nx's own file-classification config (which file types it considers relevant to the dependency graph at all) rather than trying to make GitHub's simpler path filter itself doc-aware, since the graph-aware tool is already the more precise, more correct layer for this specific distinction.

**Problem 4:** A finance team and a marketing-site team share one monorepo. The finance team's CI pipeline (handling PCI-scoped payment processing) and the marketing team's CI pipeline currently both use one shared, repository-wide GitHub Actions secret for their respective cloud deployments, because "it was easier to set up one secret than several." A security audit flags this. Walk through the exploitable risk and the fix, referencing this chapter's security section.

*Answer:* Any compromised dependency, malicious PR, or misconfigured workflow anywhere in the repository — including in the marketing site's comparatively low-stakes, likely less rigorously reviewed pipeline — has access to the exact same shared secret the PCI-scoped finance pipeline uses, meaning a compromise of the lower-stakes marketing pipeline is functionally equivalent to a compromise of the finance pipeline's own deploy credentials. This is precisely the "monorepo flattens independent security boundaries into one" risk this chapter's security section describes. The fix: split into two genuinely separate, independently-scoped secrets — a finance-specific deploy credential visible only to the finance pipeline's own Environment/Context, and a marketing-specific one visible only to marketing's — restoring the same security boundary a polyrepo would have provided by construction, using the Environment/Protected-variable/Context mechanisms already covered per-platform earlier in this series (Parts 4-10), regardless of the extra one-time setup cost of configuring two secrets instead of one.

**Problem 5:** Six months after adopting Nx in a 200-service monorepo, a platform team notices `nx affected` is reporting an unexpectedly large number of "affected" projects for even small, unrelated-looking changes — CI times have crept back up toward what they were before adoption. What's the most likely root cause, and how would you diagnose it?

*Answer:* The most likely cause is an overly broad or incorrectly modeled dependency in the project graph — a common real pattern is a genuinely shared, foundational package (a common "utils" or "types" package) that has, over time, accumulated dependents across nearly every service in the repository, meaning any change to it (even a trivial one) correctly, but now unhelpfully broadly, marks nearly the entire monorepo as affected. Diagnose using Nx's own graph visualization (`nx graph`) focused specifically on that suspect package, to see exactly how many projects transitively depend on it and whether that dependency breadth is actually necessary or has crept in accidentally (e.g. an overly broad barrel-file export pulling in far more than a given consumer actually needs). The fix is usually architectural, not tooling-related: splitting an overly broad shared package into more narrowly-scoped ones, so a change to one narrow slice doesn't transitively implicate every consumer of the broader package it used to be part of — the same "keep shared dependencies narrowly scoped" discipline that limits blast radius in ordinary software architecture, here directly determining CI efficiency too.

**Problem 6:** An organization currently has 15 separate repositories (one per microservice) and is considering consolidating into one monorepo, primarily to make cross-service refactoring easier. What would you tell them about the CI/CD cost of this decision, referencing this chapter directly?

*Answer:* The consolidation itself is free from a pure "can we do it" standpoint, but this chapter's entire content is the honest list of what they're signing up for to keep CI/CD working well afterward, not a one-time migration cost: they'll need to adopt path filtering at minimum (cheap, immediate), very likely a dependency-graph-aware tool like Nx or Turborepo once shared-dependency blind spots start causing missed test runs (a near-certainty at 15+ services with genuine cross-service code sharing, which is presumably part of why they want easier cross-service refactoring in the first place), and should plan for the security-scoping discipline covered in this chapter's security section from day one rather than retrofitting it later. The honest framing: monorepo consolidation trades an easier cross-service-refactoring story for a real, ongoing CI/CD tooling investment — worth doing deliberately, with this chapter's techniques budgeted for from the start, rather than discovering the need for each one reactively as CI times or security gaps become painful in production.

**Problem 7:** A team has adopted every technique in this chapter's decision-stack diagram except a merge queue, reasoning that "our affected-detection is solid, so individual PRs are always tested correctly before merge." A production incident traces back to two PRs — one modifying a shared authentication library, one modifying a service that consumes it in a way the first PR's author couldn't have anticipated — each individually passing CI, that together broke production once both merged within the same hour. Diagnose exactly what went wrong and why "solid affected-detection" didn't prevent it.

*Answer:* Affected-detection correctly determined the scope of *each PR individually* — the auth-library PR correctly triggered the consuming service's tests, and by the problem's own framing, passed them. The gap isn't in affected-detection's correctness at all; it's a structural gap only a merge queue closes: each PR was tested against `main`'s state *at that PR's own approval time*, not against the state `main` would actually have once *both* PRs had landed. If the consuming-service PR was authored and approved before the auth-library PR merged, its own test run never saw the auth-library change at all — by the time both were actually merged, the combination that broke production was never validated together, only each PR against an now-stale baseline. This is precisely the scenario this chapter's merge-queue section describes, and it's a category of failure affected-detection — however correct — structurally cannot catch on its own, because affected-detection answers "what does this one PR need testing against," not "what will `main` actually look like once every currently-queued PR has landed."

**Problem 8:** An ML platform team is migrating several previously-separate repositories (each containing model training code plus multi-gigabyte model weight files) into one monorepo. Six weeks after migration, new engineer onboarding — previously a five-minute `git clone` — now takes over 40 minutes, and CI checkout time has become the dominant cost in every pipeline run. Diagnose the likely cause and propose the fix, referencing the specific techniques covered in this chapter.

*Answer:* The likely cause is exactly the large-binary-file problem this chapter's Git LFS section describes — consolidating several repos' worth of multi-gigabyte model weight files (very likely with meaningful version churn over each repo's history, as models get retrained and re-committed) into one repository means every clone now downloads the *full history* of every one of those large binaries, compounding across every formerly-separate repo's own binary history. The fix is two-layered, combining techniques from different sections of this chapter: first, migrate the large model-weight files to Git LFS going forward (and, if feasible, rewrite history to move existing large-file commits to LFS too, accepting the one-time disruption of a history rewrite for a genuinely large, ongoing win) so the Git repository itself stores only small pointers; second, apply the git-scaling techniques from earlier in this chapter (shallow clone, partial clone) to CI jobs specifically, so a given training-service's CI run doesn't need to materialize every other service's model weights at all — sparse checkout scoped to just the directories that specific job's affected-detection determined it actually needs.

**Problem 9:** A team on Bitbucket Pipelines (Part 7) is evaluating whether to migrate their growing 40-service monorepo to GitHub or GitLab specifically to get native monorepo support, versus staying on Bitbucket and building the equivalent tooling themselves. How would you frame this decision for them?

*Answer:* Per this chapter's own analysis, the real intelligence in monorepo CI/CD — affected-detection via Nx/Turborepo/Bazel — is platform-agnostic and works identically regardless of which of the four platforms delivers its output; what genuinely differs is the *delivery mechanism* (native path filtering, dynamic pipeline generation) each platform provides natively versus requires hand-building. Bitbucket's specific gap (no native path filtering, requiring a hand-written `git diff` script) is real but narrow — it affects only the coarse first-pass filtering layer, not the actual dependency-graph-aware affected-detection that does the real work. The honest framing: migrating platforms purely to fix this one, relatively narrow gap is very likely not worth the migration cost (re-establishing every pipeline, every secret, every integration already built on Bitbucket) unless Bitbucket's other limitations from Part 7 (thin security scanning, no CODEOWNERS-equivalent) are *also* independently pushing toward a migration — in which case monorepo support becomes one additional data point in a decision that should be made on the fuller picture, not the deciding factor on its own.

---

## Summary and What's Next

Monorepo CI/CD is fundamentally about answering "which of many projects in this repository were actually affected by this specific change" correctly and efficiently — a problem every platform's own native path filtering (Part 4's GitHub `paths:`, Part 6's GitLab `rules: changes:`) can only partially solve, since it has no concept of a dependency graph and therefore silently misses shared-dependency changes' true downstream impact. Package-manager workspaces (npm/Yarn/pnpm) solve the separate, prerequisite problem of local cross-package dependency resolution, distinct from task orchestration. Nx, Turborepo, and Bazel each solve the affected-detection graph-walking problem at different scale and ecosystem-breadth tradeoffs — Nx and Turborepo via inferred or declared project/task graphs, Bazel via fully hermetic, explicitly-declared dependencies and optional remote execution, justified mainly at the largest scale. Remote caching compounds with affected-detection as a genuinely separate, multiplicative lever, and test impact analysis narrows further still, to the individual-test level, for teams whose bottleneck has moved past project-level granularity. Every platform-native dynamic-pipeline mechanism covered across Parts 4-11 (GitHub path filters, GitLab dynamic child pipelines, CircleCI dynamic config, Jenkins Shared Library helpers) is the delivery vehicle for these tools' output, not a substitute for the underlying dependency-graph awareness itself. Git-level scaling techniques (shallow clone, partial clone, sparse checkout) address the separate "just checking out the code is slow" problem, and merge queues address the separate "two individually-passing changes can still conflict when combined" correctness problem that affected-only testing alone doesn't solve — most effective, per this chapter's closing sections, when paired with trunk-based development's small, frequent merges rather than large, infrequent ones. A monorepo does not imply monolithic deployment (each service keeps its own independent release pipeline) or flattened security boundaries (each service's credentials should remain as tightly scoped as if it lived in its own repository) — both are choices a team must actively make, not properties the monorepo structure grants or removes automatically. Cost attribution and the incremental, pain-driven adoption case study closed the chapter by treating every technique here as a toolkit matched to actual measured scale, not a checklist to front-load into a still-small repository.

**Part 13** moves to Progressive Delivery — Argo Rollouts and Flagger, extending Part 1's deployment-strategies discussion and Part 3's GitOps coverage with the concrete Kubernetes-native tooling that implements canary and blue-green rollouts with automated, metric-driven promotion and rollback. Where this chapter addressed how to *validate* a change efficiently at monorepo scale, Part 13 addresses how to *release* a validated change safely, regardless of how many or how few services a given monorepo contains.

This closing pairing is deliberate: a monorepo's affected-only detection determines which services need a new release candidate built at all; Part 13's progressive delivery tooling determines how safely each of those services' own independent deployment pipeline actually rolls that candidate out to real traffic — two genuinely separate concerns, covered in two separate chapters, that together complete the full "validate, then safely release" picture this series has been building toward since Part 1.

