fix: preserve catalog version range policy on update (#12416)

A named catalog whose name parses as a version (e.g. catalog:express4-21)
had its range policy overridden by pnpm update because whichVersionIsPinned
misread the catalog: reference in the previous specifier as a pinned
version. The catalog reference carries no pinning of its own, so the prefix
from the catalog entry is now preserved.

Closes https://github.com/pnpm/pnpm/issues/10321
This commit is contained in:
Zoltan Kochan
2026-06-15 11:58:29 +02:00
committed by GitHub
parent a8c4704ac0
commit 29ab905c21
4 changed files with 65 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/resolving.npm-resolver": patch
"pnpm": patch
---
Fixed `pnpm update` overriding the version range policy of a named catalog whose name parses as a version (e.g. `catalog:express4-21`). The `catalog:` reference carries no pinning of its own, so the prefix from the catalog entry (such as `~`) is now preserved instead of being widened to `^` [#10321](https://github.com/pnpm/pnpm/issues/10321).

View File

@@ -2042,6 +2042,55 @@ describe('update', () => {
})
})
// A named catalog whose name parses as a version (e.g. "express4-21") must not
// have its update policy overridden. The "catalog:express4-21" reference in the
// manifest carries no pinning of its own, so the "~" prefix from the catalog
// entry must be preserved instead of being widened to "^" (issue #10321).
test('update via install mutation preserves the ~ range of a version-like named catalog (issue #10321)', async () => {
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{
name: 'project1',
dependencies: {
'@pnpm.e2e/foo': 'catalog:foo1-0',
},
}])
const mutateOpts = {
...options,
lockfileOnly: true,
catalogs: {
'foo1-0': { '@pnpm.e2e/foo': '~1.0.0' },
},
}
await mutateModules(installProjects(projects), mutateOpts)
expect(readLockfile().catalogs['foo1-0']).toEqual({
'@pnpm.e2e/foo': { specifier: '~1.0.0', version: '1.0.0' },
})
// Simulate `pnpm update` via the "install" mutation with update=true.
const { updatedCatalogs } = await mutateModules(
installProjects(projects).map((project) => ({
...project,
mutation: 'install' as const,
update: true,
updatePackageManifest: true,
})),
mutateOpts
)
// The "~" prefix must be preserved, not widened to "^".
expect(updatedCatalogs).toEqual({
'foo1-0': {
'@pnpm.e2e/foo': '~1.0.0',
},
})
expect(readLockfile().catalogs['foo1-0']).toEqual({
'@pnpm.e2e/foo': { specifier: '~1.0.0', version: '1.0.0' },
})
})
// Similar to above but with updateToLatest (simulating `pnpm upgrade -r --latest`)
test('update via install mutation with updateToLatest preserves catalog: in manifest (issue #11658)', async () => {
await addDistTag({ package: '@pnpm.e2e/foo', version: '100.1.0', distTag: 'latest' })

View File

@@ -2,6 +2,11 @@ import type { PinnedVersion } from '@pnpm/types'
import { parseRange } from 'semver-utils'
export function whichVersionIsPinned (spec: string): PinnedVersion | undefined {
// A catalog reference carries no version pinning of its own; the pinning is
// defined by the catalog entry it points to. Bail out so a catalog name that
// happens to look like a version (e.g. "catalog:express4-21") isn't misread
// as a pinned version.
if (spec.startsWith('catalog:')) return undefined
const colonIndex = spec.indexOf(':')
if (colonIndex !== -1) {
spec = spec.substring(colonIndex + 1)

View File

@@ -15,6 +15,11 @@ test.each([
['npm:@pnpm.e2e/qar@100.0.0', 'patch'],
['jsr:@foo/foo@1.0.0', 'patch'],
['jsr:foo@^1.0.0', 'major'],
['catalog:', undefined],
['catalog:default', undefined],
['catalog:foo', undefined],
// A catalog name that parses as a version must not be treated as a pin.
['catalog:express4-21', undefined],
])('whichVersionIsPinned()', (spec, expectedResult) => {
expect(whichVersionIsPinned(spec)).toEqual(expectedResult)
})