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.
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 declarespeerDependencies: { 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.jsonoverridesfield takes effect. If a workspace’s ownpackage.jsondeclares a conflictingoverrides, it is silently ignored — the root always wins. -
pnpm content-addressable store caches the old version. After changing an override, run
pnpm store pruneif you see the old version still appearing inpnpm 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 civsnpm installin CI.npm cideletesnode_modulesand installs strictly from the lockfile;npm installmay update the lockfile to resolve new ranges. Always usenpm ci(orpnpm 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.
Related
Permalink to "Related"- Dependency Pinning Best Practices — parent page covering the full pinning workflow from direct to transitive dependencies
- Lockfile Mapping & Analysis — techniques for parsing and diffing lockfiles to detect unexpected resolution changes
- Automating Hash Generation in Webpack 5 — build-tool integration for computing and embedding SRI hashes at bundle time