diff --git a/src/github/app.ts b/src/github/app.ts index 5c859c9366..5a9850c9b0 100644 --- a/src/github/app.ts +++ b/src/github/app.ts @@ -361,9 +361,11 @@ async function mintInstallationToken( throw new Error( "GitHub installation token response did not include a token.", ); - const expiresAtMs = payload.expires_at - ? Date.parse(payload.expires_at) - : Date.now() + 50 * 60_000; + // A present-but-UNPARSEABLE expires_at must fall back like an absent one (#10026): Date.parse returns NaN for + // a malformed string, and a NaN expiry makes `expiresAtMs - MARGIN > Date.now()` forever false — silently + // disabling the cache so every job re-mints and re-triggers the thundering-herd this cache exists to prevent. + const parsedExpiry = payload.expires_at ? Date.parse(payload.expires_at) : Number.NaN; + const expiresAtMs = Number.isFinite(parsedExpiry) ? parsedExpiry : Date.now() + 50 * 60_000; await writeCachedToken(installationId, { token: payload.token, expiresAtMs }); return payload.token; } diff --git a/test/unit/github-app.test.ts b/test/unit/github-app.test.ts index 13e2a8d791..b9063793ac 100644 --- a/test/unit/github-app.test.ts +++ b/test/unit/github-app.test.ts @@ -200,6 +200,47 @@ describe("GitHub check runs", () => { expect(mints).toBe(1); }); + it("REGRESSION: an unparseable expires_at must not disable the installation-token cache (#10026)", async () => { + const privateKey = await generatePrivateKeyPem(); + let mints = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) { + mints += 1; + return Response.json({ token: `installation-token-${mints}`, expires_at: "not-a-date" }); // present but unparseable + } + return new Response("not found", { status: 404 }); + }); + + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }); + const first = await createInstallationToken(env, 777); + const second = await createInstallationToken(env, 777); + // The NaN expiry used to make every cache comparison false, re-minting on every call. With the finite + // fallback the entry is honored: exactly one mint, the same token reused. + expect(first).toBe("installation-token-1"); + expect(second).toBe("installation-token-1"); + expect(mints).toBe(1); // NOT re-minted + }); + + it("a well-formed expires_at is unchanged: token reused on the second call (#10026)", async () => { + const privateKey = await generatePrivateKeyPem(); + let mints = 0; + const expiresAt = "2030-01-01T00:00:00Z"; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) { + mints += 1; + return Response.json({ token: `installation-token-${mints}`, expires_at: expiresAt }); + } + return new Response("not found", { status: 404 }); + }); + + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }); + expect(await createInstallationToken(env, 888)).toBe("installation-token-1"); + expect(await createInstallationToken(env, 888)).toBe("installation-token-1"); + expect(mints).toBe(1); // cached against the far-future, well-formed expiry + }); + it("(#3811) samples clock skew from the installation-token mint response's Date header", async () => { const privateKey = await generatePrivateKeyPem(); vi.useFakeTimers();