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

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

Root orchestration delegating to package scripts A root build script fans out through the workspace runner to per-package build scripts that each compile their own source. root package.json "build": "pnpm -r build" workspace runner topological order packages/utils "build": "tsc -p ." builds first packages/ui "build": "tsc -p ." depends on utils packages/app "build": "vite build" depends on ui
The root script owns no build logic; it delegates to per-package scripts, which the runner executes in dependency order.

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 on npm install (with no args) in the local project, and immediately after git dependencies are installed. It also runs before npm publish. This is the canonical place to build from source so consumers of a git install get compiled output.
  • prepublishOnly — runs only on npm 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 seriallyprebuild, 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-scripts on every CI install step.
  • Review postinstall, prepare, and prepublishOnly hooks 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-run in prepublishOnly before 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

Core JavaScript Package Workflows