From 1f02abe3813974840a5ef2a6c4075720fc583a8a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 13 May 2026 03:01:05 +0100 Subject: [PATCH] fix(update): make pnpm preflight resolution deterministic --- src/infra/update-runner.test.ts | 123 ++++++++++++++++++++++++++++++++ src/infra/update-runner.ts | 27 +++++-- 2 files changed, 144 insertions(+), 6 deletions(-) diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 2a58ca5cd84..caa73f971cc 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -385,6 +385,129 @@ describe("runGatewayUpdate", () => { expect(calls).not.toContain("pnpm ui:build"); }); + it("uses pnpm highest resolution mode for update installs", async () => { + await setupGitCheckout({ packageManager: "pnpm@8.0.0" }); + await setupUiIndex(); + const stableTag = "v1.0.1-1"; + const installEnvs: NodeJS.ProcessEnv[] = []; + const doctorNodePath = await resolveStableNodePath(process.execPath); + const { runCommand } = createGitInstallRunner({ + stableTag, + installCommand: "pnpm install", + buildCommand: "pnpm build", + uiBuildCommand: "pnpm ui:build", + doctorCommand: `${doctorNodePath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive --fix`, + onCommand: (key, options) => { + if (key === "pnpm install") { + installEnvs.push(options?.env ?? {}); + } + return undefined; + }, + }); + + const result = await runWithCommand(runCommand, { channel: "stable" }); + + expect(result.status).toBe("ok"); + expect(installEnvs).toHaveLength(1); + expect(installEnvs[0]).toMatchObject({ + PNPM_CONFIG_RESOLUTION_MODE: "highest", + npm_config_resolution_mode: "highest", + pnpm_config_resolution_mode: "highest", + }); + }); + + it("uses pnpm highest resolution mode for dev preflight installs", async () => { + await setupGitPackageManagerFixture(); + const upstreamSha = "upstream123"; + const installEnvs: NodeJS.ProcessEnv[] = []; + const doctorNodePath = await resolveStableNodePath(process.execPath); + const doctorCommand = `${doctorNodePath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive --fix`; + + const runCommand = async ( + argv: string[], + options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number }, + ) => { + const key = argv.join(" "); + if (key === `git -C ${tempDir} rev-parse --show-toplevel`) { + return { stdout: tempDir, stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse HEAD`) { + return { stdout: "abc123", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse --abbrev-ref HEAD`) { + return { stdout: "main", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`) { + return { stdout: "origin/main", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} fetch --all --prune --tags`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse @{upstream}`) { + return { stdout: upstreamSha, stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-list --max-count=10 ${upstreamSha}`) { + return { stdout: `${upstreamSha}\n`, stderr: "", code: 0 }; + } + if (key === "pnpm --version") { + return { stdout: "10.0.0", stderr: "", code: 0 }; + } + if ( + key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) && + key.endsWith(` ${upstreamSha}`) && + preflightPrefixPattern.test(key) + ) { + return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 }; + } + if ( + key.startsWith("git -C /tmp/") && + preflightPrefixPattern.test(key) && + key.includes(" checkout --detach ") && + key.endsWith(upstreamSha) + ) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm install") { + installEnvs.push(options?.env ?? {}); + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm build" || key === "pnpm ui:build") { + return { stdout: "", stderr: "", code: 0 }; + } + if ( + key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) && + preflightPrefixPattern.test(key) + ) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} worktree prune`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rebase ${upstreamSha}`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === doctorCommand) { + return { stdout: "", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }; + + const result = await runWithCommand(runCommand, { channel: "dev" }); + + expect(result.status).toBe("ok"); + expect(installEnvs).toHaveLength(2); + for (const env of installEnvs) { + expect(env).toMatchObject({ + PNPM_CONFIG_RESOLUTION_MODE: "highest", + npm_config_resolution_mode: "highest", + pnpm_config_resolution_mode: "highest", + }); + } + }); + it("returns error and stops early when build fails", async () => { await setupGitCheckout({ packageManager: "pnpm@8.0.0" }); const stableTag = "v1.0.1-1"; diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index d0e5c2ed515..2f197bb7be3 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -529,6 +529,21 @@ function resolveBuildEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv | undefined }; } +function resolveInstallEnv( + manager: "pnpm" | "bun" | "npm", + env?: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv | undefined { + if (manager !== "pnpm") { + return env; + } + return { + ...env, + PNPM_CONFIG_RESOLUTION_MODE: env?.PNPM_CONFIG_RESOLUTION_MODE ?? "highest", + npm_config_resolution_mode: env?.npm_config_resolution_mode ?? "highest", + pnpm_config_resolution_mode: env?.pnpm_config_resolution_mode ?? "highest", + }; +} + function isSupersededInstallFailure( step: UpdateStepResult, steps: readonly UpdateStepResult[], @@ -1006,9 +1021,8 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< const depsStepName = preflightIgnoreScripts ? `preflight deps install (ignore scripts) (${shortSha})` : `preflight deps install (${shortSha})`; - const depsStep = await runStep( - step(depsStepName, depsStepArgv, worktreeDir, manager.env), - ); + const installEnv = resolveInstallEnv(manager.manager, manager.env); + const depsStep = await runStep(step(depsStepName, depsStepArgv, worktreeDir, installEnv)); steps.push(depsStep); let finalDepsStep = depsStep; if ( @@ -1023,7 +1037,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< `preflight deps install (ignore scripts) (${shortSha})`, retryArgv, worktreeDir, - manager.env, + installEnv, ), ); steps.push(retryStep); @@ -1200,6 +1214,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< }; } try { + const installEnv = resolveInstallEnv(manager.manager, manager.env); const depsStep = await runStep( step( "deps install", @@ -1207,7 +1222,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< compatFallback: manager.fallback && manager.manager === "npm", }), gitRoot, - manager.env, + installEnv, ), ); steps.push(depsStep); @@ -1216,7 +1231,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< const retryArgv = managerInstallIgnoreScriptsArgs(manager.manager); if (retryArgv) { const retryStep = await runStep( - step("deps install (ignore scripts)", retryArgv, gitRoot, manager.env), + step("deps install (ignore scripts)", retryArgv, gitRoot, installEnv), ); steps.push(retryStep); finalDepsStep = retryStep;