Pinning Transitive Dependencies in Monorepos

Permalink to "Pinning Transitive Dependencies in Monorepos"

Part of Dependency Pinning Best Practices — this page covers exactly how to declare, verify, and gate version locks for indirect packages across all workspaces in a monorepo.

Quick reference

Permalink to "Quick reference"
Item Value
npm override field overrides (package.json root, npm ≥ 8)
pnpm override field pnpm.overrides (package.json root)
Yarn field resolutions (package.json root, Classic & Berry)
Lockfile commands npm ci, pnpm install --frozen-lockfile, yarn --immutable
SRI algorithm SHA-384 (minimum recommended)
CI gate Fail on any lockfile diff not paired with an override declaration

Why transitive pinning exists

Permalink to "Why transitive pinning exists"

Monorepos share a single lockfile across multiple workspaces. When workspace A depends on react-dom@18 and workspace B depends on react@17, the package manager resolves both trees simultaneously, hoisting shared transitive packages to the root node_modules (npm/Yarn) or managing them through a content-addressable store (pnpm). The side effect is that a single transitive update — say semver bumping from 7.5.3 to 7.5.4 — can silently propagate to every workspace in the repository the next time install runs.

This is the exact attack surface that supply-chain compromises exploit: a tampered patch release of a deeply nested utility package reaches production without ever touching your direct dependencies. Worse, that package does not need to be imported by anything to run code on the machine — an install/postinstall lifecycle script executes during resolution itself, which is why Disabling npm Install Scripts belongs alongside pinning in any monorepo hardening pass. The Supply Chain Auditing & Dependency Verification discipline treats the registry fetch boundary as a trust perimeter that must be cryptographically anchored, not just semantically versioned.

Override declarations solve this by intercepting resolution before the lockfile is written. Every consumer of the named package — regardless of which workspace requested it or at what depth — receives the exact version you specified.


Monorepo transitive dependency resolution with override interception Diagram showing workspace A and workspace B each pulling a transitive package through the root resolver. An override declaration intercepts the resolution before the lockfile is written, forcing both workspaces to receive the same pinned version. Workspace A react-dom@18 Workspace B react@17 Workspace C webpack@5 Root Resolver semver@^7.0.0 → ? pnpm.overrides semver → 7.5.4 intercepts Frozen Lockfile semver 7.5.4 ✓

Canonical implementation example

Permalink to "Canonical implementation example"

The root package.json is the single control point for transitive overrides in a monorepo. Declare every indirect package you need to pin in the appropriate field for your package manager, then regenerate the lockfile.

{
  "name": "my-monorepo",
  "private": true,
  "workspaces": ["apps/*", "packages/*"],
  "overrides": {
    "semver": "7.5.4",
    "lodash": "4.17.21",
    "minimatch": "9.0.3"
  },
  "pnpm": {
    "overrides": {
      "semver": "7.5.4",
      "lodash": "4.17.21",
      "minimatch": "9.0.3",
      "semver@<7.5.4": "7.5.4"
    }
  }
}

After saving the manifest, regenerate the frozen lockfile so the new pins are committed:

# npm
npm install && git add package-lock.json

# pnpm
pnpm install && git add pnpm-lock.yaml

# Yarn Berry
yarn install && git add yarn.lock

Commit the updated lockfile together with the manifest change in the same atomic commit. This pairing is the audit trail that lets your CI gate distinguish intentional pin updates from unexpected drift.

The three managers accept the same idea through different fields, and they differ in exactly two places that matter for a monorepo: whether you can scope a pin to one ancestor instead of flattening it repo-wide, and whether you can patch a transitive package without forking it.

Override mechanics compared across three package managers A four column matrix comparing npm 8 or later, pnpm and Yarn Berry across four rows: the manifest field that declares a pin, whether an ancestor selector is supported, the frozen install command, and whether local patching of a transitive package is possible. Override mechanism npm 8+ pnpm Yarn Berry Manifest field overrides pnpm.overrides resolutions Ancestor selector flat only parent>child flat only Frozen install npm ci --frozen-lockfile --immutable Local patching fork required pnpm patch patch: protocol

Because those behaviours diverge, a monorepo should also pin the package manager itself: an engineer who runs npm install in a pnpm workspace produces a second lockfile that carries none of your overrides. Enforcing Package Manager Versions with Corepack closes that hole by binding the repository to one manager and one version.

Variant examples

Permalink to "Variant examples"

Scoped overrides — pin a range, not a specific dependant

Permalink to "Scoped overrides — pin a range, not a specific dependant"

pnpm allows selector syntax that targets only the problematic ancestry, leaving other consumers unaffected:

{
  "pnpm": {
    "overrides": {
      "webpack>loader-utils": "3.2.1",
      "babel-loader>semver": "7.5.4"
    }
  }
}

This is useful when forcing a flat version causes peer conflicts in an unrelated workspace. The > selector narrows the override to packages resolved only inside a specific ancestor. Read the key left to right: everything before the > is the ancestor the rule applies inside, everything after it is the package being replaced, and the value is the single version every match resolves to.

Anatomy of a scoped pnpm override entry The JSON entry quote webpack greater-than loader-utils quote colon quote 3.2.1 quote split into five labelled segments: the ancestor package, the greater-than selector that scopes the match, the target package being replaced, the colon separator, and the exact pinned version. override key pinned value "webpack > loader-utils" : "3.2.1" ancestor match only here selector scopes it target package replaced exact version no range, no caret

A key without a > applies to every occurrence in the graph, so reach for the scoped form only when a flat pin actually breaks something — the narrower the selector, the more places an unpinned copy can survive.

Yarn resolutions with protocol overrides

Permalink to "Yarn resolutions with protocol overrides"

Yarn Berry supports the patch: and portal: protocols in the resolutions field, which lets you apply a local patch to a transitive package without forking it:

{
  "resolutions": {
    "semver": "7.5.4",
    "lodash": "patch:[email protected]#./.yarn/patches/lodash-4.17.21.patch"
  }
}

Generate the patch file with yarn patch [email protected], apply your change, and commit the .yarn/patches/ directory. Yarn’s --immutable flag then ensures the patch is applied deterministically on every install.

SRI hash verification for the output bundle

Permalink to "SRI hash verification for the output bundle"

Once transitive packages are pinned and the build runs, compute SHA-384 hashes for every vendor bundle so the browser can verify them at fetch time. This is the cryptographic close of the loop: pinning guarantees what code enters the build; SRI guarantees that code — unchanged — reaches the browser.

// scripts/generate-sri.js
const { createHash } = require('crypto');
const { readFileSync, readdirSync, writeFileSync } = require('fs');
const path = require('path');

const DIST = path.resolve(__dirname, '../dist');
const manifest = {};

readdirSync(DIST)
  .filter(f => f.endsWith('.js') || f.endsWith('.css'))
  .forEach(file => {
    const buf = readFileSync(path.join(DIST, file));
    const hash = createHash('sha384').update(buf).digest('base64');
    manifest[file] = `sha384-${hash}`;
  });

writeFileSync(
  path.join(DIST, 'sri-manifest.json'),
  JSON.stringify(manifest, null, 2)
);
console.log('SRI manifest written:', Object.keys(manifest).length, 'files');

The generated sri-manifest.json feeds your HTML templating step, which embeds integrity="sha384-…" and crossorigin="anonymous" on every <script> and <link> tag. Omitting crossorigin="anonymous" is the most common SRI deployment mistake — the browser will reject the resource even when the hash matches, because a CORS-credentialed request cannot be verified against a declared digest.

For the full build-tool integration path, see Automating Hash Generation in Webpack 5.

Gotchas and edge cases

Permalink to "Gotchas and edge cases"
  • Peer dependency conflicts after an override. When you pin [email protected] but a workspace declares peerDependencies: { semver: "^6.0.0" }, the package manager will warn and may refuse to install. Resolve by updating the host package or choosing a version that satisfies every declared range before committing.

  • Override fields are not inherited by nested workspaces. In npm, only the root package.json overrides field takes effect. If a workspace’s own package.json declares a conflicting overrides, it is silently ignored — the root always wins.

  • pnpm content-addressable store caches the old version. After changing an override, run pnpm store prune if you see the old version still appearing in pnpm why. The store content hash does not automatically invalidate on a manifest change alone.

  • Regenerate SRI hashes after every pin update. If a transitive update changes the bytes of a vendor bundle, the SRI hash changes. Embedding a stale hash causes a browser load failure. Always run the hash generation script as part of the same PR that updates the lockfile, and make the mismatch loud rather than silent — see Failing CI on SRI Hash Drift for the gate that turns an unexpected digest into a red build instead of a broken deploy.

  • npm ci vs npm install in CI. npm ci deletes node_modules and installs strictly from the lockfile; npm install may update the lockfile to resolve new ranges. Always use npm ci (or pnpm install --frozen-lockfile / yarn --immutable) in CI so overrides cannot be silently overwritten by a fresh resolution pass.

Verification steps

Permalink to "Verification steps"

Check that the override took effect

Permalink to "Check that the override took effect"
# pnpm — show all resolved versions of a package
pnpm why semver

# npm — show the full resolution tree for a package
npm ls semver --all

# Yarn Berry
yarn why semver

Expected output for pnpm why semver after a successful override:

Legend: production dependency, optional only, dev only

my-monorepo /.
└─┬ some-workspace 1.0.0
  └── semver 7.5.4    ← override applied

If any line shows a version other than your pinned target, the override declaration is missing a selector or the lockfile was not regenerated.

Validate the frozen lockfile in CI

Permalink to "Validate the frozen lockfile in CI"
name: Lockfile integrity
on: [pull_request]
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install with frozen lockfile
        run: pnpm install --frozen-lockfile
        # Fails if the lockfile is out of sync with package.json overrides

      - name: Assert no unintended lockfile diff
        run: |
          git diff --exit-code HEAD -- pnpm-lock.yaml package-lock.json yarn.lock
        # Fails if install regenerated the lockfile (meaning overrides were incomplete)

      - name: Build and verify SRI manifest
        run: |
          pnpm run build
          node scripts/generate-sri.js
          # Fails if any bundle hash changed unexpectedly

The three steps run in a fixed order because each failure means something different. A lockfile diff with no matching override declaration is drift and must block the merge; a changed bundle digest with a legitimate pin behind it is merely a stale manifest that needs regenerating and recommitting.

CI gate decision tree for lockfile and hash changes A decision tree with three questions. If the pull request has no lockfile diff the gate passes. If it has one without a paired override declaration the pull request is blocked as drift. If the pin is paired but the vendor bundle digest changed, the SRI manifest must be refreshed and the gate re-run; otherwise the gate passes. Lockfile diff in the PR? Paired override declared? Bundle digest changed? Refresh sri-manifest.json recommit, re-run the gate Gate passes no resolution changed Block the pull request drift without a pin Gate passes pins and hashes agree yes yes yes no no no

The --frozen-lockfile flag makes pnpm install exit with a non-zero code if the current pnpm-lock.yaml does not satisfy the manifest. This is your primary gate against resolution drift reaching a merge. Pair it with Automated SBOM Generation to produce a machine-readable artifact of every pinned transitive package for compliance sign-off.


Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Does pnpm's strict isolation eliminate the need for transitive pinning?

No. pnpm’s symlink store prevents phantom dependencies but does not freeze versions. A transitive package can still be updated to a newer semver-compatible release unless an override is declared.

What is the difference between overrides and resolutions?

overrides is the npm 8+ and pnpm field; resolutions is the Yarn Classic / Berry equivalent. Both intercept resolution before the lockfile is written, forcing all consumers of the named package to use the specified version.

Will forcing a transitive version break peer dependency contracts?

It can. Always run the full test suite after adding an override. If a workspace declares a peerDependency range that excludes your pinned version, you will see a peer conflict warning — resolve it by updating the host package or choosing a version inside all declared ranges.

How often should transitive pins be updated?

Review pins whenever a CVE is published for a pinned package, or at a minimum on a monthly cadence aligned with your lockfile rotation policy. Dependabot and Renovate both support override-aware PRs in recent versions.

Permalink to "Related"

Related Articles

npm ci vs pnpm --frozen-lockfile vs yarn --immutable
Dependency Pinning Best Practices Supply Chain Auditing & Depend…