fix: check allowBuild for packages with cached side-effects#11039
fix: check allowBuild for packages with cached side-effects#11039
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes a strictDepBuilds/allowBuilds enforcement gap where previously-built (side-effects cached) dependencies could bypass the allowlist check on subsequent installs, by re-detecting revoked approvals after the install completes.
Changes:
- Add post-install detection in
mutateModules()to mark previously-approved packages asignoredBuildswhen approval is revoked. - Add an e2e regression test covering the cached-side-effects approval revocation case (#11035).
- Document that pnpm CLI e2e tests run against the bundled
pnpm/dist/pnpm.mjs, requiring a bundle rebuild before running them.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
installing/deps-installer/src/install/index.ts |
Adds logic to detect revoked build approvals and append affected packages to ignoredBuilds. |
pnpm/test/install/lifecycleScripts.ts |
Adds an e2e test to assert strictDepBuilds fails after removing prior build approval with cached side-effects present. |
AGENTS.md |
Adds guidance about rebuilding the pnpm bundle before running CLI e2e tests. |
.changeset/fix-cached-builds-bypass-allowbuilds.md |
Adds a changeset describing the behavior fix for strictDepBuilds/allowBuilds. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
When `enableGlobalVirtualStore` is true and `allowBuilds` is not
configured, GVS hashes incorrectly included ENGINE_NAME for all
packages. This meant that approving builds later wouldn't change
the hash, so symlinks were never updated and build artifacts ended
up in the wrong directory.
Root cause: `createAllowBuildFunction()` returned `undefined` when
`allowBuilds` was not configured, causing `builtDepPaths` to be
`undefined` and `includeEngine` to be `true` for all packages.
Fix:
- Default `allowBuilds` to `{}` in GVS mode (in extendInstallOptions
and headlessInstall) so hashes are engine-agnostic by default
- In approve-builds handler, call `install()` from deps-installer
in GVS mode (with frozenLockfile) instead of rebuild, so the
headless install detects the allowBuilds change and re-links
- Break circular dependency: replace rebuild.handler() call in
installing/commands with direct buildProjects() from
building/after-install
Closes #11042
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Workspace roots may not have a package.json. Use the manifest already available from config rather than reading from disk. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Pass dir, lockfileDir, modulesDir to install() for correct context - Throw descriptive error when package.json can't be read - Add deps-installer and deps-restorer to changeset Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous change only passed the root project manifest, which meant workspace project build scripts didn't run. Now passes all workspace projects, matching the original recursiveRebuild behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Revert to single-project buildProjects call matching original
rebuild.handler() behavior. Use rootProjectManifest from config
(falls back to {} for workspace roots without package.json).
Passing all workspace projects caused wrong build order and
overwrote per-project module settings like hoistPattern.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…attern Use allProjects for correct workspace builds (topological ordering, all importers walked). Pass hoistPattern/publicHoistPattern as undefined so buildProjects doesn't overwrite per-project module settings that were configured during the install phase. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sort projects via selectedProjectsGraph to assign correct buildIndex per topological chunk. Destructure out hoistPattern/publicHoistPattern from opts to prevent buildProjects from overwriting per-project module settings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move rebuild handler and recursive rebuild to a new package to break the circular dependency between installing/commands and building/commands. Now: - installing/commands depends on building.rebuild-command (not building.commands) - building/commands re-exports from building.rebuild-command - building/commands can depend on installing.deps-installer for GVS Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Only default allowBuilds to {} in extendInstallOptions (the install()
entry point), not in headlessInstall itself. Direct callers like
installPnpm pass enableGlobalVirtualStore: true without allowBuilds
and expect engine-aware hashes. Defaulting in headlessInstall changed
those hashes and broke pnpm version switching.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix grammar in --pending help text ("were not built")
- Use path.join for package.json path construction
- Add @pnpm/building.rebuild-command to changeset
- Add README.md for rebuild-command package
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add the default in both the config reader (for CLI installs) and extendInstallOptions (for programmatic install() calls). This ensures GVS hashes are engine-agnostic by default regardless of entry point. headlessInstall is intentionally not changed since direct callers like installPnpm need engine-aware hashes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Default allowBuilds to {} in headlessInstall, extendInstallOptions,
and the config reader when GVS is enabled
- Fix installPnpm to pass allowBuilds: { '@pnpm/exe': true } to both
findPnpmGvsPath and installFromLockfile so hash computation is
consistent (exe has platform-specific binaries needing engine hashes)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ensures add, update, and dedupe handlers forward the rebuild command so recursive installs trigger the rebuild step. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…mistic install The optimistic repeat install check in installDeps would bail out with "Already up to date" since the lockfile hasn't changed, causing the GVS reinstall to be skipped entirely. Setting optimisticRepeatInstall: false ensures the install actually runs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Required params are not assignable to the Command type which has commands as optional. Keep it optional in the signature — the CLI always provides it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Packages with build side-effects cached in the store had isBuilt: true, causing buildModules to skip them entirely. The allowBuild check was never reached, so they didn't appear in ignoredBuilds and strictDepBuilds wouldn't fail. Now check allowBuild for all packages with requiresBuild that are already built, adding unapproved ones to ignoredBuilds even though they don't need rebuilding. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a package's build approval is revoked (removed from allowBuilds) after its side-effects were cached in the store, buildModules skips it (isBuilt: true) and the allowBuild check is never reached. Fix: in mutateModules, compare the previous allowBuilds (from .modules.yaml) with the current config. Packages that were previously approved but are no longer get added to ignoredBuilds, so strictDepBuilds fails and the warning is shown. This works across all install paths (fresh, repeat, headless) because it operates after _install() returns, at the mutateModules level. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use env override for store dir (CLI args don't override the env var set by execPnpmSync). Remove debug assertion. Verified: test passes with fix, fails without it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Write storeDir and other settings to pnpm-workspace.yaml in the test instead of passing via CLI args or env overrides. Simpler and more explicit. Revert the unused storeDir option fix in execPnpmSync since it's no longer needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
E2e tests use the bundled dist/pnpm.mjs, not individual package lib/ outputs. Document that pnpm --filter pnpm run compile must be run after changing any package before running e2e tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use the same allowBuild function comparison as the rest of the codebase, which handles version-specific entries, ranges, and dangerouslyAllowAllBuilds correctly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Only scan lockfile packages when there are actually revoked names (uncommon). Build the set of revoked names first from the small allowBuilds config, then scan the lockfile only if needed. Also fix comment to accurately describe behavior and simplify the check to use opts.allowBuilds directly instead of createAllowBuildFunction. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Packages with build side-effects cached in the store had isBuilt: true, causing buildModules to filter them out before the allowBuild check. Now checks allowBuild for all requiresBuild packages with isBuilt: true in a pre-loop, adding unapproved ones to ignoredBuilds. This fixes the case where a package like esbuild was built in a previous project and its side-effects are in the store — a new project that hasn't approved it now correctly reports it as ignored. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use createAllowBuildFunction for both old and current allowBuilds to correctly handle version-specific entries (e.g. pkg@1.0.0) - Only add to ignoredBuilds when current result is undefined (undecided), not false (explicitly denied), consistent with buildModules behavior - Short-circuit when no previous allowBuilds entries were true Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Print 'Done! Approved: pkg1, pkg2' after rebuild completes so the user gets feedback even when the rebuild produces no visible output. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When _rebuild skips a package because its side-effects are already cached in the store, log 'pkg@version: reused from store cache'. Also revert the verbose approve-builds messages — the rebuild output itself is sufficient feedback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
_rebuild now logs what happened to each package: - 'reused from store cache' when side-effects are cached - 'skipped (no build scripts)' when requiresBuild is false - 'skipped (not allowed)' when allowBuild denies it Packages that run postinstall show their output as before. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the premature 'running build scripts...' message with a post-completion 'built successfully' message that only appears when postinstall hooks actually ran. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a package has requiresBuild but isBuilt (side-effects cached in the store), log 'pkg@version: already built (store cache)' so the user knows why no build scripts ran. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The root cause: when importing packages from the store, side-effects cache was applied for any package not explicitly denied (allowBuild !== false). This meant unapproved packages (allowBuild === undefined) got cached build artifacts, setting isBuilt: true and bypassing the allowBuild check in buildModules. Fix: only apply side-effects cache when allowBuild returns true (explicitly approved). This is checked in three places: - deps-restorer/src/index.ts (isolated linker) - deps-restorer/src/linkHoistedModules.ts (hoisted linker) - deps-installer/src/install/link.ts (non-headless install) Also add status messages to _rebuild so users see what happened to each package (built successfully, skipped, reused from cache). Remove the buildModules pre-loop workaround since it's no longer needed — unapproved packages now have isBuilt: false. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When buildModules skips a package because it was already imported with cached side effects (isBuilt: true), print a message so users know the build was reused from the store. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
buildSelectedPkgs was overwriting .modules.yaml with config-derived storeDir/virtualStoreDir values instead of preserving the existing ones from the modules manifest. This caused pnpm install to detect a mismatch and prompt to reinstall from scratch after approve-builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Without this, .modules.yaml had no allowBuilds after approve-builds, causing the next GVS install to detect a mismatch and reimport all packages (showing deps as newly added). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace all process.stdout.write calls in _rebuild and buildModules with globalInfo from @pnpm/logger. Revert approve-builds return vs await to keep consistent with the GVS path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
reverts some logs added via #11039
Closes #11035
Summary
Root cause fix: don't apply cached side-effects for unapproved packages
When importing packages from the store, side-effects cache was applied for any package not explicitly denied (
allowBuild !== false). This meant unapproved packages (allowBuild === undefined) got cached build artifacts, settingisBuilt: trueand bypassing theallowBuildcheck inbuildModules.Fix: Only apply side-effects cache when
allowBuildreturnstrue(explicitly approved). Changed in three locations:installing/deps-restorer/src/index.ts(isolated linker)installing/deps-restorer/src/linkHoistedModules.ts(hoisted linker)installing/deps-installer/src/install/link.ts(non-headless install)Revocation detection
When a package's build approval is revoked between installs (was
truein.modules.yaml, now undefined), detect it inmutateModulesand add toignoredBuildssostrictDepBuildsfails.Status messages in
_rebuildUsers now see what happened to each package during rebuild:
pkg@version: built successfullypkg@version: skipped (no build scripts)pkg@version: skipped (not allowed)pkg@version: reused from store cacheAnd during install:
pkg@version: reused from store (side effects cache)buildSelectedPkgsfixesstoreDir,virtualStoreDir,virtualStoreDirMaxLengthfrom existing.modules.yamlinstead of overwriting with config-derived values (which caused "reinstall from scratch" prompt)allowBuildsto.modules.yamlso GVS doesn't detect a mismatch on next installignoredBuildswith existing entries for packages not being rebuiltTest plan
strictDepBuildsfailsesbuild(cached) andcore-js(uncached) appear in ignored buildsapprove-builds esbuild !core-jsshowsesbuild@0.27.4: built successfullypnpm installshowsAlready up to datewithout reinstall prompt🤖 Generated with Claude Code