Root-Level vs Package-Level Scripts
Where a script lives — in the root package.json or inside an individual package — dictates monorepo stability, CI throughput, and supply-chain exposure. This guide details the operational differences between root-level orchestrator scripts and package-level task definitions, covering lifecycle hooks, pre/post conventions, the run semantics of npm, pnpm, and Yarn, argument passing, environment handling, and hardened CI/CD patterns for modern JavaScript workspaces.
Get the boundary wrong and you inherit path-resolution failures, environment drift, and non-deterministic builds. Get it right and the root becomes a thin orchestration layer that delegates real work to the packages that own it — a model that scales cleanly as part of disciplined Core JavaScript Package Workflows.
Execution Context and Scope Boundaries
Root scripts and package scripts run in fundamentally different execution environments. The single biggest source of confusion is process.cwd(): a script always runs with its working directory set to the directory of the package.json that declares it.
| Context | Root-Level (/package.json) |
Package-Level (/packages/*/package.json) |
|---|---|---|
process.cwd() |
Monorepo root | The package directory |
| Dependency resolution | Hoisted root node_modules and workspace symlinks |
Local node_modules, falling back to hoisted deps |
| Environment variables | Repo-wide .env and CI context |
Package-scoped overrides |
| Natural responsibility | Cross-cutting orchestration, lint, type-check, release | Compile, bundle, unit test, publish |
node_modules/.bin PATH |
Root bin directory | Package bin, then root bin |
When a package manager runs any script, it prepends the relevant node_modules/.bin to PATH, which is why "build": "tsc -p ." works without a path to the tsc binary. In a workspace, the package-level run resolves binaries from its own .bin first, then the hoisted root .bin.
# Root execution inherits the global toolchain
npm run lint
# process.cwd() === /monorepo-root
# Package execution isolates to the local directory
npm run build --workspace=@scope/ui
# process.cwd() === /monorepo-root/packages/ui
Proper scoping is what keeps builds reproducible. A root script that assumes it runs inside a package — globbing src/** or reading a local tsconfig.json — will silently operate against the wrong directory.
The scope a script runs in determines which working directory, which binaries, and which dependencies it sees, and conflating root and package scope is the source of most scripting confusion. A root script runs with the repo root as its working directory and the root's node_modules/.bin on its path; a package script runs with that package's directory as its working directory and its own local binaries first. Running a tool from the wrong scope — invoking a package's build from the root without descending into it — either fails to find the tool or operates on the wrong files, which is why the scope boundary must be explicit in how scripts are organized.
This boundary is what makes the root-versus-package distinction a real architectural choice rather than a matter of taste. Root scripts orchestrate — they fan a task across packages, often through a task runner that adds ordering and caching — while package scripts do the actual work of building or testing one package. Keeping the two layers distinct means a root command has one obvious meaning, a package remains responsible for its own implementation, and CI can target either the whole repo or a single package without command collisions.
Diagram: Root Delegating to Package Scripts
This is the model to aim for: the root build does not invoke tsc or vite directly. It calls the workspace runner, which discovers each package's own build script and runs them respecting the dependency graph.
Lifecycle Scripts and pre/post Hooks
Beyond ordinary scripts, package managers recognize a fixed set of lifecycle names that fire automatically at well-defined moments. Two matter most for publishing.
prepare— runs onnpm install(with no args) in the local project, and immediately aftergitdependencies are installed. It also runs beforenpm publish. This is the canonical place to build from source so consumers of a git install get compiled output.prepublishOnly— runs only onnpm publish, never on a plain install. Use it for guards that should never block a normal install: running tests, checking the working tree is clean, or validating the packed tarball.
{
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "vitest run",
"prepare": "npm run build",
"prepublishOnly": "npm run test && npm pack --dry-run"
}
}
Every package manager also supports the pre/post prefix convention: defining prebuild and postbuild makes them run automatically around build. They run serially — prebuild, then build, then postbuild — and a non-zero exit from any of them aborts the chain.
{
"scripts": {
"prebuild": "rimraf dist",
"build": "tsc -p .",
"postbuild": "node ./scripts/copy-assets.mjs"
}
}
A caution for Yarn users: Yarn Berry (v2+) deliberately dropped automatic pre/post arbitrary-script hooks (it still honors the standard lifecycle events). If you migrate a repo that relied on prebuild/postbuild, fold those into a single explicit command ("build": "rimraf dist && tsc -p .") or chain them yourself. This is one of the more common breakages when migrating from Yarn 1 to pnpm workspaces or to Berry.
The pre and post wrappers are automatic: defining prebuild and postbuild alongside build makes npm run them before and after the named script with no explicit wiring, which is convenient for setup and teardown but easy to forget exists when debugging why extra work runs. The lifecycle hook to treat with real caution is postinstall, because it runs on every consumer's machine whenever they install your package, with their privileges. Reserve it for genuinely local, offline work, and run CI installs with ignored scripts so a compromised transitive dependency cannot execute its own postinstall during your build.
Run Semantics Across Package Managers
The three major runners differ in how they target workspaces, order execution, and forward arguments.
npm (v7+)
# Target a single workspace
npm run build --workspace=@scope/ui
# Run across every workspace (no guaranteed topological order)
npm run build --workspaces
# Skip workspaces that lack the script instead of failing
npm run build --workspaces --if-present
npm runs workspaces in directory order, not dependency order — it has no built-in topological sort. For ordered builds you need a task runner on top.
pnpm (v8+)
# Recursive run across all packages, topologically ordered
pnpm -r build
# Filter to one package
pnpm --filter @scope/ui build
# Include the package and everything it depends on
pnpm --filter '...@scope/ui' build
# Only packages changed since main, plus their dependents
pnpm --filter '...[origin/main]' build
pnpm's --filter syntax is the most expressive of the three and is covered in depth in pnpm Workspace Filtering. For the day-to-day patterns of scoping a recursive run to exactly the packages you mean, see Running Scripts Across Workspaces with pnpm.
Yarn (v3+ / Berry)
# Explicit single-workspace routing
yarn workspace @scope/ui run build
# Parallel, topological, bounded concurrency
yarn workspaces foreach -pt --jobs 4 run build
The package managers differ in how they run scripts across a workspace, and knowing the semantics prevents surprises. npm uses --workspaces (with --if-present to skip packages lacking the script), pnpm uses -r/--recursive (which skips missing scripts by default and respects topological order), and Yarn uses workspaces foreach. The important shared behavior is that a fan-out runs each package's script, so a script that assumes it is the only one running — writing to a shared path, binding a fixed port — collides when packages run in parallel, which is usually a latent bug the parallelism exposes rather than a package-manager quirk.
Passing Arguments and Environment
Forwarding arguments to the underlying command is where the runners diverge most.
# npm and pnpm require -- to separate runner flags from script args
npm run test -- --coverage --watch=false
pnpm test -- --coverage
# Yarn Berry forwards trailing args directly (no -- needed)
yarn test --coverage
Inside a script, $npm_config_* and $npm_package_* environment variables expose config and manifest fields, but they are awkward and shell-dependent. Prefer explicit env handling. For cross-platform variable assignment, use a tiny dependency rather than inline VAR=value, which fails on Windows cmd:
{
"scripts": {
"build:prod": "cross-env NODE_ENV=production tsc -p tsconfig.build.json",
"ci": "node --run build && node --run test"
}
}
Node.js 22 ships a built-in node --run <script> that executes a package.json script without spawning the package manager — faster, and it refuses to run pre/post hooks, which makes script behavior explicit. It does not traverse workspaces, so it complements rather than replaces a workspace runner.
Passing arguments through to a package script is a common source of confusion because the syntax differs by package manager and by whether you are targeting one package or many. The double-dash convention forwards everything after it to the underlying command, so pnpm --filter pkg run test -- --watch runs that package's test script with --watch appended. When fanning across packages, arguments apply to every invocation, which is usually what you want for a flag like --coverage but not for a positional argument that only makes sense for one package.
Environment variables interact with scripts in ways that matter for both correctness and caching. A script that reads an env var has an input that a task runner must know about to hash correctly, so declaring env inputs is what keeps a cached result valid when the variable changes. For secrets, prefer injecting them only into the specific job that needs them rather than exposing them workspace-wide, and keep them out of committed script definitions. The environment a script runs in is part of its behavior, so treating it deliberately — declared for caching, scoped for secrets — is part of writing scripts that are both reproducible and safe.
Root Orchestration vs Per-Package Scripts
The durable pattern is a thin root that delegates. Each package owns the how (its own tsc/vite/vitest invocation); the root owns the what and when (which packages, in which order, with what concurrency).
{
"name": "monorepo-root",
"private": true,
"scripts": {
"build": "pnpm -r build",
"test": "pnpm -r --workspace-concurrency=4 test",
"lint": "eslint . --max-warnings=0",
"typecheck": "tsc -b",
"ci": "pnpm run lint && pnpm run typecheck && pnpm run build && pnpm run test"
}
}
Note that cross-cutting concerns with no per-package variation — repo-wide eslint . driven by a shared ESLint config in the workspace, or a project-references tsc -b — belong at the root, because there is nothing package-specific to delegate. Anything that compiles, bundles, or tests a single package belongs in that package.
Once dependency-aware ordering and caching become the bottleneck, move orchestration out of raw pnpm -r and into a task runner. Turborepo Pipeline Configuration lets you declare task graphs and cache boundaries so unchanged packages are skipped entirely.
The clean division is thin root scripts that orchestrate and full package scripts that implement, wired so a single root command has an obvious meaning:
// root package.json
{
"scripts": {
"build": "turbo run build",
"test": "turbo run test",
"test:changed": "turbo run test --filter='...[origin/main]'",
"lint": "turbo run lint"
}
}
// packages/ui/package.json
{
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts",
"test": "vitest run"
}
}
The root build fans across packages through the task runner, which derives the topological order and replays cached results; each package's build does the actual work. Contributors get one entry point per task and never need to remember which packages define which scripts or in what order they run, which is the separation of orchestration from implementation that keeps a growing workspace approachable.
Concurrency and Caching
Unbounded parallelism is a common cause of out-of-memory failures on shared CI runners. Bound it explicitly.
# pnpm: cap concurrent package processes
pnpm -r --workspace-concurrency=4 build
# Turborepo: cap concurrent tasks and verify the graph before running
turbo run build --concurrency=4
turbo run build --dry=json
A turbo.json declares the dependency edges and cache outputs so a task only re-runs when its inputs change:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**/*.ts", "test/**/*.ts"],
"outputs": ["coverage/**"]
},
"lint": {}
}
}
The ^build notation means "build my dependencies first." Declaring outputs is what makes a hit cacheable; an empty outputs (as on lint) caches the pass/fail result without restoring files.
CI/CD Integration
In CI, the root script is the orchestration entry point. Install with a frozen lockfile so the environment replicates local state exactly — this is the same determinism guarantee discussed in Lockfile Management Strategies.
name: Monorepo CI
on: [push, pull_request]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # needed for changed-package filters
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install (no lifecycle scripts)
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Build changed packages and their dependents
run: pnpm --filter "...[origin/${{ github.base_ref || 'main' }}]" run build
- name: Lint and type-check at the root
run: pnpm run lint && pnpm run typecheck
Installing with --ignore-scripts blocks every dependency's postinstall/prepare from running during resolution, then your pipeline triggers the build steps you actually trust. Pair that with --frozen-lockfile so CI fails loudly on any lockfile drift instead of silently resolving new versions.
In CI, the root-versus-package distinction determines how a pipeline targets work, and getting it right keeps the pipeline both correct and fast. Whole-repo tasks — a lint sweep, a format check — run from the root, often as a delegating script that fans across packages. Package-specific work is targeted with a filter so only the relevant packages run, and change-aware pipelines combine the two: a root-level orchestration script fans a task across the affected set, while each package's own script does the work. This layering means CI has one obvious entry point per task without every package needing to know about the pipeline.
The pattern that scales is to keep the root scripts thin orchestration and let a task runner handle ordering and caching. A root build that simply invokes the runner's build across the workspace gives contributors a single command, while the runner derives the topological order, parallelizes independent packages, and replays cached results. Contributors never need to remember which packages define which scripts or in what order they must run, because the root script plus the runner encode that knowledge, which is exactly the separation of orchestration from implementation that keeps a growing monorepo approachable.
Security Hardening and Script Isolation
Lifecycle hooks are a supply-chain attack surface: a malicious dependency's postinstall runs with your shell's privileges the moment it lands in node_modules.
# 1. Install without running any dependency lifecycle scripts
pnpm install --frozen-lockfile --ignore-scripts
# 2. Rebuild only the native modules you explicitly trust
pnpm rebuild esbuild
# 3. Run audited build steps yourself
pnpm run build
Hardening checklist
- Enforce
--ignore-scriptson every CI install step. - Review
postinstall,prepare, andprepublishOnlyhooks before merging a dependency bump. - Keep root scripts to read-only orchestration (
lint,typecheck,test); never let them run untrusted code. - Validate the published artifact with
npm pack --dry-runinprepublishOnlybefore any registry push.
Invoking tools through the package manager rather than a bare command keeps a script bound to the pinned binary from the lockfile rather than whatever happens to be on the global path. Using pnpm exec eslint (or npm exec) resolves the local, version-pinned ESLint, so a script cannot silently run a different, globally-installed version — a small but real reproducibility and supply-chain guard. Combined with running installs under --ignore-scripts, keeping secrets scoped to the specific job that needs them, and reserving postinstall for local offline work, this treats scripts as a controlled surface where the tools, the code, and the environment are all deliberate rather than ambient.
Common Pitfalls
| Mistake | Impact | Resolution |
|---|---|---|
Assuming process.cwd() points at a package in a root script |
Broken globs, wrong tsconfig read |
Delegate via pnpm -r/--filter or a task runner |
Omitting --if-present on workspace-wide commands |
Non-zero exit when a package lacks the script | Add --if-present to broad runs |
Relying on pre/post hooks under Yarn Berry |
Hooks silently never fire | Chain commands explicitly (a && b) |
Forgetting -- before script args in npm/pnpm |
Flags consumed by the runner, not the script | Use npm run test -- --coverage |
Unbounded -r concurrency on CI runners |
Out-of-memory crashes | Set --workspace-concurrency / --concurrency |
| Letting dependency lifecycle scripts run on install | Arbitrary code execution | Install with --ignore-scripts |
Concurrency, caching, and running only what changed
Fanning a script across every package is correct but wasteful when only a few packages changed, so the mature pattern layers concurrency, caching, and change-awareness on top of the basic fan-out. A task runner runs independent packages in parallel up to a concurrency limit, respects topological order so a package runs after its dependencies, and caches each task's output keyed on its inputs so an unchanged package is replayed rather than recomputed. This turns a whole-workspace script from a linear sweep into a parallel, cache-aware operation whose cost tracks the change rather than the repo.
Change-awareness is the final layer and the biggest win. Filtering a script to packages changed since a base ref, plus their dependents, means a one-line edit runs that package and its consumers and nothing else — the affected set. The precondition is git history and an accurate dependency graph, because the filter diffs against the base and walks the graph to find dependents; a shallow clone or an undeclared import breaks it. Combined with caching, change-aware execution is what keeps a large monorepo's everyday scripts fast: the filter removes work the change cannot reach, and the cache replays any survivor whose inputs are unchanged.
Hardening scripts against supply-chain risk
Scripts are executable code that runs with the developer's or CI job's privileges, so how a workspace organizes and constrains them is a supply-chain concern. Lifecycle scripts — preinstall, install, postinstall — run automatically on every install from every dependency, which is a vector a compromised transitive package can exploit; running installs with --ignore-scripts and allow-listing only vetted native builds neutralizes it. Reserve postinstall in your own packages for genuinely local, offline work, never for network operations, because it runs on every consumer's machine.
Isolation extends to how orchestration scripts invoke tools. Prefer invoking local binaries through the package manager (pnpm exec, npm exec) so the resolved binary is the pinned one from the lockfile rather than whatever is on the global path, and keep secrets out of scripts and their environment except where a job explicitly needs them. Treating scripts as a controlled surface — pinned tools, no arbitrary install-time code, least-privilege environment — keeps the convenience of workspace scripting from becoming an unmonitored place where untrusted code runs with real credentials.
Frequently Asked Questions
When should I put a script at the root instead of inside a package?
Put it at the root when it has no per-package variation — repo-wide linting, a project-references tsc -b, release orchestration, or the ci aggregate. Put it in the package when it compiles, bundles, tests, or publishes that single package, since that is the unit that owns the logic.
What is the difference between prepare and prepublishOnly?
prepare runs on local install and on git-dependency install as well as before publish, so it is the right hook for building from source. prepublishOnly runs only on npm publish, making it the place for publish-time guards like tests and tarball validation that should never block an ordinary install.
Why do pre/post hooks not fire in my Yarn project?
Yarn Berry (v2+) removed automatic pre/post execution for arbitrary scripts; only the standard lifecycle events remain. Chain the steps explicitly with && or call them from a single script instead of relying on the prefix convention.
How do I forward arguments to the underlying command?
With npm and pnpm, separate runner flags from script arguments with --, as in pnpm test -- --coverage. Yarn Berry forwards trailing arguments directly, so yarn test --coverage works without the separator.
How do I run scripts in dependency order across the workspace?
npm runs workspaces in directory order with no topological sort, so use pnpm -r (which orders topologically), pnpm's ... filter syntax, yarn workspaces foreach -t, or a task runner with dependsOn edges to guarantee dependencies build first.
Why does running a script from the repo root fail to find the tool?
Root and package scripts run in different scopes. A tool installed as a package's dependency is on that package's node_modules/.bin, not the root's, so invoking it from the root misses it. Add a delegating root script that descends into packages, or target the package with --filter/--workspace.
How do I run a script only in the packages that changed?
Filter to packages changed since a base ref plus their dependents (pnpm's --filter '...[origin/main]', or a task runner's affected command). Fetch full history so the base resolves, and keep the dependency graph accurate so the dependent traversal is complete.
How should I structure scripts in a monorepo?
Keep root scripts as thin orchestration that fans a task across packages (ideally through a task runner), and package scripts as the real implementation. That gives one obvious entry point per task while each package owns how it builds itself, avoiding command collisions as the workspace grows.
How do I pass arguments to a package script from the root?
Use the double-dash convention: everything after -- is forwarded to the underlying command, so pnpm --filter pkg run test -- --watch runs that package's test script with --watch. When fanning across packages, forwarded flags apply to every invocation.
Why does a tool run from the root fail to find itself?
Root and package scripts run in different scopes: a tool installed as a package's dependency is on that package's node_modules/.bin, not the root's. Add a delegating root script that descends into packages, or target the package with --filter/--workspace.
Related
- Running Scripts Across Workspaces with pnpm — the recursive-run and filter patterns for scoping a script to exactly the packages you mean.
- Turborepo Pipeline Configuration — graduate from raw recursive runs to a cached, dependency-aware task graph.
- pnpm Workspace Filtering — the full
--filterselector language for targeting packages by name, path, and change set. - Setting Up Shared ESLint Configs in Workspaces — the canonical example of a root-level, repo-wide script.