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

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.

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.

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


Permalink to "Related"

Related Articles

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