Compare commits

..

1 Commits

Author SHA1 Message Date
Claude Bot
8f671c6bf0 fix(install): allow --frozen-lockfile on pruned monorepo workspaces
When a monorepo is pruned (e.g. by `turbo prune --docker`), the
resulting directory has fewer workspaces but keeps the original lockfile.
Previously, `bun install --frozen-lockfile` would fail because it
detected removed workspace dependencies as lockfile changes.

The lockfile is a superset of what's needed — all dependency versions
for the remaining workspaces are already pinned. Now, when the only
diffs are removals (no additions, updates, or other changes), the
frozen-lockfile check treats the lockfile as valid.

Closes #26973

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-12 15:56:32 +00:00
5 changed files with 158 additions and 68 deletions

View File

@@ -193,6 +193,14 @@ pub fn installWithManager(
had_any_diffs = manager.summary.hasDiffs();
// When --frozen-lockfile is set and the only diffs are removed
// workspace dependencies (e.g. from `turbo prune`), the lockfile
// is a superset of what's needed. Treat this as no diff so the
// lockfile stays intact and the frozen-lockfile check passes.
if (had_any_diffs and manager.options.enable.frozen_lockfile and manager.summary.hasOnlyRemovals()) {
had_any_diffs = false;
}
if (!had_any_diffs) {
// always grab latest scripts for root package
var builder_ = manager.lockfile.stringBuilder();

View File

@@ -551,6 +551,19 @@ pub fn Package(comptime SemverIntType: type) type {
this.removed_trusted_dependencies.count() > 0 or
this.patched_dependencies_changed;
}
/// Returns true when the only difference is removed dependencies
/// (no additions, updates, or other changes). This is the case when
/// workspaces have been pruned (e.g., by `turbo prune`) — the lockfile
/// is a superset of what's needed and should be accepted by --frozen-lockfile.
pub inline fn hasOnlyRemovals(this: Summary) bool {
return this.remove > 0 and
this.add == 0 and this.update == 0 and
!this.overrides_changed and !this.catalogs_changed and
this.added_trusted_dependencies.count() == 0 and
this.removed_trusted_dependencies.count() == 0 and
!this.patched_dependencies_changed;
}
};
pub fn generate(

View File

@@ -202,11 +202,7 @@ const OutgoingMessagePrototype = {
_closed: false,
_headerNames: undefined,
appendHeader(name, value) {
if ((this._header !== undefined && this._header !== null) || this[headerStateSymbol] === NodeHTTPHeaderState.sent) {
throw $ERR_HTTP_HEADERS_SENT("append");
}
validateHeaderName(name);
validateHeaderValue(name, value);
validateString(name, "name");
var headers = (this[headersSymbol] ??= new Headers());
headers.append(name, value);
return this;

View File

@@ -1031,69 +1031,6 @@ describe("node:http", () => {
expect(() => validateHeaderValue("Foo", "Bar\r")).toThrow();
});
test("appendHeader validates header name and value like setHeader", async () => {
await using server = createServer((req, res) => {
res.end("ok");
});
server.listen({ port: 0 });
await once(server, "listening");
const port = (server.address() as AddressInfo).port;
const req = request({ port, method: "GET", path: "/" });
// Invalid header names should throw ERR_INVALID_HTTP_TOKEN
expect(() => req.appendHeader("invalid header", "value")).toThrow(
expect.objectContaining({ code: "ERR_INVALID_HTTP_TOKEN" }),
);
expect(() => req.appendHeader("x(test)", "value")).toThrow(
expect.objectContaining({ code: "ERR_INVALID_HTTP_TOKEN" }),
);
expect(() => req.appendHeader("", "value")).toThrow(expect.objectContaining({ code: "ERR_INVALID_HTTP_TOKEN" }));
expect(() => req.appendHeader("x:test", "value")).toThrow(
expect.objectContaining({ code: "ERR_INVALID_HTTP_TOKEN" }),
);
// Undefined value should throw ERR_HTTP_INVALID_HEADER_VALUE
expect(() => req.appendHeader("x-test", undefined as any)).toThrow(
expect.objectContaining({ code: "ERR_HTTP_INVALID_HEADER_VALUE" }),
);
// CRLF in value should throw ERR_INVALID_CHAR
expect(() => req.appendHeader("x-test", "value\r\ninjected: true")).toThrow(
expect.objectContaining({ code: "ERR_INVALID_CHAR" }),
);
// Null byte in value should throw ERR_INVALID_CHAR
expect(() => req.appendHeader("x-test", "val\x00ue")).toThrow(
expect.objectContaining({ code: "ERR_INVALID_CHAR" }),
);
// Valid headers should work
req.appendHeader("x-valid", "value");
req.appendHeader("x-valid", "another-value");
req.end();
await once(req, "response");
});
test("appendHeader throws after headers sent", async () => {
const { promise, resolve } = Promise.withResolvers<void>();
await using server = createServer((req, res) => {
res.write("data");
expect(() => res.appendHeader("x-late", "value")).toThrow(
expect.objectContaining({ code: "ERR_HTTP_HEADERS_SENT" }),
);
res.end();
resolve();
});
server.listen({ port: 0 });
await once(server, "listening");
const port = (server.address() as AddressInfo).port;
const res = await fetch("http://localhost:" + port);
await res.text();
await promise;
});
test("req.req = req", done => {
const server = createServer((req, res) => {
req.req = req;

View File

@@ -0,0 +1,136 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
import { join } from "path";
// Test for https://github.com/oven-sh/bun/issues/26973
// `bun install --frozen-lockfile` should succeed on a pruned monorepo
// (e.g. output of `turbo prune --docker`) where some workspaces are removed
// but the lockfile is a superset of what's needed.
test("frozen-lockfile succeeds on pruned monorepo with subset of workspaces", async () => {
// Step 1: Create a full monorepo and generate a lockfile
const fullDir = tempDirWithFiles("full-monorepo", {
"package.json": JSON.stringify({
name: "test-monorepo",
workspaces: ["packages/*", "apps/*"],
}),
"packages/shared/package.json": JSON.stringify({
name: "@test/shared",
version: "1.0.0",
}),
"packages/utils/package.json": JSON.stringify({
name: "@test/utils",
version: "1.0.0",
}),
"apps/web/package.json": JSON.stringify({
name: "@test/web",
version: "1.0.0",
dependencies: {
"@test/shared": "workspace:*",
},
}),
"apps/api/package.json": JSON.stringify({
name: "@test/api",
version: "1.0.0",
dependencies: {
"@test/utils": "workspace:*",
},
}),
});
// Generate a lockfile with the full set of workspaces
const installResult = Bun.spawnSync({
cmd: [bunExe(), "install", "--save-text-lockfile", "--ignore-scripts"],
cwd: fullDir,
env: bunEnv,
});
expect(installResult.exitCode).toBe(0);
const lockfileContent = await Bun.file(join(fullDir, "bun.lock")).text();
// Step 2: Create a pruned monorepo (only @test/web and its dependency @test/shared)
// but use the FULL lockfile from the original monorepo
const prunedDir = tempDirWithFiles("pruned-monorepo", {
"package.json": JSON.stringify({
name: "test-monorepo",
workspaces: ["packages/shared", "apps/web"],
}),
"packages/shared/package.json": JSON.stringify({
name: "@test/shared",
version: "1.0.0",
}),
"apps/web/package.json": JSON.stringify({
name: "@test/web",
version: "1.0.0",
dependencies: {
"@test/shared": "workspace:*",
},
}),
"bun.lock": lockfileContent,
});
// Step 3: Run frozen install on the pruned output — this should succeed
const frozenResult = Bun.spawnSync({
cmd: [bunExe(), "install", "--frozen-lockfile", "--ignore-scripts"],
cwd: prunedDir,
env: bunEnv,
});
const stderr = frozenResult.stderr.toString();
expect(stderr).not.toContain("lockfile had changes, but lockfile is frozen");
expect(frozenResult.exitCode).toBe(0);
});
test("frozen-lockfile still fails when a new workspace is added", async () => {
// This test ensures we don't accidentally make frozen-lockfile too permissive.
// If a workspace is ADDED (not just removed), the frozen lockfile check
// should still fail.
const fullDir = tempDirWithFiles("frozen-fail-monorepo", {
"package.json": JSON.stringify({
name: "test-monorepo",
workspaces: ["packages/*"],
}),
"packages/shared/package.json": JSON.stringify({
name: "@test/shared",
version: "1.0.0",
}),
});
// Generate a lockfile with only @test/shared
const installResult = Bun.spawnSync({
cmd: [bunExe(), "install", "--save-text-lockfile", "--ignore-scripts"],
cwd: fullDir,
env: bunEnv,
});
expect(installResult.exitCode).toBe(0);
const lockfileContent = await Bun.file(join(fullDir, "bun.lock")).text();
// Now create a directory with an ADDITIONAL workspace not in the lockfile
const modifiedDir = tempDirWithFiles("frozen-fail-modified", {
"package.json": JSON.stringify({
name: "test-monorepo",
workspaces: ["packages/*"],
}),
"packages/shared/package.json": JSON.stringify({
name: "@test/shared",
version: "1.0.0",
}),
"packages/extra/package.json": JSON.stringify({
name: "@test/extra",
version: "1.0.0",
}),
"bun.lock": lockfileContent,
});
// This should fail because a new workspace was added that's not in the lockfile
const frozenResult = Bun.spawnSync({
cmd: [bunExe(), "install", "--frozen-lockfile", "--ignore-scripts"],
cwd: modifiedDir,
env: bunEnv,
});
const stderr = frozenResult.stderr.toString();
expect(stderr).toContain("lockfile had changes, but lockfile is frozen");
expect(frozenResult.exitCode).not.toBe(0);
});