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 733680b1bc.
- Required merge gates passed before the squash merge.

Prepared head SHA: 733680b1bc
Review: https://github.com/openclaw/openclaw/pull/84737#issuecomment-4503692161

Co-authored-by: Ruben Cuevas <hi@rubencu.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
Rubén Cuevas
2026-05-24 02:37:21 -04:00
committed by GitHub
parent 4d150209c3
commit 501f2cbfe4
3 changed files with 164 additions and 14 deletions

View File

@@ -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.

View File

@@ -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}`) {

View File

@@ -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}`,