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.
blog/monorepo-affectedThe 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:
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 allowedThe 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 -uYou 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:
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
| Concern | Build everything | Affected builds |
|---|---|---|
| Docs-only PR | 40 packages, 25 min | 0 packages, seconds |
| CI cost | scales with repo size | scales with change size |
| Fan-out logic | bespoke shell script | a loop over the affected set |
| Merge gate | passes if all 40 pass | passes if affected packages pass |
| Run it locally | not really | orchstep 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
- Variables & Outputs — capture the affected set from a git diff
- Loops — fan out over the affected packages
- Quick Start — your first workflow in two minutes
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.
curl -fsSL https://orchstep.dev/install.sh | sh