mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fa86caf94f
* fix(release): keep protected tooling trusted after main moves * fix(release): cover protected tooling recovery paths * fix(release): honor live tooling contracts * fix(release): revalidate tooling at npm publish * fix(release): bind npm publishers to live tooling * fix(release): preserve trusted dispatch identity * fix(release): revalidate parent authorization * fix(release): bind ClawHub to release parent * docs(release): define frozen tooling identity * test(release): align ClawHub protected dispatch ref * fix(release): trust protected plugin npm preflight tooling * docs(release): scope protected writer guarantees * fix(release): keep protected tooling foundation npm-only * test(release): cover trusted npm preflight tooling
899 lines
32 KiB
TypeScript
899 lines
32 KiB
TypeScript
#!/usr/bin/env node
|
|
// Dispatches full release validation against a temporary SHA-pinned branch.
|
|
import {
|
|
execFileSync,
|
|
spawnSync,
|
|
type ExecFileSyncOptionsWithStringEncoding,
|
|
} 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";
|
|
import { parse as parseYaml } from "yaml";
|
|
import { isRecord as isJsonRecord } from "../packages/normalization-core/src/record-coerce.ts";
|
|
import { execGhRead } from "./lib/plain-gh.mjs";
|
|
|
|
const WORKFLOW = "full-release-validation.yml";
|
|
const TRUSTED_WORKFLOW_PATH = `.github/workflows/${WORKFLOW}`;
|
|
const RELEASE_ISOLATION_TOOLING_CONTRACT = "2";
|
|
const RELEASE_ISOLATION_TOOLING_CONTRACT_ENV = "RELEASE_ISOLATION_TOOLING_CONTRACT";
|
|
const RELEASE_EVIDENCE_VERIFIER_PATHS = [
|
|
"scripts/release-ci-summary.mjs",
|
|
".agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs",
|
|
];
|
|
const GH_READ_TIMEOUT_MS = 60_000;
|
|
export const FULL_RELEASE_WAIT_TIMEOUT_MINUTES = 720;
|
|
export const FULL_RELEASE_WAIT_POLL_INTERVAL_MS = 45_000;
|
|
const FULL_RELEASE_PROGRESS_INTERVAL_MS = 5 * 60_000;
|
|
const GH_READ_OPTIONS = {
|
|
encoding: "utf8",
|
|
killSignal: "SIGKILL",
|
|
stdio: ["ignore", "pipe", "inherit"],
|
|
timeout: GH_READ_TIMEOUT_MS,
|
|
} satisfies ExecFileSyncOptionsWithStringEncoding;
|
|
const RELEASE_BRANCH_PATTERN = /^release\/([0-9]{4}\.(?:[1-9]|1[0-2])\.[1-9][0-9]*)$/u;
|
|
const EXTENDED_STABLE_BRANCH_PATTERN = /^extended-stable\/([0-9]{4}\.(?:[1-9]|1[0-2])\.33)$/u;
|
|
const RELEASE_CONTEXT_BRANCH_PATTERN =
|
|
/^(?:release\/[0-9]{4}\.(?:[1-9]|1[0-2])\.[1-9][0-9]*|extended-stable\/[0-9]{4}\.(?:[1-9]|1[0-2])\.33)$/u;
|
|
const RELEASE_TAG_PATTERN =
|
|
/^v([0-9]{4}\.(?:[1-9]|1[0-2])\.[1-9][0-9]*(?:-(?:alpha|beta)\.[1-9][0-9]*)?)$/u;
|
|
const TRUSTED_WORKFLOW_TAG_PATTERN = /^release-publish\/([a-f0-9]{12})-[1-9][0-9]*$/u;
|
|
const SHA_PATTERN = /^[a-f0-9]{40}$/u;
|
|
const RERUN_GROUPS = new Set([
|
|
"all",
|
|
"ci",
|
|
"plugin-prerelease",
|
|
"install-smoke",
|
|
"cross-os",
|
|
"live-e2e",
|
|
"package",
|
|
"qa-parity",
|
|
"qa-live",
|
|
"npm-telegram",
|
|
"performance",
|
|
]);
|
|
const DEFAULT_INPUTS = {
|
|
provider: "openai",
|
|
mode: "both",
|
|
rerun_group: "all",
|
|
reuse_evidence: "true",
|
|
fail_fast: "false",
|
|
};
|
|
|
|
type ReleaseInputs = Record<string, string> &
|
|
typeof DEFAULT_INPUTS &
|
|
Partial<Record<"release_profile" | "allow_unreleased_changelog", string>>;
|
|
type CommandOptions = {
|
|
dryRun?: boolean;
|
|
stdio?: "inherit" | ["ignore", "pipe" | "ignore", "inherit" | "ignore"];
|
|
};
|
|
type TemporaryRefParams = {
|
|
keepBranch: boolean;
|
|
dryRun: boolean;
|
|
parentConclusion: string;
|
|
evidenceVerified: boolean;
|
|
};
|
|
type TrustedWorkflowHarness = {
|
|
contract: "1" | "2";
|
|
verifierPath: string;
|
|
};
|
|
|
|
function stringValue(value: unknown, fallback = ""): string {
|
|
return typeof value === "string" ? value : fallback;
|
|
}
|
|
|
|
function displayValue(value: unknown): string {
|
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
return String(value);
|
|
}
|
|
return value === null ? "null" : (JSON.stringify(value) ?? "<undefined>");
|
|
}
|
|
|
|
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-tooling-sha>] [--trusted-workflow-ref <main-or-release-publish-tag>] [--keep-branch] [--dry-run] [-- -f key=value ...]
|
|
|
|
Creates temporary remote branches pinned to the exact Tooling SHA and Validation SHA,
|
|
dispatches Full Release Validation with the full Validation SHA as its ref input
|
|
and expected_sha as its immutable identity,
|
|
watches the parent run, verifies all child workflow head SHAs match the trusted
|
|
workflow lineage through the release evidence manifest, then deletes both
|
|
temporary branches by default. --keep-branch retains both branches. Exact-target and changelog-only Release SHA
|
|
evidence reuse stay enabled; pass -f reuse_evidence=false to force a fresh
|
|
run. Child workflows collect independent failures by default; pass
|
|
-f fail_fast=true to cancel each child after its first failed job. The release
|
|
branch accepts only its final package version or a matching beta prerelease.
|
|
Exact alpha tags remain supported for Tideclaw. The release profile defaults to
|
|
beta for beta candidates and exact alpha tags, and stable otherwise; pass
|
|
-f release_profile=full for the broad advisory sweep. Focused retries must use
|
|
one controller rerun_group; the removed release-checks aggregate and the direct
|
|
child's manual qa aggregate are not accepted.`);
|
|
}
|
|
|
|
function run(command: string, args: string[], options: CommandOptions = {}) {
|
|
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: string, args: string[], options: CommandOptions = {}) {
|
|
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: string[], index: number, optionName: string): string {
|
|
const value = argv[index + 1];
|
|
if (value === undefined || value === "" || value.startsWith("-")) {
|
|
throw new Error(`${optionName} requires a value`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function parseArgs(argv: string[]) {
|
|
const inputs: ReleaseInputs = { ...DEFAULT_INPUTS };
|
|
const args = {
|
|
sha: "",
|
|
targetRef: "",
|
|
trustedWorkflowRef: "main",
|
|
workflowSha: "",
|
|
keepBranch: false,
|
|
dryRun: false,
|
|
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 === "--trusted-workflow-ref") {
|
|
args.trustedWorkflowRef = 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 === "--") {
|
|
const extras = argv.slice(i + 1);
|
|
for (let extraIndex = 0; extraIndex < extras.length; extraIndex += 1) {
|
|
const extra = extras[extraIndex]!;
|
|
let assignment;
|
|
if (extra === "-f") {
|
|
assignment = readOptionValue(extras, extraIndex, extra);
|
|
extraIndex += 1;
|
|
} else {
|
|
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 (!["true", "false"].includes(args.inputs.fail_fast)) {
|
|
throw new Error("fail_fast must be true or false");
|
|
}
|
|
if (
|
|
Object.hasOwn(args.inputs, "allow_unreleased_changelog") &&
|
|
!["true", "false"].includes(args.inputs.allow_unreleased_changelog ?? "")
|
|
) {
|
|
throw new Error("allow_unreleased_changelog 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 (!RERUN_GROUPS.has(args.inputs.rerun_group)) {
|
|
throw new Error(`rerun_group must be one of: ${[...RERUN_GROUPS].join(", ")}`);
|
|
}
|
|
if (Object.hasOwn(args.inputs, "ref")) {
|
|
throw new Error("SHA-pinned release validation reserves the ref input for --sha");
|
|
}
|
|
if (Object.hasOwn(args.inputs, "expected_sha")) {
|
|
throw new Error("SHA-pinned release validation reserves expected_sha for the resolved --sha");
|
|
}
|
|
if (Object.hasOwn(args.inputs, "trusted_workflow_json")) {
|
|
throw new Error("SHA-pinned release validation reserves trusted_workflow_json");
|
|
}
|
|
if (
|
|
args.targetRef &&
|
|
!RELEASE_CONTEXT_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");
|
|
}
|
|
if (
|
|
args.trustedWorkflowRef !== "main" &&
|
|
!TRUSTED_WORKFLOW_TAG_PATTERN.test(args.trustedWorkflowRef)
|
|
) {
|
|
throw new Error(
|
|
"--trusted-workflow-ref must be main or a protected release-publish/<12hex>-<decimal> tag",
|
|
);
|
|
}
|
|
if (args.trustedWorkflowRef !== "main" && !SHA_PATTERN.test(args.workflowSha.toLowerCase())) {
|
|
throw new Error(
|
|
"protected release-publish workflow refs require --workflow-sha with an explicit full Tooling SHA",
|
|
);
|
|
}
|
|
if (
|
|
RELEASE_CONTEXT_BRANCH_PATTERN.test(args.targetRef) &&
|
|
!SHA_PATTERN.test(args.workflowSha.toLowerCase())
|
|
) {
|
|
throw new Error(
|
|
"release-branch validation requires --workflow-sha with an explicit full Tooling SHA",
|
|
);
|
|
}
|
|
return args;
|
|
}
|
|
|
|
export function resolveRemoteTargetRefSha(
|
|
targetRef: string,
|
|
executeGit: (args: string[]) => string = (args) => run("git", args),
|
|
) {
|
|
if (RELEASE_CONTEXT_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] ?? "";
|
|
}
|
|
|
|
export function verifyTargetRef(
|
|
targetRef: string,
|
|
targetSha: string,
|
|
targetVersion: string,
|
|
resolveRemoteSha: (ref: string) => string = resolveRemoteTargetRefSha,
|
|
isAncestor: (ancestor: string, descendant: string) => boolean = (ancestor, descendant) =>
|
|
runStatus("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
|
|
stdio: ["ignore", "ignore", "ignore"],
|
|
}).status === 0,
|
|
) {
|
|
if (!targetRef) {
|
|
return targetSha;
|
|
}
|
|
const releaseMatch = targetRef.match(RELEASE_BRANCH_PATTERN);
|
|
const extendedStableMatch = targetRef.match(EXTENDED_STABLE_BRANCH_PATTERN);
|
|
const tagMatch = targetRef.match(RELEASE_TAG_PATTERN);
|
|
if (releaseMatch) {
|
|
const releaseVersion = releaseMatch[1]!;
|
|
const prereleaseMatch = targetVersion.match(
|
|
/^([0-9]{4}\.(?:[1-9]|1[0-2])\.[1-9][0-9]*)-beta\.[1-9][0-9]*$/u,
|
|
);
|
|
if (targetVersion !== releaseVersion && prereleaseMatch?.[1] !== releaseVersion) {
|
|
throw new Error(
|
|
`Target package version ${targetVersion} does not belong to release branch ${targetRef}; expected ${releaseVersion} or a beta prerelease of it`,
|
|
);
|
|
}
|
|
} else if (extendedStableMatch) {
|
|
if (targetVersion !== extendedStableMatch[1]) {
|
|
throw new Error(
|
|
`Target package version ${targetVersion} does not match extended-stable branch ${targetRef}`,
|
|
);
|
|
}
|
|
} else if (tagMatch && targetVersion !== tagMatch[1]) {
|
|
throw new Error(
|
|
`Target package version ${targetVersion} does not match release tag ${targetRef}`,
|
|
);
|
|
}
|
|
const remoteSha = resolveRemoteSha(targetRef);
|
|
if (!remoteSha) {
|
|
throw new Error(`Target ref ${targetRef} does not resolve to a commit`);
|
|
}
|
|
if (RELEASE_CONTEXT_BRANCH_PATTERN.test(targetRef)) {
|
|
if (!isAncestor(targetSha, remoteSha)) {
|
|
throw new Error(
|
|
`Target SHA ${targetSha} is not reachable from release branch ${targetRef} at ${remoteSha}`,
|
|
);
|
|
}
|
|
return targetRef;
|
|
}
|
|
if (remoteSha.toLowerCase() !== targetSha.toLowerCase()) {
|
|
throw new Error(`Target ref ${targetRef} does not resolve to ${targetSha}`);
|
|
}
|
|
return targetRef;
|
|
}
|
|
|
|
function resolveSha(requestedSha: string) {
|
|
const rev = requestedSha || "HEAD";
|
|
return run("git", ["rev-parse", "--verify", `${rev}^{commit}`], { dryRun: false });
|
|
}
|
|
|
|
function fetchTargetRef(targetRef: string) {
|
|
if (!targetRef) {
|
|
return;
|
|
}
|
|
const sourceRef = RELEASE_CONTEXT_BRANCH_PATTERN.test(targetRef)
|
|
? `refs/heads/${targetRef}`
|
|
: `refs/tags/${targetRef}`;
|
|
run("git", ["fetch", "--no-tags", "origin", sourceRef], {
|
|
stdio: "inherit",
|
|
});
|
|
}
|
|
|
|
function resolveTargetSha(requestedSha: string, targetRef: string) {
|
|
fetchTargetRef(targetRef);
|
|
const revision = requestedSha || "HEAD";
|
|
const resolved = runStatus("git", ["rev-parse", "--verify", `${revision}^{commit}`], {
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
});
|
|
const resolvedSha = typeof resolved.stdout === "string" ? resolved.stdout.trim() : "";
|
|
if (resolved.status !== 0 || !resolvedSha) {
|
|
throw new Error(
|
|
targetRef
|
|
? `Target SHA ${revision} is not available locally after fetching ${targetRef}`
|
|
: `Target SHA ${revision} is not available locally; pass --target-ref so it can be fetched by name`,
|
|
);
|
|
}
|
|
return resolvedSha;
|
|
}
|
|
|
|
function targetVersionForTarget(
|
|
targetSha: string,
|
|
readPackageJson: (sha: string) => string = (sha) => run("git", ["show", `${sha}:package.json`]),
|
|
): string {
|
|
let version: unknown;
|
|
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 version;
|
|
}
|
|
|
|
function releaseProfileForVersion(version: string): "beta" | "stable" {
|
|
return /-(?:alpha|beta)\.[1-9][0-9]*$/u.test(version) ? "beta" : "stable";
|
|
}
|
|
|
|
export function releaseProfileForTarget(
|
|
targetSha: string,
|
|
readPackageJson: (sha: string) => string = (sha) => run("git", ["show", `${sha}:package.json`]),
|
|
): "beta" | "stable" {
|
|
return releaseProfileForVersion(targetVersionForTarget(targetSha, readPackageJson));
|
|
}
|
|
|
|
export function verifyTrustedWorkflowRef(
|
|
workflowSha: string,
|
|
trustedWorkflowRef: string,
|
|
resolveRemoteTagSha: (tag: string) => string = (tag) =>
|
|
run("git", ["ls-remote", "--tags", "origin", `refs/tags/${tag}`]).split(/\s+/u)[0] ?? "",
|
|
isMainAncestor: (sha: string) => boolean = (sha) =>
|
|
runStatus("git", ["merge-base", "--is-ancestor", sha, "refs/remotes/origin/main"]).status === 0,
|
|
) {
|
|
if (trustedWorkflowRef === "main") {
|
|
if (!isMainAncestor(workflowSha)) {
|
|
throw new Error(
|
|
`Workflow SHA ${workflowSha} is not reachable from current origin/main; refusing an untrusted release harness.`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const tagMatch = trustedWorkflowRef.match(TRUSTED_WORKFLOW_TAG_PATTERN);
|
|
if (!tagMatch) {
|
|
throw new Error(
|
|
"trusted workflow ref must be main or a protected release-publish/<12hex>-<decimal> tag",
|
|
);
|
|
}
|
|
if (workflowSha.slice(0, 12) !== tagMatch[1]) {
|
|
throw new Error(
|
|
`Trusted workflow tag ${trustedWorkflowRef} does not match Tooling SHA ${workflowSha}`,
|
|
);
|
|
}
|
|
const remoteTagSha = resolveRemoteTagSha(trustedWorkflowRef);
|
|
if (!remoteTagSha) {
|
|
throw new Error(`Trusted workflow tag ${trustedWorkflowRef} does not exist on origin`);
|
|
}
|
|
if (remoteTagSha.toLowerCase() !== workflowSha.toLowerCase()) {
|
|
throw new Error(
|
|
`Trusted workflow tag ${trustedWorkflowRef} resolves to ${remoteTagSha}, expected ${workflowSha}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function resolveTrustedWorkflowSha(requestedSha: string, trustedWorkflowRef: string) {
|
|
if (trustedWorkflowRef === "main") {
|
|
run("git", ["fetch", "--no-tags", "origin", "refs/heads/main:refs/remotes/origin/main"], {
|
|
stdio: "inherit",
|
|
});
|
|
}
|
|
const workflowSha = resolveSha(requestedSha || "origin/main");
|
|
verifyTrustedWorkflowRef(workflowSha, trustedWorkflowRef);
|
|
return workflowSha;
|
|
}
|
|
|
|
function collectRunId(dispatchOutput: string) {
|
|
const match = dispatchOutput.match(/actions\/runs\/(\d+)/);
|
|
return match?.[1] ?? "";
|
|
}
|
|
|
|
function findLatestRunId(branch: string, sha: string) {
|
|
const json = execGhRead(
|
|
[
|
|
"run",
|
|
"list",
|
|
"--workflow",
|
|
WORKFLOW,
|
|
"--branch",
|
|
branch,
|
|
"--event",
|
|
"workflow_dispatch",
|
|
"--limit",
|
|
"20",
|
|
"--json",
|
|
"databaseId,headSha,createdAt",
|
|
],
|
|
GH_READ_OPTIONS,
|
|
);
|
|
const runs: unknown = JSON.parse(json);
|
|
if (!Array.isArray(runs)) {
|
|
throw new Error("Full Release Validation run list response was not an array");
|
|
}
|
|
const match = runs.find((runItem: unknown) => isJsonRecord(runItem) && runItem.headSha === sha);
|
|
const databaseId = isJsonRecord(match) ? match.databaseId : undefined;
|
|
return typeof databaseId === "string" || typeof databaseId === "number" ? String(databaseId) : "";
|
|
}
|
|
|
|
function readWorkflowRun(parentRunId: string, workflowSha: string) {
|
|
if (!/^[1-9][0-9]*$/u.test(parentRunId)) {
|
|
throw new Error("parent run ID must be a positive decimal");
|
|
}
|
|
const workflowRun: unknown = JSON.parse(
|
|
execGhRead(["api", `repos/openclaw/openclaw/actions/runs/${parentRunId}`], GH_READ_OPTIONS),
|
|
);
|
|
if (!isJsonRecord(workflowRun)) {
|
|
throw new Error(`Full Release Validation run ${parentRunId} returned an invalid response`);
|
|
}
|
|
if (workflowRun.head_sha !== workflowSha) {
|
|
throw new Error(
|
|
`Full Release Validation run ${parentRunId} head ${displayValue(workflowRun.head_sha)} does not match trusted workflow SHA ${workflowSha}`,
|
|
);
|
|
}
|
|
return workflowRun;
|
|
}
|
|
|
|
function readActiveParentJobs(parentRunId: string) {
|
|
const response: unknown = JSON.parse(
|
|
execGhRead(
|
|
["api", `repos/openclaw/openclaw/actions/runs/${parentRunId}/jobs?per_page=100`],
|
|
GH_READ_OPTIONS,
|
|
),
|
|
);
|
|
if (!isJsonRecord(response) || !Array.isArray(response.jobs)) {
|
|
throw new Error(`Full Release Validation run ${parentRunId} returned invalid jobs`);
|
|
}
|
|
return response.jobs
|
|
.filter((job) => isJsonRecord(job) && job.status !== "completed")
|
|
.map((job) => ({
|
|
name: isJsonRecord(job) ? stringValue(job.name, "<unnamed>") : "<unnamed>",
|
|
status: isJsonRecord(job) ? stringValue(job.status, "pending") : "pending",
|
|
url: isJsonRecord(job) ? stringValue(job.html_url) : "",
|
|
}));
|
|
}
|
|
|
|
function waitForWorkflowRun(parentRunId: string, workflowSha: string) {
|
|
let lastSummary = "";
|
|
let consecutiveErrors = 0;
|
|
const startedAt = Date.now();
|
|
const deadline = startedAt + FULL_RELEASE_WAIT_TIMEOUT_MINUTES * 60_000;
|
|
let nextProgressAt = startedAt + FULL_RELEASE_PROGRESS_INTERVAL_MS;
|
|
while (Date.now() < deadline) {
|
|
let suite: Record<string, unknown> | undefined;
|
|
try {
|
|
suite = readWorkflowRun(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 status = stringValue(suite?.status, "pending").toLowerCase();
|
|
const conclusion = stringValue(suite?.conclusion, "pending").toLowerCase();
|
|
const summary = `${status}/${conclusion}`;
|
|
if (summary !== lastSummary) {
|
|
console.log(`Parent run status: ${summary}`);
|
|
lastSummary = summary;
|
|
}
|
|
if (suite?.status === "completed") {
|
|
if (suite.conclusion === "success") {
|
|
return suite;
|
|
}
|
|
throw new Error(
|
|
`Full Release Validation concluded ${stringValue(suite.conclusion, "unknown").toLowerCase()}: https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`,
|
|
);
|
|
}
|
|
const now = Date.now();
|
|
if (now >= nextProgressAt) {
|
|
const elapsedMinutes = Math.floor((now - startedAt) / 60_000);
|
|
try {
|
|
const activeJobs = readActiveParentJobs(parentRunId);
|
|
console.log(
|
|
`Parent run progress after ${elapsedMinutes}m: ${activeJobs.length} active job(s)`,
|
|
);
|
|
for (const job of activeJobs) {
|
|
console.log(`- ${job.name}: ${job.status}${job.url ? ` ${job.url}` : ""}`);
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
`Parent run progress query failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
nextProgressAt += FULL_RELEASE_PROGRESS_INTERVAL_MS;
|
|
}
|
|
const remainingMs = deadline - Date.now();
|
|
if (remainingMs <= 0) {
|
|
break;
|
|
}
|
|
Atomics.wait(
|
|
new Int32Array(new SharedArrayBuffer(4)),
|
|
0,
|
|
0,
|
|
Math.min(FULL_RELEASE_WAIT_POLL_INTERVAL_MS, remainingMs),
|
|
);
|
|
}
|
|
throw new Error(
|
|
`Timed out after ${FULL_RELEASE_WAIT_TIMEOUT_MINUTES} minutes waiting for Full Release Validation: https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`,
|
|
);
|
|
}
|
|
|
|
export function releaseEvidenceVerificationArgs(
|
|
parentRunId: unknown,
|
|
verifierSourceSha: string,
|
|
verifierSourceFile: string,
|
|
trustedWorkflowRef = "main",
|
|
) {
|
|
if (!/^[1-9][0-9]*$/u.test(String(parentRunId))) {
|
|
throw new Error("parent run ID must be a positive decimal");
|
|
}
|
|
const trustedWorkflowFullRef =
|
|
trustedWorkflowRef === "main"
|
|
? "refs/heads/main"
|
|
: TRUSTED_WORKFLOW_TAG_PATTERN.test(trustedWorkflowRef)
|
|
? `refs/tags/${trustedWorkflowRef}`
|
|
: "";
|
|
if (!trustedWorkflowFullRef) {
|
|
throw new Error("trusted workflow ref must be main or a protected release-publish tag");
|
|
}
|
|
return [
|
|
"--validate-run",
|
|
String(parentRunId),
|
|
"--trusted-workflow-ref",
|
|
trustedWorkflowRef,
|
|
"--trusted-workflow-full-ref",
|
|
trustedWorkflowFullRef,
|
|
"--trusted-workflow-sha",
|
|
verifierSourceSha,
|
|
"--json",
|
|
"--verifier-source-sha",
|
|
verifierSourceSha,
|
|
"--verifier-source-file",
|
|
verifierSourceFile,
|
|
];
|
|
}
|
|
|
|
export function shouldDeleteTemporaryWorkflowRef(params: TemporaryRefParams) {
|
|
return (
|
|
!params.keepBranch &&
|
|
(params.dryRun || (params.parentConclusion === "success" && params.evidenceVerified))
|
|
);
|
|
}
|
|
|
|
export function assertTrustedWorkflowHarness(
|
|
workflowSha: string,
|
|
pathExists: (relativePath: string) => boolean = (relativePath) =>
|
|
runStatus("git", ["cat-file", "-e", `${workflowSha}:${relativePath}`], {
|
|
stdio: ["ignore", "ignore", "ignore"],
|
|
}).status === 0,
|
|
readPath: (relativePath: string) => string = (relativePath) =>
|
|
run("git", ["show", `${workflowSha}:${relativePath}`]),
|
|
): TrustedWorkflowHarness {
|
|
if (!pathExists(TRUSTED_WORKFLOW_PATH)) {
|
|
throw new Error(
|
|
`trusted workflow SHA ${workflowSha} does not contain ${TRUSTED_WORKFLOW_PATH}`,
|
|
);
|
|
}
|
|
let workflow: unknown;
|
|
try {
|
|
workflow = parseYaml(readPath(TRUSTED_WORKFLOW_PATH));
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Tooling SHA ${workflowSha} contains invalid ${TRUSTED_WORKFLOW_PATH}: ${error instanceof Error ? error.message : String(error)}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
const contract =
|
|
isJsonRecord(workflow) && isJsonRecord(workflow.env)
|
|
? workflow.env[RELEASE_ISOLATION_TOOLING_CONTRACT_ENV]
|
|
: undefined;
|
|
if (contract !== "1" && contract !== RELEASE_ISOLATION_TOOLING_CONTRACT) {
|
|
throw new Error(
|
|
`Tooling SHA ${workflowSha} does not declare a supported ${RELEASE_ISOLATION_TOOLING_CONTRACT_ENV} in ${TRUSTED_WORKFLOW_PATH}`,
|
|
);
|
|
}
|
|
const workflowInputs =
|
|
isJsonRecord(workflow) &&
|
|
isJsonRecord(workflow.on) &&
|
|
isJsonRecord(workflow.on.workflow_dispatch) &&
|
|
isJsonRecord(workflow.on.workflow_dispatch.inputs)
|
|
? workflow.on.workflow_dispatch.inputs
|
|
: undefined;
|
|
if (!workflowInputs || !Object.hasOwn(workflowInputs, "expected_sha")) {
|
|
throw new Error(
|
|
`Tooling SHA ${workflowSha} is missing workflow_dispatch input expected_sha in ${TRUSTED_WORKFLOW_PATH}`,
|
|
);
|
|
}
|
|
if (
|
|
contract === RELEASE_ISOLATION_TOOLING_CONTRACT &&
|
|
!Object.hasOwn(workflowInputs, "trusted_workflow_json")
|
|
) {
|
|
throw new Error(
|
|
`Tooling SHA ${workflowSha} declares ${RELEASE_ISOLATION_TOOLING_CONTRACT_ENV}=2 but is missing workflow_dispatch input trusted_workflow_json in ${TRUSTED_WORKFLOW_PATH}`,
|
|
);
|
|
}
|
|
const verifierPath = RELEASE_EVIDENCE_VERIFIER_PATHS.find((relativePath) =>
|
|
pathExists(relativePath),
|
|
);
|
|
if (!verifierPath) {
|
|
throw new Error(
|
|
`trusted workflow SHA ${workflowSha} does not contain a supported release evidence verifier`,
|
|
);
|
|
}
|
|
return { contract, verifierPath };
|
|
}
|
|
|
|
export function releaseEvidenceVerifierPath(worktreeRoot: string) {
|
|
const candidates = RELEASE_EVIDENCE_VERIFIER_PATHS.map((relativePath) =>
|
|
join(worktreeRoot, relativePath),
|
|
);
|
|
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: string,
|
|
workflowSha: string,
|
|
trustedWorkflowRef: string,
|
|
) {
|
|
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: unknown = JSON.parse(
|
|
run(process.execPath, [
|
|
verifier,
|
|
...releaseEvidenceVerificationArgs(parentRunId, workflowSha, verifier, trustedWorkflowRef),
|
|
]),
|
|
);
|
|
if (
|
|
!isJsonRecord(evidence) ||
|
|
evidence.valid !== true ||
|
|
!isJsonRecord(evidence.current) ||
|
|
!isJsonRecord(evidence.root)
|
|
) {
|
|
throw new Error(`Full Release Validation evidence is invalid for run ${parentRunId}.`);
|
|
}
|
|
console.log(
|
|
`ok release evidence current=${displayValue(evidence.current.runId)} root=${displayValue(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 = resolveTargetSha(args.sha, args.targetRef);
|
|
const targetVersion = targetVersionForTarget(targetSha);
|
|
args.inputs.release_profile ??= releaseProfileForVersion(targetVersion);
|
|
args.inputs.allow_unreleased_changelog ??= args.targetRef ? "false" : "true";
|
|
const targetContextRef = verifyTargetRef(args.targetRef, targetSha, targetVersion);
|
|
const workflowSha = resolveTrustedWorkflowSha(args.workflowSha, args.trustedWorkflowRef);
|
|
const trustedWorkflowHarness = assertTrustedWorkflowHarness(workflowSha);
|
|
if (trustedWorkflowHarness.contract === "1") {
|
|
args.inputs.reuse_evidence = "false";
|
|
}
|
|
const shortSha = workflowSha.slice(0, 12);
|
|
const branch = `release-ci/${shortSha}-${Date.now()}`;
|
|
const remoteBranchRef = `refs/heads/${branch}`;
|
|
const targetBranch = `validation/target-${targetSha.slice(0, 12)}-${Date.now()}`;
|
|
const remoteTargetBranchRef = `refs/heads/${targetBranch}`;
|
|
const dispatchInputs = {
|
|
ref: targetSha,
|
|
expected_sha: targetSha,
|
|
...(trustedWorkflowHarness.contract === RELEASE_ISOLATION_TOOLING_CONTRACT
|
|
? {
|
|
trusted_workflow_json: JSON.stringify({
|
|
ref: args.trustedWorkflowRef,
|
|
fullRef:
|
|
args.trustedWorkflowRef === "main"
|
|
? "refs/heads/main"
|
|
: `refs/tags/${args.trustedWorkflowRef}`,
|
|
sha: workflowSha,
|
|
}),
|
|
}
|
|
: {}),
|
|
...(targetContextRef !== targetSha ? { target_context_ref: targetContextRef } : {}),
|
|
...args.inputs,
|
|
};
|
|
|
|
console.log(`Validation SHA: ${targetSha}`);
|
|
console.log(`Tooling SHA: ${workflowSha}`);
|
|
console.log(`Trusted workflow ref: ${args.trustedWorkflowRef}`);
|
|
console.log(
|
|
`Frozen validation tuple: candidate=${targetSha} tooling=${workflowSha} rerun_group=${args.inputs.rerun_group}`,
|
|
);
|
|
console.log(`Temporary target ref: ${targetBranch}`);
|
|
console.log(`Temporary workflow ref: ${branch}`);
|
|
|
|
let parentRunId: string | undefined;
|
|
let parentConclusion = "";
|
|
let evidenceVerified = false;
|
|
try {
|
|
run("git", ["push", "origin", `${targetSha}:${remoteTargetBranchRef}`], {
|
|
dryRun: args.dryRun,
|
|
stdio: "inherit",
|
|
});
|
|
run("git", ["push", "origin", `${workflowSha}:${remoteBranchRef}`], {
|
|
dryRun: args.dryRun,
|
|
stdio: "inherit",
|
|
});
|
|
|
|
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 completedRun = waitForWorkflowRun(parentRunId, workflowSha);
|
|
parentConclusion = stringValue(completedRun.conclusion);
|
|
if (parentConclusion !== "success") {
|
|
throw new Error(
|
|
`Full Release Validation concluded ${parentConclusion.toLowerCase() || "without a conclusion"}: https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`,
|
|
);
|
|
}
|
|
verifyReleaseEvidence(parentRunId, workflowSha, args.trustedWorkflowRef);
|
|
evidenceVerified = true;
|
|
} finally {
|
|
if (
|
|
shouldDeleteTemporaryWorkflowRef({
|
|
keepBranch: args.keepBranch,
|
|
dryRun: args.dryRun,
|
|
parentConclusion,
|
|
evidenceVerified,
|
|
})
|
|
) {
|
|
run("git", ["push", "origin", `:${remoteBranchRef}`, `:${remoteTargetBranchRef}`], {
|
|
dryRun: args.dryRun,
|
|
stdio: "inherit",
|
|
});
|
|
} else {
|
|
const keptRefs = `${remoteBranchRef} and ${remoteTargetBranchRef}`;
|
|
console.warn(
|
|
args.keepBranch
|
|
? `Kept ${keptRefs}`
|
|
: `Kept ${keptRefs}: ${
|
|
parentConclusion === "success"
|
|
? "release evidence was not verified"
|
|
: `parent concluded ${parentConclusion || "without a conclusion"}`
|
|
}. Keep it through GitHub reruns or evidence diagnosis; delete it after verified success.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
console.error(
|
|
`[full-release-validation] FAILED: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
console.error("[full-release-validation] FAILED (exit 1)");
|
|
process.exitCode = 1;
|
|
}
|
|
}
|