Monorepo Pipelines on GitHub Actions
Split monorepo GitHub Actions work into a change detection job and per-shape matrix shards on warp- labels sized from 2 to 16 vCPU, with warm snapshot runners.
Last verified:
Overview
Run a monorepo on GitHub Actions by computing the changed projects in one small job, then fanning that list into matrix jobs whose runs-on label is sized to the work each job shape actually does. On WarpBuild that means a lint shard on warp-ubuntu-latest-x64-2x at $0.004 per minute and an integration shard on warp-ubuntu-latest-x64-8x at $0.016 per minute inside the same workflow, with snapshot runners carrying the toolchain and the working tree between runs.
The shape most monorepos start with is one job called build that installs everything, builds everything, and tests everything. That job has three problems and they compound. Every step waits for the step before it, so wall clock is the sum of the pipeline rather than the width of it. The label has to be large enough for the heaviest step, so the lint phase burns 16 vCPU rates while it reads files. And a change to one package rebuilds all of them, because the job has no notion of which projects the commit touched.
The fix is a three stage pipeline. Stage one is a cheap job that diffs the commit range and emits a JSON array of changed project paths. Stage two is a set of matrix jobs, one per shape, each consuming that array and each pinned to a label sized for its shape. Stage three is a single gate job that collects the matrix results so branch protection has one required check to point at.
A monorepo that holds a Go service, an iOS app, and a Windows agent can address all three from one workflow file with warp- labels. Runner storage is ephemeral on every platform and is deleted when the runner terminates, which is why the caching and snapshot decisions below matter more in a monorepo than in a single project repository.
This page owns the tool agnostic shape. Build system specific configuration lives on its own pages: remote cache and action keys on running Bazel on GitHub Actions, base and head SHA selection on Nx affected builds on GitHub Actions, and task graph configuration on moon task pipelines on GitHub Actions.
If you want the runners inside your own account and region, BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS. The pipeline shape below is identical on BYOC labels except for the snapshot behavior, which is called out where it appears.
Configuration
The detection job does three things: check out enough history to diff against, map changed files to project directories, and publish those directories as job outputs. Keep it on the smallest label, because it reads git metadata and runs a few string operations.
name: monorepo
on:
push:
branches: [main]
pull_request:
concurrency:
group: monorepo-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
changed:
runs-on: warp-ubuntu-latest-x64-2x
outputs:
projects: ${{ steps.select.outputs.projects }}
services: ${{ steps.select.outputs.services }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
filter: blob:none
- id: select
env:
BASE: ${{ github.event_name == 'pull_request'
&& github.event.pull_request.base.sha
|| github.event.before }}
run: |
to_json() { printf '%s\n' "$1" | jq -R . | jq -sc 'map(select(. != ""))'; }
DIFF=$(git diff --name-only "$BASE" "$GITHUB_SHA")
PROJECTS=$(printf '%s\n' "$DIFF" | grep -E '^(apps|packages)/' | cut -d/ -f1-2 | sort -u)
SERVICES=$(printf '%s\n' "$DIFF" | grep -E '^services/' | cut -d/ -f1-2 | sort -u)
{
echo "projects=$(to_json "$PROJECTS")"
echo "services=$(to_json "$SERVICES")"
} >> "$GITHUB_OUTPUT"
lint:
needs: changed
if: needs.changed.outputs.projects != '[]'
runs-on: warp-ubuntu-latest-x64-2x
strategy:
fail-fast: false
matrix:
project: ${{ fromJSON(needs.changed.outputs.projects) }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 1
sparse-checkout: |
${{ matrix.project }}
tooling
- run: make -C ${{ matrix.project }} lint
unit:
needs: changed
if: needs.changed.outputs.projects != '[]'
runs-on: warp-ubuntu-latest-x64-4x
strategy:
fail-fast: false
matrix:
project: ${{ fromJSON(needs.changed.outputs.projects) }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 1
- uses: WarpBuilds/cache@v1
with:
path: ~/.cache/toolchain
key: toolchain-${{ hashFiles('tooling/versions.lock') }}
restore-keys: toolchain-
- run: make -C ${{ matrix.project }} test
integration:
needs: changed
if: needs.changed.outputs.services != '[]'
runs-on: warp-ubuntu-latest-x64-8x;snapshot.key=mono-integration
strategy:
fail-fast: false
matrix:
service: ${{ fromJSON(needs.changed.outputs.services) }}
steps:
- uses: actions/checkout@v5
- run: make -C ${{ matrix.service }} integration-test
build:
needs: changed
if: needs.changed.outputs.projects != '[]'
runs-on: >-
${{ github.ref == 'refs/heads/main'
&& 'warp-ubuntu-latest-x64-16x;snapshot.enabled=true'
|| 'warp-ubuntu-latest-x64-16x;snapshot.key=mono-build' }}
steps:
- uses: actions/checkout@v5
- run: echo "booted from snapshot ${WARPBUILD_SNAPSHOT_KEY:-none}"
- run: make build-changed PROJECTS='${{ needs.changed.outputs.projects }}'
- name: Cleanup credentials
if: github.ref == 'refs/heads/main'
run: |
rm -rf $HOME/.ssh $HOME/.aws
git clean -ffdx
- name: Save snapshot
if: github.ref == 'refs/heads/main'
uses: WarpBuilds/snapshot-save@v1
with:
alias: "mono-build"
fail-on-error: true
wait-timeout-minutes: 60
gate:
needs: [lint, unit, integration, build]
if: always()
runs-on: warp-ubuntu-latest-x64-2x
steps:
- run: test "${{ contains(needs.*.result, 'failure') }}" = "false"Four things in that file are worth reading closely.
The snapshot label syntax is a suffix on the runner label, separated by a semicolon. snapshot.enabled=true always boots from the base image and lets WarpBuilds/snapshot-save@v1 capture a fresh snapshot at the end of the job. snapshot.key=<alias> boots from the existing snapshot for that alias and falls back to the base image when none exists. A runner booted from a snapshot exports WARPBUILD_SNAPSHOT_KEY, which is the cheapest way to confirm the alias resolved. Snapshots are deleted after 15 days, so the main branch job doubles as the refresher; add a scheduled trigger if main goes quiet for two weeks. The snapshot runner docs carry the full behavior.
Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. Snapshot labels on macOS, Windows, or BYOC runners are silently ignored and the job runs normally without snapshot behavior. In a monorepo that also builds an iOS target, that means the macOS half of the pipeline plans around a cache archive and a narrow checkout instead.
Aliases are per shape rather than per project. mono-integration holds the container images and fixtures that the service tests pull, and mono-build holds the compiler caches and the warm working tree. Keying a snapshot per project multiplies restore fees and storage without holding anything the next project needs. The unit shards stay on the base image and restore a cache archive instead, for the break-even reason in the bottlenecks section below.
The gate job exists because a matrix that skips has no result to require. Branch protection points at gate, which stays green when a matrix is empty and fails when any leg fails.
Sizing
Size the label to the job shape, not to the repository. The four shapes below cover most monorepo pipelines, and the rates come from the cloud runner catalog.
| Job shape | What it does | Label | vCPU | RAM | Storage | USD per minute |
|---|---|---|---|---|---|---|
| Change detection | git diff, path mapping, matrix emit | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 |
| Lint and typecheck | one project per shard, IO bound | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 |
| Unit tests | one project per shard, CPU bound | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 |
| Integration tests | containers, database, fixtures | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 |
| Build and package | full compile and link across projects | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 |
| Release build | whole graph on a tag, no fan-out | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 |
Every Ubuntu size carries 4 GB of RAM per vCPU, which is the number that decides where a shape lands. Typecheck and lint hold one project's syntax tree, so they rarely exceed 8 GB. Unit shards run test workers in parallel and are bounded by cores. Integration shards run the service under test plus its dependencies as containers, and memory is what pushes them from the 4x to the 8x label. A link step over a large native graph is the one shape that genuinely wants 16 or 32 vCPU, and it is also the shape that runs once per pipeline instead of once per project.
Linux ARM64 carries the same size ladder from 2x through 32x at $0.003 to $0.048 per minute. Pick one architecture per pipeline stage and stay on it, because cache entries and native modules do not cross architectures.
The list prices below are the arithmetic against GitHub-hosted Linux x64 runners at the same shape, checked on 2026-08-13 against the GitHub Actions minute multipliers reference.
| vCPU | WarpBuild label | WarpBuild USD per minute | GitHub-hosted USD per minute | Lower list price |
|---|---|---|---|---|
| 2 | warp-ubuntu-latest-x64-2x | $0.004 | $0.006 | 33 percent |
| 4 | warp-ubuntu-latest-x64-4x | $0.008 | $0.012 | 33 percent |
| 8 | warp-ubuntu-latest-x64-8x | $0.016 | $0.022 | 27 percent |
| 16 | warp-ubuntu-latest-x64-16x | $0.032 | $0.042 | 24 percent |
| 32 | warp-ubuntu-latest-x64-32x | $0.064 | $0.082 | 22 percent |
The 2 vCPU row compares against ubuntu-latest on private repositories, which is the shape paying teams run. GitHub gives public repositories a 4 vCPU, 16 GB shape at no charge. The full ladder including macOS and Windows sits on the pricing page.
Bottlenecks
Full checkout on every shard. A fan-out that turns one job into thirty multiplies the clone thirty times. On a repository with several years of history and large binary fixtures, that is often the single largest line in the shard budget. Three levers cut it: fetch-depth: 1 on every job that does not compute a diff, filter: blob:none on the detection job that does, and sparse-checkout on shards that only touch one project directory plus shared tooling. A snapshot alias helps further, because the runner boots with a warm .git directory and the checkout becomes a fetch of the new commits. The mechanics of each lever are written up in faster git checkout on GitHub Actions.
Cache key churn across projects. The default monorepo cache key hashes a root lockfile, so a dependency bump in one package invalidates the archive for every package. Every shard then misses, reinstalls, and writes a new archive, which multiplies both restore time and cache operations. Build keys from the narrowest input that actually changes the content: the toolchain version plus the lockfile section for that project. Add restore-keys prefixes so a near miss restores a slightly stale archive instead of nothing. Keep the dependency store cache separate from build output caches, because the two churn on different schedules. Cache storage is billed at $0.20 per GB-month and cache write or restore operations at $0.0001 each, so thirty shards writing their own archive is a real line item rather than a rounding error.
Jobs that serialize behind a single build step. The common shape is a build job that produces artifacts, followed by test jobs with needs: build. The whole test stage then waits for the slowest project in the build, even when the tests it runs need one of them. Two fixes work. Publish per project artifacts so a test shard downloads only what it consumes. Or let each shard build its own dependencies from the shared cache, which trades a little duplicated compute for a much shorter critical path. Measure before choosing: if the build stage is two minutes and the artifact upload is four, the artifact is the problem.
Runner size mismatch inside the fan-out. A shard pinned to a large label that spends its time waiting on the network costs the large rate for the whole wall clock. CI observability reports system metrics from the runner agent correlated with the job logs, which tells you whether a shard was CPU bound, memory bound, or idle on IO. That distinction is what decides whether a shape moves up a label or down one. When a failure reproduces only on the runner, the Action Debugger pauses the workflow and opens an SSH session on the machine so you can inspect the working tree directly.
Snapshot boot overhead. Boot times for snapshot runners can be slower than the default runners and take 45 to 60 seconds, and snapshot restore is billed at $0.04 per job. Both are fixed costs paid before the first step runs. On a warp-ubuntu-latest-x64-8x label at $0.016 per minute, the $0.04 restore fee equals 150 seconds of runner time, so the alias pays off only when it removes more than that. Run the same arithmetic on a 4 vCPU unit shard at $0.008 per minute and the fee equals 300 seconds, which is longer than the shard itself, so unit shards belong on the base image with a cache archive. Apply snapshots to the heavy shapes where install and image pull dominate, and leave the small shards alone.
Proof
Public OSS repositories running warp- labels are citable evidence for this shape. The near/nearcore CI workflow runs the NEAR protocol node pipeline across two different sizes in the same file, using warp-ubuntu-2404-x64-16x for the heavy compile and test legs and warp-ubuntu-2404-x64-8x for the lighter ones, which is the per shape sizing described above (checked on 2026-08-13).
Verify the shape on your own repository with two numbers from the workflow run view. The first is the critical path: the longest single chain from the detection job to the gate. The second is total billable minutes across every job. A healthy fan-out drives the first number down while the second rises modestly. When both rise, a shard is oversized or the matrix is selecting projects the commit did not touch.
A worked monthly cost model
Take a monorepo running 900 pipelines per month across pull requests and main. A typical commit touches four projects and two services. The shard durations below are measured on the label each shape is assigned.
| Stage | Label | USD per minute | Shards per run | Minutes per shard | Monthly minutes | Monthly USD |
|---|---|---|---|---|---|---|
| Change detection | warp-ubuntu-latest-x64-2x | $0.004 | 1 | 1 | 900 | $3.60 |
| Lint and typecheck | warp-ubuntu-latest-x64-2x | $0.004 | 4 | 2 | 7,200 | $28.80 |
| Unit tests | warp-ubuntu-latest-x64-4x | $0.008 | 4 | 5 | 18,000 | $144.00 |
| Integration tests | warp-ubuntu-latest-x64-8x | $0.016 | 2 | 9 | 16,200 | $259.20 |
| Build and package | warp-ubuntu-latest-x64-16x | $0.032 | 1 | 7 | 6,300 | $201.60 |
| Snapshot restore | integration and build | $0.04 per job | 3 | 2,700 jobs | $108.00 | |
| Snapshot storage | 2 aliases | $0.025 per snapshot-hour | 1,460 hours | $36.50 | ||
| Cache storage | unit shard toolchain | $0.20 per GB-month | 60 GB | $12.00 | ||
| Cache operations | unit shard toolchain | $0.0001 per operation | 27,000 | $2.70 | ||
| Total | 48,600 minutes | $796.40 |
Three readings come out of that table.
Runner minutes alone are $637.20. The same pipeline collapsed into one serialized job on warp-ubuntu-latest-x64-16x at 22 minutes per run is 19,800 minutes for $633.60, so the fan-out buys a critical path of about 10 minutes instead of 22 for roughly the same money. Fan-out trades total minutes for wall clock, and per shape sizing is what keeps the bill flat while it does.
Running the identical fan-out entirely on the 16x label costs 48,600 minutes at $0.032, which is $1,555.20. The $918.00 difference against the sized version is the whole argument for reading the sizing table before copying a runs-on line between jobs.
The same 48,600 minutes on GitHub-hosted Linux x64 runners at matching shapes costs $885.60: 8,100 minutes at $0.006, 18,000 at $0.012, 16,200 at $0.022, and 6,300 at $0.042. GitHub list prices checked on 2026-08-13 at the GitHub Actions minute multipliers reference. GitHub-hosted runners have no snapshot equivalent to fold into the comparison.
SSO is available for a flat $250 per month, whatever the user count, which is the one org level line that does not scale with the pipeline. Every rate above is listed on the pricing page.
FAQ
How should a monorepo split jobs across runner sizes?
Size each job shape to the work it does rather than to the repository. Lint and typecheck shards fit warp-ubuntu-latest-x64-2x at $0.004 per minute, unit shards fit the 4x label at $0.008, integration shards that start containers fit the 8x label at $0.016, and a full build or link step fits the 16x label at $0.032. A single label for every shape means you pay the largest rate for the smallest work.
Do snapshot runners work for every job in a monorepo pipeline?
No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. Snapshot labels on macOS, Windows, or BYOC runners are silently ignored and the job runs normally, so plan those jobs around a cache archive and a narrow checkout instead.
Why does the dependency cache miss on every pull request in a monorepo?
Because the key is usually built from a root lockfile hash, so any dependency change in any project invalidates the archive for every project. Build keys per toolchain and per project, add restore-keys prefixes so a near miss still restores, and keep the dependency store cache separate from build output caches.
Does fanning out across matrix jobs cost more than one large job?
It costs more runner minutes and about the same money when the labels are sized per shape. In the worked model on this page, 900 pipelines per month run 19,800 minutes as one 16 vCPU job for $633.60, or 48,600 minutes fanned across four sized labels for $637.20 in runner minutes with a much shorter critical path. Running that same fan-out entirely on the 16x label costs $1,555.20.
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.