mirror of
https://github.com/pnpm/pnpm.git
synced 2026-05-12 18:49:41 -04:00
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:
6
.changeset/self-update-no-downgrade.md
Normal file
6
.changeset/self-update-no-downgrade.md
Normal 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).
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -58,6 +58,9 @@
|
||||
{
|
||||
"path": "../../../installing/env-installer"
|
||||
},
|
||||
{
|
||||
"path": "../../../lockfile/fs"
|
||||
},
|
||||
{
|
||||
"path": "../../../lockfile/types"
|
||||
},
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user