Monorepo CI
A monorepo is a great way to share code and a terrible way to run a default CI pipeline. Running the entire suite on every change is wasteful when only one project changed; running per-project independently is fine until two PRs touch the same files. Monorepo CI is the practice of getting both right.
In one paragraph
Monorepo CI has two halves. The first is affected-projects detection: given a diff, figure out which projects actually need to be re-tested. Nx, Bazel, and Turborepo all expose a version of this. The second is scope-aware merging: a frontend PR should not wait behind a backend migration in the queue. A scope-aware merge queue runs independent lanes per project so the queue scales with the team, not with the size of the repo.
Why the default monorepo CI is a trap
The naive CI for a monorepo is the same as for a regular repo: run the full test suite on every PR. This works fine for the first dozen projects. It stops working somewhere between 50 and 100, when the bill and the wait time both pass the threshold of "what we are willing to pay for a typo fix to land."
The cost shows up in two places:
- CI minutes: a typo fix in one project rebuilds and re-tests every project. Multiply by team size and PR rate, and the bill becomes a top-five infrastructure cost.
- Engineer wait time: a 60-minute CI suite blocks every PR, even the ones that should have taken thirty seconds.
The point of monorepo CI is to make the cost of a PR proportional to the size of the change, not to the size of the repo.
Affected-projects detection
The core idea is the project graph. Each project declares the files it depends on (source, configuration, shared libraries). A change is a diff. Given the diff, walk the graph backward to find every project that depends on any changed file. That set is what needs to be re-tested. Everything else is skipped.
Common ways to express the graph:
- Bazel: BUILD files declare inputs and deps.
bazel querywalks the graph. Strong determinism, high operational cost. - Nx: implicit graph derived from imports plus an explicit
project.json.nx affectedis the query. Strong fit for JavaScript and TypeScript monorepos. - Turborepo: similar shape to Nx, lighter touch.
turbo run --filterdrives the affected set. - Pants: the Bazel-shaped option for Python-heavy monorepos.
- Hand-rolled: a script that maps top-level directories to projects and uses
git diff --name-only. Plenty of teams ship this for a year before they hit the limits.
The tool matters less than the discipline of declaring project boundaries. Once the boundaries exist, any of these tools can compute the affected set.
Scope-aware merge queue
Affected-projects detection makes PR CI fast. The other half of the puzzle is the merge queue. A naive single-queue setup serializes every merge in the repo, so a frontend PR waits behind a backend migration that has nothing to do with it. The bigger the repo, the worse the bottleneck.
The fix is independent merge lanes per scope. The queue understands which project a PR touches and runs lanes in parallel. PRs that touch a single scope live in that scope's lane. Cross-cutting PRs that touch two scopes join both lanes and merge only after passing each. This is what makes a merge queue scale to a real monorepo without becoming a serialization tax.
Mergify supports this pattern natively. Scopes are declared once, the queue runs parallel lanes per scope, and batching is scope-aware so unrelated PRs do not contaminate each other's batches. For the per-product detail, see Mergify Merge Queue and the parallel queues guide.
FAQ
What is monorepo CI?
Monorepo CI is the set of practices that keep continuous integration tractable when many projects live in a single repository. The big problem: running every test for every project on every change is slow and expensive. The big answer: only run the tests for the projects a change actually affects, and run independent projects in parallel.
How is monorepo CI different from regular CI?
In a single-project repo, CI is straightforward: every PR runs the suite. In a monorepo, the same default is a trap. A CSS change in one app should not wait for a backend migration's full integration suite, and the bill for running everything every time grows linearly with the number of projects in the repo. Monorepo CI is everything that makes 'only run what matters' actually work.
What is affected-projects detection?
A build-time analysis that, given a diff, returns the set of projects that need to be re-tested. Nx, Bazel, and Turborepo all expose a version of this. The idea: each project declares its inputs (source files, dependencies, configuration), and the tool walks the graph backward from a change to find every project that depends on the changed files.
What is a scope-aware merge queue?
A merge queue that understands a monorepo's project graph and runs independent merge lanes per project. A frontend PR does not wait behind a backend migration. Cross-cutting PRs that touch both projects join both lanes and merge once they pass each. Mergify supports this pattern natively with scoped queues.
Should I use Bazel for a monorepo?
If incremental, deterministic builds across multiple languages and project types are a load-bearing requirement, Bazel earns its keep. If not, the operational cost is real and may outweigh the benefit. Many monorepos do fine with Nx or Turborepo (JavaScript-heavy), Pants (Python), or a simpler scope detection layer wired into GitHub Actions.
How do I run only the right tests on a PR?
Two pieces. First, a way to compute the affected set from a diff (Nx, Turborepo, Bazel, or a custom script that reads project boundaries). Second, a CI step that uses the affected set to choose which test suites to invoke. The combination is what turns 'run everything' into 'run what changed'.
Does this work with GitHub Actions?
Yes. The pattern is a setup job that computes the affected set (using nx affected, turbo run --filter, or bazel query), exports it, and downstream jobs read it via matrix or conditional steps. Most monorepo CI on GitHub Actions today is built this shape.
A merge queue built for monorepos.
Mergify runs independent merge lanes per scope, batches per scope, and tests every PR against the actual future state of main. Built for teams that outgrew running everything on every change.