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.
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.
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
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.
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.
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.
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.
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 |
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.
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.