diff --git a/scripts/watch-pr-ci.mts b/scripts/watch-pr-ci.mts index 7e18623167b..ee59d76b743 100644 --- a/scripts/watch-pr-ci.mts +++ b/scripts/watch-pr-ci.mts @@ -32,6 +32,7 @@ const RollupCheckSchema = z.object({ workflowRun: optionalNullable( z.object({ databaseId: optionalNumber, + event: optionalString, workflow: optional(z.object({ databaseId: optionalNumber })), }), ), @@ -66,7 +67,7 @@ const RollupResponseSchema = z.object({ repository: z.object({ pullRequest: RollupPageSchema.nullish() }).nullish(), }), }); -const RunListItemSchema = z.object({ id: z.number(), created_at: z.string() }); +const RunListItemSchema = z.object({ id: z.number(), workflow_id: optionalNumber }); const RunListSchema = z .object({ workflow_runs: validArray(RunListItemSchema) }) .transform((response) => response.workflow_runs) @@ -89,7 +90,7 @@ const FAILURE_CONCLUSIONS = new Set([ "STALE", "TIMED_OUT", ]); -const ROLLUP_QUERY = `query($owner:String!,$name:String!,$pr:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$pr){state mergeable headRefOid statusCheckRollup{state contexts(first:100,after:$cursor){totalCount pageInfo{hasNextPage endCursor} nodes{kind:__typename ... on CheckRun{name status conclusion databaseId checkSuite{workflowRun{databaseId workflow{databaseId}}}} ... on StatusContext{context state}}}}}}}`; +const ROLLUP_QUERY = `query($owner:String!,$name:String!,$pr:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$pr){state mergeable headRefOid statusCheckRollup{state contexts(first:100,after:$cursor){totalCount pageInfo{hasNextPage endCursor} nodes{kind:__typename ... on CheckRun{name status conclusion databaseId checkSuite{workflowRun{databaseId event workflow{databaseId}}}} ... on StatusContext{context state}}}}}}}`; const GH_READ_OPTIONS = { stdio: ["ignore", "pipe", "pipe"], timeout: 60_000, @@ -192,7 +193,7 @@ function checkRunIdentity(check: RollupCheck) { const newerJob = (a: JobIdentity, b: JobIdentity) => a.runId !== b.runId ? a.runId > b.runId : a.checkId > b.checkId; -export function classifyRollup(rollup: RollupPayload | null | undefined) { +export function classifyRollup(rollup: RollupPayload | null | undefined, runs: RunListItem[] = []) { const rawNodes = rollup?.contexts?.nodes ?? []; const hiddenContextCount = Math.max( 0, @@ -221,8 +222,8 @@ export function classifyRollup(rollup: RollupPayload | null | undefined) { let supersededCount = 0; // Re-triggers leave every prior run's check runs on the SHA forever and GitHub's aggregate // counts them. A check is superseded when a newer same-workflow check shares its name - // (GitHub's latest-name-wins semantics), or when it was cancelled and its workflow has a - // newer run (draft->ready cancels the old run before the replacement posts check runs). + // (GitHub's latest-name-wins semantics), or when its cancelled workflow has a newer run. + // Actions run metadata also proves target-run supersession before a newer run posts jobs. // Older-run checks with unique names stay visible so distinct invocations are not dropped. const nodes = rawNodes.filter((check) => { const identity = checkRunIdentity(check); @@ -236,8 +237,13 @@ export function classifyRollup(rollup: RollupPayload | null | undefined) { return false; } } - const newestRun = newestRunByWorkflow.get(identity.workflowId); - if (check.conclusion === "CANCELLED" && newestRun !== undefined && newestRun > identity.runId) { + const newestRun = newestRunByWorkflow.get(identity.workflowId) ?? identity.runId; + if ( + check.conclusion === "CANCELLED" && + (newestRun > identity.runId || + (check.checkSuite?.workflowRun?.event === "pull_request_target" && + runs.some((run) => run.workflow_id === identity.workflowId && run.id > identity.runId))) + ) { supersededCount += 1; return false; } @@ -310,6 +316,13 @@ const findRun = (repo: string, sha: string, after?: number) => RunListSchema.parse(execGhJson(buildFindRunArgs(repo, sha), GH_READ_OPTIONS)), after, ); +const findTargetRuns = (repo: string, sha: string) => + RunListSchema.parse( + execGhJson( + ["api", `repos/${repo}/actions/runs?event=pull_request_target&head_sha=${sha}&per_page=100`], + GH_READ_OPTIONS, + ), + ); const readRun = (repo: string, runId: number) => RunStatusSchema.parse( execGhJson( @@ -541,7 +554,18 @@ async function main(argv = process.argv.slice(2)) { if (blocked !== null) { return blocked; } - const result = classifyRollup(pr.statusCheckRollup); + let result = classifyRollup(pr.statusCheckRollup); + if ( + result.verdict === "FAILING" && + pr.statusCheckRollup?.contexts?.nodes?.some( + (check) => + check.conclusion === "CANCELLED" && + check.checkSuite?.workflowRun?.event === "pull_request_target" && + checkRunIdentity(check), + ) + ) { + result = classifyRollup(pr.statusCheckRollup, findTargetRuns(args.repo, args.headSha)); + } lastState = pr.statusCheckRollup?.state ?? "NONE"; lastPending = result.pendingCount; console.log( diff --git a/test/scripts/watch-pr-ci.test.ts b/test/scripts/watch-pr-ci.test.ts index 687445733db..3a0bb1eeb8e 100644 --- a/test/scripts/watch-pr-ci.test.ts +++ b/test/scripts/watch-pr-ci.test.ts @@ -320,6 +320,78 @@ describe("watch-pr-ci", () => { ).toEqual({ verdict: "PENDING", pendingCount: 2, failingNames: [], supersededCount: 1 }); }); + it.each<{ + label: string; + name?: string; + conclusion?: string; + event?: string | null; + workflowId?: number | null; + newerRunId?: number; + newerWorkflowId?: number | null; + ciStatus?: string; + verdict?: string; + }>([ + { label: "pending CI", ciStatus: "IN_PROGRESS", verdict: "PENDING" }, + { label: "successful CI with a different check name", name: "target guard", verdict: "GREEN" }, + { label: "latest target cancellation", newerRunId: 100 }, + { label: "real target failure", conclusion: "FAILURE" }, + { label: "another event's cancellation", event: "pull_request" }, + { label: "another workflow", newerWorkflowId: 20 }, + { label: "unknown event", event: null }, + { label: "unknown visible workflow identity", workflowId: null }, + { label: "unknown replacement workflow identity", newerWorkflowId: null }, + ])( + "uses newer same-workflow target-run identity without hiding $label", + ({ + name = "dispatch", + conclusion = "CANCELLED", + event = "pull_request_target", + workflowId = 10, + newerRunId = 200, + newerWorkflowId = 10, + ciStatus = "COMPLETED", + verdict = "FAILING", + }) => { + expect( + classifyRollup( + { + state: "FAILURE", + contexts: { + nodes: [ + { + kind: "CheckRun", + name, + status: "COMPLETED", + conclusion, + checkSuite: { + workflowRun: { + databaseId: 100, + event: event ?? undefined, + workflow: { databaseId: workflowId ?? undefined }, + }, + }, + }, + { + kind: "CheckRun", + name: "CI", + status: ciStatus, + conclusion: ciStatus === "COMPLETED" ? "SUCCESS" : null, + checkSuite: { workflowRun: { databaseId: 200, workflow: { databaseId: 20 } } }, + }, + ], + }, + }, + [{ id: newerRunId, workflow_id: newerWorkflowId ?? undefined }], + ), + ).toEqual({ + verdict, + pendingCount: ciStatus === "IN_PROGRESS" ? 1 : 0, + failingNames: verdict === "FAILING" ? [name] : [], + supersededCount: verdict === "FAILING" ? 0 : 1, + }); + }, + ); + it("keeps only the newest same-run check attempt while its replacement is pending", () => { expect( classifyRollup({