CI/CD Pipeline Optimization for Monorepos
A monorepo CI pipeline that rebuilds and retests every package on every commit gets slower with each package you add, until a one-line change waits twenty minutes for green. This guide covers the three levers that keep monorepo CI fast — affected detection, cross-run caching, and parallel sharding — and it extends the Monorepo Architecture & Orchestration section with the CI-side of the task runner you have already chosen.
Concept overview and where it fits
The core insight is that most commits touch a small fraction of the repo, so most of CI's work is recomputing results that have not changed. Three techniques exploit that: affected detection skips packages no changed file can reach, caching replays outputs for inputs that have been built before, and sharding spreads the remaining work across parallel runners. They compose — affected narrows the set, cache replays what it can, and sharding parallelizes the rest.
Initialization: give CI the history it needs
Start by giving CI the history it needs to compute a diff. A shallow clone has no base commit to compare against, so affected detection silently falls back to 'everything changed'. Fetch enough history and pass an explicit base.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history so the merge-base exists
- uses: pnpm/action-setup@v4
with: { version: 10 }
- run: pnpm install --frozen-lockfile --ignore-scripts
- run: pnpm turbo run build test --filter='...[origin/main]'
Architecture: affected runs on the graph
Affected detection works off the dependency graph, not the file list. A changed file marks its own package dirty, and the tool then walks dependents — every package that imports the dirty one — because their behavior could change too. This is why the graph must be accurate: an undeclared import means a real dependent is missed and shipped untested. The same graph powers pnpm's filter selectors and Nx affected.
Execution strategy: cache then shard
Layer a remote cache on top so a result computed on one runner is reused on every other, including local developer machines. With remote caching configured, a shard that already built a package downloads the artifact instead of rebuilding it.
- run: pnpm turbo run build --filter='...[origin/main]'
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
Security and isolation
Caching is a trust boundary: a poisoned cache entry can inject a malicious artifact into every consumer of it. Scope cache write access to trusted branches only, keep pull-request builds read-only against the shared cache, and include the lockfile in the cache key so a dependency change can never replay a stale artifact. Run installs with --ignore-scripts in CI so a compromised dependency cannot execute during the build.
CI/CD integration: matrix sharding
Sharding splits the remaining task set across N runners using a matrix. Combine it with affected so each shard only runs its slice of the changed set.
test:
strategy:
matrix:
shard: [1, 2, 3, 4]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: pnpm turbo run test --filter='...[origin/main]'
-- --shard=${{ matrix.shard }}/4
Common Pitfalls and Remediation
| Mistake | Impact | Remediation |
|---|---|---|
| Shallow clone in CI | Affected falls back to 'all changed' | fetch-depth: 0 and pass an explicit base. |
| Cache key without lockfile | Stale artifacts replay after a dep bump | Include the lockfile hash in the key. |
| PR builds writing the shared cache | Cache poisoning surface | Make PR builds read-only against the cache. |
| Sharding without affected | Every shard runs the whole suite | Filter to the affected set before sharding. |
Frequently Asked Questions
How much history does affected detection actually need?
Enough to reach the merge-base with your comparison branch. fetch-depth: 0 is the safe default; a shallow clone often lacks the base commit and forces a full rebuild.
Do I need a task runner to get affected builds?
A task runner (Turborepo, Nx) or pnpm's --filter makes it turnkey. You can script it by hand with git diff plus the workspace graph, but the runners already model dependents correctly.
Will caching make my CI non-deterministic?
Only if the cache key is wrong. Include every input that affects the output — source, lockfile, task config, and declared env — and a cache hit is provably identical to a rebuild.
Should pull-request builds populate the remote cache?
Keep them read-only. Let trusted branch builds write the shared cache so an untrusted PR cannot inject an artifact that other builds later replay.
Related
- Choosing a Monorepo Task Runner — the runner that supplies affected detection and caching.
- Remote Caching Setup — the shared cache that makes CI reuse work across runners.
- pnpm Workspace Filtering — change-aware filters that select only affected packages.
- Configuring Nx Affected Commands in CI — the Nx-specific affected setup for pipelines.