Running Only the Tests a Change Affects
Pick the tests a change actually affects with path filters, dependency graph selection, or coverage maps. Detection job YAML, matrix fan-out, and cost math.
Last verified:
Running only the tests a change affects means computing the affected set from the diff in a detection job, then fanning out test jobs over that set instead of over the whole suite. The selection has to be conservative in one direction only: a test that should have run and did not is a defect that reaches the default branch, so every scheme needs a named safety net that runs everything on a fixed trigger.
This guide covers what breaks when selection is added without one, the three selection strategies ranked by how much they can be trusted, a workflow whose detection job output drives a matrix of affected projects, and the arithmetic that prices selected runs against full runs at a stated change profile. For the vocabulary see the task graph entry, for the repository shape that makes selection worth doing see the monorepo solution page, and for the rest of the spend see the hub on reducing GitHub Actions costs.
Diagnosis
Two distinct problems live under the same heading, and they pull in opposite directions.
The suite grew and the diff did not
A repository accumulates projects faster than any single pull request touches them. The suite runs all of them anyway, so billed minutes track the size of the repository while the work under review tracks the size of the diff. The gap widens every quarter and nothing in a green run reports it.
Measure it before changing anything. The Jobs report aggregates per unique repository, workflow, and job name combination and gives duration P75 and P90 for each, with CSV export (reports documentation). Export a month, group the rows by project, and multiply run count by P75 duration: that product is what each project costs to keep in the default set. On GitHub-hosted runners, pull the same shape from the workflow jobs API and aggregate it yourself.
Selection was added and nothing catches the misses
The second problem appears after the first is fixed. A selection rule that skips a test which the change actually broke produces a green pull request and a broken default branch, and the failure surfaces later with a wider blast radius. The symptoms are specific enough to name.
| Symptom | What it means | Where to look |
|---|---|---|
| Billed minutes scale with repository size rather than diff size | No selection layer at all | Jobs report, run count times duration P75 per job name |
| A change passes and the default branch breaks on the same commit | The selection rule missed an edge | Diff against the affected set the detection job printed |
| A dependency bump touches one file and tests one project | Lockfile and shared config paths are inside a narrow glob | Path filter globs against the changed file list |
| Generated code changes and nothing runs | The generator output is untracked or outside every filter | Generated paths against the filter set |
| The run fails before any test job starts | The affected set was empty and the matrix expanded to zero entries | Detection job output and the guard on the test job |
| Selection lands and wall clock does not move | The full fan-out already ran in parallel | Job start times across the matrix |
| Nobody trusts the pull request result | No full run exists to compare against | Schedule and push triggers on the workflow |
Fix
Pick the strategy whose blind spots you can live with, then bound those blind spots with a safety net. The three options rank cleanly by how much of the real dependency structure each one can see.
| Strategy | What it reads | Misses it produces | Trust |
|---|---|---|---|
| Path filters | Changed file paths matched against globs | Anything coupled across a glob boundary: shared config, generated code, a lockfile bump, a base image change | Lowest |
| Dependency graph selection | The project graph declared in build files, walked from the changed projects to their dependents | Edges the build files never declare: fixtures loaded by path, runtime service lookups, environment config | Middle |
| Coverage-based selection | A recorded map from each test to the source lines that test executed | Code paths no recorded run reached, plus drift between map refreshes | Highest while the map is fresh |
Path filters also have a first-class form in the workflow file itself, covered in the answer on running a job only when files change. The detection job below computes the same idea once and publishes the result, which is what lets a single decision drive a matrix instead of a per-job condition.
Three rules make any of them safe to gate on.
Resolve the base explicitly. Compute the merge base against the target branch rather than diffing against the previous commit, so a pull request that sat open for a week still sees every file it changed. That requires full history on the checkout, since the default shallow fetch has no merge base to find.
Widen on the paths that mean everything changed. A lockfile, a shared build config, a base image tag, or the detection script itself should expand the affected set to all projects. Encode that as an explicit list of trigger paths inside the detection step rather than leaving it to the graph.
Name the safety net. The full suite runs on a nightly schedule and on every push to the default branch, whatever the selection said about the pull request. A miss then costs one night of exposure, and a scheduled full run that fails after a green pull request is the signal that a rule has a hole. Keep the escape hatch manual as well: a label on the pull request that forces the full set.
Configuration
The detection job resolves the affected projects and publishes them as a JSON array. The test job expands that array into its matrix. A third job with a fixed name owns the branch protection check.
name: test
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "0 3 * * *"
jobs:
detect:
name: detect affected projects
runs-on: warp-ubuntu-latest-x64-2x
outputs:
projects: ${{ steps.affected.outputs.projects }}
count: ${{ steps.affected.outputs.count }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- id: base
run: |
ref="${{ github.event.pull_request.base.ref || 'main' }}"
echo "sha=$(git merge-base "origin/$ref" HEAD)" >> "$GITHUB_OUTPUT"
- id: affected
env:
FORCE_FULL: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-tests') }}
run: |
if [ "$FORCE_FULL" = "true" ]; then
projects=$(node scripts/list-projects.mjs --all)
else
projects=$(node scripts/list-projects.mjs --affected-since "${{ steps.base.outputs.sha }}")
fi
echo "projects=$projects" >> "$GITHUB_OUTPUT"
echo "count=$(echo "$projects" | jq 'length')" >> "$GITHUB_OUTPUT"
test:
name: test ${{ matrix.project }}
needs: detect
if: needs.detect.outputs.count != '0'
runs-on: warp-ubuntu-latest-x64-8x
strategy:
fail-fast: false
matrix:
project: ${{ fromJSON(needs.detect.outputs.projects) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test --workspace ${{ matrix.project }}
gate:
name: tests
needs: [detect, test]
if: always()
runs-on: warp-ubuntu-latest-x64-2x
steps:
- name: Fail when detection failed
if: needs.detect.result != 'success'
run: exit 1
- name: Fail when an affected project failed
if: needs.test.result == 'failure' || needs.test.result == 'cancelled'
run: exit 1What each piece does:
fetch-depth: 0gives the checkout full history sogit merge-basehas something to resolve; the default fetch is shallow and the merge base lookup fails on it (actions/checkout, checked on 2026-08-13).- The
FORCE_FULLexpression turns the schedule trigger, pushes tomain, and afull-testslabel into the same code path, so the safety net and the manual escape hatch share one branch of the script. fromJSONconverts the job output string into the matrix vector, since job outputs are strings andstrategy.matrixneeds a list (GitHub Actions contexts, checked on 2026-08-13).- The
ifguard on the test job earns its place for a subtler reason than it looks: an empty matrix vector fails the run rather than skipping the job, so a diff that touches only documentation would turn red without the count check. gatecarries a fixed name while the matrix job names carry the project, so branch protection has one check to require that survives every change in the affected set. It fails oncancelledas well asfailure, which is what stops a cancelled matrix from reporting a pass.- A matrix expands to at most 256 jobs per run (GitHub Actions usage limits, checked on 2026-08-13), so a repository past that count needs the full-set branch to chunk projects into groups rather than emit one job each.
The same detection job drives matrices on any of them by changing one runs-on label. The two labels above resolve to these machines, from the WarpBuild cloud runners documentation, checked on 2026-08-13.
runs-on label | vCPU | RAM | Storage | USD per minute |
|---|---|---|---|---|
warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 |
warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 |
Where a project's suite is itself the long pole, the two techniques compose: select the affected projects here, then shard each project's suite across a second matrix dimension as described in the guide to matrix sharding.
Cost or Time Model
Selection pays in billed minutes, so the model needs a change profile rather than a single number. Stated profile: 40 projects, each with a 6 minute test job on warp-ubuntu-latest-x64-8x, a 1 minute detection job on warp-ubuntu-latest-x64-2x, 40 runs a day over 22 working days, and a distribution measured from one month of merged pull requests.
| Change shape | Share of runs | Projects tested | Billed minutes | Cost per run |
|---|---|---|---|---|
| One project touched | 70 percent | 1 | 7 | $0.10 |
| Three projects touched | 20 percent | 3 | 19 | $0.29 |
| Eight projects touched | 8 percent | 8 | 49 | $0.77 |
| Lockfile or shared config touched | 2 percent | 40 | 241 | $3.84 |
| Weighted average of the four rows | 100 percent | 2.74 | 17.4 | $0.27 |
| Full suite on every run | 100 percent | 40 | 240 | $3.84 |
The weighted average is the number to plan against: 0.70 * 1 + 0.20 * 3 + 0.08 * 8 + 0.02 * 40 is 2.74 projects, or 16.4 test minutes plus the 1 minute detection job. The lockfile row is what keeps the average honest, because widening on shared paths is the rule that makes the other three rows safe.
Add the safety net back. The nightly full run costs $3.84 once a day, or $84.48 over 22 days, and pushes to the default branch add one full run per merge on top of that. Even counting the nightly, the month lands at roughly $319 against $3,379 for testing everything on every run.
Those billed minutes carry a list price. GitHub rates below are from the GitHub Actions billing reference, checked on 2026-08-13.
| Plan | 8 vCPU minutes per month | On warp-ubuntu-latest-x64-8x at $0.016 | On the GitHub-hosted 8-core larger runner at $0.022 |
|---|---|---|---|
| Full suite on every run | 211,200 | $3,379.20 | $4,646.40 |
| Selection plus a nightly full run | 19,747 | $315.96 | $434.44 |
The 880 detection jobs add 880 minutes on warp-ubuntu-latest-x64-2x, or $3.52 a month, which is the entire overhead the selection layer introduces. warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13.
Read the wall clock separately from the invoice. A matrix of 40 projects and a matrix of 3 both finish in roughly the duration of the slowest single project when every job starts at once, so wall clock moves only where the full fan-out exceeded the parallelism the run actually received, or where projects ran in sequence. The billed minutes move in every case.
Full rates by runner type are on the pricing page.
FAQ
How do I run only the tests a pull request affects in GitHub Actions?
Add a detection job that checks out full history, computes the merge base against the target branch, resolves the affected projects from the diff, and writes a JSON array to a job output. A dependent test job expands that array with fromJSON into strategy.matrix, so the fan-out is the affected set rather than the whole suite. Guard the test job with a count check, because an empty matrix list fails the run with a matrix error rather than skipping the job.
What happens when the selection misses a test that should have run?
That is the failure mode selection has to be designed around, so every scheme carries a safety net that runs the full suite on a fixed trigger: a nightly schedule and every push to the default branch. A miss then costs one night of exposure instead of a defect that sits in the branch indefinitely, and a full run that fails while the pull request passed is the signal that the selection rule has a hole in it.
Does test selection cut wall clock or billed minutes?
Billed minutes move first and by the larger factor, because the jobs that never start are the ones that stop being charged. Wall clock moves only when the full fan-out was wider than the parallelism the run actually got, or when the suite ran serially, since a matrix of 40 projects and a matrix of 3 both finish in roughly the duration of the slowest single project when every job starts at once.
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.