Configuring Nx Affected Commands in CI
Your CI runs nx run-many --target=build on every pull request, rebuilds all 40 packages, and burns 25 minutes even when the change touched a single README. The fix is nx affected: it inspects what actually changed against a base commit, walks the project graph to find every project that depends on the change, and runs the target only for that subset. This page shows the exact base/head SHA selection, the affected algorithm, distributed execution, and a complete GitHub Actions workflow.
Symptoms
You are running the full graph on every commit when you see signs like these:
> nx run-many --target=build --all
✔ nx run pkg-a:build
✔ nx run pkg-b:build
... (38 more) ...
Successfully ran target build for 40 projects (24m 51s)
Error: No base and head SHAs could be calculated.
Assuming all projects are affected.
The second message is the most expensive trap: when Nx cannot resolve a base SHA, it conservatively marks every project as affected, silently undoing all your savings while the command still exits 0.
Root cause
nx affected is only as good as the two commits it diffs. It computes the set of changed files between a base and a head SHA, maps each file to its owning project, then expands that seed set across the project graph so every dependent is included. On a feature branch the natural base is the merge-base with main; on a push to main the natural base is the previous successful commit. CI runners default to a shallow fetch-depth: 1 clone, so the base commit is not present in .git, the diff cannot be computed, and Nx falls back to "all affected". Getting affected right in CI is a core part of Nx Workspace Architecture, because the project graph that powers nx graph is the same graph that powers affected targeting.
How the affected algorithm works
Nx builds a project graph by statically analyzing imports, package.json dependencies, and explicit implicitDependencies. When you run nx affected --target=build, Nx:
- Diffs the working tree between
--baseand--headto get a list of changed files. - Maps each file to its owning project (or to a global file like
nx.jsonor the lockfile, which marks all projects affected). - Computes the transitive closure of dependents: if
uichanged andwebimportsui, bothuiandwebare affected. - Filters the affected set to projects that actually have the requested target configured, then schedules them as a task graph.
A change to a root-level file listed in namedInputs (such as a lockfile or tsconfig.base.json) intentionally invalidates the whole graph, because it can alter any project's resolution.
Setup
1. Confirm the project graph is correct
Before trusting affected, verify the graph itself. A missing edge means a dependent gets skipped and ships broken.
npx nx graph --file=graph.json
npx nx show projects --affected --base=origin/main --head=HEAD
2. Select the base and head SHAs
The base/head pair differs by event. For pull requests, diff against the merge-base with the target branch. For pushes to main, diff against the last successful commit. The nrwl/nx-set-shas action resolves both automatically by querying the last successful CI run on the branch and exporting NX_BASE and NX_HEAD:
- uses: nrwl/nx-set-shas@v4
with:
main-branch-name: 'main'
Without this action you can set them manually, but you must fetch enough history first:
git fetch origin main --depth=50
export NX_BASE=$(git merge-base origin/main HEAD)
export NX_HEAD=$HEAD
npx nx affected --target=build --base=$NX_BASE --head=$NX_HEAD
3. Configure targetDefaults and inputs in nx.json
nx.json targetDefaults declare each target's cache behavior and inputs. Correct inputs keep the cache hash stable so unchanged projects stay off the critical path:
{
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"cache": true
},
"test": {
"inputs": ["default", "^production"],
"cache": true
},
"lint": {
"inputs": ["default", "{workspaceRoot}/.eslintrc.json"],
"cache": true
}
},
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": ["default", "!{projectRoot}/**/*.spec.ts"],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
}
}
dependsOn: ["^build"] forces each project's upstream dependencies to build first, which is what makes parallel affected builds correct rather than racy.
4. Run targets with parallelism and DTE
Run multiple targets in one invocation and cap concurrency with --parallel. For large graphs, distributed task execution (DTE) shards the task graph across multiple agents via Nx Cloud:
npx nx affected -t lint test build --parallel=3
# Coordinator on the main runner, agents started separately
npx nx affected -t build --parallel=3 --distribute-on="3 linux-medium-js"
Full GitHub Actions workflow
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Full history so merge-base and the last successful SHA resolve
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
# Resolves NX_BASE / NX_HEAD from the last successful run
- uses: nrwl/nx-set-shas@v4
with:
main-branch-name: 'main'
- run: pnpm install --frozen-lockfile
# One command, three targets, scoped to affected projects only
- run: npx nx affected -t lint test build --parallel=3
env:
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
fetch-depth: 0 is the single most important line: a shallow clone is the usual reason affected silently degrades to "all projects". Pair the install step with --frozen-lockfile exactly as in Lockfile Management Strategies so the dependency tree is deterministic across runners.
Validation
Confirm affected is actually narrowing the set before you trust it in merge gates:
# List the projects affected by the current branch
npx nx show projects --affected --base=origin/main --head=HEAD
# Dry-run the task graph without executing anything
npx nx affected -t build --base=origin/main --head=HEAD --graph=stdout
If the project list contains every project for a one-line change, your base SHA is wrong or a global file (lockfile, nx.json, a sharedGlobals input) changed.
CI guardrails
- Set
fetch-depth: 0(or fetch at least to the merge-base) so the base SHA is always present. - Use
nrwl/nx-set-shasrather than hand-rolled SHA math; it handles the "first run on a branch" edge case. - Pin
NX_CLOUD_ACCESS_TOKENas a read-write secret only on trusted branches; use a read-only token for fork PRs to prevent cache poisoning. - Add a scheduled nightly
nx run-many --allbuild so a stale cache or a missing graph edge cannot hide a broken project indefinitely. - Treat any "Assuming all projects are affected" log line as a CI warning, not noise.
Frequently Asked Questions
Why does nx affected run every project on a tiny change?
Almost always because the base SHA is unavailable. Shallow clones (fetch-depth: 1) leave the merge-base out of .git, so Nx cannot diff and conservatively marks all projects affected. Set fetch-depth: 0 and use nrwl/nx-set-shas to resolve the base and head reliably.
What is the difference between nx affected and nx run-many?
nx run-many runs a target for an explicit list of projects (or --all), with no change detection. nx affected first computes which projects changed relative to a base SHA and which projects depend on them, then runs the target only for that subset. Use run-many for full rebuilds and affected for PR validation.
How does --parallel relate to distributed task execution?
--parallel=N runs up to N tasks concurrently on a single machine. Distributed task execution (DTE) spreads the task graph across multiple CI agents through Nx Cloud, so the wall-clock time scales with the number of agents rather than the cores on one runner. They compose: each DTE agent still honors its own --parallel limit.
Related
- Nx Workspace Architecture — how the project graph that powers affected targeting is built and inferred.
- Remote Caching Setup — store affected task outputs so repeated CI runs replay from cache.
- Choosing a Monorepo Task Runner — when affected-style targeting favors Nx over alternatives.
- Lockfile Management Strategies — why a lockfile change invalidates the entire affected set.