mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 17:31:34 +00:00
* 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
314 lines
9.5 KiB
JavaScript
Executable File
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);
|
|
}
|
|
}
|