Files
openclaw/scripts/full-release-validation-at-sha.mjs
Peter Steinberger fece8c9f54 fix(release): keep validation evidence immutable across reruns (#103906)
* fix(release): bind validation evidence to exact attempts

* test(release): cover exact validation attempts
2026-07-10 20:16:19 +01:00

304 lines
8.9 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Dispatches full release validation against a temporary SHA-pinned branch.
import { execFileSync, spawnSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const WORKFLOW = "full-release-validation.yml";
const DEFAULT_INPUTS = {
provider: "openai",
mode: "both",
release_profile: "full",
rerun_group: "all",
reuse_evidence: "false",
};
function usage() {
console.error(`Usage: node scripts/full-release-validation-at-sha.mjs [--sha <target-sha>] [--workflow-sha <trusted-main-ref>] [--keep-branch] [--dry-run] [-- -f key=value ...]
Creates a temporary remote branch pinned to trusted main release tooling,
dispatches Full Release Validation with the target commit as its ref input,
watches the parent run, verifies all child workflow head SHAs match the trusted
workflow SHA, then deletes the temporary branch by default. Exact-target
evidence reuse is disabled because it is trusted only from main.`);
}
function run(command, args, options = {}) {
if (options.dryRun) {
console.log(["+", command, ...args].join(" "));
return "";
}
const output = execFileSync(command, args, {
encoding: "utf8",
stdio: options.stdio ?? ["ignore", "pipe", "inherit"],
});
return typeof output === "string" ? output.trim() : "";
}
function runStatus(command, args, options = {}) {
if (options.dryRun) {
console.log(["+", command, ...args].join(" "));
return { status: 0, stdout: "" };
}
return spawnSync(command, args, {
encoding: "utf8",
stdio: options.stdio ?? ["ignore", "pipe", "inherit"],
});
}
function readOptionValue(argv, index, optionName) {
const value = argv[index + 1];
if (value === undefined || value === "" || value.startsWith("-")) {
throw new Error(`${optionName} requires a value`);
}
return value;
}
export function parseArgs(argv) {
const args = {
sha: "",
workflowSha: "",
keepBranch: false,
dryRun: false,
inputs: { ...DEFAULT_INPUTS },
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") {
usage();
process.exit(0);
}
if (arg === "--sha") {
args.sha = readOptionValue(argv, i, arg);
i += 1;
continue;
}
if (arg === "--workflow-sha") {
args.workflowSha = readOptionValue(argv, i, arg);
i += 1;
continue;
}
if (arg === "--keep-branch") {
args.keepBranch = true;
continue;
}
if (arg === "--dry-run") {
args.dryRun = true;
continue;
}
if (arg === "--") {
for (const extra of argv.slice(i + 1)) {
const assignment = extra.startsWith("-f") ? extra.slice(2).trim() : extra;
const [key, ...valueParts] = assignment.split("=");
if (!key || valueParts.length === 0) {
throw new Error(`Unsupported extra argument after --: ${extra}`);
}
args.inputs[key] = valueParts.join("=");
}
break;
}
if (arg === "-f") {
const assignment = readOptionValue(argv, i, arg);
i += 1;
const [key, ...valueParts] = assignment.split("=");
if (!key || valueParts.length === 0) {
throw new Error(`Invalid -f assignment: ${assignment}`);
}
args.inputs[key] = valueParts.join("=");
continue;
}
if (arg.startsWith("-f") && arg.includes("=")) {
const assignment = arg.slice(2).trim();
const [key, ...valueParts] = assignment.split("=");
if (!key || valueParts.length === 0) {
throw new Error(`Invalid -f assignment: ${arg}`);
}
args.inputs[key] = valueParts.join("=");
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
if (args.inputs.reuse_evidence !== "false") {
throw new Error("SHA-pinned release validation always disables evidence reuse");
}
if (Object.hasOwn(args.inputs, "ref")) {
throw new Error("SHA-pinned release validation reserves the ref input for --sha");
}
return args;
}
function resolveSha(requestedSha) {
const rev = requestedSha || "HEAD";
return run("git", ["rev-parse", "--verify", `${rev}^{commit}`], { dryRun: false });
}
function resolveTrustedWorkflowSha(requestedSha) {
run("git", ["fetch", "--no-tags", "origin", "refs/heads/main:refs/remotes/origin/main"], {
stdio: "inherit",
});
const workflowSha = resolveSha(requestedSha || "origin/main");
const ancestry = runStatus("git", [
"merge-base",
"--is-ancestor",
workflowSha,
"refs/remotes/origin/main",
]);
if (ancestry.status !== 0) {
throw new Error(
`Workflow SHA ${workflowSha} is not reachable from current origin/main; refusing an untrusted release harness.`,
);
}
return workflowSha;
}
function collectRunId(dispatchOutput) {
const match = dispatchOutput.match(/actions\/runs\/(\d+)/);
return match?.[1] ?? "";
}
function findLatestRunId(branch, sha) {
const json = run("gh", [
"run",
"list",
"--workflow",
WORKFLOW,
"--branch",
branch,
"--event",
"workflow_dispatch",
"--limit",
"20",
"--json",
"databaseId,headSha,createdAt",
]);
const runs = JSON.parse(json);
const match = runs.find((runItem) => runItem.headSha === sha);
return match?.databaseId ? String(match.databaseId) : "";
}
function childRunIds(parentRunId) {
const jobsJson = run("gh", ["run", "view", parentRunId, "--json", "jobs"]);
const jobs = JSON.parse(jobsJson).jobs ?? [];
const summaryJob = jobs.find((job) => job.name === "Verify full validation");
if (!summaryJob?.databaseId) {
return [];
}
const log = run("gh", [
"run",
"view",
parentRunId,
"--job",
String(summaryJob.databaseId),
"--log",
]);
return [...new Set([...log.matchAll(/actions\/runs\/(\d+)/g)].map((match) => match[1]))];
}
function verifyChildHeads(parentRunId, workflowSha) {
const ids = childRunIds(parentRunId);
if (ids.length === 0) {
throw new Error(
`Could not find child workflow run ids in parent verifier logs for ${parentRunId}.`,
);
}
let failed = false;
for (const id of ids) {
const json = run("gh", ["run", "view", id, "--json", "name,status,conclusion,headSha,url"]);
const child = JSON.parse(json);
const ok =
child.headSha === workflowSha &&
child.status === "completed" &&
child.conclusion === "success";
console.log(
`${ok ? "ok" : "bad"} ${child.name} ${child.status}/${child.conclusion} ${child.headSha} ${child.url}`,
);
failed ||= !ok;
}
if (failed) {
throw new Error(`One or more child workflows failed or did not run at ${workflowSha}.`);
}
}
function main() {
const args = parseArgs(process.argv.slice(2));
const targetSha = resolveSha(args.sha);
const workflowSha = resolveTrustedWorkflowSha(args.workflowSha);
const shortSha = workflowSha.slice(0, 12);
const branch = `release-ci/${shortSha}-${Date.now()}`;
const remoteBranchRef = `refs/heads/${branch}`;
const dispatchInputs = { ref: targetSha, ...args.inputs };
console.log(`Target SHA: ${targetSha}`);
console.log(`Trusted workflow SHA: ${workflowSha}`);
console.log(`Temporary workflow ref: ${branch}`);
run("git", ["push", "origin", `${workflowSha}:${remoteBranchRef}`], {
dryRun: args.dryRun,
stdio: "inherit",
});
let parentRunId;
try {
const dispatchArgs = ["workflow", "run", WORKFLOW, "--ref", branch];
for (const [key, value] of Object.entries(dispatchInputs)) {
dispatchArgs.push("-f", `${key}=${value}`);
}
const dispatchOutput = run("gh", dispatchArgs, { dryRun: args.dryRun });
if (dispatchOutput) {
console.log(dispatchOutput);
}
parentRunId = collectRunId(dispatchOutput);
if (!parentRunId && !args.dryRun) {
for (let attempt = 0; attempt < 60; attempt += 1) {
parentRunId = findLatestRunId(branch, workflowSha);
if (parentRunId) {
break;
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5000);
}
}
if (!parentRunId) {
if (args.dryRun) {
return;
}
throw new Error("Could not determine Full Release Validation run id.");
}
console.log(`Parent run: https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`);
const watch = runStatus(
"gh",
["run", "watch", parentRunId, "--exit-status", "--interval", "30"],
{
stdio: "inherit",
},
);
if (watch.status !== 0) {
throw new Error(
`Full Release Validation failed: https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`,
);
}
verifyChildHeads(parentRunId, workflowSha);
} finally {
if (!args.keepBranch) {
run("git", ["push", "origin", `:${remoteBranchRef}`], {
dryRun: args.dryRun,
stdio: "inherit",
});
} else {
console.log(`Kept ${remoteBranchRef}`);
}
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}