From e931b9b8f69b2749f45b6a5cc9785b4326e6a89a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:06:51 +0000 Subject: [PATCH 1/2] install: re-resolve catalog references on plain `bun update` from the workspace root `bun update` (no --latest) gated re-resolution on `is_root_dependency`, which only covers direct dependencies of the package in cwd. A `catalog:` reference is declared in a workspace package, so running from the root left it deduped onto the locked version even when a newer in-range version was published; only `--latest` (which rewrites the catalog text and trips `catalogs_changed`) re-resolved it. Catalog definitions live in the root package.json, so treat a catalog reference as eligible for re-resolution regardless of which workspace the update runs in. --- .../PackageManager/PackageManagerEnqueue.rs | 9 ++- test/cli/install/catalogs.test.ts | 69 +++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/install/PackageManager/PackageManagerEnqueue.rs b/src/install/PackageManager/PackageManagerEnqueue.rs index b21bc8ff9788..cbb1eb605a32 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.rs +++ b/src/install/PackageManager/PackageManagerEnqueue.rs @@ -1976,9 +1976,12 @@ fn get_or_put_resolved_package_with_find_result( // `manager.workspace_package_json_cache` only — disjoint from // `manager.lockfile`. this.to_update - // If updating, only update packages in the current workspace - && unsafe { &*(*this_ptr).lockfile } - .is_root_dependency(unsafe { &mut *this_ptr }, dependency_id) + // If updating, only update packages in the current workspace. + // `catalog:` references live in workspace packages but the catalog + // definition is root-level, so they re-resolve regardless of cwd. + && (dependency.version.tag == dependency::version::Tag::Catalog + || unsafe { &*(*this_ptr).lockfile } + .is_root_dependency(unsafe { &mut *this_ptr }, dependency_id)) // no need to do a look up if update requests are empty (`bun update` with no args) && (this.update_requests.is_empty() || this.updating_packages.contains( diff --git a/test/cli/install/catalogs.test.ts b/test/cli/install/catalogs.test.ts index 4a5d31c121a7..1169bbf81b7b 100644 --- a/test/cli/install/catalogs.test.ts +++ b/test/cli/install/catalogs.test.ts @@ -380,6 +380,75 @@ describe("update", () => { }); } + for (const args of [[], ["-r"]] as const) { + test(`update without --latest from root moves catalogs within range (${args.join(" ") || "no args"})`, async () => { + // The lockfile pins no-deps@1.0.0 (as if 1.1.0 was published after install). + // `bun update` from the workspace root must re-resolve catalog references + // within range the same as a direct dependency would be. + const { packageDir } = await registry.createTestDir(); + const url = registry.registryUrl(); + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "catalog-update-from-root", + workspaces: { + packages: ["packages/*"], + catalog: { "no-deps": "^1.0.0" }, + }, + }), + ), + write( + join(packageDir, "packages", "pkg1", "package.json"), + JSON.stringify({ + name: "pkg1", + dependencies: { "no-deps": "catalog:" }, + }), + ), + write( + join(packageDir, "bun.lock"), + JSON.stringify({ + lockfileVersion: 1, + configVersion: 1, + workspaces: { + "": { name: "catalog-update-from-root" }, + "packages/pkg1": { name: "pkg1", dependencies: { "no-deps": "catalog:" } }, + }, + catalog: { "no-deps": "^1.0.0" }, + packages: { + "no-deps": [ + "no-deps@1.0.0", + `${url}no-deps/-/no-deps-1.0.0.tgz`, + {}, + "sha512-v4w12JRjUGvfHDUP8vFDwu0gUWu04j0cv9hLb1Abf9VdaXu4XcrddYFTMVBVvmldKViGWH7jrb6xPJRF0wq6gw==", + ], + "pkg1": ["pkg1@workspace:packages/pkg1"], + }, + }), + ), + ]); + + const { err, exitCode } = await runUpdate(packageDir, ...args); + expect(err).not.toContain("error:"); + + const root = await file(join(packageDir, "package.json")).json(); + expect(root.workspaces.catalog).toEqual({ "no-deps": "^1.1.0" }); + + expect((await file(join(packageDir, "packages", "pkg1", "package.json")).json()).dependencies).toEqual({ + "no-deps": "catalog:", + }); + expect(await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).toEqual({ + name: "no-deps", + version: "1.1.0", + }); + + const lock = await file(join(packageDir, "bun.lock")).text(); + expect(lock).toContain("no-deps@1.1.0"); + expect(lock).not.toContain("no-deps@1.0.0"); + expect(exitCode).toBe(0); + }); + } + test("update without --latest stays in range and keeps catalog references", async () => { const { packageDir } = await registry.createTestDir(); await Promise.all([ From 02dc29569e9e5f595ff39685c8f1a637fa052dad Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:43:03 +0000 Subject: [PATCH 2/2] review: condense catalog-update comment --- src/install/PackageManager/PackageManagerEnqueue.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/install/PackageManager/PackageManagerEnqueue.rs b/src/install/PackageManager/PackageManagerEnqueue.rs index cbb1eb605a32..a0a15b6a1e07 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.rs +++ b/src/install/PackageManager/PackageManagerEnqueue.rs @@ -1976,9 +1976,7 @@ fn get_or_put_resolved_package_with_find_result( // `manager.workspace_package_json_cache` only — disjoint from // `manager.lockfile`. this.to_update - // If updating, only update packages in the current workspace. - // `catalog:` references live in workspace packages but the catalog - // definition is root-level, so they re-resolve regardless of cwd. + // Update direct deps of the current workspace; catalogs are root-scoped. && (dependency.version.tag == dependency::version::Tag::Catalog || unsafe { &*(*this_ptr).lockfile } .is_root_dependency(unsafe { &mut *this_ptr }, dependency_id))