Files
openclaw/scripts/full-release-validation-at-sha.mjs
Peter Steinberger 8a66398b2b fix(release): harden validation orchestration (#106873)
* fix(release): harden validation orchestration

* fix(ci): repair release gate regressions

* docs(agents): record PR review guard mode

* test(ci): stabilize release gate coverage

* refactor(agents): remove session factory test export
2026-07-13 17:53:10 -07:00

481 lines
15 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 RELEASE_BRANCH_PATTERN =
/^(?:release\/[0-9]{4}\.[0-9]+\.[0-9]+|extended-stable\/[0-9]{4}\.[0-9]+\.33)$/u;
const RELEASE_TAG_PATTERN = /^v[0-9]{4}\.[0-9]+\.[0-9]+(?:-(?:alpha|beta)\.[0-9]+)?$/u;
const DEFAULT_INPUTS = {
provider: "openai",
mode: "both",
rerun_group: "all",
reuse_evidence: "true",
};
const WORKFLOW_RUN_CHECK_SUITE_QUERY = `
query($owner: String!, $repo: String!, $oid: GitObjectID!, $after: String) {
repository(owner: $owner, name: $repo) {
object(oid: $oid) {
... on Commit {
checkSuites(first: 100, after: $after) {
nodes {
status
conclusion
workflowRun {
url
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
}`;
function usage() {
console.error(`Usage: node scripts/full-release-validation-at-sha.mjs [--sha <target-sha>] [--target-ref <canonical-release-branch-or-tag>] [--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 and changelog-only Release SHA
evidence reuse stay enabled; pass -f reuse_evidence=false to force a fresh
run. The release profile defaults to beta for alpha/beta package versions and
stable otherwise; pass -f release_profile=full for the broad advisory sweep.`);
}
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: "",
targetRef: "",
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 === "--target-ref") {
args.targetRef = 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 (
args.inputs.release_profile &&
!["beta", "stable", "full"].includes(args.inputs.release_profile)
) {
throw new Error("release_profile must be beta, stable, or full");
}
if (Object.hasOwn(args.inputs, "ref")) {
throw new Error("SHA-pinned release validation reserves the ref input for --sha");
}
if (
args.targetRef &&
!RELEASE_BRANCH_PATTERN.test(args.targetRef) &&
!RELEASE_TAG_PATTERN.test(args.targetRef)
) {
throw new Error("--target-ref must be a canonical OpenClaw release branch or tag");
}
return args;
}
export function resolveRemoteTargetRefSha(targetRef, executeGit = (args) => run("git", args)) {
if (RELEASE_BRANCH_PATTERN.test(targetRef)) {
return executeGit(["ls-remote", "--heads", "origin", `refs/heads/${targetRef}`]).split(
/\s+/u,
)[0];
}
const tagRef = `refs/tags/${targetRef}`;
const peeledSha = executeGit(["ls-remote", "--tags", "origin", `${tagRef}^{}`]).split(/\s+/u)[0];
if (peeledSha) {
return peeledSha;
}
return executeGit(["ls-remote", "--tags", "origin", tagRef]).split(/\s+/u)[0];
}
function verifyTargetRef(targetRef, targetSha) {
if (!targetRef) {
return targetSha;
}
const remoteSha = resolveRemoteTargetRefSha(targetRef);
if (remoteSha !== targetSha) {
throw new Error(`Target ref ${targetRef} does not resolve to ${targetSha}`);
}
return targetRef;
}
function resolveSha(requestedSha) {
const rev = requestedSha || "HEAD";
return run("git", ["rev-parse", "--verify", `${rev}^{commit}`], { dryRun: false });
}
export function releaseProfileForTarget(
targetSha,
readPackageJson = (sha) => run("git", ["show", `${sha}:package.json`]),
) {
let version;
try {
version = JSON.parse(readPackageJson(targetSha)).version;
} catch {
throw new Error(`Could not read package.json from target SHA ${targetSha}`);
}
if (typeof version !== "string" || !/^[0-9]{4}\.[0-9]+\.[0-9]+(?:-.+)?$/u.test(version)) {
throw new Error(`Target SHA ${targetSha} has an invalid package version`);
}
return /-(?:alpha|beta)\.[1-9][0-9]*$/u.test(version) ? "beta" : "stable";
}
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 selectWorkflowRunCheckSuite(nodes, parentRunId) {
if (!/^[1-9][0-9]*$/u.test(String(parentRunId))) {
throw new Error("parent run ID must be a positive decimal");
}
const expectedUrl = `https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`;
return nodes.find((node) => node?.workflowRun?.url === expectedUrl);
}
function readWorkflowRunCheckSuite(parentRunId, workflowSha) {
let after;
for (let page = 0; page < 20; page += 1) {
const queryArgs = [
"api",
"graphql",
"-F",
"owner=openclaw",
"-F",
"repo=openclaw",
"-F",
`oid=${workflowSha}`,
"-f",
`query=${WORKFLOW_RUN_CHECK_SUITE_QUERY}`,
];
if (after) {
queryArgs.push("-F", `after=${after}`);
}
const response = JSON.parse(run("gh", queryArgs));
const checkSuites = response?.data?.repository?.object?.checkSuites;
if (!checkSuites || !Array.isArray(checkSuites.nodes)) {
throw new Error("GraphQL response did not include commit check suites");
}
const match = selectWorkflowRunCheckSuite(checkSuites.nodes, parentRunId);
if (match) {
return match;
}
if (!checkSuites.pageInfo?.hasNextPage) {
return undefined;
}
after = checkSuites.pageInfo.endCursor;
if (!after) {
throw new Error("GraphQL check-suite pagination omitted its end cursor");
}
}
throw new Error("Full Release Validation check suite was not found within 20 pages");
}
function waitForWorkflowRun(parentRunId, workflowSha) {
let lastSummary = "";
let consecutiveErrors = 0;
for (let attempt = 0; attempt < 480; attempt += 1) {
let suite;
try {
suite = readWorkflowRunCheckSuite(parentRunId, workflowSha);
consecutiveErrors = 0;
} catch (error) {
consecutiveErrors += 1;
if (consecutiveErrors >= 3) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
console.warn(`Parent run status query failed; retrying: ${message}`);
}
const summary = suite
? `${String(suite.status).toLowerCase()}/${String(suite.conclusion ?? "pending").toLowerCase()}`
: "awaiting-check-suite";
if (summary !== lastSummary) {
console.log(`Parent run status: ${summary}`);
lastSummary = summary;
}
if (suite?.status === "COMPLETED") {
if (suite.conclusion === "SUCCESS") {
return;
}
throw new Error(
`Full Release Validation concluded ${String(suite.conclusion).toLowerCase()}: https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`,
);
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 45_000);
}
throw new Error(
`Timed out waiting for Full Release Validation: https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`,
);
}
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);
args.inputs.release_profile ??= releaseProfileForTarget(targetSha);
const targetContextRef = verifyTargetRef(args.targetRef, targetSha);
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,
...(targetContextRef !== targetSha ? { target_context_ref: targetContextRef } : {}),
...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}`);
waitForWorkflowRun(parentRunId, workflowSha);
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);
}
}