From 72a7020f9be45f8d67bc3d48773d8b7a88b60982 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Sun, 3 May 2026 01:10:57 +0200 Subject: [PATCH] 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 --- .changeset/self-update-no-downgrade.md | 6 + engine/pm/commands/package.json | 1 + .../commands/src/self-updater/selfUpdate.ts | 47 ++++++++ .../test/self-updater/selfUpdate.test.ts | 108 ++++++++++++++++++ engine/pm/commands/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 6 files changed, 168 insertions(+) create mode 100644 .changeset/self-update-no-downgrade.md diff --git a/.changeset/self-update-no-downgrade.md b/.changeset/self-update-no-downgrade.md new file mode 100644 index 0000000000..1c57aa5206 --- /dev/null +++ b/.changeset/self-update-no-downgrade.md @@ -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). diff --git a/engine/pm/commands/package.json b/engine/pm/commands/package.json index a34c128ec2..f896b56905 100644 --- a/engine/pm/commands/package.json +++ b/engine/pm/commands/package.json @@ -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:*", diff --git a/engine/pm/commands/src/self-updater/selfUpdate.ts b/engine/pm/commands/src/self-updater/selfUpdate.ts index 1da6fad246..8d8c4aff89 100644 --- a/engine/pm/commands/src/self-updater/selfUpdate.ts +++ b/engine/pm/commands/src/self-updater/selfUpdate.ts @@ -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 { + // 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 +} diff --git a/engine/pm/commands/test/self-updater/selfUpdate.test.ts b/engine/pm/commands/test/self-updater/selfUpdate.test.ts index 3b23d19d28..2ada901222 100644 --- a/engine/pm/commands/test/self-updater/selfUpdate.test.ts +++ b/engine/pm/commands/test/self-updater/selfUpdate.test.ts @@ -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') diff --git a/engine/pm/commands/tsconfig.json b/engine/pm/commands/tsconfig.json index a7f3147d75..2f66f1e757 100644 --- a/engine/pm/commands/tsconfig.json +++ b/engine/pm/commands/tsconfig.json @@ -58,6 +58,9 @@ { "path": "../../../installing/env-installer" }, + { + "path": "../../../lockfile/fs" + }, { "path": "../../../lockfile/types" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f872a1a371..30de27a5ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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