mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
063fcaf89e
* improve(ci): add trusted FRV proof broker * fix(ci): tighten FRV proof broker boundaries * test(ci): narrow FRV broker workflow lookup * fix(ci): renew FRV broker mutation authority * fix(ci): bind FRV proof to broker attempt
379 lines
13 KiB
JavaScript
Executable File
379 lines
13 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { pathToFileURL } from "node:url";
|
|
import { isRecord } from "./lib/record-shared.mjs";
|
|
|
|
const REPOSITORY = "openclaw/openclaw";
|
|
const BROKER_WORKFLOW = ".github/workflows/frv-proof-broker.yml";
|
|
const FIXTURE_WORKFLOW = ".github/workflows/frv-proof-fixture.yml";
|
|
const FIXTURE_WORKFLOW_ID = "frv-proof-fixture.yml";
|
|
const FIXTURE_NAME = "FRV Proof Fixture";
|
|
const FIXTURE_OPERATION = "noop";
|
|
const SHA_PATTERN = /^[0-9a-f]{40}$/u;
|
|
const POLL_INTERVAL_MS = 5_000;
|
|
const POLL_LIMIT = 120;
|
|
|
|
function requiredString(value, label) {
|
|
if (typeof value !== "string" || value.length === 0) {
|
|
throw new Error(`${label} is required`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requiredPositiveInteger(value, label) {
|
|
const number = Number(value);
|
|
if (!Number.isSafeInteger(number) || number < 1) {
|
|
throw new Error(`${label} must be a positive integer`);
|
|
}
|
|
return number;
|
|
}
|
|
|
|
function assertExactKeys(value, expected, label) {
|
|
if (!isRecord(value)) {
|
|
throw new Error(`${label} must be an object`);
|
|
}
|
|
const compare = (left, right) => left.localeCompare(right);
|
|
const actual = Object.keys(value).toSorted(compare);
|
|
const wanted = [...expected].toSorted(compare);
|
|
if (JSON.stringify(actual) !== JSON.stringify(wanted)) {
|
|
throw new Error(`${label} keys must be exactly: ${wanted.join(", ")}`);
|
|
}
|
|
}
|
|
|
|
function requiredEnv(env, name) {
|
|
return requiredString(env[name], name);
|
|
}
|
|
|
|
export function validateBrokerRequest(event, env) {
|
|
if (!isRecord(event)) {
|
|
throw new Error("GITHUB_EVENT_PATH must contain an object");
|
|
}
|
|
const repository = requiredEnv(env, "GITHUB_REPOSITORY");
|
|
if (repository !== REPOSITORY) {
|
|
throw new Error(`FRV proof broker requires repository ${REPOSITORY}`);
|
|
}
|
|
if (requiredEnv(env, "GITHUB_EVENT_NAME") !== "workflow_dispatch") {
|
|
throw new Error("FRV proof broker requires workflow_dispatch");
|
|
}
|
|
if (requiredEnv(env, "GITHUB_REF") !== "refs/heads/main") {
|
|
throw new Error("FRV proof broker must run from refs/heads/main");
|
|
}
|
|
const workflowSha = requiredEnv(env, "GITHUB_WORKFLOW_SHA");
|
|
const eventSha = requiredEnv(env, "GITHUB_SHA");
|
|
if (!SHA_PATTERN.test(workflowSha) || workflowSha !== eventSha) {
|
|
throw new Error("FRV proof broker requires one exact trusted workflow SHA");
|
|
}
|
|
const expectedWorkflowRef = `${REPOSITORY}/${BROKER_WORKFLOW}@refs/heads/main`;
|
|
if (requiredEnv(env, "GITHUB_WORKFLOW_REF") !== expectedWorkflowRef) {
|
|
throw new Error(`FRV proof broker requires ${expectedWorkflowRef}`);
|
|
}
|
|
const actor = requiredEnv(env, "GITHUB_ACTOR");
|
|
if (actor !== requiredEnv(env, "GITHUB_TRIGGERING_ACTOR")) {
|
|
throw new Error("FRV proof broker actor must match the triggering actor");
|
|
}
|
|
const runId = requiredPositiveInteger(requiredEnv(env, "GITHUB_RUN_ID"), "GITHUB_RUN_ID");
|
|
const runAttempt = requiredPositiveInteger(
|
|
requiredEnv(env, "GITHUB_RUN_ATTEMPT"),
|
|
"GITHUB_RUN_ATTEMPT",
|
|
);
|
|
|
|
assertExactKeys(event.inputs, ["head_sha", "pr_number"], "workflow inputs");
|
|
const inputs = event.inputs;
|
|
const prNumber = requiredPositiveInteger(inputs.pr_number, "pr_number");
|
|
const headSha = requiredString(inputs.head_sha, "head_sha");
|
|
if (!SHA_PATTERN.test(headSha)) {
|
|
throw new Error("head_sha must be exactly 40 lowercase hex characters");
|
|
}
|
|
const correlation = `frv-proof-${runId}-${runAttempt}`;
|
|
|
|
return {
|
|
actor,
|
|
correlation,
|
|
headSha,
|
|
prNumber,
|
|
repository: REPOSITORY,
|
|
runId,
|
|
workflowSha,
|
|
};
|
|
}
|
|
|
|
function record(value, label) {
|
|
if (!isRecord(value)) {
|
|
throw new Error(`${label} must be an object`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function nestedRecord(value, key, label) {
|
|
return record(record(value, label)[key], `${label}.${key}`);
|
|
}
|
|
|
|
function validateActorPermission(value, actor) {
|
|
const permission = requiredString(record(value, "actor permission").permission, "permission");
|
|
if (!new Set(["admin", "maintain", "write"]).has(permission)) {
|
|
throw new Error(`actor ${actor} lacks repository write permission`);
|
|
}
|
|
}
|
|
|
|
function validatePullRequest(value, context) {
|
|
const pull = record(value, "pull request");
|
|
if (pull.number !== context.prNumber || pull.state !== "open") {
|
|
throw new Error("proof target must be the requested open pull request");
|
|
}
|
|
if (nestedRecord(pull, "head", "pull request").sha !== context.headSha) {
|
|
throw new Error("pull request head SHA does not match the requested exact head");
|
|
}
|
|
if (nestedRecord(pull, "head", "pull request").repo?.full_name !== context.repository) {
|
|
throw new Error("pull request head must belong to the trusted repository");
|
|
}
|
|
if (nestedRecord(pull, "base", "pull request").ref !== "main") {
|
|
throw new Error("pull request base must be main");
|
|
}
|
|
if (nestedRecord(pull, "base", "pull request").repo?.full_name !== context.repository) {
|
|
throw new Error("pull request base repository does not match");
|
|
}
|
|
}
|
|
|
|
function validateFixtureWorkflow(value) {
|
|
const workflow = record(value, "fixture workflow");
|
|
if (
|
|
workflow.name !== FIXTURE_NAME ||
|
|
workflow.path !== FIXTURE_WORKFLOW ||
|
|
workflow.state !== "active"
|
|
) {
|
|
throw new Error("fixture workflow identity does not match the fixed active workflow");
|
|
}
|
|
requiredPositiveInteger(workflow.id, "fixture workflow id");
|
|
}
|
|
|
|
function validateMainRef(value, workflowSha) {
|
|
const ref = record(value, "main ref");
|
|
if (ref.ref !== "refs/heads/main") {
|
|
throw new Error("main ref identity does not match");
|
|
}
|
|
const mainSha = requiredString(nestedRecord(ref, "object", "main ref").sha, "main ref SHA");
|
|
if (!SHA_PATTERN.test(mainSha) || mainSha !== workflowSha) {
|
|
throw new Error("trusted main moved before fixture dispatch");
|
|
}
|
|
}
|
|
|
|
export function validateFixtureRun(value, expected) {
|
|
const run = record(value, "fixture run");
|
|
if (nestedRecord(run, "repository", "fixture run").full_name !== expected.repository) {
|
|
throw new Error("fixture run repository does not match");
|
|
}
|
|
if (run.path !== FIXTURE_WORKFLOW) {
|
|
throw new Error("fixture run workflow does not match");
|
|
}
|
|
if (run.head_sha !== expected.headSha || run.head_branch !== expected.branch) {
|
|
throw new Error("fixture run source does not match the trusted main workflow SHA");
|
|
}
|
|
if (run.event !== "workflow_dispatch") {
|
|
throw new Error("fixture run event does not match");
|
|
}
|
|
const expectedTitle = `${FIXTURE_NAME} [${FIXTURE_OPERATION}] ${expected.correlation}`;
|
|
if (run.display_title !== expectedTitle) {
|
|
throw new Error("fixture run operation or correlation does not match");
|
|
}
|
|
const runId = requiredPositiveInteger(run.id, "fixture run id");
|
|
if (expected.runId !== undefined && runId !== expected.runId) {
|
|
throw new Error("fixture run id changed");
|
|
}
|
|
if (requiredPositiveInteger(run.run_attempt, "fixture run attempt") !== expected.attempt) {
|
|
throw new Error("fixture run attempt does not match");
|
|
}
|
|
if (run.status !== "completed" || run.conclusion !== expected.conclusion) {
|
|
throw new Error(`fixture run must complete with ${expected.conclusion}`);
|
|
}
|
|
return run;
|
|
}
|
|
|
|
function fixtureRunIdentityMatches(value, context) {
|
|
if (!isRecord(value)) {
|
|
return false;
|
|
}
|
|
return (
|
|
value.head_branch === "main" &&
|
|
value.event === "workflow_dispatch" &&
|
|
value.display_title === `${FIXTURE_NAME} [${FIXTURE_OPERATION}] ${context.correlation}`
|
|
);
|
|
}
|
|
|
|
async function waitForInitialRun(api, context, sleep) {
|
|
const branch = encodeURIComponent("main");
|
|
for (let poll = 0; poll < POLL_LIMIT; poll += 1) {
|
|
const response = record(
|
|
await api.request(
|
|
"GET",
|
|
`/actions/workflows/${FIXTURE_WORKFLOW_ID}/runs?event=workflow_dispatch&branch=${branch}&per_page=20`,
|
|
),
|
|
"fixture workflow runs",
|
|
);
|
|
const runs = response.workflow_runs;
|
|
if (!Array.isArray(runs)) {
|
|
throw new Error("fixture workflow runs response is invalid");
|
|
}
|
|
const candidate = runs.find((run) => fixtureRunIdentityMatches(run, context));
|
|
if (candidate) {
|
|
const run = record(candidate, "fixture run");
|
|
if (run.status !== "completed") {
|
|
await sleep(POLL_INTERVAL_MS);
|
|
continue;
|
|
}
|
|
return validateFixtureRun(run, {
|
|
attempt: 1,
|
|
branch: "main",
|
|
conclusion: "failure",
|
|
correlation: context.correlation,
|
|
headSha: context.workflowSha,
|
|
repository: context.repository,
|
|
});
|
|
}
|
|
await sleep(POLL_INTERVAL_MS);
|
|
}
|
|
throw new Error("timed out waiting for the broker-owned fixture run");
|
|
}
|
|
|
|
async function waitForRerun(api, context, fixtureRunId, sleep) {
|
|
for (let poll = 0; poll < POLL_LIMIT; poll += 1) {
|
|
const run = record(await api.request("GET", `/actions/runs/${fixtureRunId}`), "fixture run");
|
|
const attempt = requiredPositiveInteger(run.run_attempt, "fixture run attempt");
|
|
if (attempt < 2 || run.status !== "completed") {
|
|
await sleep(POLL_INTERVAL_MS);
|
|
continue;
|
|
}
|
|
return validateFixtureRun(run, {
|
|
attempt: 2,
|
|
branch: "main",
|
|
conclusion: "success",
|
|
correlation: context.correlation,
|
|
headSha: context.workflowSha,
|
|
repository: context.repository,
|
|
runId: fixtureRunId,
|
|
});
|
|
}
|
|
throw new Error("timed out waiting for the failed-job rerun");
|
|
}
|
|
|
|
async function validateMutationAuthority(api, context) {
|
|
validateActorPermission(
|
|
await api.request("GET", `/collaborators/${encodeURIComponent(context.actor)}/permission`),
|
|
context.actor,
|
|
);
|
|
validatePullRequest(await api.request("GET", `/pulls/${context.prNumber}`), context);
|
|
}
|
|
|
|
async function validateFixturePrerequisite(api) {
|
|
validateFixtureWorkflow(await api.request("GET", `/actions/workflows/${FIXTURE_WORKFLOW_ID}`));
|
|
}
|
|
|
|
async function validateTrustedMain(api, context) {
|
|
validateMainRef(await api.request("GET", "/git/ref/heads/main"), context.workflowSha);
|
|
}
|
|
|
|
export async function runProofBroker({ api, env, event, sleep = setTimeoutPromise }) {
|
|
const context = validateBrokerRequest(event, env);
|
|
await validateFixturePrerequisite(api);
|
|
await validateMutationAuthority(api, context);
|
|
await validateTrustedMain(api, context);
|
|
|
|
await api.request("POST", `/actions/workflows/${FIXTURE_WORKFLOW_ID}/dispatches`, {
|
|
inputs: {
|
|
correlation: context.correlation,
|
|
operation: FIXTURE_OPERATION,
|
|
},
|
|
ref: "main",
|
|
});
|
|
const initialRun = await waitForInitialRun(api, context, sleep);
|
|
const fixtureRunId = requiredPositiveInteger(initialRun.id, "fixture run id");
|
|
await validateMutationAuthority(api, context);
|
|
await api.request("POST", `/actions/runs/${fixtureRunId}/rerun-failed-jobs`);
|
|
await waitForRerun(api, context, fixtureRunId, sleep);
|
|
return {
|
|
actor: context.actor,
|
|
correlation: context.correlation,
|
|
fixtureRunAttempt: 2,
|
|
fixtureRunId,
|
|
headSha: context.headSha,
|
|
operation: FIXTURE_OPERATION,
|
|
prNumber: context.prNumber,
|
|
repository: context.repository,
|
|
sourceRef: "refs/heads/main",
|
|
workflowSha: context.workflowSha,
|
|
};
|
|
}
|
|
|
|
function setTimeoutPromise(milliseconds) {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, milliseconds);
|
|
});
|
|
}
|
|
|
|
export function createGitHubApi({ repository, token, fetchImpl = fetch }) {
|
|
if (repository !== REPOSITORY) {
|
|
throw new Error(`FRV proof broker requires repository ${REPOSITORY}`);
|
|
}
|
|
const baseUrl = `https://api.github.com/repos/${repository}`;
|
|
return {
|
|
async request(method, path, body) {
|
|
const response = await fetchImpl(`${baseUrl}${path}`, {
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
method,
|
|
signal: AbortSignal.timeout(30_000),
|
|
});
|
|
if (!response.ok) {
|
|
const detail = (await response.text()).slice(0, 500);
|
|
throw new Error(`GitHub API ${method} ${path} failed (${response.status}): ${detail}`);
|
|
}
|
|
if (response.status === 204) {
|
|
return null;
|
|
}
|
|
return response.json();
|
|
},
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const eventPath = requiredEnv(process.env, "GITHUB_EVENT_PATH");
|
|
const event = JSON.parse(readFileSync(eventPath, "utf8"));
|
|
const token = requiredEnv(process.env, "GH_TOKEN");
|
|
const api = createGitHubApi({ repository: REPOSITORY, token });
|
|
const receipt = await runProofBroker({ api, env: process.env, event });
|
|
const receiptPath = requiredEnv(process.env, "FRV_PROOF_RECEIPT");
|
|
writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
|
|
if (process.env.GITHUB_OUTPUT) {
|
|
appendFileSync(process.env.GITHUB_OUTPUT, `receipt_path=${receiptPath}\n`);
|
|
}
|
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
appendFileSync(
|
|
process.env.GITHUB_STEP_SUMMARY,
|
|
[
|
|
"## FRV failed-job rerun proof",
|
|
"",
|
|
`- Pull request: #${receipt.prNumber}`,
|
|
`- Exact head: \`${receipt.headSha}\``,
|
|
`- Trusted broker SHA: \`${receipt.workflowSha}\``,
|
|
`- Fixture run: \`${receipt.fixtureRunId}\`, attempt \`2\``,
|
|
"- Fixed operation: `noop`",
|
|
"- Fixture source: trusted `main` at the broker workflow SHA",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
try {
|
|
await main();
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
}
|
|
}
|