fix: refresh ignored builds when allowBuilds changes (#11366)

* fix: refresh ignored builds when allowBuilds changes

* refactor: extract isBuildExplicitlyDisallowed into @pnpm/building.policy

Removes the duplicated ignored-build filter from deps-installer and
deps-restorer and exposes it as `isBuildExplicitlyDisallowed` on
`@pnpm/building.policy`, alongside `createAllowBuildFunction`.

* fix: respect ignoredWorkspaceStateSettings in allowBuilds stale-state check

The fallback that flagged installs when allowBuilds went from unset to
non-empty bypassed the ignoredSettings filter, so callers that explicitly
opted out of allowBuilds tracking (via ignoredWorkspaceStateSettings)
could still be forced into a redundant install.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
palkim
2026-05-05 10:17:11 +09:00
committed by Zoltan Kochan
parent 159b4c0fb4
commit ab6c42d99e
13 changed files with 163 additions and 7 deletions

View File

@@ -0,0 +1,10 @@
---
"@pnpm/building.policy": patch
"@pnpm/installing.deps-installer": patch
"@pnpm/installing.deps-restorer": patch
"@pnpm/deps.status": patch
"@pnpm/workspace.state": patch
"pnpm": patch
---
Treat `allowBuilds` as an install-state input and clear previously ignored builds when they are explicitly disallowed.

View File

@@ -32,6 +32,7 @@
},
"dependencies": {
"@pnpm/config.version-policy": "workspace:*",
"@pnpm/deps.path": "workspace:*",
"@pnpm/types": "workspace:*"
},
"devDependencies": {

View File

@@ -1,5 +1,13 @@
import { expandPackageVersionSpecs } from '@pnpm/config.version-policy'
import type { AllowBuild } from '@pnpm/types'
import * as dp from '@pnpm/deps.path'
import type { AllowBuild, DepPath } from '@pnpm/types'
export function isBuildExplicitlyDisallowed (depPath: DepPath, allowBuild?: AllowBuild): boolean {
if (!allowBuild) return false
const { name, version } = dp.parse(depPath)
if (!name || !version) return false
return allowBuild(name, version) === false
}
export function createAllowBuildFunction (
opts: {

View File

@@ -1,5 +1,6 @@
import { expect, it } from '@jest/globals'
import { createAllowBuildFunction } from '@pnpm/building.policy'
import { createAllowBuildFunction, isBuildExplicitlyDisallowed } from '@pnpm/building.policy'
import type { DepPath } from '@pnpm/types'
it('should allowBuilds with true value', () => {
const allowBuild = createAllowBuildFunction({
@@ -42,3 +43,21 @@ it('should allow everything when dangerouslyAllowAllBuilds is true', () => {
expect(typeof allowBuild).toBe('function')
expect(allowBuild!('foo', '1.0.0')).toBeTruthy()
})
it('isBuildExplicitlyDisallowed() flags only builds the policy explicitly forbids', () => {
const allowBuild = createAllowBuildFunction({
allowBuilds: { foo: false, bar: true },
})
expect(isBuildExplicitlyDisallowed('foo@1.0.0' as DepPath, allowBuild)).toBe(true)
expect(isBuildExplicitlyDisallowed('bar@1.0.0' as DepPath, allowBuild)).toBe(false)
expect(isBuildExplicitlyDisallowed('baz@1.0.0' as DepPath, allowBuild)).toBe(false)
})
it('isBuildExplicitlyDisallowed() returns false when no policy is set', () => {
expect(isBuildExplicitlyDisallowed('foo@1.0.0' as DepPath, undefined)).toBe(false)
})
it('isBuildExplicitlyDisallowed() returns false for unparsable depPaths', () => {
const allowBuild = createAllowBuildFunction({ allowBuilds: { foo: false } })
expect(isBuildExplicitlyDisallowed('not-a-valid-dep-path' as DepPath, allowBuild)).toBe(false)
})

View File

@@ -14,6 +14,9 @@
},
{
"path": "../../core/types"
},
{
"path": "../../deps/path"
}
]
}

View File

@@ -134,7 +134,10 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W
ignoredSettings.add('catalogs') // 'catalogs' is always ignored
for (const [settingName, settingValue] of Object.entries(workspaceState.settings)) {
if (ignoredSettings.has(settingName as keyof WorkspaceStateSettings)) continue
if (!equals(settingValue, opts[settingName as keyof WorkspaceStateSettings])) {
const currentSettingValue = settingName === 'allowBuilds'
? opts.allowBuilds ?? {}
: opts[settingName as keyof WorkspaceStateSettings]
if (!equals(settingValue, currentSettingValue)) {
return {
upToDate: false,
issue: `The value of the ${settingName} setting has changed`,
@@ -142,6 +145,18 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W
}
}
}
if (
!ignoredSettings.has('allowBuilds') &&
workspaceState.settings.allowBuilds == null &&
opts.allowBuilds != null &&
!isEmpty(opts.allowBuilds)
) {
return {
upToDate: false,
issue: 'The value of the allowBuilds setting has changed',
workspaceState,
}
}
}
if ((opts.configDependencies != null || workspaceState.configDependencies != null) && !equals(opts.configDependencies ?? {}, workspaceState.configDependencies ?? {})) {
return {

View File

@@ -197,6 +197,64 @@ describe('checkDepsStatus - settings change detection', () => {
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The value of the peersSuffixMaxLength setting has changed')
})
it('returns upToDate: false when allowBuilds have changed', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
...mockWorkspaceState.settings,
allowBuilds: { sqlite3: false },
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The value of the allowBuilds setting has changed')
})
it('skips the allowBuilds change detection when allowBuilds is in ignoredWorkspaceStateSettings', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
...mockWorkspaceState.settings,
allowBuilds: { sqlite3: false },
ignoredWorkspaceStateSettings: ['allowBuilds'],
}
const result = await checkDepsStatus(opts)
expect(result.issue).not.toBe('The value of the allowBuilds setting has changed')
})
})
describe('checkDepsStatus - pnpmfile modification', () => {

View File

@@ -3,7 +3,7 @@ import path from 'node:path'
import { linkBins, linkBinsOfPackages } from '@pnpm/bins.linker'
import { buildSelectedPkgs } from '@pnpm/building.after-install'
import { buildModules, type DepsStateCache, linkBinsOfDependencies } from '@pnpm/building.during-install'
import { createAllowBuildFunction } from '@pnpm/building.policy'
import { createAllowBuildFunction, isBuildExplicitlyDisallowed } from '@pnpm/building.policy'
import { parseCatalogProtocol } from '@pnpm/catalogs.protocol-parser'
import { type CatalogResultMatcher, matchCatalogResolveResult, resolveFromCatalog } from '@pnpm/catalogs.resolver'
import type { Catalogs } from '@pnpm/catalogs.types'
@@ -1494,7 +1494,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
if (ctx.modulesFile?.ignoredBuilds?.size) {
ignoredBuilds ??= new Set()
for (const ignoredBuild of ctx.modulesFile.ignoredBuilds.values()) {
if (result.currentLockfile.packages?.[ignoredBuild]) {
if (result.currentLockfile.packages?.[ignoredBuild] && !isBuildExplicitlyDisallowed(ignoredBuild, opts.allowBuild)) {
ignoredBuilds.add(ignoredBuild)
}
}

View File

@@ -3,7 +3,7 @@ import path from 'node:path'
import { linkBins, linkBinsOfPackages } from '@pnpm/bins.linker'
import { buildModules } from '@pnpm/building.during-install'
import { createAllowBuildFunction } from '@pnpm/building.policy'
import { createAllowBuildFunction, isBuildExplicitlyDisallowed } from '@pnpm/building.policy'
import {
LAYOUT_VERSION,
WANTED_LOCKFILE,
@@ -594,7 +594,7 @@ export async function headlessInstall (opts: HeadlessOptions): Promise<Installat
if (opts.modulesFile?.ignoredBuilds?.size) {
ignoredBuilds ??= new Set()
for (const ignoredBuild of opts.modulesFile.ignoredBuilds.values()) {
if (filteredLockfile.packages?.[ignoredBuild]) {
if (filteredLockfile.packages?.[ignoredBuild] && !isBuildExplicitlyDisallowed(ignoredBuild, allowBuild)) {
ignoredBuilds.add(ignoredBuild)
}
}

3
pnpm-lock.yaml generated
View File

@@ -2016,6 +2016,9 @@ importers:
'@pnpm/config.version-policy':
specifier: workspace:*
version: link:../../config/version-policy
'@pnpm/deps.path':
specifier: workspace:*
version: link:../../deps/path
'@pnpm/types':
specifier: workspace:*
version: link:../../core/types

View File

@@ -242,6 +242,38 @@ test('throw an error when strict-dep-builds is true and there are ignored script
})
})
test('allowBuilds false resolves a strict ignored-build failure on repeat install', async () => {
const project = prepare({})
writeYamlFileSync('pnpm-workspace.yaml', {
optimisticRepeatInstall: true,
strictDepBuilds: true,
})
const firstResult = execPnpmSync(['add', '@pnpm.e2e/pre-and-postinstall-scripts-example@1.0.0'])
expect(firstResult.status).toBe(1)
expect(firstResult.stdout.toString()).toContain('Ignored build scripts:')
expect(fs.existsSync('node_modules/.modules.yaml')).toBeTruthy()
writeYamlFileSync('pnpm-workspace.yaml', {
allowBuilds: {
'@pnpm.e2e/pre-and-postinstall-scripts-example': false,
},
optimisticRepeatInstall: true,
strictDepBuilds: true,
})
const secondResult = execPnpmSync(['install'])
expect(secondResult.status).toBe(0)
expect(secondResult.stdout.toString()).not.toContain('Ignored build scripts:')
const modulesManifest = project.readModulesManifest()
expect(modulesManifest?.allowBuilds).toStrictEqual({
'@pnpm.e2e/pre-and-postinstall-scripts-example': false,
})
expect(Array.from(modulesManifest?.ignoredBuilds ?? [])).toStrictEqual([])
})
test('the list of ignored builds is preserved after a repeat install', async () => {
const project = prepare({})
execPnpmSync(['add', '@pnpm.e2e/pre-and-postinstall-scripts-example@1.0.0', 'esbuild@0.25.0', '--config.optimistic-repeat-install=false'])

View File

@@ -16,6 +16,7 @@ export interface WorkspaceState {
}
export const WORKSPACE_STATE_SETTING_KEYS = [
'allowBuilds',
'autoInstallPeers',
'catalogs',
'dedupeDirectDeps',

View File

@@ -38,6 +38,9 @@ test('createWorkspaceState() saves lockfile-affecting settings', () => {
pnpmfiles: [],
filteredInstall: false,
settings: {
allowBuilds: {
'@pnpm.e2e/pre-and-postinstall-scripts-example': false,
},
autoInstallPeers: true,
dedupeDirectDeps: true,
excludeLinksFromLockfile: false,
@@ -58,6 +61,9 @@ test('createWorkspaceState() saves lockfile-affecting settings', () => {
},
})
expect(state.settings.allowBuilds).toStrictEqual({
'@pnpm.e2e/pre-and-postinstall-scripts-example': false,
})
expect(state.settings.overrides).toStrictEqual({ foo: '1.0.0' })
expect(state.settings.packageExtensions).toStrictEqual({
bar: { dependencies: { baz: '2.0.0' } },