Files
openclaw/scripts/full-release-validation-at-sha.mjs
Vincent Koc c47ceb0f3d improve(release): reuse exact-SHA validation evidence (#104162)
* perf(release): share changelog verification snapshots

* perf(release): reuse exact-SHA validation evidence

* feat(release): checkpoint candidate workflow state

* feat(release): watch CI transitions compactly

* fix(testbox): rotate stale reusable leases

* refactor(release): move CI verifier into scripts

* fix(release): preserve verifier executable mode

* fix(testbox): force noninteractive remote hydration

* perf(testbox): skip sync for proven clean heads

* fix(testbox): keep changed gates synchronized

* fix(testbox): isolate git state probes

* fix(testbox): isolate wrapper git commands

* fix(testbox): preserve git command contracts

* fix(release): validate reused SHA evidence

* fix(release): resume serialized plugin selections

* fix(testbox): sync source on every lease reuse

* fix(release): verify from trusted workflow checkout

* fix(release): gate evidence reuse on trusted lineage

* fix(release): support legacy verifier checkouts

* fix(testbox): export CI across shell snippets

* fix(release): revalidate reused evidence before publish

* fix(release): reject untrusted reuse before lookup

* fix(release): reuse SHA-pinned root evidence

* fix(ci): allow unreleased notes in QA packages

* fix(release): satisfy script lint contracts

* fix(release): handle Unicode workflow refs safely
2026-07-11 12:48:27 +08:00

314 lines
9.5 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 { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
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: "true",
};
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 lineage through the release evidence manifest, then deletes the
temporary branch by default. Exact-target evidence reuse stays enabled; pass
-f reuse_evidence=false to force a fresh run.`);
}
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 (!["true", "false"].includes(args.inputs.reuse_evidence)) {
throw new Error("reuse_evidence must be true or false");
}
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) : "";
}
export function releaseEvidenceVerificationArgs(parentRunId) {
if (!/^[1-9][0-9]*$/u.test(String(parentRunId))) {
throw new Error("parent run ID must be a positive decimal");
}
return ["--validate-run", String(parentRunId), "--trusted-workflow-ref", "main", "--json"];
}
export function releaseEvidenceVerifierPath(worktreeRoot) {
const candidates = [
join(worktreeRoot, "scripts", "release-ci-summary.mjs"),
join(
worktreeRoot,
".agents",
"skills",
"release-openclaw-ci",
"scripts",
"release-ci-summary.mjs",
),
];
const verifier = candidates.find((candidate) => existsSync(candidate));
if (!verifier) {
throw new Error("trusted workflow checkout does not contain a release evidence verifier");
}
return verifier;
}
function verifyReleaseEvidence(parentRunId, workflowSha) {
const verifierWorktree = mkdtempSync(join(tmpdir(), "openclaw-release-verifier-"));
try {
run("git", ["worktree", "add", "--detach", verifierWorktree, workflowSha], {
stdio: ["ignore", "ignore", "inherit"],
});
const verifier = releaseEvidenceVerifierPath(verifierWorktree);
const evidence = JSON.parse(
run(process.execPath, [verifier, ...releaseEvidenceVerificationArgs(parentRunId)]),
);
if (evidence.valid !== true) {
throw new Error(`Full Release Validation evidence is invalid for run ${parentRunId}.`);
}
console.log(
`ok release evidence current=${evidence.current.runId} root=${evidence.root.runId} reused=${Boolean(evidence.evidenceReuse)}`,
);
} finally {
runStatus("git", ["worktree", "remove", "--force", verifierWorktree], {
stdio: ["ignore", "ignore", "ignore"],
});
rmSync(verifierWorktree, { force: true, recursive: true });
}
}
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}`,
);
}
verifyReleaseEvidence(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);
}
}