mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-01 12:41:16 -04:00
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:
7
.changeset/global-minimum-release-age-policy.md
Normal file
7
.changeset/global-minimum-release-age-policy.md
Normal 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.
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
26
installing/commands/src/resolutionPolicyManifest.ts
Normal file
26
installing/commands/src/resolutionPolicyManifest.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user