Q19 of 38 · CI/CD & DevOps
How would you structure a monorepo's CI to only test changed packages?
Short answer
Short answer: Compute the affected set from the diff against the base branch — Nx, Turborepo, and Bazel all expose a 'what's affected' command. Run tests for affected packages and their dependents only. Run the full suite nightly to catch missed deps and cross-package regressions.
Detail
Monorepos can grow to thousands of packages; testing all of them on every PR is wasteful (and slow). Selective testing is the answer, but it has to be correct — missing a dependent risks shipping bugs.
Tooling that supports affected-set computation:
- Nx (
nx affected:test) — tracks the dependency graph, computes packages whose source or deps changed. - Turborepo (
turbo run test --filter=...[origin/main]) — similar, plus content-hash-based caching for skipping work entirely. - Bazel (
bazel test //...with--config=changed) — heavyweight but most precise. Used at Google, Uber. - Pants, Lerna — older / less common.
The dependency graph is the source of truth.
- Modify package A → test A.
- Modify A, and B depends on A → test A and B.
- Modify the root tsconfig → test everything (or treat as nightly only).
The CI shape:
- run: turbo run test --filter='...[origin/main]' # only affected
Pitfalls to guard against:
- Implicit deps — package A imports from B at runtime via a string path, but the dep graph doesn't see it. The affected-set misses B's tests.
- Shared config — eslint config, tsconfig changes affect everyone. Treat config files as "test all."
- Build-time codegen — generated types from a schema package; consumers should rebuild + retest if the schema changes.
Mitigations:
- Nightly full-suite — catches cases the affected-set missed.
- Strict dep-graph rules — lint rules that enforce explicit imports, ban string-path requires.
- Cache invalidation in tools — Turbo and Nx hash inputs and skip when nothing changed; this is independent of "affected-set" but hugely speeds things up.
Reporting: in PR comments, show what was tested vs. skipped: "Tested: api, web. Skipped: cli, mobile (no changes)." Devs see what's covered.
// EXAMPLE
.github/workflows/monorepo-ci.yml
name: Monorepo CI
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # need history for diff
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'npm' }
- run: npm ci
- run: npx turbo run lint test build \
--filter='...[origin/main]'