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.

Anatomy of the packageManager field The packageManager string splits into a manager name, an at separator, an exact version, a hash algorithm marker and a hex digest of the downloaded package manager tarball, which Corepack recomputes on every download. "packageManager": "[email protected]+sha224.9f3ab2c81d7b" pnpm @ 9.12.3 +sha224. 9f3ab2c81d7b… manager name separator exact version hash algorithm hex digest of the manager tarball Corepack recomputes this digest on every download and refuses to run on a mismatch.

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.

How Corepack resolves an install command Running pnpm install reaches the Corepack shim, which reads the packageManager field and checks its cache; a miss downloads the tarball from the registry, and either path then verifies the sha224 digest before either aborting with a hash mismatch or executing the pinned version. developer runs pnpm install Corepack shim reads the packageManager field cached? no download the tarball from the registry verify sha224 digest against the pin yes differs Mismatch hashes. install aborts matches run pnpm 9.12.3 same build everywhere

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.

Resolved manager version by enforcement level A matrix showing that with no packageManager field two developers and CI resolve three different pnpm versions, that a pin without Corepack still drifts, and that pinning plus corepack enable produces the same version in all three environments. project setup developer A developer B CI runner no packageManager pnpm 9.12.3 pnpm 10.4.1 pnpm 10.4.1 pinned, Corepack not enabled pnpm 9.12.3 pnpm 8.15.9 pnpm 9.12.3 pinned + corepack enable everywhere pnpm 9.12.3 pnpm 9.12.3 pnpm 9.12.3 Only the bottom row guarantees the installing version matches the version that wrote the lockfile.

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 packageManager string in a repository where nobody has run corepack enable is 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-node with cache: pnpm looks for the executable while configuring the cache, so putting it before corepack enable fails with Error: 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-scripts skips 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 corepack command 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.

Permalink to "Related"

Related Articles

Disabling npm Install Scripts
Configuring a Private npm Registry Proxy
Registry & Package Manager Hardening Supply Chain Auditing & Depend…