How Nx decides what is affected
An affected list answers one question for a pull request: which projects in the workspace could behave differently because of this change? Nx builds it in three steps.
First, it computes the set of changed files between a base ref and a head ref using git. Second, it maps each changed file to the project that owns it, based on project roots. Changes to workspace-level files such as the root package manifest or lockfile can mark many or all projects as affected, depending on how the workspace is configured. Third, it walks the project graph in reverse and adds every project that depends on a directly changed one. A one-line change to a shared library can therefore pull in every service that imports it. That transitive set is what reviewers need to see.
Two commands use this logic for different jobs:
nx show projects --affected --base=<ref> --head=<ref>only prints project names and executes nothing. Add--jsonto get output a script can parse.nx affected -t <target>uses the same calculation to decide which tasks to run, then runs them.
This article uses the first command. The goal is a report, not a build.
The list is only as correct as its inputs. A wrong base ref, shallow git history, or a missing dependency edge in the graph each produce an incomplete list without any error. For that reason, the article treats the affected list as something reviewers read and check, not a CI log line nobody opens. It is aimed at backend and platform engineers in monorepos where a reviewer should see the blast radius before reading the diff.
#!/usr/bin/env bash
set -euo pipefail
# Refs to compare. In CI these usually come from the PR event.
BASE_BRANCH="origin/main"
HEAD_REF="HEAD"
# A shallow clone can hide the real merge base. The affected list then
# comes out wrong without any error, so fail loudly instead.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
echo "Shallow clone detected; fetch full history before computing affected." >&2
exit 1
fi
# Diff against the point where the branch left main, not main's tip.
# Otherwise unrelated commits merged to main since then would show up.
BASE_SHA="$(git merge-base "$BASE_BRANCH" "$HEAD_REF")"
# Report only: lists affected project names and runs no tasks.
# --json gives a machine-readable array for later steps (e.g. a PR comment).
npx nx show projects --affected \
--base="$BASE_SHA" --head="$HEAD_REF" \
--json > affected.json
echo "Affected projects ($(jq 'length' affected.json)):"
jq -r '.[]' affected.json
# For contrast: this uses the same calculation but executes the target
# on every affected project. This article does not need it.
# npx nx affected -t test --base="$BASE_SHA" --head="$HEAD_REF"
Computes the merge base, refuses to run on a shallow clone, and writes the affected project list to JSON with nx show projects --affected without running tasks; nx affected -t is shown commented out for contrast.
Choosing the right base ref and making history available
The question the comment answers is "what does this PR change?" Only the merge base answers it. That is the commit where the PR branch diverged from its target, computed with git merge-base between the PR head SHA and the target branch. If you diff against the current tip of main instead, every commit that landed on main after the branch was cut shows up as a change. Reviewers then see projects the author never touched.
Pass the computed SHA to Nx explicitly as --base, and the PR head SHA as --head. Don't rely on a branch name, which resolves to whatever that ref points at when the job runs.
History has to be present
actions/checkout clones shallowly by default, so the merge base usually isn't in the local repository. Set fetch-depth: 0. For very large repositories, fetch enough depth on both the PR branch and the target branch to reach the fork point. If you get this wrong, you will see one of two symptoms:
git merge-baseor Nx fails because the base object is missing.- The diff is computed against an unrelated or truncated history, and nearly every project is marked affected. This one is worse because it looks like a valid result.
How the base changes the answer
| Base | What the diff contains | Suitable for the PR comment? |
|---|---|---|
| Merge base | Only the PR's own commits | Yes |
| Tip of target branch | PR changes plus the inverse of everything merged since branching | No |
| Last successful CI SHA | Everything not yet verified green, possibly including others' work | Fine for deciding what to build, wrong for describing the PR |
Print the base SHA, and how it was derived, in the comment itself. A reviewer who sees an unexpectedly long list can then check the base first.
name: affected-comment
on:
pull_request:
jobs:
affected:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Check out the PR head itself, not the synthetic merge commit
ref: ${{ github.event.pull_request.head.sha }}
# Full history so the merge base is reachable locally
fetch-depth: 0
- name: Compute merge base
id: base
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
# Fork point between the PR head and the target branch.
# Fails loudly if history is missing instead of guessing.
BASE_SHA=$(git merge-base "origin/$BASE_REF" "$HEAD_SHA")
echo "sha=$BASE_SHA" >> "$GITHUB_OUTPUT"
echo "how=git merge-base origin/$BASE_REF $HEAD_SHA" >> "$GITHUB_OUTPUT"
- run: npm ci
- name: List affected projects
env:
BASE_SHA: ${{ steps.base.outputs.sha }}
BASE_HOW: ${{ steps.base.outputs.how }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
# Pin both ends to SHAs, never branch names
{
echo "Base: $BASE_SHA (from: $BASE_HOW)"
echo "Head: $HEAD_SHA"
echo
npx nx show projects --affected --base="$BASE_SHA" --head="$HEAD_SHA"
} > affected.md
cat affected.md
A GitHub Actions job that fetches full history, computes the PR merge base with git merge-base, passes explicit base and head SHAs to Nx, and records how the base was derived so the PR comment can state it.
Building a readable PR impact report from the JSON output
nx show projects --affected --json prints a flat JSON array of project names and nothing else. That is enough for a gate but not for a reviewer. The report should answer three questions at a glance: how many projects changed, which ones, and compared to which commits.
Structure of the comment
Start with the hidden marker <!-- nx-affected-report --> on the first line. The workflow searches for this string to find and edit its earlier comment instead of posting a new one on every push. Next, add a single summary line with the count and the short base and head SHAs. If someone later reads a stale comment, they can tell exactly which commit range it describes.
Sort the names alphabetically before rendering. The CLI does not promise a stable order, and an unsorted list makes successive updates look different even when nothing changed.
Grouping
Grouping makes the report easier to scan, and the data is easy to get. nx show project <name> --json returns the resolved configuration, including projectType and tags. Bucket by application versus library, or by a tag convention such as scope:*, because those are the categories reviewers already reason in. Run this lookup only when the list is short enough to justify one process per project. On large affected sets, skip the grouping.
Long and empty lists
If a list has more than a few dozen entries, wrap it in a <details> block so the PR conversation stays readable. GitHub rejects comment bodies over roughly 65,000 characters. Cap the rendered list, state how many entries were omitted, and write the complete list to the job summary.
When the array is empty, say so explicitly: "No projects affected." A missing or blank comment looks like a broken job.
#!/usr/bin/env bash
# Render nx affected output as a PR comment body (report.md).
set -euo pipefail
BASE="${NX_BASE:?set NX_BASE}"; HEAD="${NX_HEAD:?set NX_HEAD}"
MAX=300 # cap rendered entries to stay well under the comment size limit
COLLAPSE=30 # collapse long lists; also skip per-project lookups above this
# Sort so successive updates only differ when the affected set differs.
projects=$(npx nx show projects --affected --base="$BASE" --head="$HEAD" --json | jq -r '.[]' | sort)
count=$(printf '%s' "$projects" | grep -c . || true)
{
echo '<!-- nx-affected-report -->' # marker used to find and edit this comment later
echo "**${count} affected project(s)** | base \`${BASE:0:7}\` -> head \`${HEAD:0:7}\`"
echo
if [ "$count" -eq 0 ]; then
echo 'No projects affected.'
else
[ "$count" -gt "$COLLAPSE" ] && printf '<details><summary>Show %s projects</summary>\n\n' "$count"
printf '%s\n' "$projects" | head -n "$MAX" | while read -r p; do
if [ "$count" -le "$COLLAPSE" ]; then
# One nx process per project: only worth it for short lists.
type=$(npx nx show project "$p" --json | jq -r '.projectType // "unknown"')
echo "- \`$p\` ($type)"
else
echo "- \`$p\`"
fi
done
[ "$count" -gt "$MAX" ] && echo "- ...and $((count - MAX)) more (full list in the job summary)"
[ "$count" -gt "$COLLAPSE" ] && printf '\n</details>\n'
fi
} > report.md
# Untruncated list goes to the job summary.
{ echo "### Affected projects ($count)"; printf -- '- %s\n' $projects; } >> "$GITHUB_STEP_SUMMARY"
Bash script that turns the affected JSON array into a sorted, marker-tagged Markdown comment with SHAs, project types for short lists, a collapsed and capped list for long ones, and the full list in the job summary.
Posting and updating a single comment with GitHub Actions
Permissions and token scope
Declare permissions explicitly at the job level: contents: read so checkout works, and pull-requests: write so the job can write comments. PR comments go through the issue-comments REST endpoints, which also accept issues: write. Use that scope if your organization prefers it. Set these explicitly because the default GITHUB_TOKEN is often read-only, depending on repository or organization settings. For pull requests opened from forks, the token is read-only no matter what the workflow requests. In that case the comment step will fail, and the job summary is the only place the report reliably appears.
Find-or-update by marker
Begin the comment body with a fixed hidden marker, such as an HTML comment like <!-- nx-affected-report -->. Then:
- List the PR's comments with
gh api --paginate repos/{owner}/{repo}/issues/{number}/comments. - Select the first comment whose body contains the marker and whose author is the bot account.
- If one exists, update it with a
PATCHtoissues/comments/{id}. Otherwise,POSTa new comment.
Filtering by author stops a human who pastes the marker from hijacking the update. Paginating matters because long review threads can push the bot's comment past the first page, which would cause duplicate comments.
Placement and shared output
Put this in its own job with no needs: dependency on build or test jobs. A red test run is exactly when reviewers want to know the blast radius.
Have the same job expose the affected list as a job output. Downstream jobs can then read needs.<job>.outputs.<name> through fromJSON() to build a matrix, or skip expensive work with an if: condition. Guard against an empty array, because a matrix with no entries makes the job fail. Because the comment and the CI jobs read the same computed list, they cannot disagree.
on: pull_request
jobs:
affected: # no needs: on build/test, so the comment posts even when tests fail
runs-on: ubuntu-latest
permissions:
contents: read # checkout
pull-requests: write # comment via issue-comments endpoints (issues: write also works)
outputs:
projects: ${{ steps.list.outputs.projects }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # nx needs base history to diff against
- run: npm ci
- id: list # one computed list feeds both the comment and the matrix
run: echo "projects=$(npx nx show projects --affected --base=origin/${{ github.base_ref }} --head=HEAD --json | jq -c .)" >> "$GITHUB_OUTPUT"
- env:
GH_TOKEN: ${{ github.token }}
PROJECTS: ${{ steps.list.outputs.projects }}
PR: ${{ github.event.pull_request.number }}
run: |
MARKER='<!-- nx-affected-report -->'
BODY="$MARKER"$'\n'"**Affected projects:** $(jq -r 'join(", ")' <<< "$PROJECTS")"
# Paginate so long threads don't hide the old comment; filter by bot author
ID=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments" \
--jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"$MARKER\"))) | .id" | head -n1)
if [ -n "$ID" ]; then
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$ID" -f body="$BODY"
else
gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -f body="$BODY"
fi
test:
needs: affected
if: needs.affected.outputs.projects != '[]' # an empty matrix would fail the job
runs-on: ubuntu-latest
strategy:
matrix:
project: ${{ fromJSON(needs.affected.outputs.projects) }}
steps:
- uses: actions/checkout@v4
- run: npm ci && npx nx test ${{ matrix.project }}
A pull_request workflow in which one independent job computes the Nx affected list, finds and updates the marker comment (or creates it), and exposes the same JSON as an output that drives a guarded test matrix.
When to avoid it and where the report misleads
Fork pull requests
For a PR opened from a fork, the pull_request event gives the workflow a read-only GITHUB_TOKEN and no repository secrets, so the comment step fails. Switching to pull_request_target gets you a write token, but that workflow runs in the context of the base repository. If you check out the fork's head and run npm install or any Nx command, you execute untrusted code with write access. Lifecycle scripts, Nx plugins, and project configuration can all run arbitrary code.
You have two safe options. The first is to skip the comment for forks. The second is to split the work across two workflows. An unprivileged pull_request workflow computes the affected list and uploads it as an artifact. A separate privileged workflow, triggered by workflow_run, downloads that artifact and posts it without checking out PR code. Treat the artifact as hostile input: validate project names against an expected pattern before rendering them.
Graph blind spots
The project graph only records dependencies Nx can see, so the report can miss real impact. It does not catch:
- dynamic imports built from strings
- config files read at runtime
- services coupled only through HTTP or a queue
It can also fail in the other direction. Over-broad implicitDependencies, or global inputs such as the root package.json, lockfiles, and nx.json, can mark almost everything as affected. Audit namedInputs before trusting the list.
Stale comments
If a run is skipped, filtered out for drafts, or cancelled by concurrency, the previous comment stays on the PR. Include the head SHA in the comment so readers can check it against the latest commit and tell whether the comment is current.
Alternatives: job summary, check runs, and CI logs
The in-place comment is not the only place to put this report, and for some teams it is not the best one.
| Option | Visible to reviewers | Notification noise | History across pushes | Extra API calls | Fork PRs (pull_request event) |
|---|---|---|---|---|---|
| Updated single comment | High, in the conversation | One notification on creation | Lost unless you keep edit history | Find plus create or update | Fails with a read-only token |
| New comment per push | High | One per push | Full, in the timeline | One create | Fails with a read-only token |
| Job summary | Medium, one click into the run | None | Per run, kept with that run's retention | None, written to GITHUB_STEP_SUMMARY |
Works |
| Check run | Medium, in the Checks tab | None | Per commit | One create | Fails with a read-only token |
| CI logs only | Low | None | Per run | None | Works |
The job summary is the underrated option. It needs no write permissions, so it works on fork PRs where the default GITHUB_TOKEN cannot comment. Reviewers do have to open the run to see it. A check run ties the report to a specific commit, which avoids the stale-comment problem described above, but it lives in a tab most reviewers skip.
A new comment per push keeps the history of how impact changed across pushes. On an active PR, though, it buries the human discussion.
How to choose:
- Small teams that review in the conversation tab: use the updated comment.
- Open-source repositories with many forks: use the job summary.
- Large teams that already gate merges on checks: use a check run.
- No one reads the report today: leaving it in the CI logs costs nothing and is a reasonable starting point.
Impact on cost and engineering time
The cost has three parts. First, Nx has to compute the project graph, which means reading every project configuration and running inference plugins. Second, those plugins usually need node_modules installed, because they load tool configs to infer targets. Third, if the comment runs in its own job, that job repeats checkout with full history and the dependency install, unless a dependency cache is in place.
The time saved is mostly human. Reviewers see the blast radius before they open the diff. Nobody scrolls CI logs to learn which services a change touches. The same computed list can also gate downstream jobs, so the work is done once and reused.
Illustrative estimate (assumed numbers, not a benchmark)
Runner cost = PRs × pushes per PR × extra minutes per run Reviewer time saved = PRs × minutes saved per PR
Assumed inputs:
- 200 PRs a month
- 3 pushes per PR
- 1.5 extra minutes for a separate uncached job
- 0.2 extra minutes for a step added to an existing job that has already installed dependencies
- 2 reviewer minutes saved per PR
Results:
- Separate uncached job: 200 × 3 × 1.5 = 900 runner minutes.
- Step in an existing job: 200 × 3 × 0.2 = 120 runner minutes.
- Reviewer time saved: 200 × 2 = 400 engineer minutes.
When the trade is worth it
It pays off when reviewers regularly ask "what does this touch?" or when you already compute the affected list to gate jobs. In both cases, attach the comment to a job that has already installed dependencies; don't pay for a second setup. If PRs rarely cross project boundaries, the comment mostly restates the diff, and the runner minutes buy nothing.
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.