What it is

Nx is a build system and task runner for monorepos. It models a workspace as a set of projects, such as applications and libraries. Each project defines targets, such as build, test, and lint, and each target runs through an executor or a plain command. From the source code and configuration, Nx builds a project graph that records which projects depend on which. It uses that graph to order tasks, run them in parallel where it can, and cache their results.

nx run-many and nx affected are two ways to tell Nx which projects to run a target on:

  • nx run-many runs a target on a set of projects that you name explicitly, or on every project that defines the target.
  • nx affected runs a target only on projects touched by a Git diff, plus every project that depends on them.

Both commands use the same executors, the same task ordering, and the same cache. The difference is how they select projects. That difference decides what a green CI run actually proves, which is the subject of this article.

Who should use it

This article is for engineers who own CI for an Nx monorepo and have to decide which command each pipeline runs.

  • Teams whose monorepo has many projects benefit most from affected on pull requests. For a small change, it skips most of the repository.
  • Teams with a few tightly coupled projects often gain little from affected. A cached run-many costs about the same and does not depend on diff logic.
  • Release and platform engineers who need a result that holds for the whole repository should use run-many for releases and scheduled verification, whatever the PR pipeline uses.
  • Repos with heavy runtime coupling need extra care with affected. This includes plugins loaded by name, shared fixtures read from disk, and services linked only by network contracts. Every such coupling has to be declared, or affected will skip projects silently.

If your repository is not managed by Nx, the concepts still apply to any graph-based incremental build. The commands and configuration keys below are specific to Nx.

How it works: how nx affected and run-many decide what to run

nx affected and nx run-many both execute targets such as build, test, or lint across a monorepo. They differ in how they choose the projects.

nx affected

nx affected computes the work from version control. Given a base and head ref (--base and --head), it diffs the two and gets a list of changed files. It maps each file to the project whose root contains it. Changes to some workspace-level files can mark many projects as affected at once. Nx then walks the project graph in reverse to find every project that depends on a changed project, directly or transitively. It runs the target on that combined set.

Affected commands keep pull request pipelines proportional to the size of the change. A one-line fix in a leaf service should not rebuild forty unrelated packages.

nx run-many

nx run-many does no change detection. You give it a target and a set of projects: either a named list or every project that defines the target. It runs exactly that set. The result depends only on what you asked for, not on Git history. That makes it the predictable choice for release builds, scheduled verification, and recovery after a broken main branch.

Silent failures in affected

nx affected trusts its inputs completely. If the base ref points to the wrong commit, the diff is wrong and the project set is wrong. If the graph is missing an edge, such as a runtime-loaded module, dependents of a changed project are skipped. In both cases Nx reports success on a partial run and gives no warning. Engineers running Nx in CI should treat the choice of base ref and the completeness of the graph as configuration they own and check, not as defaults to trust.

#!/usr/bin/env bash
set -euo pipefail

BASE_BRANCH="${BASE_BRANCH:-main}"

git fetch --no-tags origin "$BASE_BRANCH"

if ! BASE_SHA="$(git merge-base "origin/$BASE_BRANCH" HEAD)"; then
  # nx affected will not warn about a bad base. Fall back to a full run.
  echo "No merge base found; running full build instead of affected" >&2
  npx nx run-many -t lint test build
  exit 0
fi
HEAD_SHA="$(git rev-parse HEAD)"

echo "Affected projects for $BASE_SHA..$HEAD_SHA:"
npx nx show projects --affected --base="$BASE_SHA" --head="$HEAD_SHA"

npx nx affected -t lint test build --base="$BASE_SHA" --head="$HEAD_SHA"

A CI script that pins and checks the base ref before running nx affected, logs the computed project set, falls back to a full nx run-many when the base cannot be resolved, and shows run-many for release builds.

Choosing the base ref: merge-base for PRs, last green build for main

For a pull request, the correct base is the merge-base: the commit where the branch diverged from its target, found with git merge-base origin/main HEAD. Diffing against the tip of main would count every change merged since the branch was cut. That inflates the affected set and runs work that has nothing to do with the PR. Pass the result to nx affected through --base and --head, or through the NX_BASE and NX_HEAD environment variables.

On main, the base should be the last commit whose pipeline succeeded. Your CI system records that SHA, and the job reads it back. A plain HEAD~1 only covers the most recent commit. If two merges land close together and the first build fails or is cancelled, the second build never re-checks the first commit's projects.

Shallow clones break both approaches. With a fetch depth of 1, the merge-base or the last green SHA usually isn't in local history. Then git merge-base fails, or Nx falls back to comparing against something meaningless. Fetch full history, or at least enough depth to reach the base, and fetch the target branch explicitly.

The base calculation becomes fragile in three common situations:

  • Fork PRs may not have the upstream branch available locally.
  • Force pushes can remove the recorded SHA from history.
  • Squash merges turn a whole branch into one commit, which makes "last green" ambiguous.

In each case, have the job check that the base commit exists with git cat-file -e. If the check fails, fall back to run-many. Running too much costs minutes. Silently skipping projects lets broken code reach main while the pipeline reports success.

#!/usr/bin/env bash
set -euo pipefail

TARGET_BRANCH="${TARGET_BRANCH:-main}"

if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
  git fetch --unshallow origin
fi
git fetch origin "+refs/heads/${TARGET_BRANCH}:refs/remotes/origin/${TARGET_BRANCH}"

HEAD_SHA="$(git rev-parse HEAD)"
BASE_SHA=""

if [ "${IS_PR:-false}" = "true" ]; then
  # PR: compare against the divergence point, not the current tip of main.
  BASE_SHA="$(git merge-base "origin/${TARGET_BRANCH}" HEAD || true)"
else
  # main: compare against the last successful pipeline, not HEAD~1.
  BASE_SHA="${LAST_GREEN_SHA:-}"
fi

if [ -n "$BASE_SHA" ] && git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
  export NX_BASE="$BASE_SHA" NX_HEAD="$HEAD_SHA"
  echo "Affected run: ${NX_BASE}..${NX_HEAD}"
  npx nx affected -t lint test build
else
  # An unknown base means unknown scope. Run too much rather than skip broken projects.
  echo "Base '${BASE_SHA:-<none>}' not usable; falling back to run-many" >&2
  npx nx run-many -t lint test build
fi

A provider-neutral CI script that deepens a shallow clone, chooses the merge-base for PRs or the last green SHA for main, checks that the base commit exists, and falls back to nx run-many when it does not.

Where the project graph is blind and how to declare the missing edges

Nx builds its graph from static evidence: import statements, package.json dependencies, and the files under each project's root. Anything outside that evidence is invisible to it. The common gaps:

  • Runtime-only coupling: a worker that loads a plugin by a name read from configuration.
  • Dynamically constructed paths: require or import() calls with a computed string.
  • Shared config or fixtures read at runtime: a fixtures/ folder or a YAML file opened with fs.
  • Generated code: clients produced from a schema that lives in another project.
  • Network contracts: two services that share only an OpenAPI or protobuf definition.

You can declare a project-level edge with implicitDependencies in project.json. A file-level edge needs a named input. Define it in nx.json under namedInputs, using a path such as {workspaceRoot}/contracts/**/*, then reference it from targetDefaults or from a target's inputs.

Global inputs work the other way and widen the affected set. Changes to nx.json, the root lockfile, tsconfig.base.json and root tool configs can mark many or all projects as affected. Nx analyzes some of these files more precisely than others, so check the result with nx show projects --affected rather than guessing. Every file you add to a broad shared input, such as sharedGlobals, has the same effect. If that input catches files that change often, almost every PR touches everything, and affected costs what run-many costs while adding the risk of a wrong base ref.

These declarations also go stale without warning. Remove a runtime coupling and the implicitDependencies entry keeps forcing extra builds. Add one without declaring it and CI stays green while it skips the project that breaks. Review these entries whenever you change service boundaries, the same way you would review CODEOWNERS.

cat > nx.json <<'EOF'
{
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "sharedGlobals": ["{workspaceRoot}/.nvmrc"],
    "contracts": ["{workspaceRoot}/contracts/**/*"],
    "fixtures": ["{workspaceRoot}/fixtures/**/*"]
  },
  "targetDefaults": {
    "build": { "inputs": ["default", "^default"] },
    "test": { "inputs": ["default", "^default", "fixtures"] }
  }
}
EOF

cat > apps/billing-worker/project.json <<'EOF'
{
  "name": "billing-worker",
  "implicitDependencies": ["payment-plugins"],
  "targets": {
    "build": { "inputs": ["default", "^default", "contracts"] }
  }
}
EOF

nx show projects --affected --base=origin/main --head=HEAD

The example adds named inputs for files read at runtime, declares a project-level implicitDependencies edge, keeps sharedGlobals small, and checks the resulting affected set.

Run-many vs affected: when to use each and when to avoid it

Both commands run the same targets on the same executors. They differ in which projects they select, and that changes what a green run tells you.

Criterion run-many affected
Correctness guarantee Every selected project ran (or was restored from cache) against this commit Only projects the graph links to the diff ran
Auditability Simple: the list of projects that ran is the list you asked for Requires reconstructing the base ref and graph at run time
Dependence on diff logic None Complete. A wrong base or a missing edge silently narrows the run
Cost as the repo grows Grows with the total number of projects Grows with the size of the change and how many projects depend on it
Suitable uses Releases, nightly runs, cache and graph audits PR feedback

Use run-many when you need the result to hold for the whole repository: release builds, nightly verification, and catching flaky or environment-dependent failures that affected never revisits because no one touched that code. It also works as an audit. If a full run fails where affected passed, either the cache restored a stale result or the graph is missing an edge.

Use affected for PRs, where fast feedback matters more than full coverage.

Avoid relying on affected alone in CI. With that setup, PRs merge green while untouched dependents break, and nobody notices until a release. Also avoid uncached full builds on every PR. Once they take long enough, engineers start re-running, skipping, or force-merging past CI. If neither command fits, plain per-project pipelines work for loosely coupled repos. For large repos, distributing tasks across agents reduces wall-clock time without reducing what gets checked.

How computation caching changes the trade-off

With a warm cache, the gap between the two commands shrinks. Nx hashes each task's inputs: source files, the hashes of its dependencies, the command and its arguments, and any environment or runtime inputs you declare. It replays the stored output and logs whenever that hash matches. On a PR that touches two projects out of eighty, run-many still hashes all eighty, but seventy-eight of them restore from the local or remote cache in seconds. The wall-clock worst case only applies to a cold cache or a change in a widely shared input, such as a root config file or the lockfile.

Caching depends on the same inputs as affected, so the two share the same failure mode. If a task reads something its hash ignores, Nx returns a false cache hit. Examples include an undeclared environment variable, a generated file outside the project root, or a tool version that is not modeled as a runtime input. In that case run-many replays a stale green result just as affected would skip the project. Running everything does not protect you against an incomplete hash.

For the same reason, a cached full build is not a clean full build. It only shows that every task matched a hash that once passed. It does not show that the tasks would pass if executed today on a fresh agent. To get that guarantee, schedule a verification run that bypasses the cache with --skip-nx-cache. This executes every task for real without reading cached results. If it fails while cached runs pass, you have a missing input. Declare it in the task's inputs rather than disabling caching.

#!/usr/bin/env bash
set -euo pipefail

TARGETS="lint test build"

case "${CI_PIPELINE_TRIGGER:-pr}" in
  pr)
    # Warm local/remote cache: unchanged projects are still hashed,
    # but their outputs and logs are replayed instead of executed.
    npx nx run-many -t $TARGETS
    ;;
  nightly)
    # Clean full build: execute every task for real and ignore cached results.
    # This checks that the code passes today on a fresh agent,
    # not only that each task's hash once passed.
    npx nx run-many -t $TARGETS --skip-nx-cache
    ;;
esac

A CI script that uses cached run-many for pull requests and a scheduled --skip-nx-cache run to find missing task inputs, with an example of declaring those inputs instead of disabling the cache.

A layered Nx CI strategy you can implement

Run four layers, each with one job.

Pull requests: nx affected against the merge-base. This run gates merges, so it has to be fast and trusted enough that people don't retry it out of habit.

Main: nx affected against the last green commit if a broken main costs you minutes. Use nx run-many if a broken main blocks a deploy train. Either way, main's result gates releases, not merges.

Scheduled: a nightly run-many with --skip-nx-cache. It blocks nothing directly. Its job is to catch missing graph edges and undeclared inputs before they ship.

Escalation: before choosing a mode, the pipeline should list the changed files with git diff --name-only between base and head. If any path matches a global input, switch to run-many. Typical global inputs are nx.json, the root package.json, the lockfile, the base tsconfig, and shared CI scripts. Changes there can alter every project's build without touching project source, and the graph may not reflect that.

The trade-off is late detection. A nightly failure might cover thirty merges. Bisect it with git bisect run, using the single failing task with the cache skipped as the test. Then add the edge or input you find, so the same class of failure gets caught at PR time from then on.

Audit your affected configuration when you see any of these signals:

  • Main fails on code that passed its PR.
  • Nightly full runs fail while affected runs pass.
  • Affected sets come back empty for real changes.
  • Trivial edits mark every project as affected.

Impact on CI cost and engineering time: an illustrative estimate

Illustrative estimate (assumed numbers, not a benchmark)

Assumed inputs: 200 tasks per full run (projects × targets), 2 minutes average task time, 15% affected fraction, 85% cache hit rate on a warm cache, 0.1 minutes to restore each cache hit, 60 CI runs per day, 4 agents.

Formula: daily agent-minutes = runs/day × [(tasks executed × task time) + (cache hits × restore time)]. Wall-clock per run is roughly the per-run figure divided by agent count, if the work distributes evenly.

Strategy Tasks executed per run Agent-minutes per run Daily agent-minutes Wall-clock per run (4 agents)
Affected only 30 60 3,600 ~15 min
Run-many, warm cache 30 (+170 restored) 77 4,620 ~19 min
Run-many, cold cache 200 400 24,000 ~100 min

The result depends most on the cache hit rate. If the hit rate drops to 70%, the warm run-many cost roughly doubles, to about 8,000 daily minutes. Affected-only depends on the affected fraction in the same way. If shared libraries push that fraction to 40%, affected-only costs 9,600 minutes. That is more than a well-cached full run. Measure both numbers before you pick a default.

The CI minutes are the smaller part of the cost. When a missing graph edge lets a broken change pass affected-only checks, the failure surfaces later, on main or in production. By then several merges have landed, so someone has to bisect them to find the cause. The layered approach puts a bound on that search. A periodic full run on main fails within one interval of the bad merge, which narrows the suspects to the commits since the last green full run. It also points directly at the edge you need to declare.

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 run-many vs affected, 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.