open-design/scripts/approve-fork-pr-workflows.ts
Marc Chan 125dcd0174
fix(ci): run fork visual reports from trusted code (#2935)
* fix: run fork visual reports from trusted code

* fix: auto-approve strict web visual capture

* fix: address visual report review feedback

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix: propagate visual report storage failures

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix: validate PR screenshots before upload

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix: validate visual PR identity before comment

* fix: harden fork visual report validation

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix: address remaining fork visual report review feedback

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix: handle stale fork visual report lookup

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix: allow stale fork visual report fallback

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)
2026-05-26 06:17:04 +00:00

454 lines
15 KiB
TypeScript

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<WorkflowRunsResponse>;
loadPullRequestsForHeadSha?: (repo: string, headSha: string) => Promise<PullRequest[]>;
loadPullRequestsForHeadRef?: (repo: string, headOwnerAndRef: string) => Promise<PullRequest[]>;
};
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function waitForPendingApprovalRuns(
loadRuns: () => Promise<WorkflowRun[]>,
sleep: (ms: number) => Promise<void> = delay,
now: () => number = Date.now,
config: PendingRunPollingConfig = {},
): Promise<WorkflowRun[]> {
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<void> => {
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<T>(path: string, init: RequestInit = {}): Promise<T> {
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<T>(path: string): Promise<T[]> {
const results: T[] = [];
for (let page = 1; ; page += 1) {
const separator = path.includes("?") ? "&" : "?";
const items = await github<T[]>(`${path}${separator}per_page=100&page=${page}`);
results.push(...items);
if (items.length < 100) return results;
}
}
async function approveRun(run: WorkflowRun): Promise<void> {
const repo = getRepo();
if (dryRun) {
console.log(`[dry-run] would approve workflow run ${run.id} (${run.name ?? run.path})`);
return;
}
await github<void>(`/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<PullRequest[]> {
return githubPaginated<PullRequest>(`/repos/${repo}/commits/${headSha}/pulls`);
}
async function listPullRequestsForHeadRef(repo: string, headOwnerAndRef: string): Promise<PullRequest[]> {
return githubPaginated<PullRequest>(`/repos/${repo}/pulls?state=open&head=${encodeURIComponent(headOwnerAndRef)}`);
}
async function listWorkflowRunsForHeadSha(
repo: string,
headSha: string,
loadWorkflowRunsResponsePage: (path: string) => Promise<WorkflowRunsResponse>,
): Promise<WorkflowRun[]> {
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<WorkflowRun[]> {
const files = Array.isArray(filesOrDeps) ? filesOrDeps : undefined;
const deps = Array.isArray(filesOrDeps) ? maybeDeps : filesOrDeps;
const loadWorkflowRunsResponsePage = deps.loadWorkflowRunsResponsePage ?? ((path: string) => github<WorkflowRunsResponse>(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<void> {
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<PullRequest>(`/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<PullRequestFile>(`/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<PullRequest>(`/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<PullRequest>(`/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();
}