Build and test only what changed in GitHub Actions

Speed up GitHub Actions and cut runner minutes by scoping 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.

How StarSling works

AI agents open the PR

StarSling agents inspect your workflow, apply this optimization, and open a reviewable PR automatically.

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.12

Affected-only, diffed against the merge base
# 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@afb73a62d26e41464e9254689e1fd6122ee683c1 # v5.0.1
  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

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
- run: turbo run build test   # every package, every PR, even a
                                #   one-line change to one app

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.

      - 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 &

non-adjacent lines joined by # ...

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.
      - 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
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.
          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]}
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.

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.

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.

Verify on your repo

Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to audit and fix this practice in your own repo.

Prompt for your coding agent
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?

  1. Confirm your slowest job builds/tests the whole tree regardless of the diff (it's at the top of the PR check-runs list).

  2. Confirm the repo has a real graph to scope against: turbo.json, nx.json, WORKSPACE, settings.gradle, or a --changed/--onlyChanged runner.

  3. 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.

Go further

One fix, all of them, or forever.

You have the prompt for this one practice. Here is how much further you can take it, each step doing more for you than the last.

  1. Fix this one thing

    Copy the prompt above

    Hand OPT70 to your coding agent and fix it in your repo today.

  2. Fix everything, once

    Install the ci-score skill

    One prompt grades your whole workflow config against all 11 CI Score checks, this one included, and hands your agent a ranked fix for every gap it finds. Open source, runs locally. It grades configuration, not speed.

  3. Keep it fixed, forever

    Install the StarSling GitHub App

    Connect GitHub and the fixes stay applied as your CI evolves, with agents that keep inspecting your workflows and opening optimization PRs you review.

More best practices for GitHub Actions

Where to go next in the CI best-practices catalog.

All GitHub Actions best practices

More ways to improve GitHub Actions

If you do not know why a run is slow yet, start with the diagnostic guide. Let an agent find which of these applies: /ci-speedup. If your workflows are already optimized but still slow, see our guide to fast GitHub Actions and GitHub Actions runner alternatives. Building containers in CI? The Docker workflow guide covers layer caching end to end. Want to see how your configuration measures up before changing anything? CI Score grades workflow config against a pass/fail rubric of these practices. It is not a speed measurement. To find exploitable workflow paths before attackers do, ci-secure checks the ten critical GitHub Actions attack vectors and lets you choose which fixes to apply.

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.

Should I pin nx-set-shas at v4 or v5?

Use v5 on current GitHub-hosted runners and self-hosted runners that support Node 24 actions. As of July 21, 2026, v5.0.1 is the current release; v5 moves the action runtime from Node 20 to Node 24 while preserving the base, head, and noPreviousBuild outputs used by this workflow. If a legacy self-hosted runner cannot run Node 24 actions, keep v4 only as a temporary fallback while you upgrade it. Pin the chosen release's full commit SHA instead of a mutable major-version tag, and keep the full-run fallback keyed on noPreviousBuild.

Sources

1Nx · affected (opens in new tab)

2Turborepo · running tasks (opens in new tab)

Last updated 2026-08-19