diff --git a/test/e2e/support/base-image-publication.test.ts b/test/e2e/support/base-image-publication.test.ts index 7c0b52aaaad..4db501235f7 100644 --- a/test/e2e/support/base-image-publication.test.ts +++ b/test/e2e/support/base-image-publication.test.ts @@ -396,6 +396,53 @@ describe("base-image publication evidence", () => { ).rejects.toThrow(/duplicate id/u); }); + it("restarts collection after a concurrent workflow-run count change", async () => { + const entries = Array.from({ length: 102 }, (_, index) => ({ id: index + 1 })); + const pages = [ + { total_count: 101, workflow_runs: entries.slice(0, 100) }, + { total_count: 102, workflow_runs: entries.slice(100) }, + { total_count: 102, workflow_runs: entries.slice(0, 100) }, + { total_count: 102, workflow_runs: entries.slice(100) }, + ]; + const requests: string[] = []; + + await expect( + collectPaginated( + async (requestPath) => { + requests.push(requestPath); + return pages.shift(); + }, + "/runs?per_page=100", + "workflow_runs", + ), + ).resolves.toMatchObject({ total_count: 102, workflow_runs: entries }); + expect(requests).toEqual([ + "/runs?per_page=100&page=1", + "/runs?per_page=100&page=2", + "/runs?per_page=100&page=1", + "/runs?per_page=100&page=2", + ]); + }); + + it("fails closed after three unstable pagination attempts", async () => { + const entries = Array.from({ length: 101 }, (_, index) => ({ id: index + 1 })); + let requests = 0; + + await expect( + collectPaginated( + async (requestPath) => { + requests += 1; + return requestPath.endsWith("page=1") + ? { total_count: 101, workflow_runs: entries.slice(0, 100) } + : { total_count: 102, workflow_runs: entries.slice(100) }; + }, + "/runs?per_page=100", + "workflow_runs", + ), + ).rejects.toThrow(/total_count changed during 3 pagination attempts/u); + expect(requests).toBe(6); + }); + it("accepts a batch-push tip that descends from the newest changed input (#7372)", () => { const selection = selectPublicationRun( runsPayload([workflowRun({ head_sha: EXPECTED_SHA })]), diff --git a/tools/e2e/base-image-publication.mts b/tools/e2e/base-image-publication.mts index 41fff449db1..83fdd1bf3ca 100644 --- a/tools/e2e/base-image-publication.mts +++ b/tools/e2e/base-image-publication.mts @@ -17,6 +17,7 @@ const RUN_URL_ROOT = `https://github.com/${REPOSITORY}/actions/runs`; const WORKFLOW_URL = `https://github.com/${REPOSITORY}/blob/${MAIN_BRANCH}/${WORKFLOW_PATH}`; const PAGE_SIZE = 100; const MAX_API_PAGES = 10; +const PAGINATION_ATTEMPTS = 3; const REQUEST_ATTEMPTS = 3; const REQUEST_TIMEOUT_MS = 20_000; const MAX_RETRY_DELAY_MS = 10_000; @@ -516,16 +517,13 @@ export function validateBoundRun(payload: unknown, expected: PublicationRun): vo } } -export async function collectPaginated( +async function collectPaginationAttempt( request: (path: string) => Promise, basePath: string, collectionKey: "workflow_runs" | "jobs", - maxPages = MAX_API_PAGES, -): Promise { - if (!Number.isSafeInteger(maxPages) || maxPages < 1) { - throw new Error("pagination page cap must be a positive integer"); - } - const label = collectionKey === "workflow_runs" ? "workflow run" : "publisher job"; + maxPages: number, + label: string, +): Promise { const values: unknown[] = []; const ids = new Set(); let totalCount: number | undefined; @@ -539,7 +537,7 @@ export async function collectPaginated( } if (totalCount === undefined) totalCount = pageTotal; if (pageTotal !== totalCount) { - throw new Error(`${label} total_count changed during pagination`); + return undefined; } const pageValues = response[collectionKey]; if (!Array.isArray(pageValues) || pageValues.length > PAGE_SIZE) { @@ -563,6 +561,29 @@ export async function collectPaginated( throw new Error(`${label} pagination exceeded the ${maxPages}-page safety cap`); } +export async function collectPaginated( + request: (path: string) => Promise, + basePath: string, + collectionKey: "workflow_runs" | "jobs", + maxPages = MAX_API_PAGES, +): Promise { + if (!Number.isSafeInteger(maxPages) || maxPages < 1) { + throw new Error("pagination page cap must be a positive integer"); + } + const label = collectionKey === "workflow_runs" ? "workflow run" : "publisher job"; + for (let attempt = 1; attempt <= PAGINATION_ATTEMPTS; attempt += 1) { + const result = await collectPaginationAttempt( + request, + basePath, + collectionKey, + maxPages, + label, + ); + if (result) return result; + } + throw new Error(`${label} total_count changed during ${PAGINATION_ATTEMPTS} pagination attempts`); +} + function annotationValue(value: string): string { return value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A"); }