#!/usr/bin/env node const { spawnSync } = require("child_process"); const REQUIRED_CHECKS = ["pr-policy", "pr-evidence", "source-validation", "artifact-preview"]; const GITHUB_ACTIONS_APP_ID = 15368; const BOT_BRANCH = "automation/canonical-repo-state"; const CI_WORKFLOW_PATH = ".github/workflows/ci.yml"; function parseArgs(argv) { const options = { pollSeconds: 10, maxAttempts: 180, skipPages: false }; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; if (argument === "--skip-pages") { options.skipPages = true; continue; } if (["--repo", "--pr", "--head", "--poll-seconds", "--max-attempts"].includes(argument)) { const value = argv[index + 1]; if (!value || value.startsWith("--")) throw new Error(`${argument} requires a value.`); const key = argument.slice(2).replace(/-([a-z])/gu, (_match, letter) => letter.toUpperCase()); options[key] = ["pollSeconds", "maxAttempts"].includes(key) ? Number(value) : value; index += 1; continue; } throw new Error(`Unknown argument: ${argument}`); } if (!/^[^/]+\/[^/]+$/u.test(String(options.repo || ""))) throw new Error("--repo must be owner/name."); if (!/^\d+$/u.test(String(options.pr || ""))) throw new Error("--pr must be a positive integer."); if (!/^[0-9a-f]{40}$/u.test(String(options.head || ""))) throw new Error("--head must be a full SHA-1."); if (!Number.isInteger(options.pollSeconds) || options.pollSeconds <= 0) throw new Error("--poll-seconds must be positive."); if (!Number.isInteger(options.maxAttempts) || options.maxAttempts <= 0) throw new Error("--max-attempts must be positive."); return options; } function verificationWorkflows(options = {}) { return options.skipPages ? ["ci.yml", "codeql.yml"] : ["ci.yml", "pages.yml", "codeql.yml"]; } function runGh(args, options = {}) { const result = spawnSync("gh", args, { encoding: "utf8", input: options.input, maxBuffer: 8 * 1024 * 1024, }); if (result.error) throw result.error; if (result.status !== 0) throw new Error(result.stderr.trim() || `gh ${args.join(" ")} failed.`); return result.stdout.trim(); } function latestRequiredChecks(checkRuns, expectedCheckSuiteId, requiredChecks = REQUIRED_CHECKS) { const result = new Map(); for (const run of checkRuns || []) { if ( Number(run?.app?.id) !== GITHUB_ACTIONS_APP_ID || Number(run?.check_suite?.id) !== Number(expectedCheckSuiteId) || !requiredChecks.includes(String(run?.name || "")) ) { continue; } const prior = result.get(run.name); const currentKey = run.completed_at || run.started_at || run.created_at || ""; const priorKey = prior?.completed_at || prior?.started_at || prior?.created_at || ""; if (!prior || currentKey > priorKey || (currentKey === priorKey && Number(run.id) > Number(prior.id))) { result.set(run.name, run); } } return result; } function summarizeChecks(checkRuns, expectedCheckSuiteId, requiredChecks = REQUIRED_CHECKS) { const latest = latestRequiredChecks(checkRuns, expectedCheckSuiteId, requiredChecks); return requiredChecks.map((name) => { const run = latest.get(name); if (!run) return { name, state: "pending", conclusion: "missing" }; if (String(run.status).toLowerCase() !== "completed") return { name, state: "pending", conclusion: "in_progress" }; const conclusion = String(run.conclusion || "").toLowerCase(); return { name, state: conclusion === "success" ? "success" : "failed", conclusion }; }); } function validatePullRequest(pr, options, expectedBaseSha) { if ( Number(pr?.number) !== Number(options.pr) || pr?.state !== "open" || pr?.base?.ref !== "main" || pr?.base?.sha !== expectedBaseSha || pr?.head?.ref !== BOT_BRANCH || pr?.head?.repo?.full_name !== options.repo || pr?.head?.sha !== options.head || pr?.auto_merge ) { throw new Error("Canonical-sync PR identity, head, base, state, or merge mode changed."); } return true; } function validateProtectedMain(branch) { if (branch?.name !== "main" || branch?.protected !== true) { throw new Error("Canonical-sync merge requires GitHub to report main as protected."); } return true; } function selectCanonicalPullRequestRun(runs, options, expectedBaseSha, workflowPath = CI_WORKFLOW_PATH) { const matches = (runs || []).filter((run) => ( run?.path === workflowPath && run?.event === "pull_request" && run?.head_branch === BOT_BRANCH && run?.head_sha === options.head && run?.head_repository?.full_name === options.repo && run?.actor?.login === "github-actions[bot]" && Number.isInteger(Number(run?.check_suite_id)) && Number(run.check_suite_id) > 0 && Array.isArray(run?.pull_requests) && run.pull_requests.length === 1 && Number(run.pull_requests[0]?.number) === Number(options.pr) && run.pull_requests[0]?.base?.ref === "main" && run.pull_requests[0]?.base?.sha === expectedBaseSha && run.pull_requests[0]?.head?.ref === BOT_BRANCH && run.pull_requests[0]?.head?.sha === options.head && Number(run.pull_requests[0]?.base?.repo?.id) === Number(run.head_repository?.id) && Number(run.pull_requests[0]?.head?.repo?.id) === Number(run.head_repository?.id) )); if (matches.length > 1) { throw new Error("Multiple canonical-sync pull-request CI runs matched the exact trusted identity."); } return matches[0] || null; } function wait(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } async function waitForChecks(options, expectedCheckSuiteId, dependencies = {}, requiredChecks = REQUIRED_CHECKS) { if (!Number.isInteger(Number(expectedCheckSuiteId)) || Number(expectedCheckSuiteId) <= 0) { throw new Error("Canonical-sync PR CI did not expose a valid check-suite ID."); } const load = dependencies.loadCheckRuns || (() => { const payload = JSON.parse(runGh([ "api", `repos/${options.repo}/commits/${options.head}/check-runs?per_page=100`, ]) || "{}"); return payload.check_runs || []; }); const pause = dependencies.wait || wait; for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) { const summaries = summarizeChecks(load(), expectedCheckSuiteId, requiredChecks); process.stdout.write(`[canonical-sync] ${summaries.map((item) => `${item.name}:${item.conclusion}`).join(" ")}\n`); const failed = summaries.filter((item) => item.state === "failed"); if (failed.length) throw new Error(`Canonical-sync required checks failed: ${failed.map((item) => item.name).join(", ")}`); if (summaries.every((item) => item.state === "success")) return summaries; await pause(options.pollSeconds * 1000); } throw new Error("Timed out waiting for canonical-sync required checks."); } async function ensurePullRequestChecksStarted(options, expectedBaseSha, dependencies = {}, workflowPath = CI_WORKFLOW_PATH) { const load = dependencies.loadWorkflowRuns || (() => { const payload = JSON.parse(runGh([ "api", `repos/${options.repo}/actions/runs?head_sha=${options.head}&per_page=100`, ]) || "{}"); return payload.workflow_runs || []; }); const rerun = dependencies.rerunWorkflow || ((run) => runGh([ "api", `repos/${options.repo}/actions/runs/${run.id}/rerun`, "-X", "POST", ])); const pause = dependencies.wait || wait; for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) { const run = selectCanonicalPullRequestRun(load(), options, expectedBaseSha, workflowPath); if (!run) { await pause(options.pollSeconds * 1000); continue; } const status = String(run.status || "").toLowerCase(); const conclusion = String(run.conclusion || "").toLowerCase(); if (status === "completed" && conclusion === "action_required") { rerun(run); process.stdout.write(`[canonical-sync] restarted PR-associated CI run ${run.id}.\n`); return run; } if (["queued", "in_progress", "waiting", "requested", "pending"].includes(status)) return run; if (status === "completed" && conclusion === "success") return run; throw new Error(`Canonical-sync PR CI cannot start from ${status || "unknown"}/${conclusion || "none"}.`); } throw new Error("Timed out waiting for the canonical-sync pull-request CI run."); } async function main() { const options = parseArgs(process.argv.slice(2)); const initialBranch = JSON.parse(runGh(["api", `repos/${options.repo}/branches/main`])); validateProtectedMain(initialBranch); const initialBaseSha = String(initialBranch?.commit?.sha || ""); if (!/^[0-9a-f]{40}$/u.test(initialBaseSha)) throw new Error("Protected main did not expose a full base SHA."); const initialPr = JSON.parse(runGh(["api", `repos/${options.repo}/pulls/${options.pr}`])); validatePullRequest(initialPr, options, initialBaseSha); const pullRequestRun = await ensurePullRequestChecksStarted(options, initialBaseSha); await waitForChecks(options, pullRequestRun.check_suite_id); const pr = JSON.parse(runGh(["api", `repos/${options.repo}/pulls/${options.pr}`])); const finalBranch = JSON.parse(runGh(["api", `repos/${options.repo}/branches/main`])); validateProtectedMain(finalBranch); if (finalBranch?.commit?.sha !== initialBaseSha) { throw new Error("Protected main changed while canonical-sync checks were running."); } validatePullRequest(pr, options, initialBaseSha); const payload = JSON.stringify({ merge_method: "squash", sha: options.head, commit_title: options.skipPages ? "[skip pages] chore: synchronize canonical repository state" : "chore: synchronize canonical repository state", commit_message: "Generated artifacts reproduced and merged through protected required checks.", }); const merged = JSON.parse(runGh( ["api", `repos/${options.repo}/pulls/${options.pr}/merge`, "-X", "PUT", "--input", "-"], { input: payload }, )); if (merged?.merged !== true) throw new Error(`Canonical-sync merge failed: ${merged?.message || "merged=false"}`); for (const workflow of verificationWorkflows(options)) { runGh([ "api", `repos/${options.repo}/actions/workflows/${workflow}/dispatches`, "-X", "POST", "-f", "ref=main", ]); } const pages = options.skipPages ? " without Pages publication" : " including Pages verification"; process.stdout.write(`[canonical-sync] merged PR #${options.pr} at ${options.head} and dispatched main verification${pages}.\n`); } if (require.main === module) { main().catch((error) => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; }); } module.exports = { ensurePullRequestChecksStarted, latestRequiredChecks, parseArgs, selectCanonicalPullRequestRun, summarizeChecks, validateProtectedMain, validatePullRequest, verificationWorkflows, waitForChecks, };