#!/usr/bin/env node // Builds cheap rerun commands from a Docker E2E GitHub run or local summary. // For GitHub runs, the script downloads Docker E2E artifacts, reads // summary/failures JSON, and prints targeted workflow commands that prepare a // fresh OpenClaw tarball for the same ref before running only failed lanes. import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; const DEFAULT_WORKFLOW = "openclaw-live-and-e2e-checks-reusable.yml"; function usage() { return [ "Usage:", " node scripts/docker-e2e-rerun.mjs [--repo owner/repo] [--dir output-dir] [--workflow workflow.yml] [--ref ref]", ].join("\n"); } function parseArgs(argv) { const options = { dir: "", input: "", ref: "", repo: "", workflow: DEFAULT_WORKFLOW, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--repo") { options.repo = argv[(index += 1)] ?? ""; } else if (arg?.startsWith("--repo=")) { options.repo = arg.slice("--repo=".length); } else if (arg === "--dir") { options.dir = argv[(index += 1)] ?? ""; } else if (arg?.startsWith("--dir=")) { options.dir = arg.slice("--dir=".length); } else if (arg === "--workflow") { options.workflow = argv[(index += 1)] ?? ""; } else if (arg?.startsWith("--workflow=")) { options.workflow = arg.slice("--workflow=".length); } else if (arg === "--ref") { options.ref = argv[(index += 1)] ?? ""; } else if (arg?.startsWith("--ref=")) { options.ref = arg.slice("--ref=".length); } else if (!options.input) { options.input = arg; } else { throw new Error(`unknown argument: ${arg}\n${usage()}`); } } if (!options.input || !options.workflow) { throw new Error(usage()); } return options; } function run(command, args, options = {}) { const result = spawnSync(command, args, { encoding: "utf8", stdio: options.stdio ?? ["ignore", "pipe", "pipe"], }); if (result.status !== 0) { throw new Error( `${command} ${args.join(" ")} failed with ${result.status ?? result.signal}\n${result.stderr}`, ); } return result.stdout; } function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); } function shellQuote(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; } function ghWorkflowCommand(lanes, ref, workflow) { return [ "gh workflow run", shellQuote(workflow), "-f", `ref=${shellQuote(ref)}`, "-f", "include_repo_e2e=false", "-f", "include_release_path_suites=false", "-f", "include_openwebui=false", "-f", `docker_lanes=${shellQuote(lanes.join(" "))}`, "-f", "include_live_suites=false", "-f", "live_models_only=false", ].join(" "); } function detectRepo() { return JSON.parse(run("gh", ["repo", "view", "--json", "nameWithOwner"])).nameWithOwner; } function findFiles(rootDir, basenames, out = []) { for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) { const file = path.join(rootDir, entry.name); if (entry.isDirectory()) { findFiles(file, basenames, out); } else if (basenames.has(entry.name)) { out.push(file); } } return out; } function failedLaneEntriesFromJson(file, ref, workflow) { const parsed = readJson(file); const source = path.basename(file); if (source === "failures.json" && Array.isArray(parsed.lanes)) { return parsed.lanes .filter((lane) => lane.name) .map((lane) => ({ ghWorkflowCommand: lane.ghWorkflowCommand, lane: lane.name, localRerunCommand: lane.rerunCommand, logFile: lane.logFile, source: file, status: lane.status, })); } const lanes = Array.isArray(parsed.lanes) ? parsed.lanes : []; return lanes .filter((lane) => lane.status !== 0 && lane.name) .map((lane) => ({ ghWorkflowCommand: ghWorkflowCommand([lane.name], ref, workflow), lane: lane.name, localRerunCommand: lane.rerunCommand, logFile: lane.logFile, source: file, status: lane.status, })); } function mergeByLane(entries) { const byLane = new Map(); for (const entry of entries) { if (!byLane.has(entry.lane)) { byLane.set(entry.lane, entry); } } return [...byLane.values()].toSorted((left, right) => left.lane.localeCompare(right.lane)); } function downloadDockerArtifacts(runId, repo, outputDir) { fs.mkdirSync(outputDir, { recursive: true }); const artifacts = JSON.parse( run("gh", [ "api", `repos/${repo}/actions/runs/${runId}/artifacts?per_page=100`, "--jq", ".artifacts", ]), ); const names = artifacts .filter((artifact) => !artifact.expired && artifact.name.startsWith("docker-e2e-")) .map((artifact) => artifact.name); if (names.length === 0) { throw new Error(`No docker-e2e-* artifacts found for run ${runId}`); } for (const name of names) { run( "gh", ["run", "download", String(runId), "--repo", repo, "--name", name, "--dir", outputDir], { stdio: "inherit", }, ); } return names; } function runInfo(runId, repo) { return JSON.parse( run("gh", [ "run", "view", String(runId), "--repo", repo, "--json", "databaseId,headSha,headBranch,status,conclusion,url,workflowName", ]), ); } function printEntries(entries, ref, workflow, run) { if (run) { console.log(`Run: ${run.url}`); console.log(`Workflow: ${run.workflowName}`); } console.log(`Ref: ${ref}`); console.log( "Targeted GitHub reruns prepare a fresh OpenClaw npm tarball for that ref before lane execution.", ); if (entries.length === 0) { console.log("No failed Docker E2E lanes found."); return; } console.log(`Failed lanes: ${entries.map((entry) => entry.lane).join(", ")}`); console.log(""); console.log("Combined GitHub rerun:"); console.log( ghWorkflowCommand( entries.map((entry) => entry.lane), ref, workflow, ), ); console.log(""); console.log("Per-lane GitHub reruns:"); for (const entry of entries) { console.log( `- ${entry.lane}: ${entry.ghWorkflowCommand || ghWorkflowCommand([entry.lane], ref, workflow)}`, ); } console.log(""); console.log("Local rerun starting points:"); for (const entry of entries) { if (entry.localRerunCommand) { console.log(`- ${entry.lane}: ${entry.localRerunCommand}`); } } } const options = parseArgs(process.argv.slice(2)); const isLocalJson = fs.existsSync(options.input) && fs.statSync(options.input).isFile(); if (isLocalJson) { const ref = options.ref || process.env.GITHUB_SHA || "HEAD"; printEntries( mergeByLane(failedLaneEntriesFromJson(options.input, ref, options.workflow)), ref, options.workflow, ); } else { const repo = options.repo || detectRepo(); const run = runInfo(options.input, repo); const ref = options.ref || run.headSha || run.headBranch; const outputDir = options.dir || path.join(os.tmpdir(), `openclaw-docker-e2e-rerun-${options.input}`); const artifactNames = downloadDockerArtifacts(options.input, repo, outputDir); const files = findFiles(outputDir, new Set(["failures.json", "summary.json"])); const entries = mergeByLane( files.flatMap((file) => failedLaneEntriesFromJson(file, ref, options.workflow)), ); console.log(`Artifacts: ${artifactNames.join(", ")}`); console.log(`Downloaded: ${outputDir}`); printEntries(entries, ref, options.workflow, run); }