fix: handle minimumReleaseAge policy violations in global installs (#11753)

* fix: handle release-age policy in global installs

* refactor: dedupe global policy-callback wiring

Collapse setupPolicyHandlers + createResolutionPolicyManifestUpdater into
one createGlobalPolicyCallbacks helper used by both global add and global
update entry points.

---
Written by an agent (Claude Code, claude-opus-4-7).

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Santiago
2026-05-20 00:31:54 +02:00
committed by GitHub
parent 9cb48bb27b
commit a62055786b
8 changed files with 178 additions and 9 deletions

View File

@@ -0,0 +1,7 @@
---
"@pnpm/global.commands": patch
"@pnpm/installing.commands": patch
"pnpm": patch
---
Fix global add/update to handle minimumReleaseAge policy violations instead of surfacing an internal resolver guardrail error.

View File

@@ -19,7 +19,7 @@ import { isSubdir } from 'is-subdir'
import { symlinkDir } from 'symlink-dir'
import { checkGlobalBinConflicts } from './checkGlobalBinConflicts.js'
import { installGlobalPackages } from './installGlobalPackages.js'
import { installGlobalPackages, type ResolutionPolicyViolation } from './installGlobalPackages.js'
import { promptApproveGlobalBuilds } from './promptApproveGlobalBuilds.js'
import { readInstalledPackages } from './readInstalledPackages.js'
@@ -33,6 +33,8 @@ export type GlobalAddOptions = CreateStoreControllerOptions & {
savePrefix?: string
supportedArchitectures?: { libc?: string[] }
rootProjectManifest?: unknown
handleResolutionPolicyViolations?: (violations: readonly ResolutionPolicyViolation[]) => Promise<void>
updateResolutionPolicyManifest?: (violations: readonly ResolutionPolicyViolation[], dir: string) => Promise<void>
}
export async function handleGlobalAdd (
@@ -124,7 +126,7 @@ async function installGroup (
omitSummaryLog: true,
}
const ignoredBuilds = await installGlobalPackages(installOpts, params)
const { ignoredBuilds, resolutionPolicyViolations } = await installGlobalPackages(installOpts, params)
await promptApproveGlobalBuilds({
globalPkgDir: globalDir,
@@ -167,6 +169,7 @@ async function installGroup (
// Link bins from installed packages into global bin dir
await linkBinsOfPackages(pkgs, globalBinDir, { excludeBins: binsToSkip })
await opts.updateResolutionPolicyManifest?.(resolutionPolicyViolations, globalDir)
}
function splitCommaSeparated (param: string, baseDir: string): string[] {

View File

@@ -17,7 +17,7 @@ import { isSubdir } from 'is-subdir'
import { symlinkDir } from 'symlink-dir'
import { checkGlobalBinConflicts } from './checkGlobalBinConflicts.js'
import { installGlobalPackages } from './installGlobalPackages.js'
import { installGlobalPackages, type ResolutionPolicyViolation } from './installGlobalPackages.js'
import { promptApproveGlobalBuilds } from './promptApproveGlobalBuilds.js'
import { readInstalledPackages } from './readInstalledPackages.js'
@@ -30,6 +30,8 @@ export type GlobalUpdateOptions = CreateStoreControllerOptions & {
savePrefix?: string
supportedArchitectures?: { libc?: string[] }
rootProjectManifest?: unknown
handleResolutionPolicyViolations?: (violations: readonly ResolutionPolicyViolation[]) => Promise<void>
updateResolutionPolicyManifest?: (violations: readonly ResolutionPolicyViolation[], dir: string) => Promise<void>
}
export async function handleGlobalUpdate (
@@ -90,7 +92,7 @@ async function updateGlobalPackageGroup (
const fetchFullMetadata = opts.supportedArchitectures?.libc != null && true
const allowBuilds = opts.allowBuilds ?? {}
const ignoredBuilds = await installGlobalPackages({
const { ignoredBuilds, resolutionPolicyViolations } = await installGlobalPackages({
...opts,
global: false,
bin: path.join(installDir, 'node_modules/.bin'),
@@ -148,4 +150,5 @@ async function updateGlobalPackageGroup (
// Link bins from new installation
await linkBinsOfPackages(pkgs, globalBinDir, { excludeBins: binsToSkip })
await opts.updateResolutionPolicyManifest?.(resolutionPolicyViolations, globalDir)
}

View File

@@ -3,6 +3,18 @@ import { mutateModulesInSingleProject } from '@pnpm/installing.deps-installer'
import { createStoreController, type CreateStoreControllerOptions } from '@pnpm/store.connection-manager'
import type { IgnoredBuilds, IncludedDependencies, ProjectRootDir } from '@pnpm/types'
export interface ResolutionPolicyViolation {
name: string
version: string
code: string
reason: string
}
export interface InstallGlobalPackagesResult {
ignoredBuilds: IgnoredBuilds | undefined
resolutionPolicyViolations: ResolutionPolicyViolation[]
}
export interface InstallGlobalPackagesOptions extends CreateStoreControllerOptions {
bin: string
dir: string
@@ -24,12 +36,13 @@ export interface InstallGlobalPackagesOptions extends CreateStoreControllerOptio
saveProd?: boolean
sharedWorkspaceLockfile?: boolean
workspaceDir?: string
handleResolutionPolicyViolations?: (violations: readonly ResolutionPolicyViolation[]) => Promise<void>
}
export async function installGlobalPackages (
opts: InstallGlobalPackagesOptions,
params: string[]
): Promise<IgnoredBuilds | undefined> {
): Promise<InstallGlobalPackagesResult> {
const store = await createStoreController(opts)
let { manifest, writeProjectManifest } = await tryReadProjectManifest(opts.dir, opts)
if (manifest == null) {
@@ -42,7 +55,7 @@ export async function installGlobalPackages (
storeDir: store.dir,
}
const pinnedVersion = opts.saveExact ? 'patch' : (opts.savePrefix === '~' ? 'minor' : 'major')
const { updatedProject, ignoredBuilds } = await mutateModulesInSingleProject(
const { updatedProject, ignoredBuilds, resolutionPolicyViolations } = await mutateModulesInSingleProject(
{
allowNew: true,
binsDir: opts.bin,
@@ -57,5 +70,5 @@ export async function installGlobalPackages (
installOpts
)
await writeProjectManifest(updatedProject.manifest)
return ignoredBuilds
return { ignoredBuilds, resolutionPolicyViolations }
}

View File

@@ -13,6 +13,7 @@ import { renderHelp } from 'render-help'
import { getFetchFullMetadata } from './getFetchFullMetadata.js'
import type { InstallCommandOptions } from './install.js'
import { installDeps } from './installDeps.js'
import { createGlobalPolicyCallbacks } from './resolutionPolicyManifest.js'
export const shorthands: Record<string, string> = {
'save-catalog': '--save-catalog-name=default',
@@ -257,7 +258,10 @@ export async function handler (
if (params.includes('pnpm') || params.includes('@pnpm/exe')) {
throw new PnpmError('GLOBAL_PNPM_INSTALL', 'Use the "pnpm self-update" command to install or update pnpm')
}
return handleGlobalAdd(opts, params, commands ?? {})
return handleGlobalAdd({
...opts,
...createGlobalPolicyCallbacks(opts),
}, params, commands ?? {})
}
const include = {

View File

@@ -0,0 +1,26 @@
import { updateWorkspaceManifest } from '@pnpm/workspace.workspace-manifest-writer'
import {
type PolicyHandlersOptions,
type PolicyViolation,
setupPolicyHandlers,
} from './policyHandlers.js'
export interface GlobalPolicyCallbacks {
handleResolutionPolicyViolations?: (violations: readonly PolicyViolation[]) => Promise<void>
updateResolutionPolicyManifest?: (violations: readonly PolicyViolation[], dir: string) => Promise<void>
}
export function createGlobalPolicyCallbacks (opts: PolicyHandlersOptions): GlobalPolicyCallbacks {
const policyHandlers = setupPolicyHandlers(opts)
if (policyHandlers == null) return {}
return {
handleResolutionPolicyViolations: policyHandlers.handleResolutionPolicyViolations,
updateResolutionPolicyManifest: async (violations, dir) => {
const policyUpdates = policyHandlers.pickManifestUpdates(violations)
if (policyUpdates != null) {
await updateWorkspaceManifest(dir, policyUpdates)
}
},
}
}

View File

@@ -21,6 +21,7 @@ import { renderHelp } from 'render-help'
import type { InstallCommandOptions } from '../install.js'
import { installDeps } from '../installDeps.js'
import { parseUpdateParam } from '../recursive.js'
import { createGlobalPolicyCallbacks } from '../resolutionPolicyManifest.js'
import { type ChoiceRow, getUpdateChoices } from './getUpdateChoices.js'
export function rcOptionsTypes (): Record<string, unknown> {
return pick([
@@ -178,7 +179,10 @@ export async function handler (
hint: 'Run "pnpm setup" to create it automatically, or set the global-bin-dir setting, or the PNPM_HOME env variable. The global bin directory should be in the PATH.',
})
}
return handleGlobalUpdate(opts, params, commands ?? {})
return handleGlobalUpdate({
...opts,
...createGlobalPolicyCallbacks(opts),
}, params, commands ?? {})
}
const rebuildHandler = commands?.rebuild
if (opts.interactive) {

View File

@@ -7,6 +7,7 @@ import { prepare } from '@pnpm/prepare'
import type { ProjectManifest } from '@pnpm/types'
import isWindows from 'is-windows'
import PATH_NAME from 'path-name'
import { readYamlFileSync } from 'read-yaml-file'
import { writeYamlFileSync } from 'write-yaml-file'
import {
@@ -15,6 +16,9 @@ import {
execPnpmSync,
} from '../utils/index.js'
const PUBLIC_REGISTRY = '--config.registry=https://registry.npmjs.org/'
const IMMATURE_FOR_EVERYTHING = 1_000_000_000
function globalPkgDir (pnpmHome: string): string {
return path.join(pnpmHome, 'global', GLOBAL_LAYOUT_VERSION)
}
@@ -346,6 +350,111 @@ test('global update should not crash if there are no global packages', async ()
expect(execPnpmSync(['update', '--global'], { env }).status).toBe(0)
})
test('global add in loose minimumReleaseAge mode persists immature picks', () => {
prepare()
const global = path.resolve('..', 'global')
const pnpmHome = path.join(global, 'pnpm')
fs.mkdirSync(pnpmHome, { recursive: true })
const globalDir = globalPkgDir(pnpmHome)
const env = {
[PATH_NAME]: path.join(pnpmHome, 'bin'),
PNPM_HOME: pnpmHome,
XDG_DATA_HOME: global,
pnpm_config_minimum_release_age: String(IMMATURE_FOR_EVERYTHING),
pnpm_config_minimum_release_age_strict: 'false',
}
execPnpmSync([
'add',
'--global',
'is-positive@1.0.0',
PUBLIC_REGISTRY,
'--ignore-scripts',
], {
env,
omitEnvDefaults: ['pnpm_config_minimum_release_age'],
expectSuccess: true,
})
expect(findGlobalPkg(globalDir, 'is-positive')).toBeTruthy()
const workspaceManifest = readYamlFileSync<{ minimumReleaseAgeExclude?: string[] }>(path.join(globalDir, 'pnpm-workspace.yaml'))
expect(workspaceManifest.minimumReleaseAgeExclude).toContain('is-positive@1.0.0')
})
test('global add in strict minimumReleaseAge mode reports the user-facing error', () => {
prepare()
const global = path.resolve('..', 'global')
const pnpmHome = path.join(global, 'pnpm')
fs.mkdirSync(pnpmHome, { recursive: true })
const env = {
[PATH_NAME]: path.join(pnpmHome, 'bin'),
PNPM_HOME: pnpmHome,
XDG_DATA_HOME: global,
pnpm_config_minimum_release_age: String(IMMATURE_FOR_EVERYTHING),
pnpm_config_minimum_release_age_strict: 'true',
}
const result = execPnpmSync([
'add',
'--global',
'is-positive@1.0.0',
PUBLIC_REGISTRY,
'--ignore-scripts',
], {
env,
omitEnvDefaults: ['pnpm_config_minimum_release_age'],
})
const output = `${result.stdout.toString()}\n${result.stderr.toString()}`
expect(result.status).toBe(1)
expect(output).toContain('ERR_PNPM_NO_MATURE_MATCHING_VERSION')
expect(output).not.toContain('ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED')
})
test('global update in loose minimumReleaseAge mode persists immature picks', () => {
prepare()
const global = path.resolve('..', 'global')
const pnpmHome = path.join(global, 'pnpm')
fs.mkdirSync(pnpmHome, { recursive: true })
const globalDir = globalPkgDir(pnpmHome)
const env = {
[PATH_NAME]: path.join(pnpmHome, 'bin'),
PNPM_HOME: pnpmHome,
XDG_DATA_HOME: global,
}
execPnpmSync([
'add',
'--global',
'is-positive@1.0.0',
PUBLIC_REGISTRY,
'--ignore-scripts',
], { env, expectSuccess: true })
execPnpmSync([
'update',
'--global',
'--latest',
PUBLIC_REGISTRY,
'--ignore-scripts',
], {
env: {
...env,
pnpm_config_minimum_release_age: String(IMMATURE_FOR_EVERYTHING),
pnpm_config_minimum_release_age_strict: 'false',
},
omitEnvDefaults: ['pnpm_config_minimum_release_age'],
expectSuccess: true,
})
const workspaceManifest = readYamlFileSync<{ minimumReleaseAgeExclude?: string[] }>(path.join(globalDir, 'pnpm-workspace.yaml'))
expect(workspaceManifest.minimumReleaseAgeExclude).toEqual(expect.arrayContaining([
expect.stringMatching(/^is-positive@/),
]))
})
test('global add cleans up stale bins when re-adding a package with different bins', async () => {
prepare()
const global = path.resolve('..', 'global')