Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Compare the Nx approach

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.

Three CI levers Affected narrows, cache replays, sharding parallelizes. affected skip unreachable cache replay built inputs shard parallel runners
The three levers compose: narrow the set, replay what you can, parallelize 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.

Initialization: give CI the history it needs Start by giving CI the history it needs to compute a diff. Initialization: give CI the history it needs Start by giving CI the history it needs to compute a diff.
Initialization: give CI the history it needs — the core idea of this section at a glance.
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.

Affected vs all Rebuilding everything versus only changed packages and dependents. Build everything • N packages every commit • CI time grows with repo • one-line change waits Affected only • changed + dependents • time tracks the change • fast feedback
Affected runs only the dirty package and everything that imports it.

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.

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 machi 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.
Execution strategy: cache then shard — the core idea of this section at a glance.
      - 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.

Security and isolation Caching is a trust boundary: a poisoned cache entry can inject a malicious artifact into every consumer of it. Security and isolation Caching is a trust boundary: a poisoned cache entry can inject a malicious artifact into every consumer of it.
Security and isolation — the core idea of this section at a glance.

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.

Matrix sharding Affected set split across parallel shards. affected set changed slice split N ways matrix shards parallel runners wall-clock ÷ N
Filter to the affected set first, then split it across N runners.
  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.
Common Pitfalls and Remediation Common Pitfalls and Remediation in production JavaScript package workflows. Common Pitfalls and Remediation Common Pitfalls and Remediation in production JavaScript package workflows.
Common Pitfalls and Remediation — the core idea of this section at a glance.

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

Monorepo Architecture & Orchestration