openclaw/scripts/lib/release-priority.mjs
Peter Steinberger 53ccbd1b7b
feat(release): watch FRV children and rerun one child with a bounded budget (#161516)
Adds `pnpm frv watch --run <parent>`: it resolves Full Release Validation children from the parent's dispatch-job logs and reports each attempt transition and failed job once, with runner labels. It tolerates transient GitHub failures and resumes from a small state file.

Adds `pnpm frv rerun --run <parent> --child <key|run-id> [--max-attempts N]`: a bounded, audited single rerun-failed-jobs request. It reruns the green producer when a consumer binds its run attempt (#161317) and checks the new attempt for duplicate or missing jobs.

Retires `pnpm frv prioritize --run`, since the priority variable no longer controls admission. `--restore` stays.
2026-09-30 02:47:22 +00:00

193 lines
7.2 KiB
JavaScript

// Historical release-priority recovery records identify workflows deferred by
// the former repository-variable gate. Current release validation shares runner
// capacity with PR CI and does not pause or cancel unrelated workflows.
import { readFileSync } from "node:fs";
export const RELEASE_PRIORITY_VARIABLE = "OPENCLAW_RELEASE_PRIORITY_RUN";
const RELEASE_PRIORITY_RECORD_KIND = "openclaw.frv-release-priority";
const CI_GATE_JOB = "openclaw/ci-gate";
// Workflows retained for restoring runs deferred by the former variable gate.
// Security Review stays live: it owns approval revocation for openclaw/ci-gate.
const RELEASE_PRIORITY_WORKFLOWS = Object.freeze([
"CI",
"Auto response",
"PR context and evidence",
"Labeler",
"CodeQL",
"CodeQL Critical Quality",
"CodeQL macOS Critical Security",
"CodeQL Android Critical Security",
"Periphery Dead Code Comment",
"Workflow Sanity",
"ClawSweeper Dispatch",
"Maintainer Command Reactions",
]);
export function isReleaseBranch(name) {
return /^release(?:-ci|-publish)?\//u.test(String(name ?? ""));
}
// Release children are dispatched; operator dispatches keep their intent.
export function isDeferrableRun(run, parentRunId) {
return (
RELEASE_PRIORITY_WORKFLOWS.includes(run?.name) &&
run.event !== "workflow_dispatch" &&
!isReleaseBranch(run.head_branch) &&
String(run.id) !== String(parentRunId)
);
}
export function describeRun(run) {
const pullRequests = run.pull_requests ?? [];
const pullRequest = pullRequests.length === 1 ? pullRequests[0].number : undefined;
const repository = run.head_repository?.id;
// Forks often reuse branch names. Prefer the PR concurrency owner when GitHub supplies it.
const lane =
Number.isSafeInteger(pullRequest) && pullRequest > 0
? `pr:${pullRequest}`
: Number.isSafeInteger(repository) && repository > 0
? `repository:${repository}:branch:${String(run.head_branch ?? "")}`
: `run:${run.id}`;
return {
event: String(run.event ?? ""),
headBranch: String(run.head_branch ?? ""),
id: String(run.id),
lane,
name: String(run.name ?? ""),
url: String(run.html_url ?? ""),
};
}
// Gated workflows end skipped; a deferred CI run skips every lane and its gate
// fails naming the release, which keeps the PR unmergeable until the rerun.
export function selectDeferredRunCandidates(runs, record) {
return runs.filter(
(run) =>
run.status === "completed" &&
String(run.created_at ?? "") >= record.recordedAt &&
isDeferrableRun(run, record.parentRunId) &&
(run.name === "CI" ? run.conclusion === "failure" : run.conclusion === "skipped"),
);
}
// A deferred CI run skips preflight and every lane behind it; the gate fails
// naming the release and security-fast (`!cancelled()`) still executes.
export function isDeferredCiJobSet(jobs) {
return (
jobs.some((job) => job.name === "preflight" && job.conclusion === "skipped") &&
jobs.some((job) => job.name === CI_GATE_JOB && job.conclusion === "failure") &&
jobs.every(
(job) =>
job.name === CI_GATE_JOB || job.name === "security-fast" || job.conclusion === "skipped",
)
);
}
// A PR's CI groups by PR number with cancel-in-progress. Unknown identities stay
// separate rather than suppressing an independent run with the same branch name.
export function selectLatestRunsPerLane(runs) {
const latest = new Map();
for (const run of runs) {
const key = `${run.name}\n${run.lane}`;
if (!latest.has(key) || Number(latest.get(key).id) < Number(run.id)) {
latest.set(key, run);
}
}
return [...latest.values()];
}
export function readReleasePriorityRecord(path) {
const record = JSON.parse(readFileSync(path, "utf8"));
if (
record?.kind !== RELEASE_PRIORITY_RECORD_KIND ||
!/^[1-9][0-9]*$/u.test(String(record.parentRunId)) ||
Number.isNaN(Date.parse(record.recordedAt)) ||
!Array.isArray(record.cancelled) ||
record.cancelled.some((run) => !/^[1-9][0-9]*$/u.test(String(run?.id)))
) {
throw new Error(`release priority record is invalid: ${path}`);
}
for (const run of record.cancelled) {
run.id = String(run.id);
}
return record;
}
const ACTIONS_RUN_SEARCH_LIMIT = 1_000;
const ACTIONS_RUN_PAGE_SIZE = 100;
/** GitHub caps a filtered run search at 1,000 results, even with pagination. */
async function listReleasePriorityRunWindow(since, readPage, until = Date.now()) {
const start = Date.parse(since);
if (!Number.isFinite(start) || !Number.isFinite(until) || start > until) {
throw new Error("Invalid release-priority timestamp window");
}
const runs = new Map();
async function collect(from, to) {
const created = `${new Date(from).toISOString()}..${new Date(to).toISOString()}`;
const page = async (number) => {
const result = await readPage(created, number, ACTIONS_RUN_PAGE_SIZE);
if (
!Number.isSafeInteger(result?.total_count) ||
result.total_count < 0 ||
!Array.isArray(result.workflow_runs) ||
result.workflow_runs.some((run) => !Number.isSafeInteger(run?.id) || run.id < 1)
) {
throw new Error("Invalid GitHub Actions run inventory");
}
return result;
};
const first = await page(1);
if (first.total_count > ACTIONS_RUN_SEARCH_LIMIT) {
// Overlap the boundary second and deduplicate IDs: no timestamp precision
// assumption may discard a run at the split. A dense second fails closed.
const middle = Math.floor((from + (to - from) / 2) / 1_000) * 1_000;
if (middle <= from || middle >= to) {
throw new Error("GitHub run search exceeds its cap within one timestamp window");
}
await collect(middle, to);
await collect(from, middle);
return;
}
const windowRuns = new Map(first.workflow_runs.map((run) => [run.id, run]));
for (let number = 2; number <= Math.ceil(first.total_count / ACTIONS_RUN_PAGE_SIZE); number++) {
const next = await page(number);
if (next.total_count !== first.total_count) {
throw new Error("GitHub Actions run inventory changed during discovery");
}
for (const run of next.workflow_runs) {
windowRuns.set(run.id, run);
}
}
if (windowRuns.size !== first.total_count) {
throw new Error("Incomplete GitHub Actions run inventory");
}
for (const [id, run] of windowRuns) {
runs.set(id, run);
}
}
await collect(start, until);
return [...runs.values()];
}
/** Queue snapshots remain best-effort; restoration must cover its whole pause window. */
export async function listReleasePriorityRuns(query, apiJson, apiText) {
const parameters = new URLSearchParams(query);
const created = parameters.get("created");
if (created?.startsWith(">=")) {
return listReleasePriorityRunWindow(created.slice(2), (window, page, pageSize) => {
const bounded = new URLSearchParams(parameters);
bounded.set("created", window);
bounded.set("per_page", String(pageSize));
bounded.set("page", String(page));
return apiJson(`actions/runs?${bounded}`);
});
}
const output = await apiText(`actions/runs?${query}&per_page=100`, ".workflow_runs[] | @json");
return output
? output
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
: [];
}