diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index e73d5d68a778ea..395f55de9ecd1e 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -3424,7 +3424,7 @@ Behavior: - `bump` = e.g. bump the range even if the new version satisfies the existing range, e.g. `^1.0.0` -> `^1.1.0` - `replace` = Replace the range with a newer one if the new version falls outside it, and update nothing otherwise - `widen` = Widen the range with newer one, e.g. `^1.0.0` -> `^1.0.0 || ^2.0.0` -- `update-lockfile` = Update the lock file when in-range updates are available, otherwise `replace` for updates out of range. Works for `bundler`, `cargo`, `composer`, `npm`, `yarn`, `terraform` and `poetry` so far +- `update-lockfile` = Update the lock file when in-range updates are available, otherwise `replace` for updates out of range. Works for `bundler`, `cargo`, `composer`, `npm`, `yarn`, `pnpm`, `terraform` and `poetry` so far - `in-range-only` = Update the lock file when in-range updates are available, ignore package file updates Renovate's `"auto"` strategy works like this for npm: diff --git a/lib/modules/manager/npm/post-update/index.ts b/lib/modules/manager/npm/post-update/index.ts index 005400372fb5a5..3ee90a8c9c5b44 100644 --- a/lib/modules/manager/npm/post-update/index.ts +++ b/lib/modules/manager/npm/post-update/index.ts @@ -49,15 +49,14 @@ export function determineLockFileDirs( const pnpmShrinkwrapDirs: (string | undefined)[] = []; for (const upgrade of config.upgrades) { - if (upgrade.updateType === 'lockFileMaintenance' || upgrade.isRemediation) { + if ( + upgrade.updateType === 'lockFileMaintenance' || + upgrade.isRemediation === true || + upgrade.isLockfileUpdate === true + ) { yarnLockDirs.push(upgrade.managerData?.yarnLock); npmLockDirs.push(upgrade.managerData?.npmLock); pnpmShrinkwrapDirs.push(upgrade.managerData?.pnpmShrinkwrap); - continue; - } - if (upgrade.isLockfileUpdate) { - yarnLockDirs.push(upgrade.managerData?.yarnLock); - npmLockDirs.push(upgrade.managerData?.npmLock); } } diff --git a/lib/modules/manager/npm/post-update/pnpm.spec.ts b/lib/modules/manager/npm/post-update/pnpm.spec.ts index 3fdcc0fd06d29d..8ba7ba957a3b9c 100644 --- a/lib/modules/manager/npm/post-update/pnpm.spec.ts +++ b/lib/modules/manager/npm/post-update/pnpm.spec.ts @@ -2,7 +2,7 @@ import { envMock, mockExecAll } from '../../../../../test/exec-util'; import { Fixtures } from '../../../../../test/fixtures'; import { env, fs, mockedFunction, partial } from '../../../../../test/util'; import { GlobalConfig } from '../../../../config/global'; -import type { PostUpdateConfig } from '../../types'; +import type { PostUpdateConfig, Upgrade } from '../../types'; import { getNodeToolConstraint } from './node-version'; import * as pnpmHelper from './pnpm'; @@ -15,6 +15,7 @@ process.env.CONTAINERBASE = 'true'; describe('modules/manager/npm/post-update/pnpm', () => { let config: PostUpdateConfig; + const upgrades: Upgrade[] = [{}]; beforeEach(() => { config = partial({ constraints: { pnpm: '^2.0.0' } }); @@ -26,10 +27,22 @@ describe('modules/manager/npm/post-update/pnpm', () => { }); }); + it('does nothing when no upgrades', async () => { + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValue('package-lock-contents'); + await pnpmHelper.generateLockFile('some-dir', {}, config); + expect(execSnapshots).toMatchObject([]); + }); + it('generates lock files', async () => { const execSnapshots = mockExecAll(); fs.readLocalFile.mockResolvedValue('package-lock-contents'); - const res = await pnpmHelper.generateLockFile('some-dir', {}, config); + const res = await pnpmHelper.generateLockFile( + 'some-dir', + {}, + config, + upgrades, + ); expect(fs.readLocalFile).toHaveBeenCalledTimes(1); expect(res.lockFile).toBe('package-lock-contents'); expect(execSnapshots).toMatchSnapshot(); @@ -40,7 +53,12 @@ describe('modules/manager/npm/post-update/pnpm', () => { fs.readLocalFile.mockImplementation(() => { throw new Error('not found'); }); - const res = await pnpmHelper.generateLockFile('some-dir', {}, config); + const res = await pnpmHelper.generateLockFile( + 'some-dir', + {}, + config, + upgrades, + ); expect(fs.readLocalFile).toHaveBeenCalledTimes(1); expect(res.error).toBeTrue(); expect(res.lockFile).toBeUndefined(); @@ -50,12 +68,66 @@ describe('modules/manager/npm/post-update/pnpm', () => { it('finds pnpm globally', async () => { const execSnapshots = mockExecAll(); fs.readLocalFile.mockResolvedValue('package-lock-contents'); - const res = await pnpmHelper.generateLockFile('some-dir', {}, config); + const res = await pnpmHelper.generateLockFile( + 'some-dir', + {}, + config, + upgrades, + ); expect(fs.readLocalFile).toHaveBeenCalledTimes(1); expect(res.lockFile).toBe('package-lock-contents'); expect(execSnapshots).toMatchSnapshot(); }); + it('performs lock file updates', async () => { + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValue('package-lock-contents'); + const res = await pnpmHelper.generateLockFile('some-folder', {}, config, [ + { packageName: 'some-dep', newVersion: '1.0.1', isLockfileUpdate: true }, + { + packageName: 'some-other-dep', + newVersion: '1.1.0', + isLockfileUpdate: true, + }, + ]); + expect(fs.readLocalFile).toHaveBeenCalledTimes(1); + expect(res.lockFile).toBe('package-lock-contents'); + expect(execSnapshots).toMatchObject([ + { + cmd: 'pnpm update --no-save some-dep@1.0.1 some-other-dep@1.1.0 --recursive --lockfile-only --ignore-scripts --ignore-pnpmfile', + }, + ]); + }); + + it('performs lock file updates and install when lock file updates mixed with regular updates', async () => { + const execSnapshots = mockExecAll(); + fs.readLocalFile.mockResolvedValue('package-lock-contents'); + const res = await pnpmHelper.generateLockFile('some-folder', {}, config, [ + { + groupName: 'some-group', + packageName: 'some-dep', + newVersion: '1.1.0', + isLockfileUpdate: true, + }, + { + groupName: 'some-group', + packageName: 'some-other-dep', + newVersion: '1.1.0', + isLockfileUpdate: false, + }, + ]); + expect(fs.readLocalFile).toHaveBeenCalledTimes(1); + expect(res.lockFile).toBe('package-lock-contents'); + expect(execSnapshots).toMatchObject([ + { + cmd: 'pnpm install --recursive --lockfile-only --ignore-scripts --ignore-pnpmfile', + }, + { + cmd: 'pnpm update --no-save some-dep@1.1.0 --recursive --lockfile-only --ignore-scripts --ignore-pnpmfile', + }, + ]); + }); + it('performs lock file maintenance', async () => { const execSnapshots = mockExecAll(); fs.readLocalFile.mockResolvedValue('package-lock-contents'); @@ -76,6 +148,7 @@ describe('modules/manager/npm/post-update/pnpm', () => { 'some-dir', {}, { ...config, postUpdateOptions }, + upgrades, ); expect(fs.readLocalFile).toHaveBeenCalledTimes(1); expect(res.lockFile).toBe('package-lock-contents'); @@ -220,6 +293,7 @@ describe('modules/manager/npm/post-update/pnpm', () => { 'some-dir', {}, { ...config, constraints: { pnpm: '6.0.0' } }, + upgrades, ); expect(fs.readLocalFile).toHaveBeenCalledTimes(1); expect(res.lockFile).toBe('package-lock-contents'); @@ -254,6 +328,7 @@ describe('modules/manager/npm/post-update/pnpm', () => { 'some-dir', {}, { ...config, constraints: { pnpm: '6.0.0' } }, + upgrades, ); expect(fs.readLocalFile).toHaveBeenCalledTimes(1); expect(res.lockFile).toBe('package-lock-contents'); diff --git a/lib/modules/manager/npm/post-update/pnpm.ts b/lib/modules/manager/npm/post-update/pnpm.ts index 43daef2e0ae3fb..13348c851fa631 100644 --- a/lib/modules/manager/npm/post-update/pnpm.ts +++ b/lib/modules/manager/npm/post-update/pnpm.ts @@ -1,4 +1,5 @@ import is from '@sindresorhus/is'; +import { quote } from 'shlex'; import upath from 'upath'; import { GlobalConfig } from '../../../../config/global'; import { TEMPORARY_ERROR } from '../../../../constants/error-messages'; @@ -10,6 +11,7 @@ import type { ToolConstraint, } from '../../../../util/exec/types'; import { deleteLocalFile, readLocalFile } from '../../../../util/fs'; +import { uniqueStrings } from '../../../../util/string'; import { parseSingleYaml } from '../../../../util/yaml'; import type { PostUpdateConfig, Upgrade } from '../../types'; import { getNodeToolConstraint } from './node-version'; @@ -36,7 +38,7 @@ export async function generateLockFile( let lockFile: string | null = null; let stdout: string | undefined; let stderr: string | undefined; - let cmd = 'pnpm'; + const commands: string[] = []; try { const lazyPgkJson = lazyLoadPackageJson(lockFileDir); const pnpmToolConstraint: ToolConstraint = { @@ -67,16 +69,33 @@ export async function generateLockFile( extraEnv.NPM_AUTH = env.NPM_AUTH; extraEnv.NPM_EMAIL = env.NPM_EMAIL; } - const commands: string[] = []; - cmd = 'pnpm'; - let args = 'install --recursive --lockfile-only'; + let args = '--recursive --lockfile-only'; if (!GlobalConfig.get('allowScripts') || config.ignoreScripts) { args += ' --ignore-scripts'; args += ' --ignore-pnpmfile'; } - logger.trace({ cmd, args }, 'pnpm command'); - commands.push(`${cmd} ${args}`); + logger.trace({ args }, 'pnpm command options'); + + const lockUpdates = upgrades.filter((upgrade) => upgrade.isLockfileUpdate); + + if (lockUpdates.length !== upgrades.length) { + // This command updates the lock file based on package.json + commands.push(`pnpm install ${args}`); + } + + // rangeStrategy = update-lockfile + if (lockUpdates.length) { + logger.debug('Performing lockfileUpdate (pnpm)'); + commands.push( + `pnpm update --no-save ${lockUpdates + // TODO: types (#22198) + .map((update) => `${update.packageName!}@${update.newVersion!}`) + .filter(uniqueStrings) + .map(quote) + .join(' ')} ${args}`, + ); + } // postUpdateOptions if (config.postUpdateOptions?.includes('pnpmDedupe')) { @@ -105,7 +124,7 @@ export async function generateLockFile( } logger.debug( { - cmd, + commands, err, stdout, stderr, diff --git a/lib/modules/manager/npm/update/locked-dependency/index.spec.ts b/lib/modules/manager/npm/update/locked-dependency/index.spec.ts index 11f54635c43cc1..13408e38863eee 100644 --- a/lib/modules/manager/npm/update/locked-dependency/index.spec.ts +++ b/lib/modules/manager/npm/update/locked-dependency/index.spec.ts @@ -246,10 +246,10 @@ describe('modules/manager/npm/update/locked-dependency/index', () => { expect(res.status).toBe('update-failed'); }); - it('fails if pnpm', async () => { + it('rejects in-range remediation if pnpm', async () => { config.lockFile = 'pnpm-lock.yaml'; const res = await updateLockedDependency(config); - expect(res.status).toBe('update-failed'); + expect(res.status).toBe('unsupported'); }); }); }); diff --git a/lib/modules/manager/npm/update/locked-dependency/index.ts b/lib/modules/manager/npm/update/locked-dependency/index.ts index c9a4782b408307..bdf2310c6aabd9 100644 --- a/lib/modules/manager/npm/update/locked-dependency/index.ts +++ b/lib/modules/manager/npm/update/locked-dependency/index.ts @@ -21,12 +21,12 @@ export async function updateLockedDependency( } if (lockFile.endsWith('pnpm-lock.yaml')) { logger.debug( - 'updateLockedDependency(): pnpm is not supported yet. See https://github.com/renovatebot/renovate/issues/21438', - ); - } else { - logger.debug( - `updateLockedDependency(): unsupported lock file: ${lockFile}`, + 'Cannot patch pnpm lock file directly - falling back to using pnpm', ); + return { status: 'unsupported' }; } + + logger.debug(`updateLockedDependency(): unsupported lock file: ${lockFile}`); + return { status: 'update-failed' }; }