diff --git a/scripts/update-openapi.test.ts b/scripts/update-openapi.test.ts index 6cec8cef9b..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,12 +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}`, - assets: [{ name: "openapi.forge.json", id: 123 }], - }, - ]); + return Response.json({ + tag_name: `openapi@${version}`, + assets: [{ name: "openapi.forge.json", id: 123 }], + }); } ); @@ -64,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", @@ -86,60 +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}`, assets: [] }]) - ); - 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}`, - assets: [{ name: "openapi.forge.json" }], - }, - { - tag_name: `openapi@${"e".repeat(40)}`, - assets: [{ name: "openapi.forge.json" }], - }, - ]); - }); - assert.deepEqual(await getLatestOpenApiRelease(), { - tag: `openapi@${version}`, - version, - }); - assert.deepEqual(pages, ["1", "2"]); -}); - -await test("OpenAPI polling reports missing releases and invalid API responses", async (context) => { - for (const response of [[], [{ tag_name: "v0.2.0" }], {}]) { +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(response) + Response.json({ tag_name: `openapi@${version}`, assets }) ); await assert.rejects( getLatestOpenApiRelease(), - /No published Forge OpenAPI release|response was not an array/ + /has no openapi\.forge\.json asset/ ); } }); +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(), + /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); @@ -327,12 +320,10 @@ await test("release reads retry failures while consuming the response body", asy }) ); } - return Response.json([ - { - tag_name: `openapi@${version}`, - 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 cef8be62ab..edd2605b9b 100644 --- a/scripts/update-openapi.ts +++ b/scripts/update-openapi.ts @@ -293,48 +293,40 @@ export async function getLatestOpenApiRelease(): Promise<{ tag: string; version: string; }> { - 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 + // 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"); + } + 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. + const hasAsset = + Array.isArray(release.assets) && + release.assets.some( + (item) => isObject(item) && item.name === OPENAPI_ASSET + ); + if (!hasAsset) { + throw new Error( + `Forge release ${release.tag_name} has no ${OPENAPI_ASSET} asset` ); - 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; - } - // 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) { - throw new Error( - `Forge release ${release.tag_name} has no ${OPENAPI_ASSET} asset` - ); - } - return { tag: release.tag_name, version: match[1] }; - } - if (releases.length < 100) { - throw new Error("No published Forge OpenAPI release found"); - } } + return { tag: release.tag_name, version: match[1] }; } /**