From e579b336c01e8c6712cb539795d131e20a109947 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 24 Sep 2026 11:00:35 +0200 Subject: [PATCH 1/2] ci(repo): re-read the slim mirror destination tag before failing ECR Public can keep serving a tag's previous digest (or a 404 for a new tag) for a short while after a push, so the destination digest check now re-reads the tag up to 13 times, 10 s apart. The source check stays single-shot. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/mirror-slim-image.test.ts | 51 +++++++++++++++++++++++ .github/scripts/mirror-slim-image.ts | 36 ++++++++++++---- .github/workflows/mirror-slim-image.yml | 3 ++ 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/.github/scripts/mirror-slim-image.test.ts b/.github/scripts/mirror-slim-image.test.ts index fca7fd3810..7be6653dd3 100644 --- a/.github/scripts/mirror-slim-image.test.ts +++ b/.github/scripts/mirror-slim-image.test.ts @@ -120,6 +120,40 @@ describe("verifyDigest", () => { }), ).rejects.toThrow(InvalidPayloadError); }); + + test("re-reads a stale tag until it converges", async () => { + const heads = [fail("not found [http 404]"), ok(`${OTHER}\n`), ok(`${DIGEST}\n`)]; + let reads = 0; + const run: RunCommand = async () => heads[reads++] ?? fail("unexpected read"); + await verifyDigest({ + reference: "public.ecr.aws/supabase/cli/postgrest:v16.2", + digest: DIGEST, + run, + attempts: 5, + sleep: async () => undefined, + log: () => undefined, + }); + expect(reads).toBe(3); + }); + + test("reports the last mismatch once attempts are exhausted", async () => { + let reads = 0; + const run: RunCommand = async () => { + reads++; + return ok(`${OTHER}\n`); + }; + await expect( + verifyDigest({ + reference: "public.ecr.aws/supabase/cli/postgrest:v16.2", + digest: DIGEST, + run, + attempts: 3, + sleep: async () => undefined, + log: () => undefined, + }), + ).rejects.toThrow(`resolves to ${OTHER}, expected ${DIGEST}`); + expect(reads).toBe(3); + }); }); describe("ensureEcrPublicRepo", () => { @@ -266,6 +300,23 @@ describe("main", () => { expect(fields["destination"]).toBe("public.ecr.aws/supabase/cli/postgrest:v16.2"); }); + test("verify-digest re-reads up to ATTEMPTS times", async () => { + const heads = [ok(`${OTHER}\n`), ok(`${DIGEST}\n`)]; + let reads = 0; + const code = await main(["verify-digest"], { + env: { + REFERENCE: "public.ecr.aws/supabase/cli/postgrest:v16.2", + DIGEST, + ATTEMPTS: "2", + }, + run: async () => heads[reads++] ?? fail("unexpected read"), + sleep: async () => undefined, + log: () => undefined, + }); + expect(code).toBe(0); + expect(reads).toBe(2); + }); + test("copy-natives no-ops without a dispatch payload", async () => { const logs: string[] = []; const code = await main(["copy-natives"], { diff --git a/.github/scripts/mirror-slim-image.ts b/.github/scripts/mirror-slim-image.ts index 0398ec0282..6f63b7e5b0 100644 --- a/.github/scripts/mirror-slim-image.ts +++ b/.github/scripts/mirror-slim-image.ts @@ -54,6 +54,7 @@ export type MirrorIo = { readonly run: RunCommand; readonly runToFile?: RunCommandToFile; readonly log?: (message: string) => void; + readonly sleep?: (ms: number) => Promise; readonly readText?: (path: string) => Promise; readonly sha256File?: (path: string) => Promise; readonly httpStatus?: (url: string, method: "HEAD" | "GET") => Promise; @@ -95,23 +96,38 @@ const writeGithubOutput = ( else process.stdout.write(body); }; +const VERIFY_RETRY_DELAY_MS = 10_000; + +/** + * Checks that `reference` resolves to `digest`, re-reading up to `attempts` times. + * ECR Public can keep serving a tag's previous digest (or a 404 for a new tag) for a + * short while after a push, so the destination check needs more than one read. + */ export const verifyDigest = async (options: { readonly reference: string; readonly digest: string; readonly run: RunCommand; + readonly attempts?: number; + readonly sleep?: (ms: number) => Promise; readonly log?: (message: string) => void; }): Promise => { const log = options.log ?? console.log; - const head = await options.run(["regctl", "manifest", "head", options.reference]); - const live = head.stdout.trim(); - if (!head.ok || live !== options.digest) { + const attempts = options.attempts ?? 1; + const sleep = options.sleep ?? Bun.sleep; + for (let attempt = 1; ; attempt++) { + const head = await options.run(["regctl", "manifest", "head", options.reference]); + const live = head.stdout.trim(); + if (head.ok && live === options.digest) { + log(`${options.reference} resolves to ${options.digest}`); + return; + } const resolved = head.ok ? live : live || "missing"; const detail = !head.ok && head.stderr.trim() !== "" ? `: ${head.stderr.trim()}` : ""; - throw new InvalidPayloadError( - `${options.reference} resolves to ${resolved}, expected ${options.digest}${detail}`, - ); + const mismatch = `${options.reference} resolves to ${resolved}, expected ${options.digest}${detail}`; + if (attempt >= attempts) throw new InvalidPayloadError(mismatch); + log(`${mismatch}; re-reading (attempt ${attempt + 1}/${attempts})`); + await sleep(VERIFY_RETRY_DELAY_MS); } - log(`${options.reference} resolves to ${options.digest}`); }; export const ensureEcrPublicRepo = async (options: { @@ -475,10 +491,16 @@ export const main = async (argv: ReadonlyArray, io: MirrorIo): Promise Date: Thu, 24 Sep 2026 11:05:59 +0200 Subject: [PATCH 2/2] ci(repo): retry only stale destination reads and bound verify attempts Re-read the destination tag only on a digest mismatch or a 404, so authentication and other registry errors fail on the first read. verifyDigest now rejects a non-integer or non-positive attempt count itself, and main no longer duplicates its sleep fallback. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/mirror-slim-image.test.ts | 37 +++++++++++++++++++++++ .github/scripts/mirror-slim-image.ts | 21 +++++++------ 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/.github/scripts/mirror-slim-image.test.ts b/.github/scripts/mirror-slim-image.test.ts index 7be6653dd3..af26acc637 100644 --- a/.github/scripts/mirror-slim-image.test.ts +++ b/.github/scripts/mirror-slim-image.test.ts @@ -154,6 +154,43 @@ describe("verifyDigest", () => { ).rejects.toThrow(`resolves to ${OTHER}, expected ${DIGEST}`); expect(reads).toBe(3); }); + + test("fails on the first read when the head errors for another reason", async () => { + let reads = 0; + const run: RunCommand = async () => { + reads++; + return fail("unauthorized [http 401]"); + }; + await expect( + verifyDigest({ + reference: "public.ecr.aws/supabase/cli/postgrest:v16.2", + digest: DIGEST, + run, + attempts: 5, + sleep: async () => undefined, + log: () => undefined, + }), + ).rejects.toThrow("unauthorized [http 401]"); + expect(reads).toBe(1); + }); + + test("rejects a non-integer attempt count before reading", async () => { + let reads = 0; + const run: RunCommand = async () => { + reads++; + return ok(`${OTHER}\n`); + }; + await expect( + verifyDigest({ + reference: "public.ecr.aws/supabase/cli/postgrest:v16.2", + digest: DIGEST, + run, + attempts: Number.NaN, + log: () => undefined, + }), + ).rejects.toThrow(InvalidPayloadError); + expect(reads).toBe(0); + }); }); describe("ensureEcrPublicRepo", () => { diff --git a/.github/scripts/mirror-slim-image.ts b/.github/scripts/mirror-slim-image.ts index 6f63b7e5b0..6e0b0abc7f 100644 --- a/.github/scripts/mirror-slim-image.ts +++ b/.github/scripts/mirror-slim-image.ts @@ -98,10 +98,12 @@ const writeGithubOutput = ( const VERIFY_RETRY_DELAY_MS = 10_000; +const NOT_FOUND = /\bnot found\b|\bhttp 404\b/i; + /** - * Checks that `reference` resolves to `digest`, re-reading up to `attempts` times. - * ECR Public can keep serving a tag's previous digest (or a 404 for a new tag) for a - * short while after a push, so the destination check needs more than one read. + * Checks that `reference` resolves to `digest`, re-reading a mismatch or 404 up to + * `attempts` times. ECR Public can keep serving a tag's previous digest (or a 404 for a + * new tag) for a short while after a push, so the destination check needs more than one read. */ export const verifyDigest = async (options: { readonly reference: string; @@ -113,6 +115,8 @@ export const verifyDigest = async (options: { }): Promise => { const log = options.log ?? console.log; const attempts = options.attempts ?? 1; + if (!Number.isInteger(attempts) || attempts < 1) + throw new InvalidPayloadError(`attempts must be a positive integer, got ${attempts}`); const sleep = options.sleep ?? Bun.sleep; for (let attempt = 1; ; attempt++) { const head = await options.run(["regctl", "manifest", "head", options.reference]); @@ -124,7 +128,8 @@ export const verifyDigest = async (options: { const resolved = head.ok ? live : live || "missing"; const detail = !head.ok && head.stderr.trim() !== "" ? `: ${head.stderr.trim()}` : ""; const mismatch = `${options.reference} resolves to ${resolved}, expected ${options.digest}${detail}`; - if (attempt >= attempts) throw new InvalidPayloadError(mismatch); + const stale = head.ok || NOT_FOUND.test(head.stderr); + if (!stale || attempt >= attempts) throw new InvalidPayloadError(mismatch); log(`${mismatch}; re-reading (attempt ${attempt + 1}/${attempts})`); await sleep(VERIFY_RETRY_DELAY_MS); } @@ -491,16 +496,12 @@ export const main = async (argv: ReadonlyArray, io: MirrorIo): Promise