From 501f2cbfe4afb0d770cbaa51e6da587b01abd765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Cuevas?= Date: Sun, 24 May 2026 02:37:21 -0400 Subject: [PATCH] fix(update): avoid broad tag fetches for dev updates (#84737) Summary: - The PR changes dev-channel git updates to fetch branches with `--no-tags`, adds targeted fetching for explicit dev tag refs, updates update-runner tests, and adds a changelog entry. - Reproducibility: yes. Current main source shows dev updates still run a broad tag fetch, and the PR body sup ... al local bare-remote moved-tag reproducer showing that command fails before the branch update can continue. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(update): avoid broad tag fetches for dev updates Validation: - ClawSweeper review passed for head 733680b1bc814400e586b30d8d15459c5d6827e5. - Required merge gates passed before the squash merge. Prepared head SHA: 733680b1bc814400e586b30d8d15459c5d6827e5 Review: https://github.com/openclaw/openclaw/pull/84737#issuecomment-4503692161 Co-authored-by: Ruben Cuevas Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + src/infra/update-runner.test.ts | 120 ++++++++++++++++++++++++++++---- src/infra/update-runner.ts | 57 ++++++++++++++- 3 files changed, 164 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2566d5d3e9..1e2d257d033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/update: avoid fetching unrelated tags during dev-channel git updates so moved release tags do not block branch-based updates. (#84737) Thanks @rubencu. - MiniMax: store OAuth token expiry as an absolute millisecond timestamp so OAuth profiles no longer appear expired on every request. (#83480) Thanks @NianJiuZst. - Agents/Anthropic: strip missing or blank thinking signatures for signed-thinking providers even when recovery supplies a narrow replay policy without signature preservation. Fixes #84430. (#84448) Thanks @NianJiuZst. - Agents/channels: send a visible notice when an aborted main session cannot be resumed after restart, including Telegram group targets. (#85805) Thanks @pfrederiksen. diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 183afe80483..fedaf61182c 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -386,7 +386,7 @@ describe("runGatewayUpdate", () => { { name: "target ref", options: { devTargetRef: "main" } }, ] as const)("stops dev update when fetch fails before resolving $name", async ({ options }) => { await setupGitCheckout(); - const fetchCommand = `git -C ${tempDir} fetch --all --prune --tags`; + const fetchCommand = `git -C ${tempDir} fetch --all --prune --no-tags`; const { runner, calls } = createRunner({ ...buildGitWorktreeProbeResponses(), [fetchCommand]: { @@ -403,6 +403,102 @@ describe("runGatewayUpdate", () => { expect(calls.slice(calls.indexOf(fetchCommand) + 1)).toStrictEqual([]); }); + it("does not fetch tags for dev updates", async () => { + await setupGitPackageManagerFixture(); + const upstreamSha = "upstream123"; + const doctorNodePath = await resolveStableNodePath(process.execPath); + const doctorCommand = `${doctorNodePath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive --fix`; + const { runner, calls } = createRunner({ + ...buildGitWorktreeProbeResponses(), + [`git -C ${tempDir} fetch --all --prune --no-tags`]: { stdout: "" }, + [`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]: { + stdout: "origin/main", + }, + [`git -C ${tempDir} rev-parse @{upstream}`]: { stdout: upstreamSha }, + [`git -C ${tempDir} rev-list --max-count=10 ${upstreamSha}`]: { + stdout: `${upstreamSha}\n`, + }, + [`git -C ${tempDir} rebase ${upstreamSha}`]: { stdout: "" }, + "pnpm --version": { stdout: "10.0.0" }, + "pnpm install": { stdout: "" }, + "pnpm build": { stdout: "" }, + "pnpm ui:build": { stdout: "" }, + [doctorCommand]: { stdout: "" }, + }); + + const result = await runWithRunner(runner, { channel: "dev" }); + + expect(result.status).toBe("ok"); + expect(calls).toContain(`git -C ${tempDir} fetch --all --prune --no-tags`); + expect(calls).not.toContain(`git -C ${tempDir} fetch --all --prune --tags`); + }); + + it("fetches only the requested tag for explicit dev tag target refs", async () => { + await setupGitPackageManagerFixture(); + const targetSha = "2222222222222222222222222222222222222222"; + const doctorNodePath = await resolveStableNodePath(process.execPath); + const doctorCommand = `${doctorNodePath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive --fix`; + const { runner, calls } = createRunner({ + ...buildGitWorktreeProbeResponses(), + [`git -C ${tempDir} fetch --all --prune --no-tags`]: { stdout: "" }, + [`git -C ${tempDir} remote`]: { stdout: "origin\n" }, + [`git -C ${tempDir} fetch origin +refs/tags/v2026.5.19-beta.2:refs/tags/v2026.5.19-beta.2`]: { + stdout: "", + }, + [`git -C ${tempDir} rev-parse refs/tags/v2026.5.19-beta.2^{}`]: { + stdout: `${targetSha}\n`, + }, + [`git -C ${tempDir} rev-list --max-count=10 ${targetSha}`]: { + stdout: `${targetSha}\n`, + }, + [`git -C ${tempDir} checkout --detach ${targetSha}`]: { stdout: "" }, + "pnpm --version": { stdout: "10.0.0" }, + "pnpm install": { stdout: "" }, + "pnpm build": { stdout: "" }, + "pnpm ui:build": { stdout: "" }, + [doctorCommand]: { stdout: "" }, + }); + + const result = await runWithRunner(runner, { + channel: "dev", + devTargetRef: "refs/tags/v2026.5.19-beta.2", + }); + + expect(result.status).toBe("ok"); + expect(calls).toContain(`git -C ${tempDir} fetch --all --prune --no-tags`); + expect(calls).not.toContain(`git -C ${tempDir} fetch --all --prune --tags`); + expect(calls).toContain( + `git -C ${tempDir} fetch origin +refs/tags/v2026.5.19-beta.2:refs/tags/v2026.5.19-beta.2`, + ); + expect(calls).toContain(`git -C ${tempDir} rev-parse refs/tags/v2026.5.19-beta.2^{}`); + }); + + it("does not resolve stale local dev tag target refs after targeted tag fetch failure", async () => { + await setupGitCheckout(); + const { runner, calls } = createRunner({ + ...buildGitWorktreeProbeResponses(), + [`git -C ${tempDir} fetch --all --prune --no-tags`]: { stdout: "" }, + [`git -C ${tempDir} remote`]: { stdout: "origin\n" }, + [`git -C ${tempDir} fetch origin +refs/tags/v2026.5.19-beta.2:refs/tags/v2026.5.19-beta.2`]: { + code: 1, + stderr: "would clobber existing tag", + }, + }); + + const result = await runWithRunner(runner, { + channel: "dev", + devTargetRef: "refs/tags/v2026.5.19-beta.2", + }); + + expect(result.status).toBe("error"); + expect(result.reason).toBe("no-target-sha"); + expect(calls).toContain( + `git -C ${tempDir} fetch origin +refs/tags/v2026.5.19-beta.2:refs/tags/v2026.5.19-beta.2`, + ); + expect(calls).not.toContain(`git -C ${tempDir} rev-parse refs/tags/v2026.5.19-beta.2^{}`); + expect(calls).not.toContain(`git -C ${tempDir} rev-parse refs/tags/v2026.5.19-beta.2`); + }); + it("aborts rebase on failure", async () => { await setupGitCheckout(); const { runner, calls } = createRunner({ @@ -537,7 +633,7 @@ describe("runGatewayUpdate", () => { 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`) { + if (key === `git -C ${tempDir} fetch --all --prune --no-tags`) { return { stdout: "", stderr: "", code: 0 }; } if (key === `git -C ${tempDir} rev-parse @{upstream}`) { @@ -758,7 +854,7 @@ describe("runGatewayUpdate", () => { 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`) { + if (key === `git -C ${tempDir} fetch --all --prune --no-tags`) { return { stdout: "", stderr: "", code: 0 }; } if (key === `git -C ${tempDir} rev-parse @{upstream}`) { @@ -873,7 +969,7 @@ describe("runGatewayUpdate", () => { 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`) { + if (key === `git -C ${tempDir} fetch --all --prune --no-tags`) { return { stdout: "", stderr: "", code: 0 }; } if (key === `git -C ${tempDir} rev-parse @{upstream}`) { @@ -970,7 +1066,7 @@ describe("runGatewayUpdate", () => { 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`) { + if (key === `git -C ${tempDir} fetch --all --prune --no-tags`) { return { stdout: "", stderr: "", code: 0 }; } if (key === `git -C ${tempDir} rev-parse @{upstream}`) { @@ -1084,7 +1180,7 @@ describe("runGatewayUpdate", () => { 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`) { + if (key === `git -C ${tempDir} fetch --all --prune --no-tags`) { return { stdout: "", stderr: "", code: 0 }; } if (key === `git -C ${tempDir} rev-parse @{upstream}`) { @@ -1182,7 +1278,7 @@ describe("runGatewayUpdate", () => { 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`) { + if (key === `git -C ${tempDir} fetch --all --prune --no-tags`) { return { stdout: "", stderr: "", code: 0 }; } if (key === `git -C ${tempDir} rev-parse @{upstream}`) { @@ -1275,7 +1371,7 @@ describe("runGatewayUpdate", () => { 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`) { + if (key === `git -C ${tempDir} fetch --all --prune --no-tags`) { return { stdout: "", stderr: "", code: 0 }; } if (key === `git -C ${tempDir} rev-parse @{upstream}`) { @@ -1367,7 +1463,7 @@ describe("runGatewayUpdate", () => { if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) { return { stdout: "", stderr: "", code: 0 }; } - if (key === `git -C ${tempDir} fetch --all --prune --tags`) { + if (key === `git -C ${tempDir} fetch --all --prune --no-tags`) { return { stdout: "", stderr: "", code: 0 }; } if (key === `git -C ${tempDir} rev-parse ${targetSha}`) { @@ -1447,7 +1543,7 @@ describe("runGatewayUpdate", () => { if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) { return { stdout: "", stderr: "", code: 0 }; } - if (key === `git -C ${tempDir} fetch --all --prune --tags`) { + if (key === `git -C ${tempDir} fetch --all --prune --no-tags`) { return { stdout: "", stderr: "", code: 0 }; } if (key === `git -C ${tempDir} rev-parse refs/remotes/origin/main`) { @@ -1530,7 +1626,7 @@ describe("runGatewayUpdate", () => { if (key === `git -C ${gitRoot} status --porcelain -- :!dist/control-ui/`) { return { stdout: "", stderr: "", code: 0 }; } - if (key === `git -C ${gitRoot} fetch --all --prune --tags`) { + if (key === `git -C ${gitRoot} fetch --all --prune --no-tags`) { return { stdout: "", stderr: "", code: 0 }; } if (key === `git -C ${gitRoot} rev-parse refs/remotes/origin/main`) { @@ -1606,7 +1702,7 @@ describe("runGatewayUpdate", () => { 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`) { + if (key === `git -C ${tempDir} fetch --all --prune --no-tags`) { return { stdout: "", stderr: "", code: 0 }; } if (key === `git -C ${tempDir} rev-parse @{upstream}`) { diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index ff19e57b6d8..56db25dabbe 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -442,6 +442,11 @@ function looksLikeFullCommitSha(value: string): boolean { return /^[0-9a-f]{40}$/i.test(value.trim()); } +function resolveTagFetchRef(candidate: string): string | null { + const ref = candidate.endsWith("^{}") ? candidate.slice(0, -"^{}".length) : candidate; + return ref.startsWith("refs/tags/") ? ref : null; +} + function buildDevTargetRefResolutionCandidates(devTargetRef: string): string[] { const trimmed = devTargetRef.trim(); const candidates: string[] = []; @@ -573,7 +578,26 @@ function isSupersededInstallFailure( } function findBlockingGitFailure(steps: readonly UpdateStepResult[]): UpdateStepResult | undefined { - return steps.find((step) => step.exitCode !== 0 && !isSupersededInstallFailure(step, steps)); + return steps.find( + (step, index) => + step.exitCode !== 0 && + !isSupersededInstallFailure(step, steps) && + !isSupersededTargetRefFailure(step, steps.slice(index + 1)), + ); +} + +function isSupersededTargetRefFailure( + step: UpdateStepResult, + followingSteps: readonly UpdateStepResult[], +): boolean { + const isTargetRefProbe = step.name.startsWith("git rev-parse "); + const isTargetTagFetch = step.name.startsWith("git fetch ") && step.name.includes(" refs/tags/"); + if (!isTargetRefProbe && !isTargetTagFetch) { + return false; + } + return followingSteps.some( + (candidate) => candidate.name.startsWith("git rev-parse ") && candidate.exitCode === 0, + ); } function mergeCommandEnvironments( @@ -876,7 +900,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< const fetchFailure = await runRequiredGitStep( "git fetch", - ["git", "-C", gitRoot, "fetch", "--all", "--prune", "--tags"], + ["git", "-C", gitRoot, "fetch", "--all", "--prune", "--no-tags"], "fetch-failed", ); if (fetchFailure) { @@ -887,6 +911,35 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< if (devTargetRef) { let targetSha: string | null = null; for (const targetRefCandidate of buildDevTargetRefResolutionCandidates(devTargetRef)) { + const tagFetchRef = resolveTagFetchRef(targetRefCandidate); + if (tagFetchRef) { + const remoteListStep = await runStep( + step("git remote", ["git", "-C", gitRoot, "remote"], gitRoot), + ); + steps.push(remoteListStep); + const remotes = (remoteListStep.stdoutTail ?? "") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + let fetchedTag = false; + for (const remote of remotes) { + const targetTagFetchStep = await runStep( + step( + `git fetch ${remote} ${tagFetchRef}`, + ["git", "-C", gitRoot, "fetch", remote, `+${tagFetchRef}:${tagFetchRef}`], + gitRoot, + ), + ); + steps.push(targetTagFetchStep); + if (targetTagFetchStep.exitCode === 0) { + fetchedTag = true; + break; + } + } + if (remotes.length > 0 && !fetchedTag) { + continue; + } + } const targetShaStep = await runStep( step( `git rev-parse ${targetRefCandidate}`,