Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

Running a Script Only in Changed Packages

Running a script across every workspace package wastes time when only a few changed. This page shows how to scope script execution to the packages affected by your diff.

Exact symptoms and error messages

Exact symptoms and error messages Exact symptoms and error messages in production JavaScript package workflows. Exact symptoms and error messages Exact symptoms and error messages in production JavaScript package workflows.
Exact symptoms and error messages — the core idea of this section at a glance.
$ pnpm -r run test
# runs tests in all 40 packages even though one changed
Scope: 40 of 40 workspace projects

Root cause analysis

pnpm -r and npm --workspaces fan a script across the whole set with no notion of what changed. To run only affected packages you need a change filter that consults git plus the dependency graph, exactly the affected-execution model behind pnpm workspace filtering and the CI pipeline optimization cluster, and it builds on script scope from Root-Level vs Package-Level Scripts.

Root cause analysis pnpm -r and npm --workspaces fan a script across the whole set with no notion of what changed. Root cause analysis pnpm -r and npm --workspaces fan a script across the whole set with no notion of what changed.
Root cause analysis — the core idea of this section at a glance.

pnpm -r and npm --workspaces fan a script across the entire set with no notion of what changed, so a one-package edit still runs the task in all forty. To scope execution you need a change filter that consults git for the diff and the workspace graph for dependents. That combination — changed packages plus everything that imports them — is the same affected-execution model behind pnpm workspace filtering.

Getting the base ref right is the precondition. The filter diffs your working tree against a base branch, and if CI uses a shallow clone that lacks the base commit, the diff cannot be computed and the filter falls back to selecting everything — silently undoing the speed-up. Full history plus an explicit base is what makes change-aware selection reliable.

Resolution and configuration patch

Use a change-aware selector so only the diff's packages (and their dependents) run:

Changed-only run Diff selects changed packages plus dependents to run. git diff base changed packages + dependents ...[ref] run subset skip the rest
A change filter runs only the diff's packages and everything that imports them.
# pnpm: filter to packages changed since main, plus dependents
pnpm --filter '...[origin/main]' run test
# turborepo equivalent
pnpm turbo run test --filter='...[origin/main]'

Commit a root script that encodes the base ref so contributors do not have to remember it.

CLI validation and debug commands

CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows. CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows.
CLI validation and debug commands — the core idea of this section at a glance.
# Preview which packages the filter selects
pnpm --filter '...[origin/main]' exec pwd
# Confirm the base ref is reachable
git fetch origin main && pnpm --filter '...[origin/main]' run test

Prevention and CI guardrails

  • Fetch full history in CI so the change base resolves.
  • Include dependents (...[ref]) so a change to a library also tests its consumers.
  • Encode the base ref in a committed script to keep runs consistent.
  • Fall back to a full run when a root-level file (lockfile, tsconfig) changes.
Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows. Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows.
Prevention and CI guardrails — the core idea of this section at a glance.

Why dependents must be included

Scoping to only the changed package is a trap: a change to a shared library can break every package that imports it, so running the task on just the library misses those regressions. The correct set is the changed package plus its dependents — the packages whose behavior could change because of the edit.

Include dependents The changed package plus everything that imports it. changed package the edit + dependents ...[ref] correct set no missed break
Testing dependents catches regressions a changed-only run would ship.
# ...[ref] expands to changed packages AND their dependents
pnpm --filter '...[origin/main]' run test

The leading ... is what adds the dependents; without it you test only the thing you edited and ship its consumers untested. This is why the workspace graph has to be accurate — an undeclared cross-package import is an edge the filter cannot see, so a real dependent is silently excluded. Change-aware selection is only as trustworthy as the graph it walks, which makes declaring internal dependencies properly a correctness requirement, not just tidiness.

Falling back to a full run when a global file changes

Change-aware selection is the fast path, but some changes legitimately affect everything and must trigger a full run. A change to the root lockfile, a shared tsconfig.base.json, or the CI config can alter any package's behavior, so scoping to 'changed packages' would incorrectly skip most of the work.

Scope or full run Whether a change warrants a scoped or full run. Did the change touch a global file? lockfile / base config full run shallow clone fix history package files only scoped run
A global-file change must widen to a full run; everyday edits stay scoped.

The robust pattern is to detect those global files and widen to a full run when one is touched:

if git diff --name-only origin/main | grep -qE '^(pnpm-lock.yaml|tsconfig.base.json)$'; then
  pnpm -r run test          # global change: run everything
else
  pnpm --filter '...[origin/main]' run test
fi

This keeps everyday changes fast while ensuring a change to a truly global input is never under-tested — the same 'legitimate full run versus accidental one' distinction that governs monorepo CI as a whole.

Frequently Asked Questions

Why include dependents in the filter?

A change to a library can break packages that import it, so testing only the changed library misses regressions. The ...[ref] form adds dependents, which is the correct set to validate.

Does this need a task runner?

pnpm's --filter handles it natively; a task runner adds caching on top. Either way you need accurate workspace metadata so the graph traversal finds the right dependents.

Why include dependents, not just the changed package?

Because a change to a shared library can break packages that import it. Testing only the library misses those regressions; the ...[ref] form adds dependents, which is the correct set to validate.

Why does my change filter sometimes run everything?

Usually a shallow clone with no base commit, so the diff can't be computed and the filter falls back to all packages. Fetch full history and pass an explicit base. A change to a global file (lockfile, base tsconfig) also correctly triggers a full run.

Does change-aware selection need a task runner?

No — pnpm's --filter handles it natively; a task runner just adds caching on top. Either way you need an accurate workspace graph so the dependent traversal finds the right packages.

Related

Root-Level vs Package-Level Scripts