import { fileURLToPath } from "node:url"; type PullRequest = { number: number; state: string; draft?: boolean; changed_files: number; head: { ref: string; sha: string; repo: { full_name: string } | null; }; base: { ref: string; sha: string; repo: { full_name: string }; }; }; type PullRequestFile = { filename: string; previous_filename?: string; status: string; }; type WorkflowRun = { id: number; name: string | null; event: string; head_branch?: string | null; head_repository?: { full_name: string } | null; status: string | null; conclusion: string | null; head_sha: string; path: string; pull_requests: Array<{ number: number; head: { sha: string; repo: { full_name: string } | null }; base: { ref: string; sha: string; repo: { full_name: string } }; }>; }; type WorkflowRunsResponse = { workflow_runs: WorkflowRun[]; }; type ListPendingApprovalRunsDeps = { loadWorkflowRunsResponsePage?: (path: string) => Promise; loadPullRequestsForHeadSha?: (repo: string, headSha: string) => Promise; loadPullRequestsForHeadRef?: (repo: string, headOwnerAndRef: string) => Promise; }; const dryRun = process.env.DRY_RUN === "true"; const defaultPendingRunPollIntervalMs = 3_000; const defaultPendingRunFirstAppearanceTimeoutMs = 4 * 60_000; const defaultPendingRunSettlingWindowMs = 30_000; type PendingRunPollingConfig = { pollIntervalMs?: number; firstAppearanceTimeoutMs?: number; settlingWindowMs?: number; }; function pendingRunSetSignature(runs: WorkflowRun[]): string { return runs .map((run) => `${run.id}:${run.head_sha}:${normalizeWorkflowPath(run.path)}`) .sort() .join(","); } // Workflow allowlisting is the security boundary: fork PRs may touch broader // source paths, but this script only approves low-privilege pull_request // workflows. Keep privileged workflow_run / release / deploy workflows out of // this set. const allowedWorkflowPaths = new Set([ ".github/workflows/ci.yml", ".github/workflows/visual-pr-capture.yml", ".github/workflows/visual-pr-verify.yml", ]); const visualPrCaptureWorkflowPath = ".github/workflows/visual-pr-capture.yml"; export function normalizeWorkflowPath(path: string): string { const suffixIndex = path.indexOf("@"); return suffixIndex >= 0 ? path.slice(0, suffixIndex) : path; } function requireEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`${name} is required`); return value; } function getRepo(): string { return requireEnv("GITHUB_REPOSITORY"); } function getToken(): string { return requireEnv("GITHUB_TOKEN"); } function getPrNumber(): number { return Number(requireEnv("PR_NUMBER")); } function isAllowedSourceOrTestPath(path: string): boolean { return /^(apps|packages)\/[^/]+\/(src|tests)\//.test(path); } export function isAllowedChangedPath(path: string): boolean { if (isDeniedChangedPath(path)) return false; return ( isDocsPath(path) || path.startsWith("apps/web/") || isAllowedSourceOrTestPath(path) ); } export function isAllowedVisualCaptureChangedPath(path: string): boolean { return /^apps\/web\/src\/.+\.(?:css|ts|tsx)$/.test(path); } export function isDeniedChangedPath(path: string): boolean { return ( path.startsWith(".github/") || path.startsWith("scripts/") || path.startsWith("e2e/scripts/") || path.startsWith("nix/") || path.startsWith("tools/pack/") || path === "package.json" || path.endsWith("/package.json") || path === "pnpm-lock.yaml" || path === "pnpm-workspace.yaml" || path === "flake.nix" || path === "flake.lock" || /(^|\/)tsconfig(\.[^.]+)*\.json$/.test(path) || /(^|\/)(next|vite|vitest|playwright|astro|postcss|tailwind|eslint|prettier|wrangler|electron-builder)(\.config)?\.[^.]+$/.test( path, ) || path.endsWith("esbuild.config.mjs") || path.endsWith("esbuild.config.ts") ); } function isDocsPath(path: string): boolean { return ( path === "README.md" || path === "README.zh-CN.md" || path === "CONTRIBUTING.md" || path === "CONTRIBUTING.zh-CN.md" || path === "QUICKSTART.md" || path.startsWith("docs/") ); } function changedPathSet(file: PullRequestFile): string[] { return [file.filename, file.previous_filename].filter((path): path is string => Boolean(path)); } function allChangedPathsMatch(files: PullRequestFile[], predicate: (path: string) => boolean): boolean { return files.every((file) => changedPathSet(file).every(predicate)); } function workflowAllowsChangedFiles(workflowPath: string, files: PullRequestFile[] | undefined): boolean { if (workflowPath !== visualPrCaptureWorkflowPath) return true; return files != null && files.length > 0 && allChangedPathsMatch(files, isAllowedVisualCaptureChangedPath); } export function isPendingApprovalRun(run: WorkflowRun, pull: PullRequest, files?: PullRequestFile[]): boolean { const workflowPath = normalizeWorkflowPath(run.path); return ( run.head_sha === pull.head.sha && run.event === "pull_request" && (run.status === "action_required" || run.conclusion === "action_required") && allowedWorkflowPaths.has(workflowPath) && workflowAllowsChangedFiles(workflowPath, files) ); } export function hasPullApprovalStateDrift(initialPull: PullRequest, latestPull: PullRequest): boolean { return ( latestPull.state !== "open" || Boolean(latestPull.draft) || latestPull.head.sha !== initialPull.head.sha || latestPull.base.ref !== initialPull.base.ref || latestPull.head.repo?.full_name !== initialPull.head.repo?.full_name ); } function isSamePullRequest(candidate: WorkflowRun["pull_requests"][number] | PullRequest, pull: PullRequest): boolean { return ( candidate.number === pull.number && candidate.head.sha === pull.head.sha && candidate.head.repo?.full_name === pull.head.repo?.full_name && candidate.base.ref === pull.base.ref && candidate.base.repo.full_name === pull.base.repo.full_name ); } function hasMatchingHeadIdentity(run: WorkflowRun, pull: PullRequest): boolean { return ( run.head_sha === pull.head.sha && typeof run.head_branch === "string" && run.head_branch === pull.head.ref && run.head_repository?.full_name === pull.head.repo?.full_name ); } export function runTargetsPullRequest( run: WorkflowRun, pull: PullRequest, associatedPullsForHeadSha: PullRequest[], associatedPullsForHeadRef: PullRequest[], ): boolean { if (run.pull_requests.length > 0) { if (run.pull_requests.length !== 1) return false; const [associatedPull] = run.pull_requests; if (!associatedPull) return false; return isSamePullRequest(associatedPull, pull); } if (associatedPullsForHeadSha.length === 0) { if (!hasMatchingHeadIdentity(run, pull) || associatedPullsForHeadRef.length !== 1) return false; const [associatedPull] = associatedPullsForHeadRef; if (!associatedPull) return false; return isSamePullRequest(associatedPull, pull); } if (associatedPullsForHeadSha.length !== 1) return false; const [associatedPull] = associatedPullsForHeadSha; if (!associatedPull) return false; return isSamePullRequest(associatedPull, pull); } function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } export async function waitForPendingApprovalRuns( loadRuns: () => Promise, sleep: (ms: number) => Promise = delay, now: () => number = Date.now, config: PendingRunPollingConfig = {}, ): Promise { const pollIntervalMs = config.pollIntervalMs ?? defaultPendingRunPollIntervalMs; const firstAppearanceTimeoutMs = config.firstAppearanceTimeoutMs ?? defaultPendingRunFirstAppearanceTimeoutMs; const settlingWindowMs = config.settlingWindowMs ?? defaultPendingRunSettlingWindowMs; const firstAppearanceDeadline = now() + firstAppearanceTimeoutMs; let pendingRuns: WorkflowRun[] = []; const collectRuns = async (): Promise => { pendingRuns = await loadRuns(); }; await collectRuns(); while (pendingRuns.length === 0 && now() < firstAppearanceDeadline) { await sleep(pollIntervalMs); await collectRuns(); } let settlingDeadline = pendingRuns.length > 0 ? now() + settlingWindowMs : null; let lastPendingRunSetSignature = pendingRunSetSignature(pendingRuns); while (pendingRuns.length > 0 && settlingDeadline !== null && now() < settlingDeadline) { await sleep(pollIntervalMs); const previousPendingRunSetSignature = lastPendingRunSetSignature; await collectRuns(); lastPendingRunSetSignature = pendingRunSetSignature(pendingRuns); if (lastPendingRunSetSignature !== previousPendingRunSetSignature) { settlingDeadline = now() + settlingWindowMs; } } return pendingRuns; } async function github(path: string, init: RequestInit = {}): Promise { const response = await fetch(`https://api.github.com${path}`, { ...init, headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${getToken()}`, "User-Agent": "open-design-fork-pr-workflow-approver", "X-GitHub-Api-Version": "2022-11-28", ...init.headers, }, }); if (!response.ok) { const body = await response.text(); throw new Error(`GitHub API ${init.method ?? "GET"} ${path} failed with ${response.status}: ${body}`); } if (response.status === 204) return undefined as T; return (await response.json()) as T; } async function githubPaginated(path: string): Promise { const results: T[] = []; for (let page = 1; ; page += 1) { const separator = path.includes("?") ? "&" : "?"; const items = await github(`${path}${separator}per_page=100&page=${page}`); results.push(...items); if (items.length < 100) return results; } } async function approveRun(run: WorkflowRun): Promise { const repo = getRepo(); if (dryRun) { console.log(`[dry-run] would approve workflow run ${run.id} (${run.name ?? run.path})`); return; } await github(`/repos/${repo}/actions/runs/${run.id}/approve`, { method: "POST" }); console.log(`Approved workflow run ${run.id} (${run.name ?? run.path})`); } async function listPullRequestsForHeadSha(repo: string, headSha: string): Promise { return githubPaginated(`/repos/${repo}/commits/${headSha}/pulls`); } async function listPullRequestsForHeadRef(repo: string, headOwnerAndRef: string): Promise { return githubPaginated(`/repos/${repo}/pulls?state=open&head=${encodeURIComponent(headOwnerAndRef)}`); } async function listWorkflowRunsForHeadSha( repo: string, headSha: string, loadWorkflowRunsResponsePage: (path: string) => Promise, ): Promise { const workflowRuns: WorkflowRun[] = []; for (let page = 1; ; page += 1) { const response = await loadWorkflowRunsResponsePage( `/repos/${repo}/actions/runs?event=pull_request&head_sha=${headSha}&per_page=100&page=${page}`, ); workflowRuns.push(...response.workflow_runs); if (response.workflow_runs.length < 100) return workflowRuns; } } export async function listPendingApprovalRuns( repo: string, pull: PullRequest, filesOrDeps: PullRequestFile[] | ListPendingApprovalRunsDeps = [], maybeDeps: ListPendingApprovalRunsDeps = {}, ): Promise { const files = Array.isArray(filesOrDeps) ? filesOrDeps : undefined; const deps = Array.isArray(filesOrDeps) ? maybeDeps : filesOrDeps; const loadWorkflowRunsResponsePage = deps.loadWorkflowRunsResponsePage ?? ((path: string) => github(path)); const loadPullRequestsForHeadSha = deps.loadPullRequestsForHeadSha ?? ((currentRepo: string, headSha: string) => listPullRequestsForHeadSha(currentRepo, headSha)); const loadPullRequestsForHeadRef = deps.loadPullRequestsForHeadRef ?? ((currentRepo: string, headOwnerAndRef: string) => listPullRequestsForHeadRef(currentRepo, headOwnerAndRef)); const workflowRuns = await listWorkflowRunsForHeadSha(repo, pull.head.sha, loadWorkflowRunsResponsePage); const associatedPullsForHeadSha = (await loadPullRequestsForHeadSha(repo, pull.head.sha)).filter( (candidate) => candidate.state === "open", ); const associatedPullsForHeadRef = associatedPullsForHeadSha.length === 0 ? await (async () => { const headOwner = pull.head.repo?.full_name.split("/")[0]; const headOwnerAndRef = headOwner ? `${headOwner}:${pull.head.ref}` : null; if (!headOwnerAndRef) return []; return (await loadPullRequestsForHeadRef(repo, headOwnerAndRef)).filter( (candidate) => candidate.state === "open" && candidate.head.sha === pull.head.sha && candidate.head.repo?.full_name === pull.head.repo?.full_name, ); })() : []; return workflowRuns.filter( (run) => isPendingApprovalRun(run, pull, files) && runTargetsPullRequest(run, pull, associatedPullsForHeadSha, associatedPullsForHeadRef), ); } async function main(): Promise { const repo = getRepo(); const prNumber = getPrNumber(); if (!Number.isInteger(prNumber) || prNumber <= 0) { throw new Error(`Invalid PR_NUMBER: ${process.env.PR_NUMBER ?? ""}`); } const pull = await github(`/repos/${repo}/pulls/${prNumber}`); if (pull.state !== "open") { console.log(`Skipping PR #${prNumber}: state is ${pull.state}.`); return; } if (pull.draft) { console.log(`Skipping PR #${prNumber}: draft PR.`); return; } if (!pull.head.repo) { console.log(`Skipping PR #${prNumber}: head repository is unavailable.`); return; } if (pull.head.repo.full_name === pull.base.repo.full_name) { console.log(`Skipping PR #${prNumber}: not a fork PR.`); return; } const files = await githubPaginated(`/repos/${repo}/pulls/${prNumber}/files`); if (files.length !== pull.changed_files) { console.log( `Skipping PR #${prNumber}: GitHub returned ${files.length} changed files, but PR reports ${pull.changed_files}.`, ); return; } const latestPull = await github(`/repos/${repo}/pulls/${prNumber}`); if (hasPullApprovalStateDrift(pull, latestPull)) { console.log(`Skipping PR #${prNumber}: PR head/base changed while evaluating workflow approval.`); return; } const blockedPaths = files.flatMap((file) => changedPathSet(file).filter((path) => !isAllowedChangedPath(path))); if (blockedPaths.length > 0) { console.log(`Skipping PR #${prNumber}: changed paths are outside the auto-approval allowlist.`); for (const path of blockedPaths) console.log(`- ${path}`); return; } const pendingRuns = await waitForPendingApprovalRuns(() => listPendingApprovalRuns(repo, pull, files)); if (pendingRuns.length === 0) { console.log(`No action_required pull_request workflow runs found for PR #${prNumber} at ${pull.head.sha}.`); return; } const approvalPull = await github(`/repos/${repo}/pulls/${prNumber}`); if (hasPullApprovalStateDrift(pull, approvalPull)) { console.log(`Skipping PR #${prNumber}: PR changed while waiting for approvable workflow runs.`); return; } for (const run of pendingRuns) await approveRun(run); } if (process.argv[1] === fileURLToPath(import.meta.url)) { await main(); }