---
title: "Build only what changed: affected-only CI in GitHub Actions"
description: "Run build and test only on the packages a PR touched in GitHub Actions, using Turborepo, Nx, or a --changed test runner, with a mandatory full-run fallback."
url: https://starsling.dev/best-practices/github-actions/build-only-affected
canonicalUrl: https://starsling.dev/best-practices/github-actions/build-only-affected
---

# Build and test only what changed in GitHub Actions

Best practice: Parallelization. Last updated: 2026-07-16

Scope your slowest build/test job to only the packages a PR actually changed, using your monorepo tool's affected mode, with a mandatory full-run fallback so a resolution error never silently skips work.

## Table of contents

- [Do this](#do-this)
- [Avoid this](#avoid-this)
- [Seen in the wild](#seen-in-the-wild)
- [Why it matters](#why-it-matters)
- [When to use](#when-to-use)
- [Verify on your repo](#verify)
- [Sources](#sources)

<a id="do-this"></a>

## Do this

In a workspace whose tooling knows the dependency graph (Turborepo, Nx, Bazel, Gradle, or a `--changed`-capable test runner), the long-pole job runs only the targets affected by the diff against the merge base. Crucially, it fails safe: if the tool can't resolve the graph or returns an empty set on a real diff, it runs the full build/suite instead of passing green having run nothing.

_Affected-only, diffed against the merge base_

```yaml
# The job needs `permissions: { contents: read, actions: read }`
# (plus `pull-requests: read` if you run a merge queue): nx-set-shas calls the
# Actions API to find the last successful run, and a permissions block sets every
# scope you don't list to none.
- uses: actions/checkout@v7
  with:
    fetch-depth: 0                     # full history so the base commit is reachable
- uses: nrwl/nx-set-shas@v5
  id: shas
- run: pnpm install --frozen-lockfile
- run: |
    # THE fail-safe, and it has to key off noPreviousBuild - NOT off an empty
    # NX_BASE. nx-set-shas never leaves NX_BASE empty: when it cannot find a
    # successful run to diff against it warns, silently falls back to HEAD~1,
    # and carries on. nx affected then scopes to a single commit and the check
    # goes green having tested a subset of a multi-commit PR. noPreviousBuild is
    # the only signal that says so, so branch on it and run everything.
    if [ "${{ steps.shas.outputs.noPreviousBuild }}" = "true" ]; then
      npx nx run-many -t build test
    else
      npx nx affected -t build test --base=$NX_BASE --head=$NX_HEAD
    fi
```

<a id="avoid-this"></a>

## Avoid this

Every PR rebuilds and retests the whole tree, even a one-line change to one package.

_Rebuild and retest the whole tree every PR_

```yaml
- run: turbo run build test   # every package, every PR, even a
                                #   one-line change to one app
```

<a id="seen-in-the-wild"></a>

## Seen in the wild

Adobe, discord.js, and Nx use affected-project selection so a small PR does not rebuild and retest the whole monorepo.

### nrwl/nx - .github/workflows/ci.yml

```yaml
      - name: Set SHAs
        uses: nrwl/nx-set-shas@310288c04d90696f9f1bc27c5e3caea6642b53d4 # v5.0.0
# ...
          pnpm nx affected --targets=lint,test,build,e2e,e2e-ci,format-native,lint-native,gradle:build-ci,vale,run &
```

Key lines: `uses: nrwl/nx-set-shas@310288c04d90696f9f1bc27c5e3caea6642b53d4 # v5.0.0`, `pnpm nx affected --targets=lint,test,build,e2e,e2e-ci,format-native,lint-native,gradle:build-ci,vale,run &`.

nx-set-shas resolves the base and head commits; nx affected then runs lint, test, build and e2e only for the projects that changed between them.

Source: [nrwl/nx `.github/workflows/ci.yml` lines 54-55,111-111](https://github.com/nrwl/nx/blob/03483ea23b4b36feb80d3b044591f087c2d19f00/.github/workflows/ci.yml#L54-L111) at commit `03483ea`, non-adjacent lines joined by `# ...`.

### discordjs/discord.js - .github/workflows/tests.yml

```yaml
      - name: Tests (PR)
        if: ${{ github.event_name != 'push' }}
        run: pnpm exec turbo run test --filter="...[origin/${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || 'main' }}]" --concurrency=4
```

Key line: `run: pnpm exec turbo run test --filter="...[origin/${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || 'main' }}]" --concurrency=4`.

turbo's ...[origin/<base>] is a git range, not a package name: it builds and tests only the packages whose code changed against the pull request's base branch.

Source: [discordjs/discord.js `.github/workflows/tests.yml` lines 39-41](https://github.com/discordjs/discord.js/blob/6e81ea771da9cddd8ac0dda10f581dd667bca599/.github/workflows/tests.yml#L39-L41) at commit `6e81ea7`.

### adobe/leonardo - .github/workflows/ci.yml

```yaml
          fetch-depth: 0
      - uses: moonrepo/setup-toolchain@v0.6.2
        with:
          auto-install: true
      - run: pnpm install --frozen-lockfile
      - run: moon ci 2>&1 | tee moon-ci.log; exit ${PIPESTATUS[0]}
```

Key line: `- run: moon ci 2>&1 | tee moon-ci.log; exit ${PIPESTATUS[0]}`.

moon ci runs only the tasks the diff affected, comparing against the base commit that fetch-depth: 0 makes reachable; the full graph stays untouched.

Source: [adobe/leonardo `.github/workflows/ci.yml` lines 10-15](https://github.com/adobe/leonardo/blob/eb6481da40df27654ac8efa42038007f6fad2431/.github/workflows/ci.yml#L10-L15) at commit `eb6481d`.

<a id="why-it-matters"></a>

## Why it matters

In a large monorepo, rebuilding and retesting everything on every PR is the biggest source of redundant CI work. But this is the most dangerous lever in the catalog: scoping trades correctness headroom for speed. An incomplete dependency graph can mark a truly-affected target 'unaffected' and skip it, and a green check that ran fewer tests is indistinguishable from a real speedup, until a bug ships. Adopt it only with the guardrails below.

<a id="when-to-use"></a>

## When to use

**Use it when:** A monorepo whose build tool (Turborepo/Nx/Bazel/Gradle) or test runner genuinely models the dependency graph, where the long-pole job rebuilds far more than any single PR touches.

**Be careful when:** Never scope without a full-run fallback on resolution error, a build-error exit that's distinct from a test failure, and a parallel scoped-vs-full shadow period before cutover. Always keep the full build/suite on the merge queue and `main` so the trunk is validated end to end. If your tooling doesn't model the graph, this isn't actionable.

<a id="verify"></a>

## Verify on your repo

Hand this prompt to your coding agent to audit and fix this practice in your own repo:

Inspect this repo's .github/workflows for a job that rebuilds and retests the whole tree on every PR. Confirm the repo has a real dependency graph to scope against (`turbo.json`, `nx.json`, `WORKSPACE`, `settings.gradle`, or a `--changed`-capable runner), then scope the long-pole job to only the targets affected by the diff against the merge base (resolve the base with the repo's graph tool or `git merge-base`). This is a correctness-sensitive change: it MUST fail safe, so if the base does not resolve or the scoper returns an empty set on a real diff, run the full build and test instead of passing green having run nothing, and keep the full run on the merge queue and `main`. Show me the diff and open a PR rather than applying it blindly, and do not cut over without a scoped-vs-full shadow period.

Ground these changes in the upstream docs before you edit: https://turborepo.dev/docs/crafting-your-repository/running-tasks. If you cannot fetch them, say so rather than guessing, and cite what you used in the PR description.

Prefer to check by hand:

- Confirm your slowest job builds/tests the whole tree regardless of the diff (it's at the top of the PR check-runs list).
- Confirm the repo has a real graph to scope against: `turbo.json`, `nx.json`, `WORKSPACE`, `settings.gradle`, or a `--changed`/`--onlyChanged` runner.
- Verify the guardrail: does the job fall back to a full run when the scoper errors or returns empty on a non-trivial diff? If not, it's unsafe.

<a id="more-best-practices"></a>

## More best practices for GitHub Actions

- [Shard tests across parallel jobs in GitHub Actions](https://starsling.dev/best-practices/github-actions/shard-tests)
- [Filter workflows by path in GitHub Actions](https://starsling.dev/best-practices/github-actions/path-filter-workflows)
- [All CI best practices](https://starsling.dev/best-practices/github-actions)

<a id="faq"></a>

## FAQ

### Why diff against the merge base instead of HEAD~1?

`HEAD~1` only sees the last commit, so a multi-commit PR would skip targets changed in earlier commits. Diff against the merge base, the commit where your branch forked from the base, not the current tip of the base branch (which may have moved on since). `nrwl/nx-set-shas` computes it for you; by hand it's `git merge-base origin/<base_ref> HEAD`.

### How do I roll this out without missing test coverage?

Run the scoped job in parallel with the existing full job for a week or two of real PRs, comparing pass/fail and coverage on every one, including at least one PR that touches a shared base package. Cut over only after they match, and keep the full run on the merge queue and main.

<a id="sources"></a>

## Sources

1. [Nx - affected](https://nx.dev/docs/features/ci-features/affected)
2. [Turborepo - running tasks](https://turborepo.dev/docs/crafting-your-repository/running-tasks)
