Enforcing Package Manager Versions with Corepack
Permalink to "Enforcing Package Manager Versions with Corepack"Part of Registry & Package Manager Hardening, this page shows how to make every laptop and every CI runner resolve dependencies with one exact package manager build, and how to fail the install when they do not.
Quick Reference
Permalink to "Quick Reference"| Control | Value | Effect |
|---|---|---|
packageManager field |
[email protected]+sha224.<digest> |
Declares the exact manager build for this project |
corepack enable |
command | Installs npm, pnpm and yarn shims into the Node binary directory |
corepack use [email protected] |
command | Writes the field with a digest, then installs with that build |
corepack install |
command | Downloads the pinned build into the Corepack cache |
corepack prepare [email protected] --activate |
command | Fetches a named build and makes it the active default |
COREPACK_ENABLE_STRICT |
1 (default) |
Refuses to run a manager that differs from the field |
COREPACK_ENABLE_DOWNLOAD_PROMPT |
0 in CI |
Suppresses the interactive download confirmation |
COREPACK_ENABLE_AUTO_PIN |
0 in CI |
Stops Corepack rewriting the field mid-build |
COREPACK_NPM_REGISTRY |
registry URL | Fetches manager tarballs from a mirror instead of npmjs |
COREPACK_HOME |
path | Cache directory, by default under the user cache directory |
The minimum viable setup is three things: the field in package.json, corepack enable before any install, and a check that prints the version actually in use.
The mental model
Permalink to "The mental model"A lockfile records the outcome of a resolution, not the algorithm that produced it. The algorithm lives in the package manager binary, and it changes between releases: lockfile schema versions, peer dependency defaults, deduplication behaviour, workspace link semantics. Two engineers can hold identical package.json and identical lockfiles, run the same install command, and end up with different node_modules trees purely because one of them is on pnpm 8 and the other on pnpm 9. The lockfile is only authoritative for the version of the tool that wrote it.
The packageManager field is the declaration that closes that gap. It is a single string of the form <name>@<version> with an optional +<algorithm>.<digest> suffix, and it lives at the top level of package.json. Corepack is the piece that acts on it. When Corepack is enabled, the pnpm and yarn commands on your PATH are no longer real binaries; they are shims that read the field from the nearest package.json, resolve the requested build, verify it, and then hand off. The version you typed is irrelevant, and so is whatever you installed globally last year.
The digest suffix is the part most teams skip, and it is the part that makes the pin an integrity control rather than a naming convention. It covers the package manager tarball Corepack downloads from the registry, and it is recomputed on every fetch. If the registry ever serves different bytes for [email protected] than it served on the day the field was written, Corepack refuses to execute rather than running an unverified binary that is about to be handed your dependency tree and your install scripts.
Strictness is the other half. With COREPACK_ENABLE_STRICT at its default of 1, invoking a manager that does not match the field is an error rather than a shrug. Someone who habitually types yarn in a pnpm repository gets stopped at the door instead of producing a yarn.lock nobody asked for.
Canonical example: pin, enable, verify
Permalink to "Canonical example: pin, enable, verify"Start by letting Corepack write the field for you. Do not hand-edit the digest — corepack use fetches the build, computes the hash from the bytes it received, and records both:
corepack enable
corepack use [email protected]
The resulting package.json carries the pin at the top level, alongside the engines constraint that keeps Node itself in range:
{
"name": "checkout-web",
"private": true,
"packageManager": "[email protected]+sha224.9f3ab2c81d7b0e4a5c6f8213e0d47f9a1c6b2ee5d3f80a17c9b4e2d1",
"engines": {
"node": ">=20.11.0"
},
"scripts": {
"preinstall": "node scripts/assert-package-manager.mjs"
}
}
Then make CI behave identically. The order matters: corepack enable has to run before anything that looks for a pnpm binary, including dependency caching:
# .github/workflows/build.yml
jobs:
build:
runs-on: ubuntu-latest
env:
COREPACK_ENABLE_STRICT: "1"
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
COREPACK_ENABLE_AUTO_PIN: "0"
steps:
- uses: actions/checkout@v4
# Must precede setup-node when caching by package manager,
# otherwise the cache step cannot find the pnpm executable.
- run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Show the resolved package manager
run: |
node -p "require('./package.json').packageManager"
pnpm --version
command -v pnpm
- run: pnpm install --frozen-lockfile
The verification step is not decoration. It writes the pinned string and the executing version into the build log next to each other, which turns a whole category of “works on my machine” investigations into a one-line log comparison. Pair it with --frozen-lockfile so the install refuses to re-resolve; the differences between that flag and its equivalents are covered in npm ci vs pnpm --frozen-lockfile vs yarn --immutable.
Variants
Permalink to "Variants"The three rungs of enforcement are worth naming, because teams routinely believe they are on the top one when they are on the first. Declaring the field is documentation. Declaring it and enabling Corepack is enforcement. Declaring it, enabling Corepack, and asserting the result in a preinstall hook is enforcement that survives a runner image without Corepack in it.
Assert the version without Corepack
Permalink to "Assert the version without Corepack"When the runtime image has no corepack command, or when policy forbids downloading a binary during the build, install the manager explicitly and let a preinstall script refuse anything else. The script reads npm_config_user_agent, which every mainstream manager sets for the scripts it runs:
// scripts/assert-package-manager.mjs
import { readFileSync } from 'node:fs';
const manifestUrl = new URL('../package.json', import.meta.url);
const { packageManager } = JSON.parse(readFileSync(manifestUrl, 'utf8'));
if (!packageManager) {
console.error('package.json is missing a "packageManager" field');
process.exit(1);
}
const [wantName, wantSpec] = packageManager.split('@');
const wantVersion = wantSpec.split('+')[0];
const agent = process.env.npm_config_user_agent ?? '';
const [gotName = 'unknown', gotVersion = 'unknown'] = agent.split(' ')[0].split('/');
if (gotName !== wantName || gotVersion !== wantVersion) {
console.error(`This project requires ${wantName}@${wantVersion}, but the install is running under ${gotName}@${gotVersion}.`);
console.error('Fix: corepack enable && corepack install');
process.exit(1);
}
console.log(`package manager OK: ${gotName}@${gotVersion}`);
The CI counterpart installs the exact version globally and never consults a range:
npm install --global "[email protected]"
pnpm --version
pnpm install --frozen-lockfile
Pre-seed the cache instead of downloading at install time
Permalink to "Pre-seed the cache instead of downloading at install time"corepack install reads the pin from the current project and populates the cache without running an install, which is a better fit for a container build layer than doing the download inside the dependency step:
corepack enable
corepack install # fetch the pinned build only
pnpm install --frozen-lockfile
corepack prepare [email protected] --activate does the same for a version named on the command line rather than one read from package.json, which is what you want when baking a base image shared by several repositories.
Fetch manager tarballs from a mirror
Permalink to "Fetch manager tarballs from a mirror"COREPACK_NPM_REGISTRY redirects Corepack’s downloads away from the public registry, so an air-gapped or proxied build can still resolve the pinned manager. It composes with the setup described in Configuring a Private npm Registry Proxy:
export COREPACK_NPM_REGISTRY="https://registry.internal.example.com"
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
corepack install
Keep the digest in the field when you do this. A mirror that repackages tarballs rather than proxying them byte-for-byte will produce a different hash, and you want to discover that at the gate rather than assume the mirror is faithful.
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"-
The field alone enforces nothing. A
packageManagerstring in a repository where nobody has runcorepack enableis a comment. The managers themselves do not read it, so an engineer with a global pnpm 8 install resolves with pnpm 8 and the field stays silently wrong. Treat “is Corepack enabled here?” as part of onboarding and as an explicit CI step, not an assumption. -
A version skew rewrites the lockfile without asking. Run a newer major against an older lockfile without a frozen flag and the manager migrates the file in place, applying that major’s current defaults for peer dependencies and deduplication. The install succeeds, the tree differs, and the damage shows up as an enormous unreviewed diff. Always combine the pin with a frozen install, and watch for the churn signature described in Detecting Lockfile Tampering in Pull Requests.
-
Ordering breaks the Actions cache.
actions/setup-nodewithcache: pnpmlooks for the executable while configuring the cache, so putting it beforecorepack enablefails withError: Unable to locate executable file: pnpm.Enable Corepack in a step above it. -
A preinstall hook does not run when scripts are disabled. Installing with
--ignore-scriptsskips your assertion entirely, which is exactly the flag a hardened pipeline is likely to set for other good reasons. Run the assertion as its own explicit CI step as well, and read Disabling npm Install Scripts before assuming the hook fires. -
Corepack’s bundled status is not something to rely on. It ships with Node as an experimental feature and must be turned on deliberately, and its long-term place in the Node distribution has been argued over more than once. Some distribution packages and slim container images already omit the shim. Write the pipeline so a missing
corepackcommand falls back to an explicit global install rather than failing halfway through.
Verification Steps
Permalink to "Verification Steps"1. Confirm the shim, not a global binary, is answering
Permalink to "1. Confirm the shim, not a global binary, is answering"corepack enable
command -v pnpm
pnpm --version
command -v should print a path inside the Node installation’s binary directory rather than a manager-specific location, and the version must equal the one in the field. If pnpm --version disagrees with the pin, the shim is not first on PATH.
2. Assert the resolved version mechanically
Permalink to "2. Assert the resolved version mechanically"PINNED="$(node -p "require('./package.json').packageManager.split('@')[1].split('+')[0]")"
ACTUAL="$(pnpm --version)"
echo "pinned=${PINNED} actual=${ACTUAL}"
test "${PINNED}" = "${ACTUAL}" || exit 1
Expected output on a correctly configured machine:
pinned=9.12.3 actual=9.12.3
Run this as its own CI step so the two values land in the log even when the install later fails for an unrelated reason.
3. Confirm strict mode rejects the wrong manager
Permalink to "3. Confirm strict mode rejects the wrong manager"In a project pinned to pnpm, invoke a different manager through the shim:
COREPACK_ENABLE_STRICT=1 yarn --version
Corepack refuses with a message naming the manifest that carries the pin:
Usage Error: This project is configured to use pnpm because /repo/package.json has a "packageManager" field
Setting COREPACK_ENABLE_STRICT=0 turns the same command into a silent success, which is why the variable belongs in the job environment explicitly rather than being left to the default.
4. Confirm the digest is actually checked
Permalink to "4. Confirm the digest is actually checked"Corrupt the digest by hand in a scratch copy of package.json, clear the cache entry, and re-run. Corepack aborts before executing anything:
Error: Mismatch hashes. Expected 9f3ab2c81d7b…, got 4c81d7b0e4a5…
A malformed pin fails earlier still — a range such as pnpm@^9.0.0 produces Invalid package manager specification in package.json (pnpm@^9.0.0); expected a semver version, which is the correct behaviour: the field is a pin, not a range.
Frequently Asked Questions
Permalink to "Frequently Asked Questions"What exactly does the sha224 suffix in packageManager pin?
It is the digest of the package manager tarball Corepack downloads from the registry, not a digest of your dependencies. Corepack hashes the artifact it fetched and compares it to the value in the field before executing anything. A mismatch means the registry served different bytes for that version than the machine that wrote the field received, and Corepack aborts rather than running the binary.
Does npm itself honour the packageManager field?
No. The field is inert to npm, pnpm and Yarn when they are invoked directly from a global install. Only Corepack reads it and decides which manager build to execute. That is why a repository can carry a correct pin for years while half the team quietly installs with the wrong version, and why the field alone is documentation rather than enforcement.
Should CI use Corepack or install the package manager directly?
Use Corepack when it is present, because it reads the pin from the repository and cannot drift from it. Install directly when your runtime image ships without the corepack shim or when your policy forbids fetching a binary at build time. In both cases keep the preinstall assertion, so the job fails loudly instead of resolving dependencies with an unexpected version.
Why did a different manager version produce a different lockfile?
Major releases change lockfile formats and resolution defaults. A newer pnpm reading an older lockfile migrates it in place, and defaults such as peer dependency handling and deduplication have changed between majors. Without a frozen lockfile flag the install silently re-resolves and writes a tree nobody reviewed, so the diff appears at commit time rather than at install time.
What happens if the corepack command is missing from my Node build?
Corepack ships with Node as an experimental feature, but its future as a bundled component has been debated repeatedly and some distribution packages and container images omit it. Detect it rather than assuming it: if corepack enable fails, install the pinned manager version explicitly with npm install --global and let the preinstall assertion confirm the running version before any dependency is resolved.
Related
Permalink to "Related"- Pinning Transitive Dependencies in Monorepos — holding nested versions steady once every workspace resolves with the same manager
- Verifying Sigstore Provenance for npm Packages — proving where the packages themselves were built, not just which tool installed them
- Detecting Changes in Third-Party Scripts — the runtime counterpart for code you do not install at all