From c9d75a962f1af02801085c6db1b5b268a32f21ae Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Fri, 16 Oct 2026 17:14:10 +0100 Subject: [PATCH 1/2] fix: select latest OpenAPI release by publication time --- scripts/update-openapi.test.ts | 114 ++++++++++++++++++++++++++++++++- scripts/update-openapi.ts | 48 +++++++++++--- 2 files changed, 151 insertions(+), 11 deletions(-) diff --git a/scripts/update-openapi.test.ts b/scripts/update-openapi.test.ts index 6cec8cef9b..e895330010 100644 --- a/scripts/update-openapi.test.ts +++ b/scripts/update-openapi.test.ts @@ -52,6 +52,7 @@ await test("Forge release reads are anonymous while cf requests can authenticate return Response.json([ { tag_name: `openapi@${version}`, + published_at: "2026-10-16T16:06:02Z", assets: [{ name: "openapi.forge.json", id: 123 }], }, ]); @@ -88,7 +89,18 @@ await test("Forge subprocesses receive no GitHub tokens or injected Git credenti await test("releases without the OpenAPI asset are rejected", async (context) => { context.mock.method(globalThis, "fetch", async () => - Response.json([{ tag_name: `openapi@${version}`, assets: [] }]) + Response.json([ + { + tag_name: `openapi@${version}`, + published_at: "2026-10-16T16:06:02Z", + assets: [], + }, + { + tag_name: `openapi@${"a".repeat(40)}`, + published_at: "2026-10-15T16:06:03Z", + assets: [{ name: "openapi.forge.json" }], + }, + ]) ); await assert.rejects( getLatestOpenApiRelease(), @@ -113,10 +125,12 @@ await test("OpenAPI polling skips unrelated releases, drafts, and prereleases ac { tag_name: "openapi@invalid" }, { tag_name: `openapi@${version}`, + published_at: "2026-10-16T16:06:02Z", assets: [{ name: "openapi.forge.json" }], }, { tag_name: `openapi@${"e".repeat(40)}`, + published_at: "2026-10-15T16:06:03Z", assets: [{ name: "openapi.forge.json" }], }, ]); @@ -128,6 +142,103 @@ await test("OpenAPI polling skips unrelated releases, drafts, and prereleases ac assert.deepEqual(pages, ["1", "2"]); }); +await test("OpenAPI polling selects the most recently published release regardless of list order", async (context) => { + context.mock.method(globalThis, "fetch", async () => + Response.json([ + { + tag_name: `openapi@${"f".repeat(40)}`, + created_at: "2026-10-15T09:11:19Z", + published_at: "2026-10-15T16:06:03Z", + assets: [{ name: "openapi.forge.json" }], + }, + { + tag_name: `openapi@${version}`, + created_at: "2026-10-15T09:11:19Z", + published_at: "2026-10-16T16:06:02Z", + assets: [{ name: "openapi.forge.json" }], + }, + { + tag_name: `openapi@${"a".repeat(40)}`, + created_at: "2026-10-15T09:11:19Z", + published_at: "2026-10-16T12:05:32Z", + assets: [{ name: "openapi.forge.json" }], + }, + ]) + ); + assert.deepEqual(await getLatestOpenApiRelease(), { + tag: `openapi@${version}`, + version, + }); +}); + +await test("OpenAPI polling compares publication times across all pages", async (context) => { + for (const latestPage of [1, 2]) { + const pages: number[] = []; + context.mock.method(globalThis, "fetch", async (url: string) => { + const page = Number(new URL(url).searchParams.get("page")); + pages.push(page); + const release = { + tag_name: `openapi@${page === latestPage ? version : "a".repeat(40)}`, + published_at: + page === latestPage ? "2026-10-16T16:06:02Z" : "2026-10-15T16:06:03Z", + assets: [{ name: "openapi.forge.json" }], + }; + return Response.json( + page === 1 + ? [ + release, + ...Array.from({ length: 99 }, () => ({ tag_name: "v0.2.0" })), + ] + : [release] + ); + }); + assert.deepEqual(await getLatestOpenApiRelease(), { + tag: `openapi@${version}`, + version, + }); + assert.deepEqual(pages, [1, 2]); + } +}); + +await test("an older release without the OpenAPI asset does not block the latest release", async (context) => { + context.mock.method(globalThis, "fetch", async () => + Response.json([ + { + tag_name: `openapi@${"f".repeat(40)}`, + published_at: "2026-10-15T16:06:03Z", + assets: [], + }, + { + tag_name: `openapi@${version}`, + published_at: "2026-10-16T16:06:02Z", + assets: [{ name: "openapi.forge.json" }], + }, + ]) + ); + assert.deepEqual(await getLatestOpenApiRelease(), { + tag: `openapi@${version}`, + version, + }); +}); + +await test("OpenAPI polling rejects releases with missing or invalid publication times", async (context) => { + for (const publishedAt of [undefined, null, 123, "invalid"]) { + context.mock.method(globalThis, "fetch", async () => + Response.json([ + { + tag_name: `openapi@${version}`, + published_at: publishedAt, + assets: [{ name: "openapi.forge.json" }], + }, + ]) + ); + await assert.rejects( + getLatestOpenApiRelease(), + /has an invalid published_at timestamp/ + ); + } +}); + await test("OpenAPI polling reports missing releases and invalid API responses", async (context) => { for (const response of [[], [{ tag_name: "v0.2.0" }], {}]) { context.mock.method(globalThis, "fetch", async () => @@ -330,6 +441,7 @@ await test("release reads retry failures while consuming the response body", asy return Response.json([ { tag_name: `openapi@${version}`, + published_at: "2026-10-16T16:06:02Z", assets: [{ name: "openapi.forge.json", id: 123 }], }, ]); diff --git a/scripts/update-openapi.ts b/scripts/update-openapi.ts index cef8be62ab..1221e699d3 100644 --- a/scripts/update-openapi.ts +++ b/scripts/update-openapi.ts @@ -293,6 +293,15 @@ export async function getLatestOpenApiRelease(): Promise<{ tag: string; version: string; }> { + let latest: + | { + tag: string; + version: string; + publishedAt: number; + hasAsset: boolean; + } + | undefined; + // Releases can share a target commit, so list order need not match publication order. for (let page = 1; ; page++) { const query = new URLSearchParams({ per_page: "100", page: String(page) }); const releases = await githubRequest( @@ -318,23 +327,42 @@ export async function getLatestOpenApiRelease(): Promise<{ if (!match?.[1]) { continue; } - // generate.ts downloads this asset, so never pin a release without it. - const hasAsset = - Array.isArray(release.assets) && - release.assets.some( - (item) => isObject(item) && item.name === OPENAPI_ASSET - ); - if (!hasAsset) { + const publishedAt = + typeof release.published_at === "string" + ? Date.parse(release.published_at) + : Number.NaN; + if (!Number.isFinite(publishedAt)) { throw new Error( - `Forge release ${release.tag_name} has no ${OPENAPI_ASSET} asset` + `Forge release ${release.tag_name} has an invalid published_at timestamp` ); } - return { tag: release.tag_name, version: match[1] }; + if (latest === undefined || publishedAt > latest.publishedAt) { + latest = { + tag: release.tag_name, + version: match[1], + publishedAt, + hasAsset: + Array.isArray(release.assets) && + release.assets.some( + (item) => isObject(item) && item.name === OPENAPI_ASSET + ), + }; + } } if (releases.length < 100) { - throw new Error("No published Forge OpenAPI release found"); + break; } } + if (latest === undefined) { + throw new Error("No published Forge OpenAPI release found"); + } + // generate.ts downloads this asset, so never pin a release without it. + if (!latest.hasAsset) { + throw new Error( + `Forge release ${latest.tag} has no ${OPENAPI_ASSET} asset` + ); + } + return { tag: latest.tag, version: latest.version }; } /** From e1f038543b39d13f99bdfd0ac9871d0faf60a695 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Fri, 16 Oct 2026 17:21:26 +0100 Subject: [PATCH 2/2] fix: use latest Forge OpenAPI release --- scripts/update-openapi.test.ts | 211 +++++++-------------------------- scripts/update-openapi.ts | 92 +++++--------- 2 files changed, 73 insertions(+), 230 deletions(-) diff --git a/scripts/update-openapi.test.ts b/scripts/update-openapi.test.ts index e895330010..379a474b49 100644 --- a/scripts/update-openapi.test.ts +++ b/scripts/update-openapi.test.ts @@ -39,7 +39,7 @@ const forgeCredentials = { git_config_parameters: "private git configuration", }; -await test("Forge release reads are anonymous while cf requests can authenticate", async (context) => { +await test("Forge latest release reads are anonymous while cf requests can authenticate", async (context) => { const requests: { path: string; authorization: string | null }[] = []; context.mock.method( globalThis, @@ -49,13 +49,10 @@ await test("Forge release reads are anonymous while cf requests can authenticate path: new URL(url).pathname, authorization: new Headers(init.headers).get("Authorization"), }); - return Response.json([ - { - tag_name: `openapi@${version}`, - published_at: "2026-10-16T16:06:02Z", - assets: [{ name: "openapi.forge.json", id: 123 }], - }, - ]); + return Response.json({ + tag_name: `openapi@${version}`, + assets: [{ name: "openapi.forge.json", id: 123 }], + }); } ); @@ -65,7 +62,7 @@ await test("Forge release reads are anonymous while cf requests can authenticate }); await githubResponse("/repos/cloudflare/cf/pulls", {}, "cf-write-token"); assert.deepEqual(requests, [ - { path: "/repos/cloudflare/forge/releases", authorization: null }, + { path: "/repos/cloudflare/forge/releases/latest", authorization: null }, { path: "/repos/cloudflare/cf/pulls", authorization: "Bearer cf-write-token", @@ -87,170 +84,55 @@ await test("Forge subprocesses receive no GitHub tokens or injected Git credenti assert.deepEqual(environment, originalEnvironment); }); -await test("releases without the OpenAPI asset are rejected", async (context) => { - context.mock.method(globalThis, "fetch", async () => - Response.json([ - { - tag_name: `openapi@${version}`, - published_at: "2026-10-16T16:06:02Z", - assets: [], - }, - { - tag_name: `openapi@${"a".repeat(40)}`, - published_at: "2026-10-15T16:06:03Z", - assets: [{ name: "openapi.forge.json" }], - }, - ]) - ); - await assert.rejects( - getLatestOpenApiRelease(), - /has no openapi\.forge\.json asset/ - ); -}); - -await test("OpenAPI polling skips unrelated releases, drafts, and prereleases across pages", async (context) => { - const pages: string[] = []; - context.mock.method(globalThis, "fetch", async (url: string) => { - const target = new URL(url); - assert.equal(target.searchParams.get("per_page"), "100"); - pages.push(target.searchParams.get("page") ?? ""); - if (pages.length === 1) { - return Response.json( - Array.from({ length: 100 }, () => ({ tag_name: "v0.2.0" })) - ); - } - return Response.json([ - { tag_name: `openapi@${"c".repeat(40)}`, draft: true }, - { tag_name: `openapi@${"d".repeat(40)}`, prerelease: true }, - { tag_name: "openapi@invalid" }, - { - tag_name: `openapi@${version}`, - published_at: "2026-10-16T16:06:02Z", - assets: [{ name: "openapi.forge.json" }], - }, - { - tag_name: `openapi@${"e".repeat(40)}`, - published_at: "2026-10-15T16:06:03Z", - assets: [{ name: "openapi.forge.json" }], - }, - ]); - }); - assert.deepEqual(await getLatestOpenApiRelease(), { - tag: `openapi@${version}`, - version, - }); - assert.deepEqual(pages, ["1", "2"]); -}); - -await test("OpenAPI polling selects the most recently published release regardless of list order", async (context) => { - context.mock.method(globalThis, "fetch", async () => - Response.json([ - { - tag_name: `openapi@${"f".repeat(40)}`, - created_at: "2026-10-15T09:11:19Z", - published_at: "2026-10-15T16:06:03Z", - assets: [{ name: "openapi.forge.json" }], - }, - { - tag_name: `openapi@${version}`, - created_at: "2026-10-15T09:11:19Z", - published_at: "2026-10-16T16:06:02Z", - assets: [{ name: "openapi.forge.json" }], - }, - { - tag_name: `openapi@${"a".repeat(40)}`, - created_at: "2026-10-15T09:11:19Z", - published_at: "2026-10-16T12:05:32Z", - assets: [{ name: "openapi.forge.json" }], - }, - ]) - ); - assert.deepEqual(await getLatestOpenApiRelease(), { - tag: `openapi@${version}`, - version, - }); -}); - -await test("OpenAPI polling compares publication times across all pages", async (context) => { - for (const latestPage of [1, 2]) { - const pages: number[] = []; - context.mock.method(globalThis, "fetch", async (url: string) => { - const page = Number(new URL(url).searchParams.get("page")); - pages.push(page); - const release = { - tag_name: `openapi@${page === latestPage ? version : "a".repeat(40)}`, - published_at: - page === latestPage ? "2026-10-16T16:06:02Z" : "2026-10-15T16:06:03Z", - assets: [{ name: "openapi.forge.json" }], - }; - return Response.json( - page === 1 - ? [ - release, - ...Array.from({ length: 99 }, () => ({ tag_name: "v0.2.0" })), - ] - : [release] - ); - }); - assert.deepEqual(await getLatestOpenApiRelease(), { - tag: `openapi@${version}`, - version, - }); - assert.deepEqual(pages, [1, 2]); - } -}); - -await test("an older release without the OpenAPI asset does not block the latest release", async (context) => { - context.mock.method(globalThis, "fetch", async () => - Response.json([ - { - tag_name: `openapi@${"f".repeat(40)}`, - published_at: "2026-10-15T16:06:03Z", - assets: [], - }, - { - tag_name: `openapi@${version}`, - published_at: "2026-10-16T16:06:02Z", - assets: [{ name: "openapi.forge.json" }], - }, - ]) - ); - assert.deepEqual(await getLatestOpenApiRelease(), { - tag: `openapi@${version}`, - version, - }); -}); - -await test("OpenAPI polling rejects releases with missing or invalid publication times", async (context) => { - for (const publishedAt of [undefined, null, 123, "invalid"]) { +await test("latest releases without the OpenAPI asset are rejected", async (context) => { + for (const assets of [undefined, [], [{ name: "other.json" }]]) { context.mock.method(globalThis, "fetch", async () => - Response.json([ - { - tag_name: `openapi@${version}`, - published_at: publishedAt, - assets: [{ name: "openapi.forge.json" }], - }, - ]) + Response.json({ tag_name: `openapi@${version}`, assets }) ); await assert.rejects( getLatestOpenApiRelease(), - /has an invalid published_at timestamp/ + /has no openapi\.forge\.json asset/ ); } }); -await test("OpenAPI polling reports missing releases and invalid API responses", async (context) => { - for (const response of [[], [{ tag_name: "v0.2.0" }], {}]) { - context.mock.method(globalThis, "fetch", async () => - Response.json(response) - ); +await test("OpenAPI lookup rejects invalid latest releases without scanning history", async (context) => { + for (const response of [ + null, + [], + {}, + { tag_name: 123 }, + { tag_name: "v0.2.0" }, + { tag_name: "openapi@invalid" }, + { tag_name: `openapi@${version}`, draft: true }, + { tag_name: `openapi@${version}`, prerelease: true }, + ]) { + const requests: string[] = []; + context.mock.method(globalThis, "fetch", async (url: string) => { + requests.push(new URL(url).pathname); + return Response.json(response); + }); await assert.rejects( getLatestOpenApiRelease(), - /No published Forge OpenAPI release|response was not an array/ + /not a published OpenAPI release|does not have a valid OpenAPI tag/ ); + assert.deepEqual(requests, ["/repos/cloudflare/forge/releases/latest"]); } }); +await test("a missing latest release is reported without scanning history", async (context) => { + const requests: string[] = []; + context.mock.method(globalThis, "fetch", async (url: string) => { + requests.push(new URL(url).pathname); + return Response.json({ message: "Not Found" }, { status: 404 }); + }); + await assert.rejects( + getLatestOpenApiRelease(), + /GitHub API GET \/repos\/cloudflare\/forge\/releases\/latest failed \(404/ + ); + assert.deepEqual(requests, ["/repos/cloudflare/forge/releases/latest"]); +}); + await test("the renamed updater recognizes old and new bot commits while protecting human edits", async (context) => { const bot = "41898282+github-actions[bot]@users.noreply.github.com"; const sha = "a".repeat(40); @@ -438,13 +320,10 @@ await test("release reads retry failures while consuming the response body", asy }) ); } - return Response.json([ - { - tag_name: `openapi@${version}`, - published_at: "2026-10-16T16:06:02Z", - assets: [{ name: "openapi.forge.json", id: 123 }], - }, - ]); + return Response.json({ + tag_name: `openapi@${version}`, + assets: [{ name: "openapi.forge.json", id: 123 }], + }); }); const result = getLatestOpenApiRelease(); await advanceRetry(context, 1000); diff --git a/scripts/update-openapi.ts b/scripts/update-openapi.ts index 1221e699d3..edd2605b9b 100644 --- a/scripts/update-openapi.ts +++ b/scripts/update-openapi.ts @@ -293,76 +293,40 @@ export async function getLatestOpenApiRelease(): Promise<{ tag: string; version: string; }> { - let latest: - | { - tag: string; - version: string; - publishedAt: number; - hasAsset: boolean; - } - | undefined; - // Releases can share a target commit, so list order need not match publication order. - for (let page = 1; ; page++) { - const query = new URLSearchParams({ per_page: "100", page: String(page) }); - const releases = await githubRequest( - `/repos/${FORGE_REPOSITORY}/releases?${query}`, - {}, - undefined, - "application/vnd.github+json", - readJson - ); - if (!Array.isArray(releases)) { - throw new Error("Forge releases response was not an array"); - } - for (const release of releases) { - if ( - !isObject(release) || - release.draft || - release.prerelease || - typeof release.tag_name !== "string" - ) { - continue; - } - const match = release.tag_name.match(/^openapi@([0-9a-f]{40})$/); - if (!match?.[1]) { - continue; - } - const publishedAt = - typeof release.published_at === "string" - ? Date.parse(release.published_at) - : Number.NaN; - if (!Number.isFinite(publishedAt)) { - throw new Error( - `Forge release ${release.tag_name} has an invalid published_at timestamp` - ); - } - if (latest === undefined || publishedAt > latest.publishedAt) { - latest = { - tag: release.tag_name, - version: match[1], - publishedAt, - hasAsset: - Array.isArray(release.assets) && - release.assets.some( - (item) => isObject(item) && item.name === OPENAPI_ASSET - ), - }; - } - } - if (releases.length < 100) { - break; - } + // Forge must keep its latest release marker on the current OpenAPI spec. + const release = await githubRequest( + `/repos/${FORGE_REPOSITORY}/releases/latest`, + {}, + undefined, + "application/vnd.github+json", + readJson + ); + if ( + !isObject(release) || + release.draft || + release.prerelease || + typeof release.tag_name !== "string" + ) { + throw new Error("Latest Forge release is not a published OpenAPI release"); } - if (latest === undefined) { - throw new Error("No published Forge OpenAPI release found"); + const match = release.tag_name.match(/^openapi@([0-9a-f]{40})$/); + if (!match?.[1]) { + throw new Error( + `Latest Forge release ${release.tag_name} does not have a valid OpenAPI tag` + ); } // generate.ts downloads this asset, so never pin a release without it. - if (!latest.hasAsset) { + const hasAsset = + Array.isArray(release.assets) && + release.assets.some( + (item) => isObject(item) && item.name === OPENAPI_ASSET + ); + if (!hasAsset) { throw new Error( - `Forge release ${latest.tag} has no ${OPENAPI_ASSET} asset` + `Forge release ${release.tag_name} has no ${OPENAPI_ASSET} asset` ); } - return { tag: latest.tag, version: latest.version }; + return { tag: release.tag_name, version: match[1] }; } /**