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.

SymptomWhat it meansWhere to look
Billed minutes scale with repository size rather than diff sizeNo selection layer at allJobs report, run count times duration P75 per job name
A change passes and the default branch breaks on the same commitThe selection rule missed an edgeDiff against the affected set the detection job printed
A dependency bump touches one file and tests one projectLockfile and shared config paths are inside a narrow globPath filter globs against the changed file list
Generated code changes and nothing runsThe generator output is untracked or outside every filterGenerated paths against the filter set
The run fails before any test job startsThe affected set was empty and the matrix expanded to zero entriesDetection job output and the guard on the test job
Selection lands and wall clock does not moveThe full fan-out already ran in parallelJob start times across the matrix
Nobody trusts the pull request resultNo full run exists to compare againstSchedule 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.

StrategyWhat it readsMisses it producesTrust
Path filtersChanged file paths matched against globsAnything coupled across a glob boundary: shared config, generated code, a lockfile bump, a base image changeLowest
Dependency graph selectionThe project graph declared in build files, walked from the changed projects to their dependentsEdges the build files never declare: fixtures loaded by path, runtime service lookups, environment configMiddle
Coverage-based selectionA recorded map from each test to the source lines that test executedCode paths no recorded run reached, plus drift between map refreshesHighest 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 1

What each piece does:

  • fetch-depth: 0 gives the checkout full history so git merge-base has 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_FULL expression turns the schedule trigger, pushes to main, and a full-tests label into the same code path, so the safety net and the manual escape hatch share one branch of the script.
  • fromJSON converts the job output string into the matrix vector, since job outputs are strings and strategy.matrix needs a list (GitHub Actions contexts, checked on 2026-08-13).
  • The if guard 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.
  • gate carries 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 on cancelled as well as failure, 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 labelvCPURAMStorageUSD per minute
warp-ubuntu-latest-x64-2x28 GB150GB SSD$0.004
warp-ubuntu-latest-x64-8x832 GB150GB 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 shapeShare of runsProjects testedBilled minutesCost per run
One project touched70 percent17$0.10
Three projects touched20 percent319$0.29
Eight projects touched8 percent849$0.77
Lockfile or shared config touched2 percent40241$3.84
Weighted average of the four rows100 percent2.7417.4$0.27
Full suite on every run100 percent40240$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.

Plan8 vCPU minutes per monthOn warp-ubuntu-latest-x64-8x at $0.016On the GitHub-hosted 8-core larger runner at $0.022
Full suite on every run211,200$3,379.20$4,646.40
Selection plus a nightly full run19,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.