A pipeline that only builds what changed

The monorepo pipeline ran everything on every push: four package suites, the application suite, static analysis over the whole tree, a production asset build and a Docker image. Twenty-two minutes for a change to a README, and about forty pushes a day.

The symptom

one push, 22 minutes:
  install (composer + npm)   3m 10s
  4 package suites           4m 20s
  app (feature + integration)   6m 40s
  phpstan (whole tree)       4m 10s
  vite build + docker push   4m 20s

and the change that triggered it:
  docs/deployment.md | 4 +-

Twenty-two minutes for a documentation change is the visible waste and the invisible one is larger: forty pushes a day at a median of nine minutes of genuinely unnecessary work is six hours of runner time and, more importantly, six hours of somebody waiting.

Why it happens

A pipeline that runs everything is correct by construction and is the only version that is obviously correct. Every optimisation is a claim that something did not need running, and being wrong about that produces a green build that skipped the thing that broke.

The fix

Why path filters are not enough

# the obvious version, and it is wrong
on:
  pull_request:
    paths: ['packages/http/**']

# a change to packages/contracts does not match, so the
# http suite does not run — and http depends on contracts.
# path filters answer "did this file change"; the question
# is "what does this change affect".

The dependency graph is the missing information and it is not expressible in a path filter. A change to contracts must test http, queue and the application, and the filter has no way to know that.

Computing the affected set

# 1. what changed
changed=$(git diff --name-only "$BASE"..."$HEAD")

# 2. which packages own those paths
pkgs=$(printf '%sn' "$changed" 
  | awk -F/ '/^packages//{print $2}' | sort -u)

# 3. the transitive closure over dependents, read from
#    each package's composer.json
affected=$(./bin/dependents $pkgs)

# 4. and anything outside packages/ affects the app
printf '%sn' "$changed" | grep -qv '^packages/' 
  && affected="$affected app"
// bin/dependents — the closure, from the composer.json files
foreach (glob('packages/*/composer.json') as $file) {
    $json = json_decode(file_get_contents($file), true);

    foreach (array_keys($json['require'] ?? []) as $dep) {
        if (str_starts_with($dep, 'turkerdev/')) {
            // reverse edges: dependency => dependents
            $graph[substr($dep, 10)][] = basename(dirname($file));
        }
    }
}

while ($name = array_shift($queue)) {
    if (isset($seen[$name])) { continue; }

    $seen[$name] = true;
    $queue = array_merge($queue, $graph[$name] ?? []);
}

Deriving the graph from the composer.json files rather than maintaining a second list is what keeps it correct — a new dependency is declared once and the pipeline follows. A hand-written map in a workflow file is the version that drifts silently.

The dynamic matrix

jobs:
  plan:
    outputs:
      matrix: ${{ steps.affected.outputs.matrix }}
      any:    ${{ steps.affected.outputs.any }}
    steps:
      - uses: actions/checkout@v3
        with: { fetch-depth: 0 }   # ← required for the diff
      - id: affected
        run: ./bin/affected-set >> "$GITHUB_OUTPUT"

  test:
    needs: plan
    if: needs.plan.outputs.any == 'true'
    strategy:
      matrix:
        target: ${{ fromJSON(needs.plan.outputs.matrix) }}

fetch-depth: 0 is the requirement that is easy to miss: the default shallow checkout has no merge base, so the diff is empty and the affected set is empty and nothing runs. That failure is silent and produces a green pipeline, which is the worst possible shape.

An empty matrix makes a job fail rather than skip, which is why the any output exists — a documentation-only change should skip the test job cleanly rather than error on an empty strategy.

The required check problem

branch protection requires a check named "test". a
skipped job reports as skipped, not success — and a
required check that is skipped blocks the PR forever.

  1  a second workflow with the inverse filter, reporting
     the same check name as a success — what most do
  2  one always-running job CONTAINING the conditional
  3  do not require the check — gives up the guarantee
  ci:
    needs: [plan, test]
    if: always()
    steps:
      - run: |
          [ "${{ needs.plan.result }}" = success ] || exit 1
          case "${{ needs.test.result }}" in
            success|skipped) exit 0 ;;
            *) exit 1 ;;
          esac

A single aggregating job is cleaner than the duplicate-workflow trick and requires if: always(), without which it is skipped when the matrix is skipped and the problem returns. Treating skipped as success is the deliberate part and is exactly the claim being made: nothing was affected, so nothing needed testing.

The backstop, because the claim can be wrong

# on the default branch, and nightly: everything, always
on:
  push: { branches: [main] }
  schedule: [{ cron: '0 3 * * *' }]
jobs:
  full:
    strategy: { matrix: { target: [contracts, http, queue, app] } }

The full run on the default branch is what catches a mistake in the closure logic, and it catches it after merge rather than before — which is a worse position than a slow pipeline and a much better one than never. It caught one genuine gap in the first quarter: a package depending on another through a dev dependency, which the graph builder was not reading.

Logging what was skipped

{
  echo "changed packages: $pkgs"
  echo "affected targets: $affected"
  echo "skipped:          $(comm -23 <(all_targets) <(echo "$affected"))"
} >> "$GITHUB_STEP_SUMMARY"

Printing what was skipped is what stops a green build being read as a claim it is not making, and it is one command. A pipeline that silently covers less than it appears to is the failure this whole arrangement risks, and the summary is the cheapest possible defence.

Verifying it worked

# packages/testing only
  test testing                    1m 10s   total 1m 35s
# packages/contracts — everything downstream
  test contracts http queue app   8m 20s   total 8m 45s
# docs/ only
  test (skipped)                           total 0m 25s

# median over a month: 22m → 3m 50s

The contracts case running everything is the assertion that the closure works, and it is worth checking deliberately rather than waiting for it to happen — a change to the most-depended-upon package should be indistinguishable from the old pipeline.

What this costs

A build that can be wrong about what it skipped, which is a new class of failure introduced in exchange for eighteen minutes. The nightly full run bounds it and does not prevent it, and the gap it found in the first quarter would have been a broken default branch rather than a broken release — which is the trade being made.

The affected-set script is also infrastructure now: a hundred lines of shell and PHP that decides what gets tested, with no tests of its own. Writing tests for it felt absurd until the dev-dependency gap, at which point six lines of fixture-based assertions became obviously worth having.