How it works: how Nx decides what is affected

An affected-projects comment is a short report on a pull request that lists which projects in the monorepo the change touches, either directly or through dependencies. The setup has four stages. CI resolves an explicit base and head commit for the PR. Nx computes the affected set for that range. A thin script renders the set as Markdown. A final step posts or updates a single comment on the PR. The rest of this article covers each stage in that order. This section explains the core of the mechanism, which is how Nx turns a diff into a list of projects.

Nx builds that list from a deterministic pipeline:

git diff (base..head) -> changed files -> owning projects -> dependents via graph -> affected set

First, Nx asks git which files differ between a base commit and a head commit. It then maps each changed file to the project whose root directory contains it. Finally, it walks the project graph in reverse, adding every project that depends on a directly changed project, and those projects' dependents in turn. If libs/auth changes, every app importing it is affected, even though none of its files moved.

Why root files widen the blast radius

Some files do not belong to any single project. These include the lockfile, the root package.json, a shared base tsconfig, and nx.json. Nx cannot always attribute these files to specific projects. When a change could plausibly alter how everything builds or resolves, the safe answer is to mark many or all projects affected. A lockfile bump on a PR that only edits a README can therefore produce a comment listing the whole repo. That result is correct behaviour.

What the graph cannot see

The affected list reflects the declared project graph, not runtime behaviour. Nx infers edges from static imports and from configuration such as implicitDependencies. A dynamic import built from a string at runtime does not create an edge, and neither does a service calling another over HTTP or a config file read from disk. In those cases the report under-reports, so treat it as a lower bound unless those relationships are declared explicitly.

+-----------------------+
| git diff (base..head) |
+-----------------------+
            |
            v
+-----------------------+
| changed files         |
+-----------------------+
      |              |
      | in project   | root-level file
      | root         | (lockfile, root package.json,
      |              |  shared tsconfig, nx.json)
      v              v
+------------+  +-----------------------------+
| owning     |  | no single owner: may mark   |
| projects   |  | many or all projects        |
+------------+  +-----------------------------+
      |                       |
      v                       |
+---------------------+       |  +----------------------------------+
| dependents via      |       |  | not in graph -> no edge:         |
| project graph       |       |  | dynamic import(str), HTTP calls, |
| (reverse walk)      |       |  | files read at runtime            |
+---------------------+       |  | => list is a lower bound         |
      |                       |  +----------------------------------+
      v                       |
+---------------------+       |
| affected set        |<------+
+---------------------+

Nx maps files changed between base and head to their owning projects and walks the project graph in reverse to add dependents, while root-level files can mark many or all projects affected and relationships missing from the declared graph cause under-reporting.

Why and when to post affected projects on pull requests

A diff shows which files changed. It does not show which deployable services, test suites, or publishable libraries now depend on that change. Reviewers can work this out by checking out the branch and running nx graph, but few do it on every pull request. A comment listing affected projects puts the blast radius on the PR itself. "Touches auth-core, affects 14 projects including billing-api and gateway" calls for a different review than "affects docs-site only."

The report also helps owners of shared libraries. If a utility package sits under many services, its maintainers want to know when someone outside the team changes it, or changes something it depends on. CODEOWNERS covers direct file edits. The affected list covers indirect impact through the dependency graph, which CODEOWNERS cannot see.

Who should use it

The pattern works best in medium-to-large Nx monorepos with real shared libraries and clear project boundaries. Backend and platform teams often fit this description: many services, common data-access or messaging libraries, and CI costs that grow with the number of affected projects.

In practice, three groups get the most from it:

  • Reviewers who need to judge a PR's risk without rebuilding the dependency picture in their heads.
  • Maintainers of shared libraries who want to see indirect changes that CODEOWNERS does not catch.
  • Platform or CI owners who already run nx affected and want the PR to show the same scope CI acted on.

A prerequisite applies to all three. The project graph must be accurate enough to trust, with implicit dependencies declared and module boundaries respected.

When to avoid it

Skip it in these cases:

  • Small repos. If nearly every change affects nearly every project, the comment carries no information.
  • Unreliable boundaries. If implicit dependencies are missing or projects reach into each other's internals, a short list gives reviewers false confidence. Fix the graph before you advertise it.
  • Very busy repos. On repos with high PR and push volume, repeated comment updates create notification noise. Each update also counts against API rate limits shared with other automation. A job summary or status check may serve better there.
  • Fork-heavy repos without capacity for the privileged split. Posting comments on fork PRs safely requires a second, privileged workflow and careful handling of untrusted input (see "Handling fork pull requests safely"). If you cannot maintain and review that, a job summary gives most of the value without the risk.
  • Heavy runtime coupling. If services mostly depend on each other through HTTP, message queues, or files read at runtime, and those links are not declared as implicit dependencies, the list will routinely under-report.

Getting base and head SHAs right in a pull_request workflow

Every affected result depends on two commits, so treat their selection as an explicit step rather than an implicit default. On a pull_request event, actions/checkout fetches a single commit unless told otherwise, and it checks out a synthetic merge ref rather than the PR head. Nx's default base comes from the defaultBase setting, which names a branch such as main. In a fresh CI checkout that local branch often does not exist, or it points somewhere unexpected.

Set fetch-depth: 0 on checkout, or a depth large enough to reach the merge base. Then compute the merge base yourself. Run git merge-base with origin/<base branch> and github.event.pull_request.head.sha, and pass that result to Nx as --base. Pass the head SHA as --head. With both values explicit, every run uses the same inputs, and you can log them next to the report to debug a surprising result.

Base used for comparison What the diff contains Risk
Base branch tip This PR's changes, plus the reverse of anything merged to the base branch since the PR branched Reports projects the PR never touched
Merge base Only commits introduced by this PR Requires enough history to locate it

Shallow clones are the most common way this setup breaks. With depth 1, git merge-base finds no common ancestor and fails, or the diff degrades to "everything changed." Full history has a cost. On large or long-lived repositories, fetch-depth: 0 can noticeably lengthen checkout, especially on ephemeral runners with no git cache. If that cost matters, fetch a bounded depth that covers your typical branch age. Then fail loudly when the merge base cannot be found, rather than silently reporting the wrong projects.

name: pr-affected
on: pull_request

jobs:
  affected:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          # Check out the real PR head, not the synthetic merge ref
          ref: ${{ github.event.pull_request.head.sha }}
          # Full history so the merge base is reachable
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci

      - name: Resolve base and head SHAs
        env:
          BASE_REF: ${{ github.base_ref }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
        run: |
          # Merge base = only commits this PR introduced
          if ! BASE_SHA=$(git merge-base "origin/$BASE_REF" "$HEAD_SHA"); then
            echo "::error::No merge base between origin/$BASE_REF and $HEAD_SHA (history too shallow?)"
            exit 1
          fi
          echo "base=$BASE_SHA head=$HEAD_SHA"  # logged for debugging the report
          echo "NX_BASE=$BASE_SHA" >> "$GITHUB_ENV"
          echo "NX_HEAD=$HEAD_SHA" >> "$GITHUB_ENV"

      - name: Run affected targets with explicit SHAs
        run: npx nx affected -t lint test build --base="$NX_BASE" --head="$NX_HEAD"

A pull_request workflow that checks out the PR head with full history, computes the merge base explicitly with git merge-base, fails loudly if none is found, and passes both SHAs to nx affected as --base and --head.

Producing machine-readable output with nx show projects --affected

With a trustworthy base and head in hand, ask Nx for the affected set directly instead of parsing the output of a task run. nx show projects --affected accepts the same --base and --head arguments as nx affected. With --json it prints a JSON array of project names. Your rendering step can consume that array without regexes that break when log formatting, colors or progress spinners change.

Filtering the list makes the comment more useful. Recent Nx versions let you restrict output by project type, such as applications versus libraries, and to projects that define a given target. For example, you can report only the deployable apps a change touches, or only projects that have an e2e target. Flag names and spellings have shifted between major releases, and the JSON shape has not always been stable. Run nx show projects --help against the Nx version installed in your lockfile before relying on any option. Pin Nx so an upgrade cannot quietly change what the comment step parses. Treat an Nx major bump as a change that requires re-checking this script.

Pass the exact same base and head values, ideally from the same environment variables, to nx affected -t lint test build. This is the main reason to compute the SHAs once and reuse them. If the comment and the task run derive their ranges independently, one could fall back to a different default branch or a shallower history. The PR would then claim a project is unaffected while CI built it, or report a project as affected that CI never tested. With a shared range, both steps run the same project graph calculation against the same commits, so the comment and the executed tasks cannot disagree.

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

BASE="${NX_BASE:?NX_BASE must be set, e.g. 3f1c2a9}"
HEAD="${NX_HEAD:?NX_HEAD must be set, e.g. 8b7e0d4}"

npx nx show projects --help > nx-show-projects-help.txt

npx nx show projects --affected --base="$BASE" --head="$HEAD" --json \
  > affected-all.json

npx nx show projects --affected --base="$BASE" --head="$HEAD" --type app --json \
  > affected-apps.json

jq -e 'type == "array"' affected-all.json > /dev/null

echo "Affected projects: $(jq 'length' affected-all.json)"
jq -r '.[]' affected-apps.json | sed 's/^/- app: /'

npx nx affected -t lint test build --base="$BASE" --head="$HEAD"

A CI script that computes the affected project list as JSON with nx show projects --affected, optionally filtered to applications, and then runs nx affected -t lint test build with the identical base and head so the PR comment matches the tasks CI actually executed.

Rendering and posting an idempotent PR impact comment

Keep the comment step thin: it reads the project list and renders Markdown. It should never call Nx with different arguments than the CI step did.

Rendering

Run nx show projects --affected with --type app and --type lib against the same base and head, and write each result as JSON. Render two groups with a count in each heading, such as "Applications (3)" and "Libraries (41)". Reviewers usually care most about deployable apps, so list those first.

Put any list longer than a dozen or so entries inside a <details> block so the comment doesn't take over the conversation tab. If the affected count equals the total project count, the cause is usually a root-level change such as a lockfile, nx.json, or a shared tsconfig. In that case, print one line saying every project is affected and skip the full listing.

When nothing is affected, render an explicit message like "No projects affected by this change." An empty or missing comment looks the same as a step that crashed. The explicit message tells reviewers the step ran and found nothing.

Posting idempotently

Start the body with a hidden marker such as <!-- nx-affected-report -->. Each time the step runs:

  1. List the PR's issue comments through the REST API, or with gh api.
  2. Find the comment whose body contains the marker.
  3. If one exists, PATCH it. Otherwise, create a new comment.

Without the marker, every push adds another comment, and stale reports bury the current one.

Authenticate with GITHUB_TOKEN and declare minimal job permissions: contents: read and pull-requests: write. issues: write also works, because PR comments use the issues endpoints.

#!/usr/bin/env bash
set -euo pipefail
MARKER='<!-- nx-affected-report -->'
APPS=$(jq length apps.json); LIBS=$(jq length libs.json); TOTAL=$(jq length all.json)

render_group() { # $1=heading, $2=json file; collapse long lists
  local n; n=$(jq length "$2")
  [ "$n" -eq 0 ] && return 0
  echo "### $1 ($n)"
  [ "$n" -gt 12 ] && printf '<details><summary>Show %s</summary>\n\n' "$n"
  jq -r '.[] | "- `\(.)`"' "$2"
  [ "$n" -gt 12 ] && printf '\n</details>\n'
  echo
}

{
  echo "$MARKER"; echo "## Nx affected projects"; echo
  if [ $((APPS + LIBS)) -eq 0 ]; then
    echo "No projects affected by this change."  # step ran, found nothing
  elif [ $((APPS + LIBS)) -eq "$TOTAL" ]; then
    echo "All $TOTAL projects are affected (likely a root-level change)."
  else
    render_group Applications apps.json   # deployable apps first
    render_group Libraries libs.json
  fi
} > comment.md

ID=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \
  --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" | head -n1)
if [ -n "$ID" ]; then
  gh api -X PATCH "repos/$REPO/issues/comments/$ID" -F body=@comment.md > /dev/null
else
  gh api -X POST "repos/$REPO/issues/$PR_NUMBER/comments" -F body=@comment.md > /dev/null
fi

A thin bash step that renders grouped affected projects from JSON with jq, then uses a hidden marker and gh api to update its existing PR comment or create one.

Handling fork pull requests safely

Note that permissions: pull-requests: write has no effect on pull requests opened from forks. For the pull_request event, GitHub issues a read-only GITHUB_TOKEN to fork PRs and withholds repository secrets. This is intentional. The workflow runs code the contributor controls, so the comment step fails with a 403 error.

A common workaround is to switch to pull_request_target, but that is also the dangerous one. It runs in the context of the base branch with a write-capable token and access to secrets. If that workflow checks out the PR head and runs npm ci or nx show projects --affected, it executes attacker-controlled code with those privileges. Install scripts, Nx plugins and project configuration are all code that runs during that step.

Split computing from posting

  1. Compute (unprivileged). A pull_request job checks out the PR, runs the affected query, and writes the project list and PR number to a JSON file. It then uploads that file as an artifact.
  2. Post (privileged). A separate workflow triggered by workflow_run on the first workflow's completion downloads the artifact and posts the comment. It never checks out or executes PR code.

This adds a second workflow, cross-run artifact retrieval and a harder debugging loop. It also needs a security review, because the artifact is untrusted input:

  • Validate its schema and cap its size.
  • Escape project names before rendering them as Markdown.
  • Never interpolate artifact values into run: scripts through ${{ }} expressions.
  • Don't trust the PR number in the artifact. For fork PRs, workflow_run.pull_requests is often empty, so look up the PR by the triggering run's head SHA and confirm the two match before you comment.

Validating the setup, alternatives, and impact on engineering time

Validate against the graph, not intuition

Open three throwaway PRs and compare each comment with what nx graph shows for the same change:

  • A leaf library (no dependents). Expect only that library plus any apps that import it. Extra entries usually mean a wrong base SHA.
  • A widely shared library. Expect its full set of transitive dependents. If the list is shorter than the graph shows, the checkout is probably too shallow for Nx to compute the diff correctly.
  • Only root config (for example, nx.json or the root lockfile). Root files are usually part of every project's inputs, so expect most or all projects. Confirm your rendering truncates the list cleanly instead of hitting comment size limits.

Alternatives

Option Visibility Notification noise Fork support Maintenance
PR comment (idempotent) High, in the conversation One notification, then edits Needs the workflow_run split Parsing and upsert logic
Job summary Only in the Actions run None Works on forks without extra setup Lowest
Check run Checks tab, can gate merges Low Token is read-only for forks API calls and permissions
No report None None N/A None

Cost and payoff

A full-history fetch adds checkout time that grows with repository size. The parsing script also needs a recheck after each Nx upgrade, because output formats can change. Pin your parser to the JSON output, not to human-readable text.

Illustrative estimate (assumed numbers, not a benchmark). Assume 40 PRs/week, 3 reviewer-minutes saved per PR by not reconstructing scope by hand, and 0.5 extra CI minutes per PR for the deeper checkout.

Weekly net = PRs × (review minutes saved − extra checkout minutes) = 40 × (3 − 0.5) = 100 minutes.

This estimate leaves out the unaffected tasks you skip running. Add those minutes to the saved side using your own CI data.

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 pull request comment, 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.