mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 10:28:47 +00:00
Add skipLifecycleScripts to skip unnecessary postinstall scripts
This adds a new `skipLifecycleScripts` field to package.json that allows skipping lifecycle scripts for packages where they are unnecessary. This has higher precedence than trustedDependencies and scripts in this list are: 1. Never executed (even if in trustedDependencies) 2. Excluded from the "X postinstall scripts blocked" message Key changes: - Added `skip_lifecycle_scripts` field to Lockfile struct - Created `default-skipped-lifecycle-scripts.txt` with esbuild as default - Added parsing for `skipLifecycleScripts` array in package.json - Updated PackageInstaller to check skip list before blocking or enqueueing scripts - Added test verifying skip behavior and precedence over trustedDependencies This prevents nagging users about packages like esbuild whose postinstall scripts are unnecessary for Bun users and just waste CI time. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1125,30 +1125,35 @@ pub const PackageInstaller = struct {
|
||||
// these will never be blocked
|
||||
},
|
||||
else => if (!is_trusted and this.metas[package_id].hasInstallScript()) {
|
||||
// Check if the package actually has scripts. `hasInstallScript` can be false positive if a package is published with
|
||||
// an auto binding.gyp rebuild script but binding.gyp is excluded from the published files.
|
||||
var folder_path: bun.AbsPath(.{ .sep = .auto }) = .from(this.node_modules.path.items);
|
||||
defer folder_path.deinit();
|
||||
folder_path.append(alias.slice(this.lockfile.buffers.string_bytes.items));
|
||||
const alias_str = alias.slice(this.lockfile.buffers.string_bytes.items);
|
||||
const should_skip = this.lockfile.shouldSkipLifecycleScripts(alias_str);
|
||||
|
||||
const count = this.getInstalledPackageScriptsCount(
|
||||
alias.slice(this.lockfile.buffers.string_bytes.items),
|
||||
package_id,
|
||||
resolution.tag,
|
||||
&folder_path,
|
||||
log_level,
|
||||
);
|
||||
if (count > 0) {
|
||||
if (log_level.isVerbose()) {
|
||||
Output.prettyError("Blocked {d} scripts for: {s}@{}\n", .{
|
||||
count,
|
||||
alias.slice(this.lockfile.buffers.string_bytes.items),
|
||||
resolution.fmt(this.lockfile.buffers.string_bytes.items, .posix),
|
||||
});
|
||||
if (!should_skip) {
|
||||
// Check if the package actually has scripts. `hasInstallScript` can be false positive if a package is published with
|
||||
// an auto binding.gyp rebuild script but binding.gyp is excluded from the published files.
|
||||
var folder_path: bun.AbsPath(.{ .sep = .auto }) = .from(this.node_modules.path.items);
|
||||
defer folder_path.deinit();
|
||||
folder_path.append(alias_str);
|
||||
|
||||
const count = this.getInstalledPackageScriptsCount(
|
||||
alias_str,
|
||||
package_id,
|
||||
resolution.tag,
|
||||
&folder_path,
|
||||
log_level,
|
||||
);
|
||||
if (count > 0) {
|
||||
if (log_level.isVerbose()) {
|
||||
Output.prettyError("Blocked {d} scripts for: {s}@{}\n", .{
|
||||
count,
|
||||
alias_str,
|
||||
resolution.fmt(this.lockfile.buffers.string_bytes.items, .posix),
|
||||
});
|
||||
}
|
||||
const entry = bun.handleOom(this.summary.packages_with_blocked_scripts.getOrPut(this.manager.allocator, truncated_dep_name_hash));
|
||||
if (!entry.found_existing) entry.value_ptr.* = 0;
|
||||
entry.value_ptr.* += count;
|
||||
}
|
||||
const entry = bun.handleOom(this.summary.packages_with_blocked_scripts.getOrPut(this.manager.allocator, truncated_dep_name_hash));
|
||||
if (!entry.found_existing) entry.value_ptr.* = 0;
|
||||
entry.value_ptr.* += count;
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1268,28 +1273,33 @@ pub const PackageInstaller = struct {
|
||||
};
|
||||
|
||||
if (resolution.tag != .root and is_trusted) {
|
||||
var folder_path: bun.AbsPath(.{ .sep = .auto }) = .from(this.node_modules.path.items);
|
||||
defer folder_path.deinit();
|
||||
folder_path.append(alias.slice(this.lockfile.buffers.string_bytes.items));
|
||||
const alias_str = alias.slice(this.lockfile.buffers.string_bytes.items);
|
||||
const should_skip = this.lockfile.shouldSkipLifecycleScripts(alias_str);
|
||||
|
||||
if (this.enqueueLifecycleScripts(
|
||||
alias.slice(this.lockfile.buffers.string_bytes.items),
|
||||
log_level,
|
||||
&folder_path,
|
||||
package_id,
|
||||
dep.behavior.optional,
|
||||
resolution,
|
||||
)) {
|
||||
if (is_trusted_through_update_request) {
|
||||
this.manager.trusted_deps_to_add_to_package_json.append(
|
||||
this.manager.allocator,
|
||||
bun.handleOom(this.manager.allocator.dupe(u8, alias.slice(this.lockfile.buffers.string_bytes.items))),
|
||||
) catch |err| bun.handleOom(err);
|
||||
}
|
||||
if (!should_skip) {
|
||||
var folder_path: bun.AbsPath(.{ .sep = .auto }) = .from(this.node_modules.path.items);
|
||||
defer folder_path.deinit();
|
||||
folder_path.append(alias_str);
|
||||
|
||||
if (add_to_lockfile) {
|
||||
if (this.lockfile.trusted_dependencies == null) this.lockfile.trusted_dependencies = .{};
|
||||
this.lockfile.trusted_dependencies.?.put(this.manager.allocator, truncated_dep_name_hash, {}) catch |err| bun.handleOom(err);
|
||||
if (this.enqueueLifecycleScripts(
|
||||
alias_str,
|
||||
log_level,
|
||||
&folder_path,
|
||||
package_id,
|
||||
dep.behavior.optional,
|
||||
resolution,
|
||||
)) {
|
||||
if (is_trusted_through_update_request) {
|
||||
this.manager.trusted_deps_to_add_to_package_json.append(
|
||||
this.manager.allocator,
|
||||
bun.handleOom(this.manager.allocator.dupe(u8, alias_str)),
|
||||
) catch |err| bun.handleOom(err);
|
||||
}
|
||||
|
||||
if (add_to_lockfile) {
|
||||
if (this.lockfile.trusted_dependencies == null) this.lockfile.trusted_dependencies = .{};
|
||||
this.lockfile.trusted_dependencies.?.put(this.manager.allocator, truncated_dep_name_hash, {}) catch |err| bun.handleOom(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
src/install/default-skipped-lifecycle-scripts.txt
Normal file
1
src/install/default-skipped-lifecycle-scripts.txt
Normal file
@@ -0,0 +1 @@
|
||||
esbuild
|
||||
@@ -24,6 +24,8 @@ workspace_versions: VersionHashMap = .{},
|
||||
/// Optional because `trustedDependencies` in package.json might be an
|
||||
/// empty list or it might not exist
|
||||
trusted_dependencies: ?TrustedDependenciesSet = null,
|
||||
/// Optional: packages whose lifecycle scripts should be skipped AND excluded from "blocked" count
|
||||
skip_lifecycle_scripts: ?TrustedDependenciesSet = null,
|
||||
patched_dependencies: PatchedDependenciesMap = .{},
|
||||
overrides: OverrideMap = .{},
|
||||
catalogs: CatalogMap = .{},
|
||||
@@ -2108,6 +2110,79 @@ pub fn hasTrustedDependency(this: *const Lockfile, name: []const u8) bool {
|
||||
return default_trusted_dependencies.has(name);
|
||||
}
|
||||
|
||||
const max_default_skipped_lifecycle_scripts = 512;
|
||||
|
||||
pub const default_skipped_lifecycle_scripts_list: []const []const u8 = brk: {
|
||||
// This file contains a list of packages whose lifecycle scripts should be skipped
|
||||
// and excluded from the "blocked" count message
|
||||
const data = @embedFile("./default-skipped-lifecycle-scripts.txt");
|
||||
@setEvalBranchQuota(999999);
|
||||
var buf: [max_default_skipped_lifecycle_scripts][]const u8 = undefined;
|
||||
var i: usize = 0;
|
||||
var iter = std.mem.tokenizeAny(u8, data, " \r\n\t");
|
||||
while (iter.next()) |package_ptr| {
|
||||
const package = package_ptr[0..].*;
|
||||
buf[i] = &package;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
const Sorter = struct {
|
||||
pub fn lessThan(_: void, lhs: []const u8, rhs: []const u8) bool {
|
||||
return std.mem.order(u8, lhs, rhs) == .lt;
|
||||
}
|
||||
};
|
||||
|
||||
// alphabetical so we don't need to sort later
|
||||
std.sort.pdq([]const u8, buf[0..i], {}, Sorter.lessThan);
|
||||
|
||||
var names: [i][]const u8 = undefined;
|
||||
@memcpy(names[0..i], buf[0..i]);
|
||||
const final = names;
|
||||
break :brk &final;
|
||||
};
|
||||
|
||||
/// The default list of skipped lifecycle scripts is a static hashmap
|
||||
pub const default_skipped_lifecycle_scripts = brk: {
|
||||
const StringHashContext = struct {
|
||||
pub fn hash(_: @This(), s: []const u8) u64 {
|
||||
@setEvalBranchQuota(999999);
|
||||
// truncate to u32 because Lockfile uses the same u32 string hash
|
||||
return @intCast(@as(u32, @truncate(String.Builder.stringHash(s))));
|
||||
}
|
||||
pub fn eql(_: @This(), a: []const u8, b: []const u8) bool {
|
||||
@setEvalBranchQuota(999999);
|
||||
return std.mem.eql(u8, a, b);
|
||||
}
|
||||
};
|
||||
|
||||
var map: StaticHashMap([]const u8, void, StringHashContext, max_default_skipped_lifecycle_scripts) = .{};
|
||||
|
||||
for (default_skipped_lifecycle_scripts_list) |dep| {
|
||||
if (map.len == max_default_skipped_lifecycle_scripts) {
|
||||
@compileError("default-skipped-lifecycle-scripts.txt is too large, please increase 'max_default_skipped_lifecycle_scripts' in lockfile.zig");
|
||||
}
|
||||
|
||||
const entry = map.getOrPutAssumeCapacity(dep);
|
||||
if (entry.found_existing) {
|
||||
@compileError("Duplicate skipped lifecycle script: " ++ dep);
|
||||
}
|
||||
}
|
||||
|
||||
const final = map;
|
||||
break :brk &final;
|
||||
};
|
||||
|
||||
pub fn shouldSkipLifecycleScripts(this: *const Lockfile, name: []const u8) bool {
|
||||
// Check user-specified skipLifecycleScripts first (higher precedence)
|
||||
if (this.skip_lifecycle_scripts) |skip_scripts| {
|
||||
const hash = @as(u32, @truncate(String.Builder.stringHash(name)));
|
||||
if (skip_scripts.contains(hash)) return true;
|
||||
}
|
||||
|
||||
// Fall back to default skipped list
|
||||
return default_skipped_lifecycle_scripts.has(name);
|
||||
}
|
||||
|
||||
pub const NameHashMap = std.ArrayHashMapUnmanaged(PackageNameHash, String, ArrayIdentityContext.U64, false);
|
||||
pub const TrustedDependenciesSet = std.ArrayHashMapUnmanaged(TruncatedPackageNameHash, void, ArrayIdentityContext, false);
|
||||
pub const VersionHashMap = std.ArrayHashMapUnmanaged(PackageNameHash, Semver.Version, ArrayIdentityContext.U64, false);
|
||||
|
||||
@@ -1571,6 +1571,36 @@ pub fn Package(comptime SemverIntType: type) type {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (json.asProperty("skipLifecycleScripts")) |q| {
|
||||
switch (q.expr.data) {
|
||||
.e_array => |arr| {
|
||||
if (lockfile.skip_lifecycle_scripts == null) lockfile.skip_lifecycle_scripts = .{};
|
||||
try lockfile.skip_lifecycle_scripts.?.ensureUnusedCapacity(allocator, arr.items.len);
|
||||
for (arr.slice()) |item| {
|
||||
const name = item.asString(allocator) orelse {
|
||||
log.addErrorFmt(source, q.loc, allocator,
|
||||
\\skipLifecycleScripts expects an array of strings, e.g.
|
||||
\\ <r><green>"skipLifecycleScripts"<r>: [
|
||||
\\ <green>"package_name"<r>
|
||||
\\ ]
|
||||
, .{}) catch {};
|
||||
return error.InvalidPackageJSON;
|
||||
};
|
||||
lockfile.skip_lifecycle_scripts.?.putAssumeCapacity(@as(TruncatedPackageNameHash, @truncate(String.Builder.stringHash(name))), {});
|
||||
}
|
||||
},
|
||||
else => {
|
||||
log.addErrorFmt(source, q.loc, allocator,
|
||||
\\skipLifecycleScripts expects an array of strings, e.g.
|
||||
\\ <r><green>"skipLifecycleScripts"<r>: [
|
||||
\\ <green>"package_name"<r>
|
||||
\\ ]
|
||||
, .{}) catch {};
|
||||
return error.InvalidPackageJSON;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (comptime features.is_main) {
|
||||
|
||||
@@ -2921,6 +2921,102 @@ for (const forceWaiterThread of isLinux ? [false, true] : [false]) {
|
||||
expect(await exited).toBe(0);
|
||||
assertManifestsPopulated(join(packageDir, ".bun-cache"), verdaccio.registryUrl());
|
||||
});
|
||||
|
||||
test("skipLifecycleScripts prevents scripts and excludes from blocked count", async () => {
|
||||
const testEnv = forceWaiterThread ? { ...env, BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1" } : env;
|
||||
await writeFile(
|
||||
packageJson,
|
||||
JSON.stringify({
|
||||
name: "foo",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"all-lifecycle-scripts": "1.0.0",
|
||||
"uses-what-bin": "1.0.0",
|
||||
},
|
||||
skipLifecycleScripts: ["all-lifecycle-scripts"],
|
||||
}),
|
||||
);
|
||||
|
||||
var { stdout, stderr, exited } = spawn({
|
||||
cmd: [bunExe(), "install"],
|
||||
cwd: packageDir,
|
||||
stdout: "pipe",
|
||||
stdin: "ignore",
|
||||
stderr: "pipe",
|
||||
env: testEnv,
|
||||
});
|
||||
|
||||
var err = await stderr.text();
|
||||
var out = await stdout.text();
|
||||
expect(err).toContain("Saved lockfile");
|
||||
expect(err).not.toContain("not found");
|
||||
expect(err).not.toContain("error:");
|
||||
|
||||
// Should only show 1 blocked script (uses-what-bin), not 4 (all-lifecycle-scripts + uses-what-bin)
|
||||
expect(out.replace(/\s*\[[0-9\.]+m?s\]$/m, "").split(/\r?\n/)).toEqual([
|
||||
expect.stringContaining("bun install v1."),
|
||||
"",
|
||||
"+ all-lifecycle-scripts@1.0.0",
|
||||
"+ uses-what-bin@1.0.0",
|
||||
"",
|
||||
"2 packages installed",
|
||||
"",
|
||||
"Blocked 1 postinstall. Run `bun pm untrusted` for details.",
|
||||
"",
|
||||
]);
|
||||
expect(await exited).toBe(0);
|
||||
assertManifestsPopulated(join(packageDir, ".bun-cache"), verdaccio.registryUrl());
|
||||
|
||||
// Verify scripts were skipped for all-lifecycle-scripts
|
||||
const depDir = join(packageDir, "node_modules", "all-lifecycle-scripts");
|
||||
expect(await exists(join(depDir, "preinstall.txt"))).toBeFalse();
|
||||
expect(await exists(join(depDir, "install.txt"))).toBeFalse();
|
||||
expect(await exists(join(depDir, "postinstall.txt"))).toBeFalse();
|
||||
expect(await exists(join(depDir, "preprepare.txt"))).toBeFalse();
|
||||
expect(await exists(join(depDir, "prepare.txt"))).toBeTrue(); // prepare always runs
|
||||
expect(await exists(join(depDir, "postprepare.txt"))).toBeFalse();
|
||||
|
||||
// Verify scripts were blocked for uses-what-bin
|
||||
const whatBinDir = join(packageDir, "node_modules", "uses-what-bin");
|
||||
expect(await exists(join(whatBinDir, "what-bin.txt"))).toBeFalse();
|
||||
|
||||
// Verify skipLifecycleScripts has higher precedence than trustedDependencies
|
||||
await writeFile(
|
||||
packageJson,
|
||||
JSON.stringify({
|
||||
name: "foo",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"all-lifecycle-scripts": "1.0.0",
|
||||
},
|
||||
trustedDependencies: ["all-lifecycle-scripts"],
|
||||
skipLifecycleScripts: ["all-lifecycle-scripts"],
|
||||
}),
|
||||
);
|
||||
|
||||
({ stdout, stderr, exited } = spawn({
|
||||
cmd: [bunExe(), "install"],
|
||||
cwd: packageDir,
|
||||
stdout: "pipe",
|
||||
stdin: "ignore",
|
||||
stderr: "pipe",
|
||||
env: testEnv,
|
||||
}));
|
||||
|
||||
err = await stderr.text();
|
||||
out = await stdout.text();
|
||||
expect(err).not.toContain("error:");
|
||||
|
||||
// Should not show any blocked scripts message because the only package is in skipLifecycleScripts
|
||||
const outLines = out.replace(/\s*\[[0-9\.]+m?s\]$/m, "").split(/\r?\n/);
|
||||
expect(outLines).not.toContain(expect.stringContaining("Blocked"));
|
||||
expect(await exited).toBe(0);
|
||||
|
||||
// Scripts should still be skipped even though it's in trustedDependencies
|
||||
expect(await exists(join(depDir, "preinstall.txt"))).toBeFalse();
|
||||
expect(await exists(join(depDir, "install.txt"))).toBeFalse();
|
||||
expect(await exists(join(depDir, "postinstall.txt"))).toBeFalse();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user