fix(ci): validate unindexed readiness heads with live PR metadata (#5316)

This commit is contained in:
gensokyo-dev
2026-10-20 07:19:58 +08:00
committed by GitHub
parent bf1eeee94f
commit 82c76e2cba
3 changed files with 231 additions and 10 deletions
+8
View File
@@ -11,6 +11,14 @@ export interface ReadinessResult {
readonly description: string;
}
export interface ReadinessGitHub {
graphql(
query: string,
variables: {
readonly owner: string;
readonly name: string;
readonly after: string | null;
},
): Promise<unknown>;
readonly rest: {
readonly pulls: {
get(input: {
+59 -9
View File
@@ -2,6 +2,57 @@ import { readFile } from "node:fs/promises";
const result = (state, description) => ({ state, description });
const OPEN_HEADS_QUERY = `query ReadinessOpenHeads($owner: String!, $name: String!, $after: String) {
repository(owner: $owner, name: $name) {
pullRequests(first: 100, after: $after, states: OPEN, orderBy: {field: CREATED_AT, direction: ASC}) {
nodes { number headRefOid }
pageInfo { hasNextPage endCursor }
}
}
}`;
/** An unindexed commit needs complete live head coverage, not PR bodies. */
async function openHeadsMatching(github, repository, sha) {
const matches = [];
const numbers = new Set();
const cursors = new Set();
let after = null;
do {
const response = await github.graphql(OPEN_HEADS_QUERY, {
owner: repository.owner,
name: repository.repo,
after,
});
const page = response?.repository?.pullRequests;
if (
!Array.isArray(page?.nodes) ||
typeof page.pageInfo?.hasNextPage !== "boolean"
)
throw new Error("GitHub returned incomplete open-PR head metadata.");
for (const node of page.nodes) {
if (
!Number.isSafeInteger(node?.number) ||
node.number <= 0 ||
typeof node.headRefOid !== "string" ||
!/^(?:[a-f\d]{40}|[a-f\d]{64})$/u.test(node.headRefOid) ||
numbers.has(node.number)
)
throw new Error(
"GitHub returned invalid or repeated open-PR head metadata.",
);
numbers.add(node.number);
if (node.headRefOid === sha)
matches.push({ number: node.number, state: "open", head: { sha } });
}
if (!page.pageInfo.hasNextPage) return matches;
const next = page.pageInfo.endCursor;
if (typeof next !== "string" || next.length === 0 || cursors.has(next))
throw new Error("GitHub open-PR head pagination did not advance.");
cursors.add(next);
after = next;
} while (true);
}
function readinessLines(markdown) {
const lines = markdown.split(/\r?\n/u);
const visible = [];
@@ -125,18 +176,17 @@ export async function runReadinessCheck({ github, context, core }) {
);
let verdict = evaluateReadiness(pull, template);
if (verdict.state === "success") {
const associated = await github.paginate(
let associated = await github.paginate(
github.rest.repos.listPullRequestsAssociatedWithCommit,
{ ...repository, commit_sha: pull.head.sha, per_page: 100 },
);
if (
!associated.some(
(other) =>
other.number === number &&
other.state === "open" &&
other.head.sha === pull.head.sha,
)
)
const ownsHead = (other) =>
other.number === number &&
other.state === "open" &&
other.head.sha === pull.head.sha;
if (!associated.some(ownsHead))
associated = await openHeadsMatching(github, repository, pull.head.sha);
if (!associated.some(ownsHead))
verdict = result(
"pending",
"GitHub has not confirmed this PR's head association; rerun PR readiness.",
+164 -1
View File
@@ -35,6 +35,18 @@ const makePull = (
...changes,
});
const headPage = (
nodes: readonly { number: number; headRefOid: string }[],
next: string | null = null,
) => ({
repository: {
pullRequests: {
nodes,
pageInfo: { hasNextPage: next !== null, endCursor: next },
},
},
});
function fixture(changes: Partial<ReadinessPullRequest> = {}) {
const pull = makePull(changes);
const get = vi.fn(async () => ({ data: pull }));
@@ -46,7 +58,13 @@ function fixture(changes: Partial<ReadinessPullRequest> = {}) {
) => ({}),
);
const paginate = vi.fn(async () => [pull]);
const graphql = vi.fn(
async (
..._arguments: Parameters<ReadinessGitHub["graphql"]>
): Promise<unknown> => headPage([]),
);
const github = {
graphql,
rest: {
pulls: { get },
repos: {
@@ -63,7 +81,16 @@ function fixture(changes: Partial<ReadinessPullRequest> = {}) {
runId: 123,
};
const core = { info: vi.fn(), setFailed: vi.fn() };
return { github, context, core, get, createCommitStatus, paginate, pull };
return {
github,
context,
core,
get,
createCommitStatus,
paginate,
graphql,
pull,
};
}
describe("initial author declaration", () => {
@@ -192,6 +219,7 @@ describe("GitHub status boundary", () => {
expect((await runReadinessCheck(input))?.state).toBe("pending");
expect(input.core.setFailed).not.toHaveBeenCalled();
expect(input.paginate).not.toHaveBeenCalled();
expect(input.graphql).not.toHaveBeenCalled();
});
it("keeps the initial declaration valid after later pushes", async () => {
@@ -202,6 +230,7 @@ describe("GitHub status boundary", () => {
expect.objectContaining({ sha, state: "success" }),
);
expect(input.github.rest.pulls).toEqual({ get: input.get });
expect(input.graphql).not.toHaveBeenCalled();
}
});
@@ -244,6 +273,7 @@ describe("GitHub status boundary", () => {
input.paginate.mockResolvedValue([]);
expect((await runReadinessCheck(input))?.state).toBe("pending");
expect(input.core.setFailed).not.toHaveBeenCalled();
expect(input.graphql).toHaveBeenCalledOnce();
});
it.each([{ state: "closed" as const }, { base: { ref: "other-branch" } }])(
@@ -272,6 +302,139 @@ describe("GitHub status boundary", () => {
});
});
describe("Live PR-head identity", () => {
it("confirms an unindexed commit through complete paged live PR heads", async () => {
const input = fixture();
input.paginate.mockResolvedValue([]);
input.graphql
.mockResolvedValueOnce(
headPage([{ number: 1, headRefOid: "b".repeat(40) }], "next-page"),
)
.mockResolvedValueOnce(
headPage([
{ number: input.pull.number, headRefOid: input.pull.head.sha },
]),
);
expect((await runReadinessCheck(input))?.state).toBe("success");
expect(input.graphql).toHaveBeenNthCalledWith(1, expect.any(String), {
owner: "morluto",
name: "rea",
after: null,
});
expect(input.graphql).toHaveBeenNthCalledWith(2, expect.any(String), {
owner: "morluto",
name: "rea",
after: "next-page",
});
expect(input.createCommitStatus).toHaveBeenLastCalledWith(
expect.objectContaining({ sha: input.pull.head.sha, state: "success" }),
);
});
it("rejects a shared head discovered on a later live page", async () => {
const input = fixture();
input.paginate.mockResolvedValue([]);
input.graphql
.mockResolvedValueOnce(
headPage(
[{ number: input.pull.number, headRefOid: input.pull.head.sha }],
"next",
),
)
.mockResolvedValueOnce(
headPage([{ number: 43, headRefOid: input.pull.head.sha }]),
);
expect((await runReadinessCheck(input))?.state).toBe("failure");
expect(input.core.setFailed).toHaveBeenCalledWith(
expect.stringContaining("distinct head commits"),
);
});
it("does not publish stale success when a fallback scan spans a head update", async () => {
const input = fixture();
input.paginate.mockResolvedValue([]);
input.graphql.mockResolvedValue(
headPage([
{ number: input.pull.number, headRefOid: input.pull.head.sha },
]),
);
input.get
.mockResolvedValueOnce({ data: input.pull })
.mockResolvedValueOnce({
data: makePull({ head: { sha: "b".repeat(40) } }),
});
expect((await runReadinessCheck(input))?.state).toBe("pending");
expect(input.createCommitStatus).toHaveBeenLastCalledWith(
expect.objectContaining({ sha: "b".repeat(40), state: "pending" }),
);
});
});
describe("Live PR-head coverage failures", () => {
it("leaves readiness pending when live head metadata is unavailable", async () => {
const input = fixture();
input.paginate.mockResolvedValue([]);
input.graphql.mockRejectedValue(new Error("live heads unavailable"));
await expect(runReadinessCheck(input)).rejects.toThrow(
"live heads unavailable",
);
expect(input.createCommitStatus).toHaveBeenCalledOnce();
expect(input.createCommitStatus).toHaveBeenCalledWith(
expect.objectContaining({ state: "pending" }),
);
});
it.each([
null,
{ repository: null },
{
repository: {
pullRequests: { nodes: null, pageInfo: { hasNextPage: false } },
},
},
headPage([{ number: 42, headRefOid: "unknown" }]),
headPage([{ number: 43, headRefOid: "A".repeat(40) }]),
])(
"cannot turn incomplete live metadata into success (%#)",
async (response) => {
const input = fixture();
input.paginate.mockResolvedValue([]);
input.graphql.mockResolvedValue(response);
await expect(runReadinessCheck(input)).rejects.toThrow(
/open-PR head metadata/u,
);
expect(input.createCommitStatus).toHaveBeenCalledOnce();
},
);
it("rejects non-progressing live pagination without another API request", async () => {
const input = fixture();
input.paginate.mockResolvedValue([]);
input.graphql
.mockRejectedValue(new Error("unexpected extra page"))
.mockResolvedValueOnce(headPage([], "repeat"))
.mockResolvedValueOnce(headPage([], "repeat"));
await expect(runReadinessCheck(input)).rejects.toThrow(
"pagination did not advance",
);
expect(input.graphql).toHaveBeenCalledTimes(2);
expect(input.createCommitStatus).toHaveBeenCalledOnce();
});
it("rejects overlapping live pages instead of claiming complete coverage", async () => {
const input = fixture();
input.paginate.mockResolvedValue([]);
const node = { number: input.pull.number, headRefOid: input.pull.head.sha };
input.graphql
.mockResolvedValueOnce(headPage([node], "next"))
.mockResolvedValueOnce(headPage([node]));
await expect(runReadinessCheck(input)).rejects.toThrow(
"repeated open-PR head metadata",
);
expect(input.createCommitStatus).toHaveBeenCalledOnce();
});
});
const readWorkflow = async () =>
z
.object({