Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
14d6ecb312 fix(install): properly handle workspace lifecycle script changes (#26070)
This commit addresses two issues with workspace package lifecycle scripts:

1. **Original issue (#26070)**: Workspace packages with lifecycle scripts were
   incorrectly triggering dependency re-resolution and unnecessary HTTP requests
   to the npm registry when running `bun install` after the lockfile cache expired.

2. **Test regression**: The initial fix skipped lifecycle script comparison entirely
   for workspace packages, but this caused the lockfile to not be saved when workspace
   scripts actually changed.

The fix now:
- Adds a `workspace_scripts_changed` flag to the Diff.Summary struct
- Compares lifecycle scripts for workspace packages but sets the flag instead of
  incrementing the `update` counter (which would trigger re-resolution)
- Propagates the flag from recursive workspace diffs to the parent summary
- Refreshes workspace package scripts from their package.json files when the flag
  is set, ensuring the lockfile is saved with updated scripts
- Adds `workspace_scripts_changed` to the condition for saving the lockfile

This ensures that:
- Workspace script changes don't trigger unnecessary HTTP requests
- The lockfile is still saved when workspace scripts actually change
- Both the original regression test and the existing test pass

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 21:02:45 +00:00
3 changed files with 166 additions and 1 deletions

View File

@@ -658,6 +658,12 @@ pub fn installWithManager(
}
}
// Refresh workspace package scripts if they changed (detected during diff).
// This ensures the lockfile is saved with the updated scripts.
if (manager.summary.workspace_scripts_changed) {
try refreshWorkspaceScripts(manager);
}
// append scripts to lockfile before generating new metahash
manager.loadRootLifecycleScripts(root);
defer {
@@ -848,6 +854,7 @@ pub fn installWithManager(
(manager.options.do.save_lockfile and
(did_meta_hash_change or
had_any_diffs or
manager.summary.workspace_scripts_changed or
manager.update_requests.len > 0 or
(load_result == .ok and (load_result.ok.serializer_result.packages_need_update or load_result.ok.serializer_result.migrated_from_lockb_v2)) or
manager.lockfile.isEmpty() or
@@ -1101,6 +1108,68 @@ pub fn getWorkspaceFilters(manager: *PackageManager, original_cwd: []const u8) !
return .{ workspace_filters.items, install_root_dependencies };
}
/// Refreshes workspace package scripts from their package.json files.
/// This is called when workspace scripts have changed (detected during diff)
/// to ensure the lockfile is saved with the updated scripts.
fn refreshWorkspaceScripts(manager: *PackageManager) !void {
const packages = manager.lockfile.packages.slice();
const resolutions = packages.items(.resolution);
const name_hashes = packages.items(.name_hash);
var scripts_list = packages.items(.scripts);
var metas = packages.items(.meta);
for (resolutions, name_hashes, 0..) |resolution, name_hash, pkg_id| {
if (resolution.tag != .workspace) continue;
// Get workspace path
const workspace_path = manager.lockfile.workspace_paths.getPtr(name_hash) orelse continue;
// Build package.json path
var package_json_path: bun.AbsPath(.{ .sep = .auto }) = .initTopLevelDir();
defer package_json_path.deinit();
package_json_path.append(workspace_path.slice(manager.lockfile.buffers.string_bytes.items));
package_json_path.append("package.json");
// Read and parse package.json
const json_result = manager.workspace_package_json_cache.getWithPath(
manager.allocator,
manager.log,
package_json_path.sliceZ(),
.{},
);
const json = switch (json_result) {
.entry => |entry| entry.root,
else => continue,
};
// Parse scripts from the JSON
var builder = manager.lockfile.stringBuilder();
Package.Scripts.parseCount(manager.allocator, &builder, json);
try builder.allocate();
var new_scripts: Package.Scripts = .{};
new_scripts.parseAlloc(manager.allocator, &builder, json);
new_scripts.filled = true;
builder.clamp();
// Update the scripts in the lockfile
scripts_list[pkg_id] = new_scripts;
// Update hasInstallScript flag
const has_scripts = new_scripts.hasAny() or brk: {
const dir = std.fs.path.dirname(package_json_path.slice()) orelse "";
const binding_dot_gyp_path = bun.path.joinAbsStringZ(
dir,
&[_][]const u8{"binding.gyp"},
.auto,
);
break :brk bun.sys.exists(binding_dot_gyp_path);
};
metas[pkg_id].setHasInstallScript(has_scripts);
}
}
const security_scanner = @import("./security_scanner.zig");
const std = @import("std");
const installHoistedPackages = @import("../hoisted_install.zig").installHoistedPackages;

View File

@@ -531,6 +531,9 @@ pub fn Package(comptime SemverIntType: type) type {
update: u32 = 0,
overrides_changed: bool = false,
catalogs_changed: bool = false,
/// Set when workspace package lifecycle scripts changed (doesn't trigger re-resolution,
/// but does require saving the lockfile).
workspace_scripts_changed: bool = false,
// bool for if this dependency should be added to lockfile trusted dependencies.
// it is false when the new trusted dependency is coming from the default list.
@@ -543,6 +546,7 @@ pub fn Package(comptime SemverIntType: type) type {
this.add += that.add;
this.remove += that.remove;
this.update += that.update;
this.workspace_scripts_changed = this.workspace_scripts_changed or that.workspace_scripts_changed;
}
pub inline fn hasDiffs(this: Summary) bool {
@@ -551,6 +555,11 @@ pub fn Package(comptime SemverIntType: type) type {
this.removed_trusted_dependencies.count() > 0 or
this.patched_dependencies_changed;
}
/// Returns true if the lockfile needs to be saved (either due to diffs or workspace script changes).
pub inline fn needsSaveLockfile(this: Summary) bool {
return this.hasDiffs() or this.workspace_scripts_changed;
}
};
pub fn generate(
@@ -904,6 +913,9 @@ pub fn Package(comptime SemverIntType: type) type {
});
}
// Propagate workspace_scripts_changed flag from the recursive diff
summary.workspace_scripts_changed = summary.workspace_scripts_changed or diff.workspace_scripts_changed;
break :update_mapping !diff.hasDiffs();
};
@@ -925,6 +937,9 @@ pub fn Package(comptime SemverIntType: type) type {
// number of from_deps could be greater than to_deps.
summary.add = @truncate((to_deps.len) -| (from_deps.len -| summary.remove));
// Compare lifecycle scripts (skip for root package).
// For workspace packages, script changes don't trigger re-resolution but
// do require saving the lockfile (fixes #26070).
if (from.resolution.tag != .root) {
inline for (Lockfile.Scripts.names) |hook| {
if (!@field(to.scripts, hook).eql(
@@ -933,7 +948,13 @@ pub fn Package(comptime SemverIntType: type) type {
from_lockfile.buffers.string_bytes.items,
)) {
// We found a changed life-cycle script
summary.update += 1;
if (from.resolution.tag == .workspace) {
// Workspace script changes don't affect dependency resolution,
// but we need to save the lockfile
summary.workspace_scripts_changed = true;
} else {
summary.update += 1;
}
}
}
}

View File

@@ -0,0 +1,75 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
test("workspace packages with lifecycle scripts should not trigger unnecessary HTTP requests", async () => {
// Create temp directory with test files
using dir = tempDir("issue-26070", {
"package.json": JSON.stringify({
name: "test-workspace",
workspaces: ["packages/*"],
}),
"packages/app/package.json": JSON.stringify({
name: "@workspace/app",
version: "1.0.0",
scripts: {
prepare: "echo 'Do some preparations...'",
},
dependencies: {
"@workspace/lib": "workspace:*",
},
}),
"packages/lib/package.json": JSON.stringify({
name: "@workspace/lib",
version: "1.0.0",
dependencies: {
"is-odd": "^3.0.1",
},
}),
});
// First install creates the lockfile
{
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
expect(exitCode).toBe(0);
}
// Second install with --force should detect no real changes in workspace packages
// and should NOT report "updated 1 dependencies" for lifecycle script false positives
{
await using proc = Bun.spawn({
cmd: [bunExe(), "install", "--force", "--verbose"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// The verbose output should not show "updated 1 dependencies" for workspace packages
// when no actual dependencies have changed. This was the bug - lifecycle scripts
// were incorrectly triggering this count.
const output = stdout + stderr;
// Check that we're not seeing false positive updates for workspace packages
if (
output.includes(
'Workspace package "packages/app" has added 0 dependencies, removed 0 dependencies, and updated 1 dependencies',
)
) {
throw new Error(
"Bug #26070: Workspace package lifecycle scripts are incorrectly triggering 'updated 1 dependencies' message",
);
}
expect(exitCode).toBe(0);
}
});