fix(config): apply pmOnFail default to devEngines.packageManager (singular) (#11682)

* fix(config): apply pmOnFail default to devEngines.packageManager (singular)

The pnpm v11 release notes document the `pmOnFail` default as `download`
(via the migration table that maps `managePackageManagerVersions: true` →
`pmOnFail: download (default)`). The legacy `packageManager` field already
gets that default applied at the central onFail-resolution site, but the
singular form of `devEngines.packageManager` short-circuited it by setting
`onFail = 'error'` inside `parseDevEnginesPackageManager`, so projects that
pinned a different pnpm via `devEngines.packageManager` saw a hard version
mismatch instead of an auto-download.

Drop that local `?? 'error'` and let the central default apply. The array
form of `devEngines.packageManager` keeps its own per-element defaults
('error' for the last entry, 'ignore' for the rest) — those reflect
explicit prioritisation by the user, not a system-wide fallback. Explicit
`onFail` values are still honored everywhere.

Closes #11676.

* chore: fix spelling (prioritisation → prioritization)

cspell flagged the British spelling at pre-push.

---------

Co-authored-by: Damon <damon@deeplearning.ai>
This commit is contained in:
shiminshen
2026-05-17 20:47:59 +08:00
committed by GitHub
parent 5dc8be8a42
commit ba2c8844c9
4 changed files with 71 additions and 7 deletions

View File

@@ -0,0 +1,8 @@
---
"@pnpm/config.reader": patch
"pnpm": patch
---
Fix `devEngines.packageManager` (singular form, without `onFail`) defaulting to `onFail: "error"` instead of the documented `pmOnFail: "download"`. As a result, a project that pinned a different pnpm version via `devEngines.packageManager` and ran `pnpm install` from a mismatched pnpm version failed with a hard error, even though the migration table from `managePackageManagerVersions: true` to `pmOnFail: download (default)` promises the install would auto-download the wanted version [#11676](https://github.com/pnpm/pnpm/issues/11676).
The array form of `devEngines.packageManager` keeps its existing per-element defaults (`error` for the last entry, `ignore` for the rest), since those reflect explicit prioritization by the user. Explicit `onFail` values continue to win.

View File

@@ -657,9 +657,11 @@ export async function getConfig (opts: {
// The `pmOnFail` config setting overrides whatever onFail the
// wantedPackageManager carried, so users (and internal callers) can force
// a specific behavior without editing the manifest. Otherwise, the legacy
// `packageManager` field defaults to `download` — `devEngines.packageManager`
// already has onFail set during parsing.
// a specific behavior without editing the manifest. Otherwise, both the
// legacy `packageManager` field and singular `devEngines.packageManager`
// fall through to `download` (the documented default for `pmOnFail`); the
// array form of `devEngines.packageManager` already has its own per-element
// defaults applied during parsing.
if (pnpmConfig.wantedPackageManager) {
if (pnpmConfig.pmOnFail) {
pnpmConfig.wantedPackageManager.onFail = pnpmConfig.pmOnFail
@@ -764,7 +766,7 @@ export function parsePackageManager (packageManager: string): { name: string, ve
function parseDevEnginesPackageManager (devEngines?: DevEngines): EngineDependency | undefined {
if (!devEngines?.packageManager) return undefined
let pmEngine: EngineDependency | undefined
let onFail: 'ignore' | 'warn' | 'error' | 'download'
let onFail: 'ignore' | 'warn' | 'error' | 'download' | undefined
if (Array.isArray(devEngines.packageManager)) {
const engines = devEngines.packageManager
if (engines.length === 0) return undefined
@@ -781,7 +783,11 @@ function parseDevEnginesPackageManager (devEngines?: DevEngines): EngineDependen
}
} else {
pmEngine = devEngines.packageManager
onFail = pmEngine.onFail ?? 'error'
// Singular form: leave onFail undefined when the user did not set it, so
// the central pmOnFail default ('download') applies. The array form keeps
// its own per-element defaults ('error' for the last entry, 'ignore' for
// the rest) because those reflect explicit prioritization by the user.
onFail = pmEngine.onFail
}
if (!pmEngine?.name) return undefined
return {

View File

@@ -166,6 +166,47 @@ test('runtimeOnFail=ignore overrides an existing onFail=download and removes nod
expect(context.rootProjectManifest?.devDependencies?.node).toBeUndefined()
})
test('devEngines.packageManager without onFail resolves to the documented pmOnFail default "download" (#11676)', async () => {
prepare({
devEngines: {
packageManager: {
name: 'pnpm',
version: '11.0.0',
},
},
})
const { context } = await getConfig({
cliOptions: {},
packageManager: { name: 'pnpm', version: '11.0.0' },
})
expect(context.wantedPackageManager).toMatchObject({
name: 'pnpm',
version: '11.0.0',
onFail: 'download',
})
})
test('devEngines.packageManager with explicit onFail is respected (regression guard for #11676)', async () => {
prepare({
devEngines: {
packageManager: {
name: 'pnpm',
version: '11.0.0',
onFail: 'error',
},
},
})
const { context } = await getConfig({
cliOptions: {},
packageManager: { name: 'pnpm', version: '11.0.0' },
})
expect(context.wantedPackageManager?.onFail).toBe('error')
})
test('throw error if --link-workspace-packages is used with --global', async () => {
await expect(getConfig({
cliOptions: {

View File

@@ -143,7 +143,7 @@ test('devEngines.packageManager with onFail=ignore should not check version', as
expect(stderr.toString()).not.toContain('0.0.1')
})
test('devEngines.packageManager defaults to onFail=error', async () => {
test('devEngines.packageManager defaults to onFail=download (#11676)', async () => {
prepare({
devEngines: {
packageManager: {
@@ -153,10 +153,19 @@ test('devEngines.packageManager defaults to onFail=error', async () => {
},
})
const { status, stderr } = execPnpmSync(['install'])
// The documented `pmOnFail` default is `download`. Run under COREPACK_ROOT
// to short-circuit the actual version switch (corepack owns version
// selection there), so the test exercises the resolved default without a
// network round-trip. Pre-fix, devEngines.packageManager defaulted to
// `error` and the corepack-specific download-fallthrough hint did NOT
// appear. Asserting on that hint pins the new behavior.
const { status, stderr } = execPnpmSync(['install'], {
env: { COREPACK_ROOT: '/fake/corepack' },
})
expect(status).toBe(1)
expect(stderr.toString()).toContain('This project is configured to use 0.0.1 of pnpm')
expect(stderr.toString()).toContain('does not switch versions when running under corepack')
})
test('devEngines.packageManager with a different PM name should fail with onFail=error', async () => {