Skip to content

fix: check allowBuild for packages with cached side-effects#11039

Merged
zkochan merged 51 commits intomainfrom
approve-builds
Mar 21, 2026
Merged

fix: check allowBuild for packages with cached side-effects#11039
zkochan merged 51 commits intomainfrom
approve-builds

Conversation

@zkochan
Copy link
Copy Markdown
Member

@zkochan zkochan commented Mar 20, 2026

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, setting isBuilt: true and bypassing the allowBuild check in buildModules.

Fix: Only apply side-effects cache when allowBuild returns true (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 true in .modules.yaml, now undefined), detect it in mutateModules and add to ignoredBuilds so strictDepBuilds fails.

Status messages in _rebuild

Users now see what happened to each package during rebuild:

  • pkg@version: built successfully
  • pkg@version: skipped (no build scripts)
  • pkg@version: skipped (not allowed)
  • pkg@version: reused from store cache

And during install:

  • pkg@version: reused from store (side effects cache)

buildSelectedPkgs fixes

  • Preserve storeDir, virtualStoreDir, virtualStoreDirMaxLength from existing .modules.yaml instead of overwriting with config-derived values (which caused "reinstall from scratch" prompt)
  • Write allowBuilds to .modules.yaml so GVS doesn't detect a mismatch on next install
  • Merge ignoredBuilds with existing entries for packages not being rebuilt

Test plan

  • E2e test: first install with approval, second with revoked approval → strictDepBuilds fails
  • Verified manually: both esbuild (cached) and core-js (uncached) appear in ignored builds
  • Verified: approve-builds esbuild !core-js shows esbuild@0.27.4: built successfully
  • Verified: subsequent pnpm install shows Already up to date without reinstall prompt

🤖 Generated with Claude Code

@zkochan zkochan marked this pull request as ready for review March 20, 2026 15:38
Copilot AI review requested due to automatic review settings March 20, 2026 15:38
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 as ignoredBuilds when 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.

Comment thread installing/deps-installer/src/install/index.ts Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread installing/deps-installer/src/install/index.ts Outdated
Comment thread installing/deps-installer/src/install/index.ts Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread installing/deps-installer/src/install/index.ts Outdated
Comment thread installing/deps-installer/src/install/index.ts Outdated
Comment thread pnpm/test/install/lifecycleScripts.ts
zkochan and others added 3 commits March 20, 2026 22:46
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>
@zkochan zkochan changed the base branch from main to fix/11042 March 20, 2026 22:01
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>
zkochan and others added 8 commits March 20, 2026 23:47
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>
zkochan and others added 4 commits March 21, 2026 01:49
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>
zkochan and others added 2 commits March 21, 2026 11:30
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>
zkochan and others added 22 commits March 21, 2026 12:14
…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>
Base automatically changed from fix/11042 to main March 21, 2026 11:50
@zkochan zkochan merged commit 9b801c8 into main Mar 21, 2026
10 checks passed
@zkochan zkochan deleted the approve-builds branch March 21, 2026 11:51
zkochan added a commit that referenced this pull request Mar 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

strictDepBuilds and allowBuilds checks are bypassed when package side-effects are cached in the store

2 participants