What is nx affected?
nx affected is a command in the Nx CLI. It runs a target, such as test, lint or build, only for the projects in a monorepo that a set of changes could have impacted, instead of running it for every project. Nx works out which projects those are by comparing two commits, a base and a head, and checking the changed files against the workspace's project graph. The project graph is the map of projects and the dependencies between them that Nx builds from your workspace.
The command is only as accurate as the commit range it receives. This article covers how to choose that range in GitHub Actions. The two values involved are the base SHA, which is the commit you compare from, and the head SHA, which is the commit under test. Most problems with nx affected in CI come from one of these being wrong or missing on the runner.
Who should use it?
This setup is for teams that keep several applications and libraries in one Nx workspace, run CI on GitHub Actions, and find that running every target for every project on each push or pull request takes too long or costs too much compute. Backend and platform engineers who own the CI pipeline will use the material most. They decide the checkout depth, the permissions and the base-selection logic.
It fits best when:
- The workspace has many projects, and a typical change touches only a few of them.
- Project dependencies go through code imports or declared configuration, so the project graph reflects what actually affects what.
- The team can grant the workflow
actions: readpermission and fetch enough git history to compute a merge-base.
It is a weaker fit when:
- The workspace is small enough that a full run finishes quickly. In that case the risk of a wrong range is not worth the time saved.
- Much of the behavior depends on things the project graph cannot see, such as environment variables, external configuration or services outside the repository. The "Edge cases" section covers this in more detail.
How nx affected works: deciding what to run
nx affected runs a target, such as nx affected -t test, only for the projects that a range of commits could have changed. It works in three steps.
- Diff a commit range. Nx asks git which files differ between a base and a head. It computes the merge-base of the two, so a long-lived branch is compared against the point where it forked rather than the current tip of
main. - Map files to projects. Each changed file is matched to the project that owns it in the project graph. Changes to workspace-level files, such as the root
package.jsonornx.json, can mark many projects, or all of them, as touched. - Add dependents. Nx walks the graph in reverse and adds every project that depends on a touched project, directly or transitively. If
shared-authchanges, every service that imports it gets retested.
Commit range (base → head) → Changed files → Project graph lookup → Touched projects + transitive dependents → Affected set
You set the range with the --base and --head flags or the NX_BASE and NX_HEAD environment variables. Without them, Nx uses the workspace's configured default base branch and compares it against your current working tree.
Nx trusts whatever range you give it. A base that is too recent silently drops projects that need testing. A base that is too old rebuilds everything, and you lose the speedup. Choosing the right SHA is therefore a correctness decision as well as a performance one.
This matters most to backend and platform engineers whose full monorepo CI runs take long enough that skipping unchanged projects is worth the risk of getting the range wrong.
--base / NX_BASE --head / NX_HEAD
| |
+--------------+--------------+
v
+-----------------------------+
| Commit range |
| merge-base(base,head)..head |
+--------------+--------------+
| git diff
v
+-----------------------------+
| Changed files |
+--------------+--------------+
| match each file to its owning project
v
+-----------------------------+
| Project graph lookup |
| (nx.json, root package.json |
| may touch many or all) |
+--------------+--------------+
| walk graph in reverse
v
+-----------------------------+
| Touched projects |
| + transitive dependents |
+--------------+--------------+
v
+-----------------------------+
| Affected set |
| runs: nx affected -t test |
+-----------------------------+
How nx affected turns a base/head commit range into changed files, maps them to projects through the project graph, and adds transitive dependents to produce the affected set.
Why GitHub Actions defaults give you the wrong range
That risk grows quickly when the commit range is wrong, and the GitHub Actions defaults produce a wrong range more often than not.
By default, actions/checkout clones with a depth of one, so the runner has the commit under test and nothing else. When Nx computes the merge-base between your base and head, the base commit is not in local history. Git either errors out or Nx cannot find a common ancestor, and the affected set becomes meaningless. Setting fetch-depth: 0 fetches full history. That is not only about having older commits on disk: a merge-base can only be computed when both sides of the comparison exist locally, so the main branch ref must be present too.
Full history fixes the missing-commit problem but not the choice of base. HEAD~1 assumes every push contains one commit. A push of five commits diffs only the last one. If the previous pipeline failed or was cancelled, its commits were never validated, and HEAD~1 silently skips them as well. On pull requests, diffing against the current tip of main has the opposite problem. Everything merged to main since the branch was cut shows up as "changed," so the run includes projects the PR never touched.
Naive bases compared
| Naive base | What goes wrong | Correct replacement |
|---|---|---|
| Shallow clone (default depth) | Base commit absent; merge-base cannot be computed | fetch-depth: 0 with the main ref available |
HEAD~1 on push |
Misses earlier commits in a multi-commit push and commits from failed or cancelled runs | SHA of the last successful run on that branch |
Tip of main on PR |
Over-includes unrelated changes merged after branching | Merge-base of the PR head and main |
name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
actions: read # lets gh query previous workflow runs
jobs:
affected:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history plus remote branch refs such as origin/main
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Pick base and head SHAs
env:
GH_TOKEN: ${{ github.token }}
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
# Base is where the branch left main, not main's current tip
BASE=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
else
# Base is the last commit this workflow validated successfully
BASE=$(gh run list --workflow ci.yml --branch "${{ github.ref_name }}" \
--status success --limit 1 --json headSha --jq '.[0].headSha')
# No green run yet: diff from the root commit so everything runs
[ -n "$BASE" ] || BASE=$(git rev-list --max-parents=0 HEAD | tail -n 1)
fi
echo "NX_BASE=$BASE" >> "$GITHUB_ENV"
echo "NX_HEAD=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
- run: npx nx affected -t lint test build --base="$NX_BASE" --head="$NX_HEAD"
A GitHub Actions workflow that fetches full history, uses the merge-base with main for pull requests and the last successful run's SHA for pushes, then passes both SHAs to nx affected.
Choosing the correct base for pushes and pull requests
Pushes to main
For a push, the question is not "what changed in this commit?" but "what has changed since CI last passed on this branch?" Use the SHA from the last successful run of the same workflow on that branch. If three pushes in a row fail, the fourth run includes the changes from all four, so nothing that broke along the way is skipped. CI recovers from red builds without anyone manually forcing a full run.
Pull requests
For a pull request, use the merge-base between the target branch and the PR head. This is the commit the branch forked from, or the most recent point where it last integrated the target. The diff then contains only the PR's own changes, not work that landed on main after the branch was created.
How nrwl/nx-set-shas does it
On push events, the action queries the GitHub API for workflow runs on the current branch, finds the most recent successful one, and uses its commit as the base. On pull requests, it computes the merge-base. It then exports NX_BASE and NX_HEAD as environment variables, so later nx affected steps use them without extra flags.
The API lookup needs the actions: read permission. If your repository or organization restricts the default GITHUB_TOKEN permissions, grant it explicitly in the workflow or job.
Runs are matched by workflow, not by repository. If you rename the workflow file or split one workflow into several, the new workflow has no successful-run history. The action then has nothing to anchor to and falls back to a default base. Expect the first runs after such a change to compute a different range than you intend.
Implementing a minimal workflow for push and pull_request
The workflow below covers both triggers with one job. Push events on main compare against the last successful run, and pull_request events compare against the target branch. The order of steps matters because each one depends on the state the previous step left behind.
Permissions
Grant contents: read so the job can check out code, and actions: read so nx-set-shas can query the workflow-runs API for the last successful run on main. Without actions: read, the lookup fails and the action falls back to a default base. This is the same silent drift described above. Grant nothing beyond these two scopes, since this job doesn't write to the repository.
Full-depth checkout
Set fetch-depth: 0. A shallow clone contains only the tip commit, so the base SHA that nx-set-shas computes may not exist locally. When that happens, the comparison either fails or treats everything as changed. Checkout must come first because every later step reads from this history.
Dependency install
Install with your package manager's lockfile-respecting command, such as npm ci or its equivalent. Nx itself comes from node_modules, so this has to happen before anything invokes nx.
Set the SHAs
nx-set-shas exports NX_BASE and NX_HEAD as environment variables for later steps. On pull requests it uses the merge base with the target branch. On pushes it walks back to the last commit whose workflow run succeeded. Place it after checkout so it can confirm the commit exists locally.
Run affected
nx affected -t lint test build reads NX_BASE and NX_HEAD automatically, so you don't need to pass --base or --head flags. Keeping the range in one place means the step that computes the SHAs and the step that uses them can't fall out of sync.
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
actions: read
jobs:
affected:
runs-on: ubuntu-latest
steps:
# Full history so the computed base SHA exists locally.
- uses: actions/checkout@vX
with:
fetch-depth: 0
- uses: actions/setup-node@vX
with:
node-version: lts/*
cache: npm
# Lockfile-exact install; nx is resolved from node_modules.
- run: npm ci
# Exports NX_BASE and NX_HEAD for the steps below.
# PR: merge base with target branch. Push: last green commit on main.
- uses: nrwl/nx-set-shas@vX
# Reads NX_BASE/NX_HEAD from the environment; no --base/--head needed.
- run: npx nx affected -t lint test build
A single-job workflow for push to main and pull_request that grants only contents and actions read access, checks out full history, installs dependencies, sets NX_BASE and NX_HEAD with nx-set-shas, then runs nx affected for lint, test and build.
Verifying the computed range before you trust it
Before you trust that agreement, make the range visible. Add a diagnostic step right after the SHAs are computed that does three things:
- Print
NX_BASEandNX_HEAD. Comparing two hashes by eye is crude, but it catches the most common failure: both values are identical, so the diff is empty. - Run
git log --oneline $NX_BASE..$NX_HEAD. This lists the exact commits Nx will diff. If the list is far longer than the pull request or push you expected, the base is too far back. If git reports an unknown revision, the base commit was never fetched. This usually happens with a shallow checkout, and a full-history fetch fixes it. - Run
nx show projects --affected. This prints the project set without running any targets. You can confirm the result in seconds instead of waiting for a full build to fail or quietly skip work.
Keep this step in the workflow permanently. It costs almost nothing, and the log output becomes your first stop when a run looks wrong weeks later.
| Symptom | Likely cause | What to check |
|---|---|---|
| No projects affected | Base equals head, or the base is stale | Whether NX_BASE and NX_HEAD match, and whether the git log range is empty |
No projects affected, but git log shows commits |
The changed files aren't part of any project's inputs | Which files changed and how project inputs are configured |
| Every project affected | A file all projects depend on changed (lockfile, nx.json, root config) |
Whether the diff touches root-level files |
| Every project affected, no global files changed | The base is too wide | How many commits git log lists |
| Git error resolving the base | The base commit isn't present locally | Checkout fetch depth |
steps:
- uses: actions/checkout@v4
with:
# Full history so the base commit exists locally; shallow clones cause 'unknown revision'
fetch-depth: 0
# Exports NX_BASE and NX_HEAD as environment variables for later steps
- uses: nrwl/nx-set-shas@v4
- run: npm ci
# Diagnostic step: keep it permanently so the range is always in the logs
- name: Inspect affected range
run: |
echo "NX_BASE=$NX_BASE"
echo "NX_HEAD=$NX_HEAD"
if [ "$NX_BASE" = "$NX_HEAD" ]; then
echo "::warning::Base equals head, so the affected set will be empty"
fi
echo "--- Commits in range ---"
# Fails loudly if the base commit was never fetched
git log --oneline "$NX_BASE..$NX_HEAD"
echo "--- Changed files ---"
# Look for root-level files (lockfile, nx.json) that affect every project
git diff --name-only "$NX_BASE" "$NX_HEAD"
echo "--- Affected projects ---"
# Lists the project set without running any targets
npx nx show projects --affected --base="$NX_BASE" --head="$NX_HEAD"
- run: npx nx affected -t lint test build
A GitHub Actions diagnostic step that prints NX_BASE and NX_HEAD, warns when they match, and lists the commits, changed files and affected projects so you can tell an empty or full run apart before any targets execute.
Edge cases and when to avoid relying on affected alone
Runs with no reliable base
On the first run in a new repository or branch, there is no previous successful run to anchor on. You can fall back to a wider base, such as the first commit or a full run, or you can fail the job. A wide fallback costs CI time but never skips work. Failing makes the gap visible but blocks the pipeline. On main, prefer the wide fallback, because a silently narrow base is the worst outcome.
Force-pushes and history rewrites can remove the recorded base SHA. When the base doesn't resolve, treat it like the first run and fall back to a full run. Don't fall back to HEAD~1.
Triggers other than push and pull_request need their own logic. A workflow_dispatch run has no natural base. A scheduled run usually exists to catch drift, so run everything. Merge queue (merge_group) runs should diff against the queue's base commit, not the PR's.
When affected alone isn't enough
The project graph only knows the inputs you declare. Environment variables, external config, generated code and runtime dependencies outside the workspace can change behavior without marking any project affected.
Base lookups through the GitHub API can fail because of restricted token permissions, fork PRs with read-only tokens, or a renamed workflow that no longer matches earlier runs.
Merge-base diffs also don't guarantee a green main. Two PRs can each pass on their own and still break main once combined. Catching that requires a merge queue or a post-merge run on main.
Alternatives, cost, and engineering time trade-offs
Comparing the approaches
| Approach | Correctness | Setup effort | Main cost |
|---|---|---|---|
| Full run on every change | Always complete | Trivial | Highest compute per run |
Naive HEAD~1 / tip-based affected |
Misses commits in multi-commit pushes and PRs | Trivial | Silent gaps in coverage |
Affected with nx-set-shas |
Base is the last successful run on main | Low | Needs history; large sets after failures |
Hand-written git merge-base plus API |
As precise as your code | High | You maintain it |
fetch-depth: 0 makes the base SHA reachable, but it pulls full history, which adds clone time on large repositories. Fetching only the needed branches is cheaper but easier to get wrong. If the base commit is missing, the diff fails or falls back to a wider range.
Because nx-set-shas anchors on the last green main commit, a long stretch of red builds widens the range. The fix that finally goes green may run close to everything.
Lockfile and root config changes mark every project affected. Some teams narrow inputs to stop this, which brings back the missed coverage that affected was meant to prevent.
Illustrative estimate (assumed numbers, not a benchmark). Saved minutes per run = (P_total − P_affected) × t_avg − extra_clone_time. Assume 120 projects, 1.5 minutes per project, 12 affected, and 1 minute of extra clone time. That gives (120 − 12) × 1.5 − 1 = 161 minutes saved. The savings come from skipped projects, so they disappear when a change touches shared roots.
Declare inputs accurately, accept the occasional full run, and keep a scheduled full run on main as a safety net.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nx affected github actions, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.