BLOG/TEAM CI/CD
THEME
TEAM CI/CD

Build only what changed in your monorepo

Rebuilding 40 packages because someone touched the README is how a monorepo's CI ends up taking 25 minutes. Detect the affected set, build and test only those, and gate the merge on the result.

Jun 1, 2026 OrchStep Team 6 minROLE: Platform EngineerSCALE: Scale-up
RUNNABLE DEMO
Full source for this post: blog/monorepo-affected
VIEW SOURCE

The monorepo started fast. Then it had forty packages, and every PR — even a one-line docs fix — kicked off a 25-minute pipeline that built and tested all forty. Developers learned to push, go get coffee, and context-switch. The CI bill grew linearly with package count while the value of most of each run was zero, because most packages didn't change.

The fix every scale-up converges on is "affected builds": figure out which packages a change actually touches, and only build and test those. The hard part isn't the idea, it's keeping the wiring readable — the detection, the fan-out, and the merge gate usually end up as an unmaintainable shell script that one person understands.

Here's the same thing as a workflow you can read.

Detect, then fan out

Two tasks. detect reports the affected set (in a real repo, from a git diff against the base branch). ci loops over that set, builds and tests each package, and gates the merge on the whole thing being green:

orchstep.yml
name: monorepo
# Build only what changed. `detect` reports the affected packages, then we
# build and test each one in a loop and gate the merge on the result.
defaults:
  # In a real repo this list comes from a git diff against the base branch.
  # Here it is static so the demo runs anywhere.
  affected:
    - api
    - web
    - billing

tasks:
  # `orchstep run detect`
  detect:
    steps:
      - name: changed
        func: shell
        do: echo "detecting affected packages from the base branch diff"
      - name: count
        func: shell
        do: echo "{{ vars.affected | len }} package(s) affected"

  # `orchstep run ci`
  ci:
    steps:
      - name: detect
        task: detect
      - name: per_package
        func: shell
        do: echo "building + testing {{ loop.item }}"
        loop:
          items: '{{ vars.affected }}'
      - name: gate
        func: shell
        do: echo "all affected packages green — merge allowed"

The loop over affected is the fan-out. Three packages changed, three build-and-test runs — not forty. The gate step only runs if every iteration succeeded, so a failing package keeps the merge blocked. Run it and the loop is right there in the log:

orchstep run ci
# building + testing api
# building + testing web
# building + testing billing
# all affected packages green — merge allowed

The affected set comes from git

In the demo the list is static so it runs anywhere. In your repo, detect derives it from the diff. The real version of that changed step computes packages touched since the base branch:

git diff --name-only origin/main... \
  | cut -d/ -f1-2 | sort -u

You capture that into a step output and feed it to the loop. The shape of the workflow — detect, fan out, gate — doesn't change; only the detection step gets smarter. See Variables & Outputs for capturing command output into a variable the loop can consume.

Parallel where it pays

Each package's build-and-test is independent, which is exactly what a CI matrix parallelizes well. Let the runner fan the packages out across jobs, and let each job run one package's task:

workflows/affected.yml
name: affected
on: [pull_request]

jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      packages: ${{ steps.set.outputs.packages }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      # setup-orchstep installs + caches the CLI so the next step can capture
      # its output into a matrix. Manual install alternative:
      #   - run: curl -fsSL https://orchstep.dev/install.sh | sh
      - uses: orchstep/setup-orchstep@v1
      - id: set
        run: orchstep run detect

  build:
    needs: detect
    runs-on: ubuntu-latest
    strategy:
      matrix:
        package: ${{ fromJSON(needs.detect.outputs.packages) }}
    steps:
      - uses: actions/checkout@v4
      # Manual install alternative:
      #   - run: curl -fsSL https://orchstep.dev/install.sh | sh
      #   - run: orchstep run build --var package=${{ matrix.package }}
      - uses: orchstep/run-orchstep@v1
        with:
          workflow: orchstep.yml
          task: build
          vars: |
            package=${{ matrix.package }}

The runner owns the parallelism; the workflow owns what each parallel job does. Locally you don't have a matrix, so the in-workflow loop gives you the same coverage sequentially — same packages, same tasks, runnable on your laptop.

What you actually gained

ConcernBuild everythingAffected builds
Docs-only PR40 packages, 25 min0 packages, seconds
CI costscales with repo sizescales with change size
Fan-out logicbespoke shell scripta loop over the affected set
Merge gatepasses if all 40 passpasses if affected packages pass
Run it locallynot reallyorchstep run ci

Be honest about the threshold: under ten packages with a two-minute build, affected detection is overhead you don't need — just build everything. The math flips when "build everything" stops fitting in the time developers will actually wait, which for most scale-up monorepos is well before forty packages.

Where to go next

Rebuilding the whole monorepo for a one-line change? Detect the affected set and loop over just those — curl -fsSL https://orchstep.dev/install.sh | sh.

#MONOREPO#CI#AFFECTED-BUILDS#PLATFORM
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — TEAM CI/CD