Why HEAD~1 is the wrong base for nx affected on main

nx affected answers one question: which projects changed between a base commit and a head commit? On a pull request, the base is obvious: the merge base with main. On main itself, the tempting default is HEAD~1, the commit just before the one being built. That choice is wrong whenever a previous run failed.

The failure case

Suppose two commits land on main in sequence:

  1. Push A modifies libs/billing. CI runs, and a billing test fails.
  2. Push B modifies only apps/api. CI runs with NX_BASE=HEAD~1, which resolves to A.

Nx compares B against A, sees changes only in apps/api and its dependents, and runs those targets. libs/billing is not in the diff, so its tests never run. The run goes green. Nothing in the output mentions billing, and main now shows a passing build on top of code that was never verified. A also never reached deployment through that pipeline, because its run failed and B's run did not include it.

The rule

On main, the base should be the last commit whose CI workflow succeeded, not the previous commit. Projects affected by a failed run stay affected until a run passes. The base only moves forward when there is evidence that everything up to that point was built and tested.

If you own main-branch CI in an Nx monorepo, this decides whether a green check on main covers every change or only the latest push. The rest of this tutorial covers how nrwl/nx-set-shas implements this rule.

 main branch, oldest to newest:

        +-----------+     +-----------+     +-----------+
        | commit S  |---->| push A    |---->| push B    |
        | CI: pass  |     | CI: FAIL  |     | HEAD, now |
        |           |     | billing   |     | apps/api  |
        +-----------+     +-----------+     +-----------+
              ^                 ^                 |
              |                 |                 |
              |                 +---- HEAD~1 -----+
              |                    base: wrong    |
              +------ last successful (right) ----+

 HEAD~1 base, diff A..B:
 +--------------------------------------------------+
 | affected: apps/api + its dependents              |
 | skipped:  libs/billing, never re-tested          |
 | result:   green check, billing still unverified  |
 +--------------------------------------------------+

 Last-successful base, diff S..B:
 +--------------------------------------------------+
 | affected: libs/billing + apps/api + dependents   |
 | result:   green only if billing now passes       |
 | if red:   base stays at S for the next run       |
 +--------------------------------------------------+

Using HEAD~1 as the base for push B diffs against failed push A and drops libs/billing, while using the last successful commit S keeps billing affected until a run passes.

How nx-set-shas finds the last successful commit

The action never inspects your Nx project graph. It answers one narrow question: which commit did the last fully green CI run on this branch build? Everything downstream follows from that SHA.

On push to main

When a push to main triggers the workflow, the action calls the GitHub Actions API for previous runs of the same workflow file on the same branch. It walks them from newest to oldest and stops at the first run whose overall conclusion is success. That run's commit SHA becomes the base. The commit currently being built becomes the head.

"Successful" is deliberately strict. A run where only the build job passed doesn't count, and neither does a run that was cancelled midway. The whole workflow run must conclude with success. This strictness is the point. If a push fails, its changes stay inside the next diff, and they keep reappearing until a run covers them and goes green.

On pull requests

For pull_request events, run history doesn't matter. The base comes from the PR's target branch, so nx affected compares the PR against what it will merge into.

Event Base Head
Push to main SHA of last successful run of this workflow on main Current commit
Pull request Derived from the PR's target branch Current PR commit

Exporting the result

The action writes both values to the job environment as NX_BASE and NX_HEAD. Every later step in the same job inherits them, and nx affected reads them without extra flags. Because the variables are scoped to that job, a separate job has to run the action again to get them.

Wiring it into GitHub Actions: a minimal, correct workflow

The job runs in this order: check out the repository, set up the package manager, install dependencies, run nrwl/nx-set-shas, then run nx affected for lint, test and build. Because the action exports NX_BASE and NX_HEAD to the job, the affected commands need no extra flags. If you prefer the comparison to be visible in logs, pass --base=$NX_BASE --head=$NX_HEAD explicitly. The result is the same.

Two prerequisites cause most failures:

  • Full history. actions/checkout defaults to a shallow clone of one commit. The action can report a base SHA, but that commit doesn't exist locally, so git diff fails or Nx errors out. Set fetch-depth: 0 on the checkout step.
  • Token permissions. The action calls the GitHub API to list previous workflow runs, which requires actions: read. If you add a permissions block, every scope you don't list is set to none. You also need contents: read, or checkout itself breaks.

The defaults assume your trunk is the main branch and that the current workflow is the one whose last success matters. When that doesn't hold, override the inputs. For example, your trunk might be develop, or the "green" signal might come from a separate pipeline. At the time of writing, the inputs are named along the lines of main-branch-name and workflow-id. Input names and the current major version change between releases, so check both in the action's README and pin the major version you verified.

name: CI
on:
  push:
    branches: [main]
  pull_request:

# Listing any scope sets all unlisted scopes to none.
permissions:
  contents: read   # needed by actions/checkout
  actions: read    # needed by nx-set-shas to list previous workflow runs

jobs:
  affected:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # full history so the base SHA exists locally

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

      - run: npm ci

      # Exports NX_BASE and NX_HEAD to later steps.
      # Verify the current major version and input names in the README before pinning.
      - uses: nrwl/nx-set-shas@v4
        with:
          main-branch-name: develop     # override only if trunk is not 'main'
          # workflow-id: ci.yml         # override if another workflow defines "green"

      # Nx reads NX_BASE/NX_HEAD automatically; explicit flags make the comparison visible in logs.
      - run: npx nx affected -t lint test build --base=$NX_BASE --head=$NX_HEAD

A minimal GitHub Actions job that fetches full history, grants actions: read and contents: read, runs nx-set-shas with an overridden trunk branch, and runs nx affected for lint, test and build against the exported base and head SHAs.

Verifying the base CI actually chose

Add a step right after the action that echoes $NX_BASE and $NX_HEAD. It costs nothing and gives each run a record of the SHAs it compared. When someone later asks why a project was skipped, you can read the answer in the job log.

SHAs alone are hard to judge, so in the same step run git log --oneline $NX_BASE..$NX_HEAD. This lists every commit CI treated as new. On a healthy main branch, the list should match what merged since the last green run: usually a few merge commits, or a longer list after a stretch of red builds. If git log reports an unknown revision, the checkout is shallow and the base commit was never fetched.

To reproduce CI's decision locally, fetch main and run nx show projects --affected --base=<NX_BASE> --head=<NX_HEAD> with the SHAs copied from the log. You should get the same project list CI built. A mismatch points to local changes, such as uncommitted files or a different lockfile, rather than to base selection.

When the range looks wrong, the cause is usually one of these:

  • The range is exactly one commit, but earlier runs failed. The action probably found no successful run and fell back to the previous commit. Look for its warning in the step output.
  • The fallback happens right after a workflow file rename. workflow-id still names the old file, so the action searches the run history of a workflow that no longer runs.
  • The range covers weeks of commits. The base is an old green run, which usually means main has been failing for a long time and you are rebuilding everything since then.
# .github/workflows/ci.yml (excerpt)
jobs:
  main:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          # Full history so the base SHA is present for git log and nx affected
          fetch-depth: 0

      # Sets NX_BASE and NX_HEAD from the last successful run of this workflow
      - uses: nrwl/nx-set-shas@v4

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

      # Record what CI compared so a skipped project can be explained from the log
      - name: Show affected range
        run: |
          echo "NX_BASE=$NX_BASE"
          echo "NX_HEAD=$NX_HEAD"
          # Every commit CI treats as new. One commit after red runs means the
          # action fell back. Weeks of commits mean the last green run is old.
          git log --oneline "$NX_BASE..$NX_HEAD"
          # The project list CI will build. Rerun this locally with the same SHAs.
          npx nx show projects --affected --base="$NX_BASE" --head="$NX_HEAD"

# Reproduce locally after copying the SHAs from the job log:
#   git fetch origin main
#   npx nx show projects --affected --base=<NX_BASE> --head=<NX_HEAD>

This GitHub Actions excerpt logs the base and head SHAs from nx-set-shas, lists the commit range between them, and prints the affected projects so you can rerun the same comparison locally.

Edge cases where it quietly picks the wrong base

The action trusts whatever GitHub reports as the last successful run, so anything that distorts "successful" or "last" distorts the base.

Situation Symptom Mitigation
New repo, renamed workflow, rewritten history Falls back to HEAD~1 or fails, depending on configuration Seed a green run on purpose: a manual full nx run-many run on main
Main branch renamed or split Old runs stop counting, so the fallback applies again Seed a new green run after the rename
continue-on-error on test steps Base advances past code that never passed Keep the gating job strict; put advisory checks in a separate workflow
Flaky unrelated job Base stays old, which widens the range for every project Quarantine flaky jobs outside the workflow whose status the action reads
Force-push to main Green SHA is not an ancestor or is missing locally Check out with full history; treat a missing SHA as a full run
Rate limits, restricted GITHUB_TOKEN, fork contexts API error or a silent fallback Grant actions: read; fail loudly instead of falling back
Concurrent pushes, cancel-in-progress Runs finish out of order; cancelled runs never turn green Cancel only on pull request branches, not on main
Schedule, workflow_dispatch, merge queue Whether these runs count is unclear Pin the event types the action considers, then verify in the logs

No base choice catches changes the project graph doesn't model. This includes edits to root config, environment variables, and external services. Declare them as namedInputs or implicit dependencies so that changing them marks the relevant projects affected.

Alternatives and when to use them

Each alternative trades correctness on main against CI cost or setup effort.

Option Correct on main? CI cost Setup effort
HEAD~1 Only if every push is one commit and no run ever fails Lowest None
Fixed base (origin/main) for PRs Not applicable on main Proportional to the PR's diff Low
nx run-many (everything) Always Highest None
Custom last-green lookup Yes, if the lookup is right Same as nx-set-shas Moderate to high

HEAD~1 suits repos with no shared CI history, such as a fresh repo or a throwaway branch. It can also work where merges are squashed and a failed run blocks further merges. Once a red build is followed by another push, the changes from the failed run are no longer tested.

A fixed branch base is the right default for pull requests. The PR's changes relative to origin/main are what the PR introduces, so no run history is needed. On main itself, that comparison is empty.

nx run-many fits small workspaces where a full run is already cheap. It also works as a scheduled nightly job that catches anything the project graph missed. On large monorepos it erases the benefit of using Nx.

A custom lookup is how you get last-green behaviour on CI systems other than GitHub Actions. You query your CI provider's API for the most recent successful pipeline on main. Then you check that its commit still exists in the current history and export it as NX_BASE. You also need a fallback for when no successful run is found. If that fallback quietly turns into HEAD~1, you are back to the first row of the table.

Impact on CI cost and engineering time

After a streak of red runs on main, the affected range grows with every merge. The base stays pinned to the last green commit, so each new run covers more projects and takes longer until something passes. This is correct behavior. Those projects have not been verified since the last green commit, and they still need to be. Do not cap the range, and do not swap in a narrower base to make the slowdown go away. That only reintroduces the skipped-project problem.

Remote or distributed caching takes most of the sting out of the wider range. Many projects in the range were already built and tested by PR runs or by earlier failed main runs, so they restore from cache instead of re-executing. That makes a conservative base the safer default: it rarely costs much more than a tight one.

Illustrative estimate (assumed numbers, not a benchmark)

Cost ≈ projects × (hit rate × restore time + (1 − hit rate) × full task time)

Assume 30 affected projects after a red streak, 4 minutes per uncached task, 0.2 minutes per cache restore, and an 80% hit rate.

  • No cache: 30 × 4 = 120 task-minutes
  • With cache: 30 × (0.8 × 0.2 + 0.2 × 4) = 30 × 0.96 ≈ 29 task-minutes

The engineering time saved is harder to put a number on, but it is real:

  • Fewer regressions slip onto main unnoticed.
  • Nobody has to schedule manual full rebuilds to catch projects that were skipped.
  • When a result looks wrong, checking the logged NX_BASE and NX_HEAD SHAs shows within minutes whether the range was the cause.

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-set-shas last successful commit, 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.