Monorepo

A monorepo is one version control repository holding several projects that share tooling and one change history, and what that shape means for GitHub Actions.

A monorepo is a single version control repository that holds several projects, which share one set of tooling and one change history. A change touching two of those projects lands as one commit, and a change to a shared project is visible to everything that depends on it at that same commit rather than after a package release.

The layout changes what a build has to decide. A repository is the unit a workflow trigger fires on, so a commit anywhere in the tree is an event that could run every job in the repository, and the useful question becomes how much of the tree that commit actually invalidated.

Definition

Three properties are what make a repository a monorepo. Losing any one of them turns the layout into a set of separate projects that happen to share a directory.

  1. One commit graph over every project. There is one history, one set of branches, and one set of tags. A revert of a cross project change is one revert.
  2. Shared tooling at the root. One workspace manifest or lockfile set, one formatter config, one linter config, and one place to change any of them. A tool upgrade is a single commit that every project picks up together.
  3. Source dependencies rather than published ones. A project imports its neighbour from the tree at the current commit. There is no version number between them and no window where a consumer is on an older release.

What a monorepo leaves open is as important as what it fixes. It says nothing about deployable units, release cadence, team ownership, or language. Repositories of this shape routinely ship many artifacts on independent schedules, and the source layout is what is shared.

DimensionMonorepoOne repository per project
A change spanning two projectsOne commit, one review, one mergeOne pull request per repository, ordered by hand
Dependency version a consumer usesWhatever is in the tree at that commitThe published version that consumer pinned
Upgrading a shared libraryConsumers updated in the same commitA release, then one bump per consumer, on each consumer's schedule
Tooling configOne root config applied to every projectOne config per repository, drifting apart over time
Blast radius of a broken shared libraryEvery dependent project, at onceOnly the consumers that have bumped
What a workflow trigger seesEvery push to the repository, whatever movedOnly pushes to that one project
Finding every caller of a functionOne search over the treeOne search per repository, and only for the versions in use

What changes for GitHub Actions

Workflow triggers are repository events. A workflow with a bare on: push key runs its jobs for a typo fix in a README exactly as it does for a change to the code every project imports. The trigger has no view of the dependency edges inside the tree, so the workflow supplies that view or pays for the full pipeline on every commit.

GitHub Actions offers three places to narrow the work, and they operate at different times:

  • paths and paths-ignore on the trigger. The filter is evaluated before the run is created, so a filtered out workflow produces no run at all (workflow syntax, checked on 2026-08-13). A branch protection rule that requires a check from a workflow that never runs waits on a status that never arrives.
  • if conditions on a job. The run is created and the job is evaluated and then skipped, so the check reports a conclusion and branch protection is satisfied. The tradeoff is that the condition is expression syntax evaluated against the event payload.
  • A change detection job. One job computes which projects a commit affected, writes the list to outputs, and downstream jobs consume it through a matrix. This is the only one of the three that can follow dependency edges, because it runs real code against the checkout.

Two limits bound the third option. A matrix expands to at most 256 jobs in one workflow run (GitHub Actions limits, checked on 2026-08-13), and an empty matrix list makes the downstream job fail rather than skip, so the change detection job needs a defined behavior for a commit that affects nothing.

Example

Take a repository with five projects and four dependency edges:

packages/utils    shared library, imported by services/api and apps/web
packages/ui       component library, imported by apps/web
services/api      service, imports packages/utils
apps/web          app, imports packages/ui and packages/utils
docs/             documentation site, imports nothing

This workflow builds and tests all of it on every pull request:

name: build
on:
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm run build --workspaces
      - run: npm test --workspaces

Four commits, and the work each one genuinely requires against the work this workflow does:

Commit editsProjects that must be rebuiltWhyWork this workflow does
docs/getting-started.mddocsNothing imports the docs siteBuilds and tests all five
apps/web/src/App.tsxwebNothing imports webBuilds and tests all five
packages/ui/src/Button.tsxui, webweb imports uiBuilds and tests all five
packages/utils/src/date.tsutils, api, webBoth consumers import utilsBuilds and tests all five

Row four is the case the layout exists for and the case that costs the most. A change to a shared library has to rebuild and retest every project that imports it, because at this commit there is no published version standing between them, and the consumers are compiled against the source as edited.

Row one is the case worth removing. A documentation typo triggers a full build and full test run of four projects that cannot possibly have changed behavior.

A per project workflow with a paths filter removes it, at a price:

name: api
on:
  pull_request:
    paths:
      - "services/api/**"
      - "packages/utils/**"
      - ".github/workflows/api.yml"

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm test --workspace services/api

The filter has to list packages/utils/** because the inputs of api include everything api imports. That list is a copy of the dependency graph, maintained by hand, in every workflow file. The day someone adds an import of packages/ui to api and forgets to edit this filter, a change to ui merges without api ever being tested, and the workflow stays green while doing so.

Deriving the same list from the tree removes the hand maintained copy:

name: build
on:
  pull_request:

jobs:
  select:
    runs-on: ubuntu-latest
    outputs:
      projects: ${{ steps.affected.outputs.projects }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - id: affected
        run: |
          base=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
          echo "projects=$(./scripts/affected.sh "$base")" >> "$GITHUB_OUTPUT"

  test:
    needs: select
    if: needs.select.outputs.projects != '[]'
    strategy:
      matrix:
        project: ${{ fromJSON(needs.select.outputs.projects) }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm test --workspace ${{ matrix.project }}

fetch-depth: 0 is required in the select job because actions/checkout fetches a single commit by default, and git merge-base needs both branches present to answer. Getting the base commit wrong is the common failure here, and it fails in the expensive direction quietly: a bad base makes the changed file list too small, so affected projects are skipped and the pull request merges untested.

Caching has its own monorepo shape. A cache key built from a hash of the root lockfile changes whenever any project changes a dependency, which invalidates the entry for every project at once. Keying per project and per toolchain, with a restore-keys prefix so a near miss still restores something, keeps one project's dependency bump from evicting the rest.

FAQ

What is the difference between a monorepo and one repository per project?

A monorepo keeps several projects in one repository, so a change spanning two of them is one commit on one history and each project consumes its neighbours from the tree at that commit. With one repository per project, the same change is several pull requests, each project consumes a published version of its dependencies, and an upgrade reaches consumers only when each of them bumps the version it pinned.

Does a monorepo mean everything deploys together?

No. A monorepo describes where the source lives and how history is recorded. Release cadence, deployable units, and ownership are separate decisions, and repositories of this shape commonly ship a dozen artifacts on independent schedules from one commit graph.

Why does a one line change trigger the whole pipeline in a monorepo?

Because a workflow trigger fires on a repository event and carries no opinion about which directory moved. A workflow with a bare on push key runs the same jobs whether the commit edited a README or a shared library. Narrowing the work needs a paths filter on the trigger, an if condition per job, or a job that computes the changed set and hands it to a matrix.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.