diff --git a/scripts/docker-e2e-rerun.mjs b/scripts/docker-e2e-rerun.mjs index 2a5833e75d9..20990a4f32e 100644 --- a/scripts/docker-e2e-rerun.mjs +++ b/scripts/docker-e2e-rerun.mjs @@ -92,6 +92,55 @@ function maybeGhcrImage(value) { return typeof value === "string" && value.startsWith("ghcr.io/") ? value : ""; } +const TRUSTED_WORKFLOW_INPUTS = new Map([ + ["package_artifact_run_id", "packageArtifactRunId"], + ["package_artifact_name", "packageArtifactName"], + ["docker_e2e_bare_image", "bareImage"], + ["docker_e2e_functional_image", "functionalImage"], + ["published_upgrade_survivor_baseline", "publishedUpgradeSurvivorBaseline"], + ["published_upgrade_survivor_baselines", "publishedUpgradeSurvivorBaselines"], + ["published_upgrade_survivor_scenarios", "publishedUpgradeSurvivorScenarios"], +]); + +const REUSE_INPUT_KEYS = [ + "packageArtifactRunId", + "packageArtifactName", + "bareImage", + "functionalImage", + "workflowRef", + "publishedUpgradeSurvivorBaseline", + "publishedUpgradeSurvivorBaselines", + "publishedUpgradeSurvivorScenarios", +]; + +const WORKFLOW_INPUT_RE = /(?:^|\s)-f\s+([a-z0-9_]+)=('([^']*)'|[^\s]+)/gu; +const WORKFLOW_REF_RE = /(?:^|\s)--ref\s+('([^']*)'|[^\s]+)/u; + +function trustedReuseInputsFromCommand(command) { + const text = String(command ?? ""); + if (!/^\s*gh\s+workflow\s+run\s/u.test(text)) { + return {}; + } + const inputs = {}; + const refValue = text.match(WORKFLOW_REF_RE); + if (refValue) { + inputs.workflowRef = (refValue[2] ?? refValue[1] ?? "").replace(/^'/u, "").replace(/'$/u, ""); + } + for (const match of text.matchAll(WORKFLOW_INPUT_RE)) { + const target = TRUSTED_WORKFLOW_INPUTS.get(match[1]); + const value = (match[3] ?? match[2] ?? "").replace(/^'/u, "").replace(/'$/u, ""); + if (!target || !value) { + continue; + } + const normalized = + target === "bareImage" || target === "functionalImage" ? maybeGhcrImage(value) : value; + if (normalized) { + inputs[target] = normalized; + } + } + return inputs; +} + function reuseInputsFromJson(parsed) { const packageArtifactRunId = parsed.github?.runId || ""; if (!packageArtifactRunId) { @@ -107,12 +156,11 @@ function reuseInputsFromJson(parsed) { } function sameReuseInputs(left, right) { - return ( - (left?.packageArtifactRunId || "") === (right?.packageArtifactRunId || "") && - (left?.packageArtifactName || "") === (right?.packageArtifactName || "") && - (left?.bareImage || "") === (right?.bareImage || "") && - (left?.functionalImage || "") === (right?.functionalImage || "") - ); + return REUSE_INPUT_KEYS.every((key) => (left?.[key] || "") === (right?.[key] || "")); +} + +function reuseInputsKey(inputs) { + return JSON.stringify(REUSE_INPUT_KEYS.map((key) => inputs?.[key] || "")); } function commonReuseInputs(entries) { @@ -124,8 +172,25 @@ function commonReuseInputs(entries) { return inputs.every((input) => sameReuseInputs(first, input)) ? first : {}; } +function groupByReuseInputs(entries) { + const groups = new Map(); + for (const entry of entries) { + const key = reuseInputsKey(entry.reuseInputs); + const group = groups.get(key); + if (group) { + group.push(entry); + } else { + groups.set(key, [entry]); + } + } + return [...groups.values()]; +} + function ghWorkflowCommand(lanes, ref, workflow, reuseInputs = {}) { - const workflowRef = process.env.OPENCLAW_DOCKER_E2E_WORKFLOW_REF || process.env.GITHUB_REF_NAME; + const workflowRef = + reuseInputs.workflowRef || + process.env.OPENCLAW_DOCKER_E2E_WORKFLOW_REF || + process.env.GITHUB_REF_NAME; const releasePath = lanes.some(laneNeedsReleasePath); const fields = [ "gh workflow run", @@ -159,6 +224,30 @@ function ghWorkflowCommand(lanes, ref, workflow, reuseInputs = {}) { if (reuseInputs.functionalImage) { fields.push("-f", `docker_e2e_functional_image=${shellQuote(reuseInputs.functionalImage)}`); } + if (reuseInputs.publishedUpgradeSurvivorBaseline) { + fields.push( + "-f", + `published_upgrade_survivor_baseline=${shellQuote( + reuseInputs.publishedUpgradeSurvivorBaseline, + )}`, + ); + } + if (reuseInputs.publishedUpgradeSurvivorBaselines) { + fields.push( + "-f", + `published_upgrade_survivor_baselines=${shellQuote( + reuseInputs.publishedUpgradeSurvivorBaselines, + )}`, + ); + } + if (reuseInputs.publishedUpgradeSurvivorScenarios) { + fields.push( + "-f", + `published_upgrade_survivor_scenarios=${shellQuote( + reuseInputs.publishedUpgradeSurvivorScenarios, + )}`, + ); + } return fields.join(" "); } @@ -169,20 +258,31 @@ function failureName(failure) { function failedEntryFromRecord(failure, file, ref, workflow, reuseInputs) { const lane = failureName(failure); const targetable = failure.targetable !== false; + const workflowInputs = { + ...trustedReuseInputsFromCommand(failure.ghWorkflowCommand), + ...reuseInputs, + }; return { - ghWorkflowCommand: targetable - ? failure.ghWorkflowCommand || ghWorkflowCommand([lane], ref, workflow, reuseInputs) - : "", lane, localRerunCommand: failure.rerunCommand, logFile: failure.logFile, - reuseInputs, + reuseInputs: workflowInputs, source: file, status: failure.status, targetable, }; } +function mergeReuseInputs(left = {}, right = {}) { + const merged = { ...left }; + for (const [key, value] of Object.entries(right)) { + if (value) { + merged[key] = value; + } + } + return merged; +} + function detectRepo() { return run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]).trim(); } @@ -222,7 +322,18 @@ function failedLaneEntriesFromJson(file, ref, workflow) { function mergeByLane(entries) { const byLane = new Map(); for (const entry of entries) { - if (!byLane.has(entry.lane)) { + const existing = byLane.get(entry.lane); + if (existing) { + byLane.set(entry.lane, { + ...existing, + ...entry, + localRerunCommand: existing.localRerunCommand || entry.localRerunCommand, + logFile: existing.logFile || entry.logFile, + reuseInputs: mergeReuseInputs(existing.reuseInputs, entry.reuseInputs), + source: existing.source || entry.source, + targetable: existing.targetable !== false && entry.targetable !== false, + }); + } else { byLane.set(entry.lane, entry); } } @@ -288,22 +399,33 @@ function printEntries(entries, ref, workflow, runValue) { console.log(`Failed Docker E2E entries: ${entries.map((entry) => entry.lane).join(", ")}`); if (workflowEntries.length > 0) { console.log(""); - console.log("Combined GitHub rerun:"); - console.log( - ghWorkflowCommand( - workflowEntries.map((entry) => entry.lane), - ref, - workflow, - commonReuseInputs(workflowEntries), - ), - ); - console.log(""); - console.log("Per-lane GitHub reruns:"); - for (const entry of workflowEntries) { + const workflowGroups = groupByReuseInputs(workflowEntries); + if (workflowGroups.length === 1) { + console.log("Combined GitHub rerun:"); console.log( - `- ${entry.lane}: ${entry.ghWorkflowCommand || ghWorkflowCommand([entry.lane], ref, workflow)}`, + ghWorkflowCommand( + workflowEntries.map((entry) => entry.lane), + ref, + workflow, + commonReuseInputs(workflowEntries), + ), ); + } else { + console.log("Combined GitHub reruns:"); + for (const group of workflowGroups) { + const lanes = group.map((entry) => entry.lane); + console.log( + `- ${lanes.join(", ")}: ${ghWorkflowCommand(lanes, ref, workflow, group[0]?.reuseInputs)}`, + ); + } } + console.log(""); + console.log("Per-lane GitHub reruns:"); + for (const entry of workflowEntries) { + console.log( + `- ${entry.lane}: ${ghWorkflowCommand([entry.lane], ref, workflow, entry.reuseInputs)}`, + ); + } } else { console.log(""); console.log("No targetable failed Docker E2E lanes found."); diff --git a/test/scripts/docker-e2e-helper-cli.test.ts b/test/scripts/docker-e2e-helper-cli.test.ts index 232db1477a9..a1b86f7d403 100644 --- a/test/scripts/docker-e2e-helper-cli.test.ts +++ b/test/scripts/docker-e2e-helper-cli.test.ts @@ -187,4 +187,193 @@ describe("Docker E2E helper CLIs", () => { } }, ); + + it("ignores artifact-provided GitHub rerun commands", () => { + const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-command-`); + try { + const file = path.join(root, "failures.json"); + writeFileSync( + file, + `${JSON.stringify( + { + lanes: [ + { + ghWorkflowCommand: "echo poisoned-command", + name: "gateway-network", + status: 1, + }, + ], + status: "failed", + }, + null, + 2, + )}\n`, + "utf8", + ); + + const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", "abc123"); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("docker_lanes='gateway-network'"); + expect(result.stdout).not.toContain("poisoned-command"); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("preserves whitelisted rerun inputs from artifact commands", () => { + const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-inputs-`); + try { + const file = path.join(root, "failures.json"); + writeFileSync( + file, + `${JSON.stringify( + { + lanes: [ + { + ghWorkflowCommand: + "gh workflow run 'openclaw-live-and-e2e-checks-reusable.yml' --ref 'release/2026.6' -f package_artifact_run_id='12345' -f package_artifact_name='docker-e2e-package' -f docker_e2e_bare_image='ghcr.io/openclaw/openclaw-bare:test' -f published_upgrade_survivor_baselines='openclaw@2026.5.3' -f published_upgrade_survivor_scenarios='plugin-dependency-cleanup' -f unsafe_input='do-not-copy'", + name: "published-upgrade-survivor-openclaw-2026-5-3", + status: 1, + }, + ], + status: "failed", + }, + null, + 2, + )}\n`, + "utf8", + ); + + const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", "abc123"); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const combinedCommand = result.stdout.match(/Combined GitHub rerun:\n([^\n]+)/u)?.[1] ?? ""; + expect(combinedCommand).toContain("--ref 'release/2026.6'"); + expect(combinedCommand).toContain("package_artifact_run_id='12345'"); + expect(combinedCommand).toContain( + "docker_e2e_bare_image='ghcr.io/openclaw/openclaw-bare:test'", + ); + expect(combinedCommand).toContain( + "published_upgrade_survivor_baselines='openclaw@2026.5.3'", + ); + expect(combinedCommand).toContain( + "published_upgrade_survivor_scenarios='plugin-dependency-cleanup'", + ); + expect(combinedCommand).not.toContain("unsafe_input"); + expect(result.stdout).toContain("package_artifact_run_id='12345'"); + expect(result.stdout).toContain( + "docker_e2e_bare_image='ghcr.io/openclaw/openclaw-bare:test'", + ); + expect(result.stdout).toContain( + "published_upgrade_survivor_baselines='openclaw@2026.5.3'", + ); + expect(result.stdout).toContain( + "published_upgrade_survivor_scenarios='plugin-dependency-cleanup'", + ); + expect(result.stdout).not.toContain("unsafe_input"); + expect(result.stdout).not.toContain("do-not-copy"); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("groups combined reruns by recovered workflow inputs", () => { + const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-groups-`); + try { + const file = path.join(root, "failures.json"); + writeFileSync( + file, + `${JSON.stringify( + { + lanes: [ + { + ghWorkflowCommand: + "gh workflow run 'openclaw-live-and-e2e-checks-reusable.yml' --ref 'release/2026.6' -f published_upgrade_survivor_baselines='openclaw@2026.5.3'", + name: "published-upgrade-survivor-openclaw-2026-5-3", + status: 1, + }, + { + ghWorkflowCommand: + "gh workflow run 'openclaw-live-and-e2e-checks-reusable.yml' --ref 'release/2026.6' -f published_upgrade_survivor_baselines='openclaw@2026.5.2'", + name: "published-upgrade-survivor-openclaw-2026-5-2", + status: 1, + }, + ], + status: "failed", + }, + null, + 2, + )}\n`, + "utf8", + ); + + const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", "abc123"); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("Combined GitHub reruns:"); + expect(result.stdout).toContain( + "- published-upgrade-survivor-openclaw-2026-5-3: gh workflow run", + ); + expect(result.stdout).toContain( + "- published-upgrade-survivor-openclaw-2026-5-2: gh workflow run", + ); + expect(result.stdout).toContain( + "docker_lanes='published-upgrade-survivor-openclaw-2026-5-3'", + ); + expect(result.stdout).toContain( + "docker_lanes='published-upgrade-survivor-openclaw-2026-5-2'", + ); + expect(result.stdout).not.toContain( + "docker_lanes='published-upgrade-survivor-openclaw-2026-5-3 published-upgrade-survivor-openclaw-2026-5-2'", + ); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("merges duplicate lane entries before printing reruns", () => { + const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-merge-`); + try { + const file = path.join(root, "failures.json"); + writeFileSync( + file, + `${JSON.stringify( + { + lanes: [ + { + name: "published-upgrade-survivor-openclaw-2026-5-3", + status: 1, + }, + { + ghWorkflowCommand: + "gh workflow run 'openclaw-live-and-e2e-checks-reusable.yml' --ref 'release/2026.6' -f published_upgrade_survivor_baselines='openclaw@2026.5.3'", + name: "published-upgrade-survivor-openclaw-2026-5-3", + status: 1, + }, + ], + status: "failed", + }, + null, + 2, + )}\n`, + "utf8", + ); + + const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", "abc123"); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const combinedCommand = result.stdout.match(/Combined GitHub rerun:\n([^\n]+)/u)?.[1] ?? ""; + expect(combinedCommand).toContain("--ref 'release/2026.6'"); + expect(combinedCommand).toContain( + "published_upgrade_survivor_baselines='openclaw@2026.5.3'", + ); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); });