DOC_INDEX
THEME
DOCS/Use Cases/Monorepo CI Gate

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 skip entries 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

CapabilityWhere
Change detection from gitdetect_changes (git diff --name-only)
Dependency fan-out logic in JSplan (shared affects all)
Loop with skip semanticsbuild_and_test over all packages
Parallel repo-wide checksrepo_wide_checks (lint + blocker scan)
Merge gategate 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 ci

Verified 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 check

Design 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.