From d8be9706d931320431ce13a37a7324e6bf6ea403 Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Sat, 28 Mar 2026 18:17:52 +0100 Subject: [PATCH] fix: respect frozen-lockfile flag when migrating config dependencies (#11067) * fix: respect frozen-lockfile flag when migrating config dependencies * fix: throw FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE when installing config deps with --frozen-lockfile * fix: correct changeset package name and clean up minor issues - Fix changeset referencing non-existent @pnpm/config.deps-installer (should be @pnpm/installing.env-installer) - Fix merge artifact in AGENTS.md - Revert unnecessary Promise.all refactoring in migrateConfigDeps.ts - Remove extra blank line in test file * fix: move frozenLockfile check to call site and add missing tests Move the frozenLockfile check from migrateConfigDepsToLockfile() to normalizeForInstall() to minimize the number of check points. Add unit tests for all frozenLockfile code paths: - installConfigDeps: migration fails with frozenLockfile - resolveAndInstallConfigDeps: old-format migration, new-format resolution, and up-to-date lockfile success - resolveConfigDeps: fails with frozenLockfile * refactor: consolidate duplicate frozenLockfile checks in resolveAndInstallConfigDeps Merge two identical frozenLockfile throw statements into a single check covering both lockfileChanged and depsToResolve conditions. * Delete respect-frozen-lockfile.md * refactor: order fields --------- Co-authored-by: Zoltan Kochan --- .changeset/frozen-lockfile-config-deps.md | 6 +++ AGENTS.md | 2 +- .../env-installer/src/installConfigDeps.ts | 4 ++ .../src/resolveAndInstallConfigDeps.ts | 4 ++ .../env-installer/src/resolveConfigDeps.ts | 4 ++ .../env-installer/test/installConfigDeps.ts | 19 ++++++++ .../test/resolveAndInstallConfigDeps.test.ts | 44 +++++++++++++++++ .../test/resolveConfigDeps.test.ts | 17 +++++++ pnpm/src/getConfig.ts | 17 ++++--- pnpm/src/main.ts | 1 + pnpm/test/install/configDeps.ts | 48 +++++++++++++++++++ 11 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 .changeset/frozen-lockfile-config-deps.md create mode 100644 pnpm/test/install/configDeps.ts diff --git a/.changeset/frozen-lockfile-config-deps.md b/.changeset/frozen-lockfile-config-deps.md new file mode 100644 index 0000000000..eae2ddee7e --- /dev/null +++ b/.changeset/frozen-lockfile-config-deps.md @@ -0,0 +1,6 @@ +--- +"@pnpm/installing.env-installer": minor +"pnpm": minor +--- + +Throws `FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE` when attempting to install configuration dependencies with `--frozen-lockfile` active and the env lockfile is missing or out-of-date. Previously, the operation would silently rewrite the workspace file or resolve in-memory. diff --git a/AGENTS.md b/AGENTS.md index 027e01cba2..23b6af30a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,7 @@ Example: ``` --- -"@pnpm/installing.deps-installer"lling.deps-installer": minor +"@pnpm/installing.deps-installer": minor "pnpm": minor --- diff --git a/installing/env-installer/src/installConfigDeps.ts b/installing/env-installer/src/installConfigDeps.ts index 149e3bb1fe..a83de1f7ac 100644 --- a/installing/env-installer/src/installConfigDeps.ts +++ b/installing/env-installer/src/installConfigDeps.ts @@ -18,6 +18,7 @@ import { migrateConfigDepsToLockfile } from './migrateConfigDeps.js' import type { NormalizedConfigDep } from './parseIntegrity.js' export interface InstallConfigDepsOpts { + frozenLockfile?: boolean registries: Registries rootDir: string store: StoreController @@ -105,6 +106,9 @@ async function normalizeForInstall ( } // No env lockfile yet — migrate from old inline integrity format + if (opts.frozenLockfile) { + throw new PnpmError('FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE', 'Cannot migrate configDependencies with "frozen-lockfile" because the lockfile is not up to date') + } return migrateConfigDepsToLockfile(configDepsOrLockfile, opts) } diff --git a/installing/env-installer/src/resolveAndInstallConfigDeps.ts b/installing/env-installer/src/resolveAndInstallConfigDeps.ts index 41fb4da85e..c9e53fc3ce 100644 --- a/installing/env-installer/src/resolveAndInstallConfigDeps.ts +++ b/installing/env-installer/src/resolveAndInstallConfigDeps.ts @@ -85,6 +85,10 @@ export async function resolveAndInstallConfigDeps ( depsToResolve.push({ name, specifier }) } + if (opts.frozenLockfile && (lockfileChanged || depsToResolve.length > 0)) { + throw new PnpmError('FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE', 'Cannot update configDependencies with "frozen-lockfile" because the lockfile is not up to date') + } + if (depsToResolve.length === 0) { if (lockfileChanged) { await writeEnvLockfile(opts.rootDir, envLockfile) diff --git a/installing/env-installer/src/resolveConfigDeps.ts b/installing/env-installer/src/resolveConfigDeps.ts index ef8e76b8dc..0ab5d3736c 100644 --- a/installing/env-installer/src/resolveConfigDeps.ts +++ b/installing/env-installer/src/resolveConfigDeps.ts @@ -23,6 +23,10 @@ export type ResolveConfigDepsOpts = CreateFetchFromRegistryOptions & ResolverFac } export async function resolveConfigDeps (configDeps: string[], opts: ResolveConfigDepsOpts): Promise { + if (opts.frozenLockfile) { + throw new PnpmError('FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE', 'Cannot resolve configDependencies with "frozen-lockfile" because the lockfile is not up to date') + } + const fetch = createFetchFromRegistry(opts) const getAuthHeader = createGetAuthHeaderByURI({ allSettings: opts.userConfig!, userSettings: opts.userConfig }) const { resolveFromNpm } = createNpmResolver(fetch, getAuthHeader, opts) diff --git a/installing/env-installer/test/installConfigDeps.ts b/installing/env-installer/test/installConfigDeps.ts index 49c0514268..3b9a2db0de 100644 --- a/installing/env-installer/test/installConfigDeps.ts +++ b/installing/env-installer/test/installConfigDeps.ts @@ -139,6 +139,25 @@ test('migration: installs from old inline integrity format and creates env lockf expect((envLockfile.packages['@pnpm.e2e/foo@100.0.0'].resolution as { integrity: string }).integrity).toBe(integrity) }) +test('migration fails with frozenLockfile when no env lockfile exists', async () => { + prepareEmpty() + const { storeController, storeDir } = createTempStore() + + const integrity = getIntegrity('@pnpm.e2e/foo', '100.0.0') + const configDeps: Record = { + '@pnpm.e2e/foo': `100.0.0+${integrity}`, + } + await expect(installConfigDeps(configDeps, { + registries: { + default: registry, + }, + rootDir: process.cwd(), + store: storeController, + storeDir, + frozenLockfile: true, + })).rejects.toThrow('Cannot migrate configDependencies with "frozen-lockfile"') +}) + test('installation fails if the config dependency does not have a checksum (old format)', async () => { prepareEmpty() const { storeController, storeDir } = createTempStore({ diff --git a/installing/env-installer/test/resolveAndInstallConfigDeps.test.ts b/installing/env-installer/test/resolveAndInstallConfigDeps.test.ts index c1c2b65368..e63d6b26e5 100644 --- a/installing/env-installer/test/resolveAndInstallConfigDeps.test.ts +++ b/installing/env-installer/test/resolveAndInstallConfigDeps.test.ts @@ -201,3 +201,47 @@ test('handles mixed old-format and new-format config deps together', async () => expect(envLockfile!.packages['@pnpm.e2e/foo@100.0.0']).toBeDefined() expect(envLockfile!.packages['@pnpm.e2e/bar@100.0.0']).toBeDefined() }) + +test('fails with frozenLockfile when old-format deps need migration', async () => { + prepareEmpty() + const opts = createOpts() + + const integrity = getIntegrity('@pnpm.e2e/foo', '100.0.0') + await expect(resolveAndInstallConfigDeps({ + '@pnpm.e2e/foo': `100.0.0+${integrity}`, + }, { ...opts, frozenLockfile: true })).rejects.toThrow('Cannot update configDependencies with "frozen-lockfile"') +}) + +test('fails with frozenLockfile when new-format deps need resolution', async () => { + prepareEmpty() + const opts = createOpts() + + await expect(resolveAndInstallConfigDeps({ + '@pnpm.e2e/foo': '100.0.0', + }, { ...opts, frozenLockfile: true })).rejects.toThrow('Cannot update configDependencies with "frozen-lockfile"') +}) + +test('succeeds with frozenLockfile when env lockfile is up-to-date', async () => { + prepareEmpty() + const opts = createOpts() + + // Pre-create complete env lockfile + const lockfile = createEnvLockfile() + lockfile.importers['.'].configDependencies['@pnpm.e2e/foo'] = { + specifier: '100.0.0', + version: '100.0.0', + } + lockfile.packages['@pnpm.e2e/foo@100.0.0'] = { + resolution: { integrity: getIntegrity('@pnpm.e2e/foo', '100.0.0') }, + } + lockfile.snapshots['@pnpm.e2e/foo@100.0.0'] = {} + await writeEnvLockfile(process.cwd(), lockfile) + + await resolveAndInstallConfigDeps({ + '@pnpm.e2e/foo': '100.0.0', + }, { ...opts, frozenLockfile: true }) + + const manifest = loadJsonFileSync<{ name: string, version: string }>('node_modules/.pnpm-config/@pnpm.e2e/foo/package.json') + expect(manifest.name).toBe('@pnpm.e2e/foo') + expect(manifest.version).toBe('100.0.0') +}) diff --git a/installing/env-installer/test/resolveConfigDeps.test.ts b/installing/env-installer/test/resolveConfigDeps.test.ts index 2f30003aa7..a9c2888f0e 100644 --- a/installing/env-installer/test/resolveConfigDeps.test.ts +++ b/installing/env-installer/test/resolveConfigDeps.test.ts @@ -44,3 +44,20 @@ test('configuration dependency is resolved', async () => { }) expect(envLockfile!.snapshots['@pnpm.e2e/foo@100.0.0']).toStrictEqual({}) }) + +test('fails with frozenLockfile', async () => { + prepareEmpty() + const { storeController, storeDir } = createTempStore() + + await expect(resolveConfigDeps(['@pnpm.e2e/foo@100.0.0'], { + registries: { + default: registry, + }, + rootDir: process.cwd(), + cacheDir: path.resolve('cache'), + userConfig: {}, + store: storeController, + storeDir, + frozenLockfile: true, + })).rejects.toThrow('Cannot resolve configDependencies with "frozen-lockfile"') +}) diff --git a/pnpm/src/getConfig.ts b/pnpm/src/getConfig.ts index 1dca256678..a6f4caa30a 100644 --- a/pnpm/src/getConfig.ts +++ b/pnpm/src/getConfig.ts @@ -47,12 +47,17 @@ export async function getConfig ( export async function installConfigDepsAndLoadHooks (config: Config): Promise { if (config.configDependencies) { const store = await createStoreController(config) - await resolveAndInstallConfigDeps(config.configDependencies, { - ...config, - store: store.ctrl, - storeDir: store.dir, - rootDir: config.lockfileDir ?? config.rootProjectManifestDir, - }) + try { + await resolveAndInstallConfigDeps(config.configDependencies, { + ...config, + store: store.ctrl, + storeDir: store.dir, + rootDir: config.lockfileDir ?? config.rootProjectManifestDir, + frozenLockfile: config.frozenLockfile, + }) + } finally { + await store.ctrl.close() + } } if (!config.ignorePnpmfile) { config.tryLoadDefaultPnpmfile = config.pnpmfile == null diff --git a/pnpm/src/main.ts b/pnpm/src/main.ts index d2b89b0033..b3aa6fbc75 100644 --- a/pnpm/src/main.ts +++ b/pnpm/src/main.ts @@ -152,6 +152,7 @@ export async function main (inputArgv: string[]): Promise { const hint = err['hint'] ? err['hint'] : `For help, run: pnpm help${cmd ? ` ${cmd}` : ''}` printError(err.message, hint) process.exitCode = 1 + await finishWorkers() return } if (cmd == null && cliOptions.version) { diff --git a/pnpm/test/install/configDeps.ts b/pnpm/test/install/configDeps.ts new file mode 100644 index 0000000000..9dafe9f655 --- /dev/null +++ b/pnpm/test/install/configDeps.ts @@ -0,0 +1,48 @@ +import { readEnvLockfile } from '@pnpm/lockfile.fs' +import { prepare } from '@pnpm/prepare' +import { getIntegrity } from '@pnpm/registry-mock' +import { writeYamlFileSync } from 'write-yaml-file' + +import { execPnpm, execPnpmSync } from '../utils/index.js' + +test('installing configDependencies migrating to env lockfile', async () => { + prepare() + writeYamlFileSync('pnpm-workspace.yaml', { + configDependencies: { + '@pnpm.e2e/foo': '100.0.0+' + getIntegrity('@pnpm.e2e/foo', '100.0.0'), + }, + }) + + await execPnpm(['install']) + + const envLockfile = await readEnvLockfile(process.cwd()) + expect(envLockfile?.importers['.'].configDependencies['@pnpm.e2e/foo'].version).toBe('100.0.0') +}) + +test('installing configDependencies fails with --frozen-lockfile if env lockfile is missing', async () => { + prepare() + writeYamlFileSync('pnpm-workspace.yaml', { + configDependencies: { + '@pnpm.e2e/foo': '100.0.0+' + getIntegrity('@pnpm.e2e/foo', '100.0.0'), + }, + }) + + const result = execPnpmSync(['install', '--frozen-lockfile']) + expect(result.status).toBe(1) + expect(result.stderr.toString()).toContain('Cannot update configDependencies with "frozen-lockfile" because the lockfile is not up to date') +}) + +test('installing configDependencies succeeds with --frozen-lockfile if env lockfile is present and up-to-date', async () => { + prepare() + writeYamlFileSync('pnpm-workspace.yaml', { + configDependencies: { + '@pnpm.e2e/foo': '100.0.0+' + getIntegrity('@pnpm.e2e/foo', '100.0.0'), + }, + }) + + // First install to generate the env lockfile + await execPnpm(['install']) + + // Second install with frozen-lockfile should succeed + await execPnpm(['install', '--frozen-lockfile']) +})