DevOps · Cicd

GitHub Actions for Monorepos — A Field Guide

How to structure GitHub Actions CI for a monorepo so pull requests only build and test the packages they actually touch, using path filters, matrix builds, and affected-package detection.

John Kihiu12 min read

A monorepo CI pipeline that runs the full test suite on every pull request works fine for the first fifty commits. Once you've got a dozen packages and the suite takes fifteen minutes, running everything on a one-line README change becomes the thing everyone complains about at standup. The fix isn't a faster runner — it's teaching the pipeline which packages a given change actually affects, and only running those.

Path filters are the floor, not the ceiling

The simplest tool is dorny/paths-filter or GitHub's own path-based trigger filtering: define which glob patterns belong to which package, and skip a job entirely if none of the changed files match. This handles the common case — a change to packages/api/** shouldn't trigger the frontend test job — but it's brittle for shared code. A change to a shared utilities package doesn't match any single package's filter, yet it can break every package that imports it, so path filters alone will under-test cross-cutting changes unless you also treat the shared package's consumers as affected.

YAML · PATH-FILTERED JOBS
jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      api: ${{ steps.filter.outputs.api }}
      web: ${{ steps.filter.outputs.web }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            api:
              - 'packages/api/**'
              - 'packages/shared/**'
            web:
              - 'packages/web/**'
              - 'packages/shared/**'

  test-api:
    needs: changes
    if: needs.changes.outputs.api == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: echo "run api tests"

Affected-package detection with a real dependency graph

Manually listing which paths feed into which filter works until the dependency graph gets deep enough that you can't hold it in your head. Tools built for monorepos — Nx, Turborepo, Bazel, Lerna with --since — solve this properly by building the actual package dependency graph from your workspace config and computing the affected set from a git diff, rather than from hand-maintained glob patterns. If you're already using one of these build systems, let it compute the affected list and hand that list to GitHub Actions as a matrix, instead of re-deriving the same information with path filters.

Nx and Turborepo both expose this directly

nx show projects --affected --base=origin/main and turbo run test --filter=...[origin/main] both compute the affected package list from the actual dependency graph, not guesswork. Pipe that list into a dynamic matrix job rather than hardcoding per-package jobs — it stays correct as packages are added or removed.

Turning the affected list into a matrix

A GitHub Actions matrix needs a JSON array at job-definition time, but the list of affected packages is only known after checking out the code and running the diff. The pattern is a two-job setup: a small first job computes the affected packages and sets them as a job output (a JSON string), and a second job consumes that output as its matrix, so you get one parallel runner per affected package instead of a single job looping over all of them serially.

YAML · DYNAMIC MATRIX FROM AFFECTED PACKAGES
jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - id: set
        run: |
          AFFECTED=$(npx turbo run build --filter=...[origin/main] --dry=json | jq -c '[.tasks[].package]')
          echo "matrix={\"package\":$AFFECTED}" >> "$GITHUB_OUTPUT"

  test:
    needs: detect
    if: needs.detect.outputs.matrix != '{"package":[]}'
    strategy:
      matrix: ${{ fromJson(needs.detect.outputs.matrix) }}
    runs-on: ubuntu-latest
    steps:
      - run: npx turbo run test --filter=${{ matrix.package }}

Caching per package, not per repo

A monorepo with a single lockfile at the root tempts you into one repo-wide dependency cache, which is fine for install time but wasteful for build caching — a single cache key invalidated by any dependency bump throws away build artifacts for every package, even ones untouched by that bump. Turborepo and Nx both support remote build caching keyed by each package's actual inputs (its source files plus its dependencies' outputs), so an unaffected package's build or test result is fetched from cache in seconds rather than rebuilt. This is usually a bigger CI time win than parallelism alone.

Required status checks need to match the dynamic job names

Branch protection rules that require specific check names break when jobs are generated dynamically from a matrix, because the check name includes the matrix value and isn't known in advance. Either require the upstream "detect" job plus a final aggregation job that always runs, or use GitHub's newer merge queue support, which handles this more gracefully than per-matrix required checks.

Wrapping up

Path filters get you 80% of the win for a repo with a handful of independent packages. Once packages share code, use your build tool's real dependency graph — Nx, Turborepo, or equivalent — to compute the affected set from a git diff, feed that into a dynamic GitHub Actions matrix, and cache per-package rather than per-repo. The goal is that a one-line change to a leaf package triggers one fast job, not the whole suite.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.