fix(self-update): do not downgrade when latest dist-tag is older (#11435)

* fix(self-update): do not downgrade when latest dist-tag is older

`pnpm self-update` defaults to the `latest` dist-tag, but `latest` on the
registry can lag the installed version when a new major has shipped
without being tagged. Refuse to downgrade in that case. Users can still
run `pnpm self-update latest` (explicit) to force the downgrade.

Closes #11418

* fix(self-update): use lockfile-pinned version for project-pin downgrade check

When a project pins pnpm via a range (e.g. `devEngines.packageManager.version: ">=8.0.0"`)
and the env lockfile pins an exact version above the range's lower bound,
the previous guard compared the resolved `latest` against `semver.minVersion(spec)`
and missed the downgrade. Read `packageManagerDependencies.pnpm.version` from
`pnpm-lock.yaml` and use the max of (lockfile-pinned, spec.minVersion) as the
current version. Also fix the explicit-`latest` test which mocked `latest`
as newer than the current version, defeating its own assertion.

* chore(engine.pm.commands): add lockfile/fs project reference to tsconfig
This commit is contained in:
Zoltan Kochan
2026-05-03 01:10:57 +02:00
committed by GitHub
parent c2ab95674b
commit 72a7020f9b
6 changed files with 168 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/engine.pm.commands": patch
"pnpm": minor
---
`pnpm self-update` (with no version argument) no longer downgrades pnpm when the registry's `latest` dist-tag points to an older release than the currently active version. Run `pnpm self-update latest` to force a downgrade [#11418](https://github.com/pnpm/pnpm/issues/11418).

View File

@@ -43,6 +43,7 @@
"@pnpm/installing.client": "workspace:*",
"@pnpm/installing.deps-restorer": "workspace:*",
"@pnpm/installing.env-installer": "workspace:*",
"@pnpm/lockfile.fs": "workspace:*",
"@pnpm/lockfile.types": "workspace:*",
"@pnpm/os.env.path-extender": "catalog:",
"@pnpm/resolving.npm-resolver": "workspace:*",

View File

@@ -7,6 +7,7 @@ import { type Config, type ConfigContext, parsePackageManager, types as allTypes
import { PnpmError } from '@pnpm/error'
import { createResolver } from '@pnpm/installing.client'
import { resolvePackageManagerIntegrities } from '@pnpm/installing.env-installer'
import { readEnvLockfile } from '@pnpm/lockfile.fs'
import { globalInfo, globalWarn } from '@pnpm/logger'
import { whichVersionIsPinned } from '@pnpm/resolving.npm-resolver'
import { createStoreController, type CreateStoreControllerOptions } from '@pnpm/store.connection-manager'
@@ -75,6 +76,12 @@ export async function handler (
globalInfo('Checking for updates...')
const { resolve } = createResolver({ ...opts, configByUri: opts.configByUri })
const pkgName = 'pnpm'
// `pnpm self-update` (no args) defaults to the `latest` dist-tag, but we
// refuse to downgrade in that case — `latest` on the registry can lag the
// installed version when a new major has shipped without being tagged.
// `pnpm self-update latest` (explicit) bypasses the guard so users can
// still force a downgrade when they want one.
const isImplicitLatest = params.length === 0
const bareSpecifier = params[0] ?? 'latest'
const resolution = await resolve({ alias: pkgName, bareSpecifier }, {
lockfileDir: opts.lockfileDir ?? opts.dir,
@@ -111,6 +118,15 @@ export async function handler (
if (opts.wantedPackageManager?.name === packageManager.name) {
if (opts.wantedPackageManager?.version !== resolution.manifest.version) {
if (isImplicitLatest) {
// Prefer the lockfile-pinned version when available — for range
// specs like `>=8.0.0`, the spec's lower bound understates the
// version that was actually installed (see #11418 review).
const projectCurrentVersion = await readProjectPinnedPnpmVersion(opts.rootProjectManifestDir, opts.wantedPackageManager?.version)
if (projectCurrentVersion != null && semver.lt(resolution.manifest.version, projectCurrentVersion)) {
return `The current project is set to use pnpm v${projectCurrentVersion}, which is newer than the "latest" version on the registry (v${resolution.manifest.version}). No update performed. Run "pnpm self-update latest" to downgrade.`
}
}
const { manifest, writeProjectManifest } = await readProjectManifest(opts.rootProjectManifestDir)
if (manifest.devEngines?.packageManager) {
let manifestChanged = false
@@ -165,6 +181,10 @@ export async function handler (
return `The currently active ${packageManager.name} v${packageManager.version} is already "${bareSpecifier}" and doesn't need an update`
}
if (isImplicitLatest && semver.lt(resolution.manifest.version, packageManager.version)) {
return `The currently active ${packageManager.name} v${packageManager.version} is newer than the "latest" version on the registry (v${resolution.manifest.version}). No update performed. Run "pnpm self-update latest" to downgrade.`
}
globalInfo(`Updating pnpm from v${packageManager.version} to v${resolution.manifest.version}...`)
const store = await createStoreController(opts)
@@ -222,3 +242,30 @@ function versionSpecFromPinned (version: string, pinnedVersion: PinnedVersion):
case 'patch': return version
}
}
async function readProjectPinnedPnpmVersion (rootProjectManifestDir: string, spec: string | undefined): Promise<string | undefined> {
// The env lockfile is the most accurate source for the actually-installed
// pnpm version when the spec is a range. Fall back to the spec's minimum
// version when there's no lockfile entry (e.g. exact `packageManager` pins
// below v12 don't write to the lockfile). Take the max of the two so we
// pick whichever signal is more restrictive.
let lockfilePinned: string | undefined
try {
const envLockfile = await readEnvLockfile(rootProjectManifestDir)
lockfilePinned = envLockfile?.importers['.'].packageManagerDependencies?.pnpm?.version
} catch {
// ignore — fall through to spec min version
}
let specMin: string | undefined
if (spec != null) {
try {
specMin = semver.minVersion(spec)?.version
} catch {
// invalid range — ignore
}
}
if (lockfilePinned != null && specMin != null) {
return semver.gt(lockfilePinned, specMin) ? lockfilePinned : specMin
}
return lockfilePinned ?? specMin
}

View File

@@ -214,6 +214,114 @@ test('self-update does nothing when pnpm is up to date', async () => {
expect(output).toBe('The currently active pnpm v9.0.0 is already "latest" and doesn\'t need an update')
})
test('self-update refuses to downgrade when latest is older than current', async () => {
const opts = prepare()
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
.intercept({ path: '/pnpm', method: 'GET' })
.reply(200, createMetadata('8.15.0', opts.registries.default))
const output = await selfUpdate.handler(opts, [])
expect(output).toBe('The currently active pnpm v9.0.0 is newer than the "latest" version on the registry (v8.15.0). No update performed. Run "pnpm self-update latest" to downgrade.')
// No global install dir should have been created.
const globalDir = path.join(opts.pnpmHomeDir, 'global', 'v11')
expect(fs.existsSync(globalDir)).toBe(false)
})
test('self-update latest forces the downgrade even when latest is older', async () => {
const opts = prepare()
// Mocked current pnpm is v9.0.0; mocking `latest` as v8.15.0 makes this an
// actual downgrade so the test exercises the explicit-`latest` bypass of
// the no-downgrade guard. The fixture tarball is still 9.1.0, but this test
// only checks that the install path was reached — not the resulting pinned
// version.
mockRegistryForUpdate(opts.registries.default, '8.15.0', createMetadata('8.15.0', opts.registries.default))
const output = await selfUpdate.handler(opts, ['latest'])
expect(output).not.toMatch(/No update performed/)
const globalDir = path.join(opts.pnpmHomeDir, 'global', 'v11')
expect(fs.existsSync(globalDir)).toBe(true)
})
test('self-update by exact older version skips the no-downgrade guard', async () => {
const opts = prepare()
// The fixture tarball's actual contents are still 9.1.0; only the registry
// metadata claims 8.15.0. That is fine here — this test only verifies that
// an explicit version argument bypasses the implicit-latest guard, not the
// resulting pinned version.
mockRegistryForUpdate(opts.registries.default, '8.15.0', createMetadata('8.15.0', opts.registries.default))
const output = await selfUpdate.handler(opts, ['8.15.0'])
expect(output).not.toMatch(/No update performed/)
const globalDir = path.join(opts.pnpmHomeDir, 'global', 'v11')
expect(fs.existsSync(globalDir)).toBe(true)
})
test('self-update refuses to downgrade the project pin when latest is older', async () => {
const opts = prepare({
packageManager: 'pnpm@10.0.0',
})
const pkgJsonPath = path.join(opts.dir, 'package.json')
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
.intercept({ path: '/pnpm', method: 'GET' })
.reply(200, createMetadata('9.5.0', opts.registries.default))
const output = await selfUpdate.handler({
...opts,
wantedPackageManager: {
name: 'pnpm',
version: '10.0.0',
},
}, [])
expect(output).toBe('The current project is set to use pnpm v10.0.0, which is newer than the "latest" version on the registry (v9.5.0). No update performed. Run "pnpm self-update latest" to downgrade.')
expect(JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')).packageManager).toBe('pnpm@10.0.0')
})
test('self-update refuses to downgrade the project pin when the lockfile is pinned above the range', async () => {
// Range spec like ">=8.0.0" understates the installed version when the
// env lockfile has pinned a higher exact version. The guard must consult
// the lockfile, not just the spec's lower bound.
const opts = prepare({
devEngines: {
packageManager: { name: 'pnpm', version: '>=8.0.0' },
},
})
fs.writeFileSync(path.join(opts.dir, 'pnpm-lock.yaml'), [
'---',
"lockfileVersion: '9.0'",
'',
'importers:',
'',
' .:',
' configDependencies: {}',
' packageManagerDependencies:',
' pnpm:',
" specifier: '>=8.0.0'",
' version: 10.5.0',
'',
'packages: {}',
'snapshots: {}',
'---',
'',
].join('\n'), 'utf8')
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
.intercept({ path: '/pnpm', method: 'GET' })
.reply(200, createMetadata('9.5.0', opts.registries.default))
const output = await selfUpdate.handler({
...opts,
wantedPackageManager: {
name: 'pnpm',
version: '>=8.0.0',
},
}, [])
expect(output).toBe('The current project is set to use pnpm v10.5.0, which is newer than the "latest" version on the registry (v9.5.0). No update performed. Run "pnpm self-update latest" to downgrade.')
})
test('should update packageManager field when a newer pnpm version is available', async () => {
const opts = prepare()
const pkgJsonPath = path.join(opts.dir, 'package.json')

View File

@@ -58,6 +58,9 @@
{
"path": "../../../installing/env-installer"
},
{
"path": "../../../lockfile/fs"
},
{
"path": "../../../lockfile/types"
},

3
pnpm-lock.yaml generated
View File

@@ -3876,6 +3876,9 @@ importers:
'@pnpm/installing.env-installer':
specifier: workspace:*
version: link:../../../installing/env-installer
'@pnpm/lockfile.fs':
specifier: workspace:*
version: link:../../../lockfile/fs
'@pnpm/lockfile.types':
specifier: workspace:*
version: link:../../../lockfile/types