diff --git a/.changeset/packageManager-no-lockfile.md b/.changeset/packageManager-no-lockfile.md new file mode 100644 index 0000000000..9c90a90ebd --- /dev/null +++ b/.changeset/packageManager-no-lockfile.md @@ -0,0 +1,7 @@ +--- +"@pnpm/config.reader": minor +"@pnpm/installing.env-installer": minor +"pnpm": minor +--- + +When pnpm is declared via the `packageManager` field in `package.json`, its resolution info is no longer written to `pnpm-lock.yaml` — unless the pinned pnpm version is v12 or newer. The `packageManagerDependencies` section is still populated (and reused across runs) when pnpm is declared via `devEngines.packageManager`. This makes the transition from pnpm v10 to v11 quieter by avoiding unnecessary lockfile churn for projects that pin an older pnpm in the legacy `packageManager` field. diff --git a/config/reader/src/Config.ts b/config/reader/src/Config.ts index be2e4f9a47..ec9232bbc0 100644 --- a/config/reader/src/Config.ts +++ b/config/reader/src/Config.ts @@ -43,7 +43,18 @@ export interface ConfigContext { name: string version: string } - wantedPackageManager?: EngineDependency + wantedPackageManager?: WantedPackageManager +} + +/** + * The package manager requested by the root project's manifest. + * Extends {@link EngineDependency} with the source of the declaration so that + * callers can treat the legacy `packageManager` field and + * `devEngines.packageManager` differently (e.g. only the latter persists + * resolved pnpm integrity info to `pnpm-lock.yaml`). + */ +export interface WantedPackageManager extends EngineDependency { + fromDevEngines?: boolean } /** diff --git a/config/reader/src/index.ts b/config/reader/src/index.ts index c8d15c0425..4326da7a50 100644 --- a/config/reader/src/index.ts +++ b/config/reader/src/index.ts @@ -32,6 +32,7 @@ import type { ProjectConfig, UniversalOptions, VerifyDepsBeforeRun, + WantedPackageManager, } from './Config.js' import { isConfigFileKey } from './configFileKey.js' import { extractAndRemoveDependencyBuildOptions, hasDependencyBuildOptions } from './dependencyBuildOptions.js' @@ -65,7 +66,7 @@ export { ProjectConfigsMatchItemIsNotAStringError, ProjectConfigUnsupportedFieldError, } from './projectConfig.js' -export type { Config, ConfigContext, ProjectConfig, UniversalOptions, VerifyDepsBeforeRun } +export type { Config, ConfigContext, ProjectConfig, UniversalOptions, VerifyDepsBeforeRun, WantedPackageManager } export { type ConfigFileKey, isConfigFileKey } from './configFileKey.js' export { isIniConfigKey, isNpmrcReadableKey } from './localConfig.js' @@ -658,7 +659,7 @@ function getProcessEnv (env: string): string | undefined { process.env[env.toLowerCase()] } -function getWantedPackageManager (manifest: ProjectManifest): { pm?: EngineDependency, warnings: string[] } { +function getWantedPackageManager (manifest: ProjectManifest): { pm?: WantedPackageManager, warnings: string[] } { const warnings: string[] = [] const pmFromDevEngines = parseDevEnginesPackageManager(manifest.devEngines) if (pmFromDevEngines) { @@ -669,7 +670,7 @@ function getWantedPackageManager (manifest: ProjectManifest): { pm?: EngineDepen if (manifest.packageManager) { warnings.push('Cannot use both "packageManager" and "devEngines.packageManager" in package.json. "packageManager" will be ignored') } - return { pm: pmFromDevEngines, warnings } + return { pm: { ...pmFromDevEngines, fromDevEngines: true }, warnings } } if (manifest.packageManager) { const pm = parsePackageManager(manifest.packageManager) diff --git a/installing/env-installer/src/resolvePackageManagerIntegrities.ts b/installing/env-installer/src/resolvePackageManagerIntegrities.ts index eedd3c50ed..d0a00e686c 100644 --- a/installing/env-installer/src/resolvePackageManagerIntegrities.ts +++ b/installing/env-installer/src/resolvePackageManagerIntegrities.ts @@ -13,6 +13,13 @@ export interface ResolvePackageManagerIntegritiesOpts { rootDir: string storeController: StoreController storeDir: string + /** + * Whether to read from and write to the env lockfile file on disk. + * When false, resolution happens purely in memory; callers can still use + * the returned `EnvLockfile` to perform installs without persisting the + * resolved pnpm integrity info. Defaults to true. + */ + save?: boolean } /** @@ -32,14 +39,17 @@ export function isPackageManagerResolved ( /** * Resolves integrity checksums for `pnpm`, `@pnpm/exe`, and their dependencies - * by calling resolveManifestDependencies. - * Writes the results to the `packageManagerDependencies` section of pnpm-lock.yaml. + * by calling resolveManifestDependencies. When `opts.save` is true (the + * default) the results are written to the `packageManagerDependencies` + * section of `pnpm-lock.yaml`; when false, resolution happens purely in + * memory and the returned `EnvLockfile` is never persisted to disk. */ export async function resolvePackageManagerIntegrities ( pnpmVersion: string, opts: ResolvePackageManagerIntegritiesOpts ): Promise { - const envLockfile = opts.envLockfile ?? (await readEnvLockfile(opts.rootDir)) ?? createEnvLockfile() + const save = opts.save ?? true + const envLockfile = opts.envLockfile ?? (save ? await readEnvLockfile(opts.rootDir) : undefined) ?? createEnvLockfile() if (isPackageManagerResolved(envLockfile, pnpmVersion)) { return envLockfile @@ -82,7 +92,9 @@ export async function resolvePackageManagerIntegrities ( envLockfile.packages = prunedFile.packages ?? {} envLockfile.snapshots = prunedFile.snapshots ?? {} - await writeEnvLockfile(opts.rootDir, envLockfile) + if (save) { + await writeEnvLockfile(opts.rootDir, envLockfile) + } } return envLockfile } diff --git a/pnpm/src/shouldPersistLockfile.test.ts b/pnpm/src/shouldPersistLockfile.test.ts new file mode 100644 index 0000000000..63d4ba2140 --- /dev/null +++ b/pnpm/src/shouldPersistLockfile.test.ts @@ -0,0 +1,32 @@ +import { shouldPersistLockfile } from './shouldPersistLockfile.js' + +describe('shouldPersistLockfile', () => { + test('devEngines.packageManager always persists, regardless of version', () => { + expect(shouldPersistLockfile({ version: '9.3.0', fromDevEngines: true })).toBe(true) + expect(shouldPersistLockfile({ version: '11.0.0', fromDevEngines: true })).toBe(true) + expect(shouldPersistLockfile({ version: '12.0.0', fromDevEngines: true })).toBe(true) + expect(shouldPersistLockfile({ version: '>=9.0.0', fromDevEngines: true })).toBe(true) + }) + + test('packageManager field with pnpm v11 or older does not persist', () => { + expect(shouldPersistLockfile({ version: '9.3.0' })).toBe(false) + expect(shouldPersistLockfile({ version: '10.0.0' })).toBe(false) + expect(shouldPersistLockfile({ version: '11.0.0' })).toBe(false) + expect(shouldPersistLockfile({ version: '11.0.0-rc.1' })).toBe(false) + }) + + test('packageManager field with pnpm v12 or newer persists', () => { + expect(shouldPersistLockfile({ version: '12.0.0' })).toBe(true) + expect(shouldPersistLockfile({ version: '12.5.3' })).toBe(true) + expect(shouldPersistLockfile({ version: '13.0.0' })).toBe(true) + expect(shouldPersistLockfile({ version: '100.0.0' })).toBe(true) + }) + + test('missing or invalid version does not persist', () => { + expect(shouldPersistLockfile({ version: undefined })).toBe(false) + expect(shouldPersistLockfile({ version: 'not-a-version' })).toBe(false) + // Ranges are not valid for the legacy packageManager field — its parser + // rejects them, but we still guard defensively here. + expect(shouldPersistLockfile({ version: '^12.0.0' })).toBe(false) + }) +}) diff --git a/pnpm/src/shouldPersistLockfile.ts b/pnpm/src/shouldPersistLockfile.ts new file mode 100644 index 0000000000..1fa3b94a0c --- /dev/null +++ b/pnpm/src/shouldPersistLockfile.ts @@ -0,0 +1,19 @@ +import type { WantedPackageManager } from '@pnpm/config.reader' +import semver from 'semver' + +/** + * Decides whether the resolved pnpm integrity info should be written to + * `pnpm-lock.yaml` under the project's `packageManagerDependencies` section. + * + * - `devEngines.packageManager` always persists (supports ranges / dist-tags + * that need pinning to be reproducible). + * - The legacy `packageManager` field only persists when the pinned version + * is pnpm v12 or newer. Older pins already contain an exact version in the + * manifest itself, so the lockfile entry would only add churn — and the + * quiet behavior keeps the v10 → v11 transition painless. + */ +export function shouldPersistLockfile (pm: Pick): boolean { + if (pm.fromDevEngines === true) return true + if (pm.version == null || semver.valid(pm.version) == null) return false + return semver.major(pm.version) >= 12 +} diff --git a/pnpm/src/switchCliVersion.ts b/pnpm/src/switchCliVersion.ts index 117a6d8cd5..c1e9bf63ce 100644 --- a/pnpm/src/switchCliVersion.ts +++ b/pnpm/src/switchCliVersion.ts @@ -12,11 +12,23 @@ import { createStoreController } from '@pnpm/store.connection-manager' import spawn from 'cross-spawn' import semver from 'semver' +import { shouldPersistLockfile } from './shouldPersistLockfile.js' + export async function switchCliVersion (config: Config, context: ConfigContext): Promise { const pm = context.wantedPackageManager if (pm == null || pm.name !== 'pnpm' || pm.version == null) return - let envLockfile = await readEnvLockfile(context.rootProjectManifestDir) ?? undefined + const persistLockfile = shouldPersistLockfile(pm) + + // In non-persist mode the env lockfile is intentionally not read, so there + // is no cached resolution to compare against. Since the legacy + // `packageManager` field always carries an exact version, we can skip both + // resolution and store access when the running CLI already matches. + if (!persistLockfile && pm.version === packageManager.version) return + + let envLockfile = persistLockfile + ? (await readEnvLockfile(context.rootProjectManifestDir) ?? undefined) + : undefined let storeToUse: Awaited> | undefined // Check if the env lockfile already has a resolved version that satisfies the wanted version/range. @@ -30,6 +42,7 @@ export async function switchCliVersion (config: Config, context: ConfigContext): rootDir: context.rootProjectManifestDir, storeController: storeToUse.ctrl, storeDir: storeToUse.dir, + save: persistLockfile, }) pmVersion = envLockfile.importers['.'].packageManagerDependencies?.['pnpm']?.version if (!pmVersion) { @@ -45,6 +58,7 @@ export async function switchCliVersion (config: Config, context: ConfigContext): rootDir: context.rootProjectManifestDir, storeController: storeToUse.ctrl, storeDir: storeToUse.dir, + save: persistLockfile, }) } diff --git a/pnpm/test/configurationalDependencies.test.ts b/pnpm/test/configurationalDependencies.test.ts index bff1a6d575..8e132b296b 100644 --- a/pnpm/test/configurationalDependencies.test.ts +++ b/pnpm/test/configurationalDependencies.test.ts @@ -122,7 +122,7 @@ test('config deps are installed after switching to a pnpm version that supports expect(fs.existsSync('node_modules/.pnpm-config/@pnpm.e2e/has-patch-for-foo')).toBeTruthy() }) -test('package manager is saved into the lockfile even if it matches the current version', async () => { +test('package manager from the packageManager field is not saved into the lockfile', async () => { const pnpmVersion = JSON.parse(fs.readFileSync(path.join(path.dirname(pnpmBinLocation), '..', 'package.json'), 'utf8')).version as string prepare({ packageManager: `pnpm@${pnpmVersion}`, @@ -135,18 +135,17 @@ test('package manager is saved into the lockfile even if it matches the current expect(fs.existsSync('node_modules/.pnpm-config/@pnpm.e2e/has-patch-for-foo')).toBeTruthy() - // The env lockfile should have both config dep and package manager entries + // The legacy packageManager field already pins an exact version in the + // manifest itself, so pnpm resolution info must not leak into the lockfile. + // Config dependencies are still persisted because they are managed by an + // independent code path. const envLockfile = await readEnvLockfile(process.cwd()) expect(envLockfile).not.toBeNull() expect(envLockfile!.importers['.'].configDependencies['@pnpm.e2e/has-patch-for-foo']).toStrictEqual({ specifier: '1.0.0', version: '1.0.0', }) - expect(envLockfile!.importers['.'].packageManagerDependencies).toBeDefined() - expect(envLockfile!.importers['.'].packageManagerDependencies!['pnpm']).toStrictEqual({ - specifier: pnpmVersion, - version: pnpmVersion, - }) + expect(envLockfile!.importers['.'].packageManagerDependencies).toBeUndefined() }) test('installing a new configurational dependency', async () => { diff --git a/pnpm/test/switchingVersions.test.ts b/pnpm/test/switchingVersions.test.ts index 027ba624c9..8ba9b3af06 100644 --- a/pnpm/test/switchingVersions.test.ts +++ b/pnpm/test/switchingVersions.test.ts @@ -21,6 +21,23 @@ test('switch to the pnpm version specified in the packageManager field of packag expect(stdout.toString()).toContain('Version 9.3.0') }) +test('packageManager field does not write pnpm resolution info to pnpm-lock.yaml', async () => { + prepare() + const pnpmHome = path.resolve('pnpm') + const env = { PNPM_HOME: pnpmHome } + writeJsonFileSync('package.json', { + packageManager: 'pnpm@9.3.0', + }) + + const { stdout } = execPnpmSync(['help'], { env }) + expect(stdout.toString()).toContain('Version 9.3.0') + + // The legacy packageManager field already pins an exact version in the + // manifest itself, so pnpm resolution info must not leak into the lockfile. + // This keeps the v10 -> v11 transition quiet. + expect(fs.existsSync('pnpm-lock.yaml')).toBe(false) +}) + test('do not switch to the pnpm version specified in the packageManager field of package.json, if pmOnFail is set to ignore', async () => { prepare() const pnpmHome = path.resolve('pnpm')