Monorepo CI Gate
Build and test only the packages affected by a commit, run repo-wide checks in parallel, and gate the merge
Full-monorepo builds waste most of their time on unaffected packages. This
pipeline reads the last commit, computes the affected set (anything
touching shared affects everyone), builds and tests only those packages
- with explicit
skipentries for the rest - and merges only if every check passes.
Executed for real against OrchStep v0.7.1 and a real git monorepo, including the blocked-merge failure path.
What it demonstrates
| Capability | Where |
|---|---|
| Change detection from git | detect_changes (git diff --name-only) |
| Dependency fan-out logic in JS | plan (shared affects all) |
| Loop with skip semantics | build_and_test over all packages |
| Parallel repo-wide checks | repo_wide_checks (lint + blocker scan) |
| Merge gate | gate asserts every result is pass or skip |
The workflow
name: monorepo-ci
desc: "Build and test only the packages affected by the last commit, then gate the merge"
defaults:
repo: "./mono"
all_packages:
- "api"
- "web"
- "shared"
tasks:
setup_demo_monorepo:
desc: "Create a demo monorepo with three packages (demo helper)"
steps:
- name: scaffold
func: shell
do: |
rm -rf {{ vars.repo }}
mkdir -p {{ vars.repo }}/packages/{api,web,shared}
cd {{ vars.repo }}
git init -q -b main
git config user.email ci@orchstep.dev && git config user.name "CI Demo"
for p in api web shared; do
cat > packages/$p/build.sh <<S
#!/bin/sh
echo "building $p"
S
cat > packages/$p/test.sh <<S
#!/bin/sh
echo "testing $p: 12 passed"
S
chmod +x packages/$p/*.sh
done
git add -A && git commit -qm "baseline"
echo "fix" >> packages/api/build.sh
git add -A && git commit -qm "fix api build flags"
echo "monorepo ready"
ci:
desc: "Affected-only pipeline: detect -> plan -> build+test each -> gate"
steps:
- name: detect_changes
desc: "Which packages did the last commit touch?"
func: shell
do: |
cd {{ vars.repo }}
git diff --name-only HEAD~1 HEAD | awk -F/ '/^packages\// {print $2}' | sort -u | tr '\n' ' '
outputs:
changed: "{{ result.output }}"
- name: plan
desc: "Expand to affected set: anything touching shared affects everyone"
func: transform
do: |
const changed = steps.detect_changes.changed.trim().split(/\s+/).filter(Boolean);
const all = ["api", "web", "shared"];
const targets = changed.includes("shared") ? all : changed;
// loop items cannot read steps.* (vars only), so the loop below
// iterates every package and skips the unaffected ones - which is
// also what you want in CI logs.
return { targets: targets, count: targets.length, summary: targets.join(" ") };
- name: build_and_test
desc: "Run every affected package's pipeline; skip the rest"
loop:
items: "{{ vars.all_packages }}"
as: pkg
func: shell
do: |
case " {{ steps.plan.summary }} " in
*" {{ loop.pkg }} "*)
cd {{ vars.repo }}/packages/{{ loop.pkg }}
./build.sh && ./test.sh
echo "pkg={{ loop.pkg }} result=pass" ;;
*)
echo "pkg={{ loop.pkg }} result=skip (not affected)" ;;
esac
outputs:
pkg: "{{ loop.pkg }}"
result: '{{ result.output | regexFind "result=([a-z]+)" }}'
- name: repo_wide_checks
desc: "Cheap repo-wide checks always run, in parallel"
parallel:
- name: lint_shell
func: shell
do: |
cd {{ vars.repo }}
sh -n packages/*/build.sh packages/*/test.sh && echo "lint=ok"
outputs:
ok: '{{ result.output | regexFind "lint=([a-z]+)" }}'
- name: forbid_todo
func: shell
do: |
cd {{ vars.repo }}
! grep -rn "TODO-BLOCKER" packages/ && echo "todo=clean"
outputs:
ok: '{{ result.output | regexFind "todo=([a-z]+)" }}'
- name: gate
func: assert
args:
conditions:
- condition: "steps.build_and_test.outputs.every(o => o.result === 'pass' || o.result === 'skip')"
desc: "every affected package built and tested clean"
- condition: '{{ eq steps.lint_shell.ok "ok" }}'
- condition: '{{ eq steps.forbid_todo.ok "clean" }}'
- name: report
func: shell
do: 'echo "CI_PASS affected=[{{ steps.plan.summary }}] packages={{ steps.plan.count }}"'Run it
orchstep run setup_demo_monorepo # 3 packages; last commit touches api
orchstep run ciVerified results:
# last commit touched api only
CI_PASS affected=[api] packages=1 (web, shared skipped)
# after a commit touching shared
CI_PASS affected=[api web shared] packages=3
# after a commit containing "TODO-BLOCKER"
exit 1 - gate fails on the forbid_todo checkDesign notes
Loop over everything, skip explicitly. Loop items: resolve from
vars, not from step outputs (verified v0.7.1 limitation) - so the loop
iterates the full package list and each iteration checks membership in the
affected set. The skips show up in CI logs, which reviewers actually
prefer to silent omission.
The plan is data. Dependency fan-out lives in one transform - swap the hardcoded rule for reading each package's manifest when you outgrow it.
Take it to production
Replace the demo build.sh/test.sh with your real toolchain, derive the
diff range from your CI's base ref instead of HEAD~1, and emit the gate
result as the status check your branch protection requires.