fix(ci): disambiguate fork PR workflow auto-approval (#2857)

Fork PR workflow runs can arrive without GitHub PR associations, which left ci and visual checks stuck awaiting manual approval even for allowlisted changes. Fall back to a single open PR match on head repo/ref/SHA so the approver stays conservative while still unblocking low-risk fork PR workflows.
This commit is contained in:
Marc Chan 2026-05-25 11:44:33 +08:00 committed by GitHub
parent 085963a1e2
commit e70d5bdd3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 177 additions and 8 deletions

View file

@ -17,6 +17,7 @@ test("isPendingApprovalRun matches approval-gated fork PR runs from GitHub's cap
state: "open",
changed_files: 1,
head: {
ref: "fix/workflow-linkage",
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
@ -47,6 +48,7 @@ test("isPendingApprovalRun also accepts action_required runs reported only in st
state: "open",
changed_files: 1,
head: {
ref: "fix/workflow-linkage",
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
@ -77,6 +79,7 @@ test("isPendingApprovalRun rejects runs outside the allowlist or without action_
state: "open",
changed_files: 1,
head: {
ref: "fix/workflow-linkage",
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
@ -128,6 +131,7 @@ test("runTargetsPullRequest accepts empty run.pull_requests only when the head S
state: "open",
changed_files: 1,
head: {
ref: "fix/workflow-linkage",
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
@ -149,7 +153,40 @@ test("runTargetsPullRequest accepts empty run.pull_requests only when the head S
pull_requests: [],
};
assert.equal(runTargetsPullRequest(run, pull, [pull]), true);
assert.equal(runTargetsPullRequest(run, pull, [pull], []), true);
});
test("runTargetsPullRequest accepts fork PR runs with no GitHub PR association when head identity matches", () => {
const pull = {
number: 2683,
state: "open",
changed_files: 1,
head: {
ref: "fix/workflow-linkage",
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
base: {
ref: "main",
sha: "4cd93a5c7a7b0db1961c854e55f8e0e6b1b45542",
repo: { full_name: "nexu-io/open-design" },
},
};
const run = {
id: 26273463769,
name: "CI",
event: "pull_request",
head_branch: "fix/workflow-linkage",
head_repository: { full_name: "someone/open-design" },
status: "completed",
conclusion: "action_required",
head_sha: pull.head.sha,
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
assert.equal(runTargetsPullRequest(run, pull, [], [pull]), true);
});
test("runTargetsPullRequest rejects ambiguous empty run.pull_requests associations", () => {
@ -158,6 +195,7 @@ test("runTargetsPullRequest rejects ambiguous empty run.pull_requests associatio
state: "open",
changed_files: 1,
head: {
ref: "fix/workflow-linkage",
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
@ -189,7 +227,83 @@ test("runTargetsPullRequest rejects ambiguous empty run.pull_requests associatio
pull_requests: [],
};
assert.equal(runTargetsPullRequest(run, pull, [pull, otherPull]), false);
assert.equal(runTargetsPullRequest(run, pull, [pull, otherPull], []), false);
});
test("runTargetsPullRequest rejects fork PR runs when multiple open PRs share the same head identity", () => {
const pull = {
number: 2683,
state: "open",
changed_files: 1,
head: {
ref: "fix/workflow-linkage",
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
base: {
ref: "main",
sha: "4cd93a5c7a7b0db1961c854e55f8e0e6b1b45542",
repo: { full_name: "nexu-io/open-design" },
},
};
const otherPull = {
...pull,
number: 3001,
base: {
ref: "release",
sha: "8db117d728f967d108f6fdd64cb8d921d057f7f6",
repo: { full_name: "nexu-io/open-design" },
},
};
const run = {
id: 26273463769,
name: "CI",
event: "pull_request",
head_branch: pull.head.ref,
head_repository: pull.head.repo,
status: "completed",
conclusion: "action_required",
head_sha: pull.head.sha,
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
assert.equal(runTargetsPullRequest(run, pull, [], [pull, otherPull]), false);
});
test("runTargetsPullRequest rejects empty associations when fork head identity does not match", () => {
const pull = {
number: 2683,
state: "open",
changed_files: 1,
head: {
ref: "fix/workflow-linkage",
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
base: {
ref: "main",
sha: "4cd93a5c7a7b0db1961c854e55f8e0e6b1b45542",
repo: { full_name: "nexu-io/open-design" },
},
};
const run = {
id: 26273463769,
name: "CI",
event: "pull_request",
head_branch: "different-branch",
head_repository: { full_name: "someone/open-design" },
status: "completed",
conclusion: "action_required",
head_sha: pull.head.sha,
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
assert.equal(runTargetsPullRequest(run, pull, [], [pull]), false);
});
test("runTargetsPullRequest rejects runs that GitHub already associates to a different PR", () => {
@ -198,6 +312,7 @@ test("runTargetsPullRequest rejects runs that GitHub already associates to a dif
state: "open",
changed_files: 1,
head: {
ref: "fix/workflow-linkage",
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
@ -229,7 +344,7 @@ test("runTargetsPullRequest rejects runs that GitHub already associates to a dif
],
};
assert.equal(runTargetsPullRequest(run, pull, [pull]), false);
assert.equal(runTargetsPullRequest(run, pull, [pull], []), false);
});
test("runTargetsPullRequest approves only the run that GitHub associates to the current PR when two PRs share a head SHA", () => {
@ -238,6 +353,7 @@ test("runTargetsPullRequest approves only the run that GitHub associates to the
state: "open",
changed_files: 1,
head: {
ref: "fix/workflow-linkage",
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
@ -275,8 +391,8 @@ test("runTargetsPullRequest approves only the run that GitHub associates to the
pull_requests: [otherPull],
};
assert.equal(runTargetsPullRequest(currentPrRun, pull, [pull, otherPull]), true);
assert.equal(runTargetsPullRequest(otherPrRun, pull, [pull, otherPull]), false);
assert.equal(runTargetsPullRequest(currentPrRun, pull, [pull, otherPull], []), true);
assert.equal(runTargetsPullRequest(otherPrRun, pull, [pull, otherPull], []), false);
});
test("runTargetsPullRequest ignores base tip churn for the same PR association", () => {
@ -285,6 +401,7 @@ test("runTargetsPullRequest ignores base tip churn for the same PR association",
state: "open",
changed_files: 1,
head: {
ref: "fix/workflow-linkage",
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
@ -315,7 +432,7 @@ test("runTargetsPullRequest ignores base tip churn for the same PR association",
],
};
assert.equal(runTargetsPullRequest(run, pull, [pull]), true);
assert.equal(runTargetsPullRequest(run, pull, [pull], []), true);
});
test("listPendingApprovalRuns paginates all pull_request runs for the head SHA and filters action_required client-side", async () => {
@ -324,6 +441,7 @@ test("listPendingApprovalRuns paginates all pull_request runs for the head SHA a
state: "open",
changed_files: 1,
head: {
ref: "fix/workflow-linkage",
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
@ -344,6 +462,8 @@ test("listPendingApprovalRuns paginates all pull_request runs for the head SHA a
id: 26273463600 + index,
name: "CI",
event: "pull_request",
head_branch: pull.head.ref,
head_repository: pull.head.repo,
status: "completed",
conclusion: "success",
head_sha: pull.head.sha,
@ -359,6 +479,8 @@ test("listPendingApprovalRuns paginates all pull_request runs for the head SHA a
id: 26273463769,
name: "CI",
event: "pull_request",
head_branch: pull.head.ref,
head_repository: pull.head.repo,
status: "completed",
conclusion: "action_required",
head_sha: pull.head.sha,
@ -369,6 +491,8 @@ test("listPendingApprovalRuns paginates all pull_request runs for the head SHA a
id: 26273463770,
name: "CI",
event: "pull_request",
head_branch: pull.head.ref,
head_repository: pull.head.repo,
status: "completed",
conclusion: "success",
head_sha: pull.head.sha,
@ -378,7 +502,8 @@ test("listPendingApprovalRuns paginates all pull_request runs for the head SHA a
],
};
},
loadPullRequestsForHeadSha: async () => [pull],
loadPullRequestsForHeadSha: async () => [],
loadPullRequestsForHeadRef: async () => [pull],
});
assert.deepEqual(requestedPaths, [
@ -398,6 +523,7 @@ test("hasPullApprovalStateDrift ignores base tip churn but still rejects base re
state: "open",
changed_files: 1,
head: {
ref: "fix/workflow-linkage",
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},

View file

@ -6,6 +6,7 @@ type PullRequest = {
draft?: boolean;
changed_files: number;
head: {
ref: string;
sha: string;
repo: { full_name: string } | null;
};
@ -26,6 +27,8 @@ 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;
@ -44,6 +47,7 @@ type WorkflowRunsResponse = {
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";
@ -176,10 +180,20 @@ function isSamePullRequest(candidate: WorkflowRun["pull_requests"][number] | Pul
);
}
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;
@ -188,6 +202,13 @@ export function runTargetsPullRequest(
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;
@ -286,6 +307,10 @@ async function listPullRequestsForHeadSha(repo: string, headSha: string): Promis
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,
@ -310,15 +335,33 @@ export async function listPendingApprovalRuns(
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) && runTargetsPullRequest(run, pull, associatedPullsForHeadSha),
(run) =>
isPendingApprovalRun(run, pull) &&
runTargetsPullRequest(run, pull, associatedPullsForHeadSha, associatedPullsForHeadRef),
);
}