From c43d98f441e9b1537dbc4c61ff844d534cb3f1e4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 30 Aug 2026 04:10:34 +0000 Subject: [PATCH] site: never show "no release" because GitHub rate-limited us MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit diskpush.com/download showed no release at all while v0.1.9 was published, built and downloadable. /api/releases/latest answered {"version":null,...} on every request. Unauthenticated GitHub allows exactly 60 requests an hour per IP, and this is a shared host, so that budget is not ours alone. Dropping the release cache from an hour to a minute (1798a06) made 60/hour the ceiling rather than a limit we never approached — and then fifteen AEO audit engines plus a recursive link checker crawled the site and spent it. Every call came back 403, latestRelease returned null, and the page rendered as though the project had never shipped. Rate limiting is a fact about our IP. It is not a fact about the project, and the site must not present it as one. - latestRelease keeps the last release it read and returns that when a fetch is refused, fails, or throws. A version a few minutes old is a far better answer than no version. It still returns null before it has ever read one, which is the real pre-launch state the page has a rendering for. - GITHUB_TOKEN, when set, lifts the ceiling from 60/hour to 5,000. Optional on purpose: the site has to work without one, which is what the fallback is for. Set it on the service to stop relying on the fallback at all. The shorter cache stays. It is the reason a release now appears within a minute, and with a fallback behind it the spent budget is no longer a failure. The three tests that pin this fail without the change; the two describing unchanged behaviour — asset mapping, and null before the first read — pass either way. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y5jnkZKX4AdPgBMzMosxE7 --- apps/web/lib/releases.test.ts | 114 ++++++++++++++++++++++++++++++++++ apps/web/lib/releases.ts | 44 ++++++++++--- 2 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 apps/web/lib/releases.test.ts diff --git a/apps/web/lib/releases.test.ts b/apps/web/lib/releases.test.ts new file mode 100644 index 0000000..a3e6d85 --- /dev/null +++ b/apps/web/lib/releases.test.ts @@ -0,0 +1,114 @@ +// diskpush.com/download showed no release at all while v0.1.9 was published, +// built and downloadable. +// +// Unauthenticated GitHub allows exactly 60 requests an hour per IP, and this is +// a shared host, so that budget is not ours alone. Lowering the release cache +// from an hour to a minute made 60/hour the ceiling rather than a limit we +// never approached, and a burst of crawler traffic — fifteen audit engines plus +// a recursive link checker — spent it. Every call came back 403, latestRelease +// returned null, and the page rendered as though the project had never shipped. +// +// Rate limiting is a fact about our IP, not about the project, and must never +// be presented as one. Once a release has been read, the page shows it. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const ENDPOINT = 'https://api.github.com/repos/profullstack/diskpush/releases/latest' + +const release = (tag: string) => ({ + tag_name: tag, + name: `DiskPush ${tag}`, + html_url: `https://github.com/profullstack/diskpush/releases/tag/${tag}`, + published_at: '2026-08-30T03:41:00Z', + draft: false, + prerelease: false, + assets: [ + { name: `DiskPush-${tag.slice(1)}-linux-x86_64.AppImage`, browser_download_url: 'https://example.test/a' }, + { name: `DiskPush-${tag.slice(1)}-linux-amd64.deb`, browser_download_url: 'https://example.test/d' }, + ], +}) + +const ok = (body: unknown) => ({ ok: true, json: async () => body }) as unknown as Response +const refused = () => ({ ok: false, status: 403 }) as unknown as Response + +/** A fresh module per test, because the fallback is deliberately module state. */ +async function load() { + vi.resetModules() + return import('./releases.js') +} + +describe('latestRelease', () => { + beforeEach(() => { + vi.stubEnv('GITHUB_TOKEN', '') + }) + afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() + }) + + it('reads the current release and maps its assets', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ok(release('v0.1.9')))) + const { latestRelease } = await load() + + const info = await latestRelease() + expect(info?.version).toBe('0.1.9') + expect(info?.assets.linuxAppImage).toBe('https://example.test/a') + expect(info?.assets.linuxDeb).toBe('https://example.test/d') + // Nothing in this release matches those, and a wrong URL is worse than none. + expect(info?.assets.macDmg).toBeNull() + expect(info?.assets.windowsExe).toBeNull() + }) + + it('keeps serving the last release it read when GitHub refuses', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(ok(release('v0.1.9'))) + .mockResolvedValue(refused()) + vi.stubGlobal('fetch', fetcher) + const { latestRelease } = await load() + + expect((await latestRelease())?.version).toBe('0.1.9') + // This is the regression: it used to be null, and the page showed nothing. + expect((await latestRelease())?.version).toBe('0.1.9') + expect((await latestRelease())?.version).toBe('0.1.9') + }) + + it('survives the fetch throwing outright, not just answering badly', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(ok(release('v0.1.9'))) + .mockRejectedValue(new Error('ECONNRESET')) + vi.stubGlobal('fetch', fetcher) + const { latestRelease } = await load() + + expect((await latestRelease())?.version).toBe('0.1.9') + expect((await latestRelease())?.version).toBe('0.1.9') + }) + + it('still answers null before it has ever seen a release', async () => { + // A repository with no tagged release yet is a real pre-launch state, and + // the page has a legitimate empty rendering for it. + vi.stubGlobal('fetch', vi.fn(async () => refused())) + const { latestRelease } = await load() + expect(await latestRelease()).toBeNull() + }) + + it('sends no Authorization header without a token, and one with', async () => { + // Parameters spelled out so the call log is typed; an inferred `async () =>` + // gives vi.fn an empty tuple and every mock.calls index is a type error. + const fetcher = vi.fn(async (_url: string, _init?: RequestInit) => ok(release('v0.1.9'))) + vi.stubGlobal('fetch', fetcher) + + const anon = await load() + await anon.latestRelease() + expect(fetcher.mock.calls[0][0]).toBe(ENDPOINT) + expect(fetcher.mock.calls[0][1]?.headers).not.toHaveProperty('Authorization') + + vi.stubEnv('GITHUB_TOKEN', 'ghp_example') + fetcher.mockClear() + const authed = await load() + await authed.latestRelease() + expect(fetcher.mock.calls[0][1]?.headers).toMatchObject({ + Authorization: 'Bearer ghp_example', + }) + }) +}) diff --git a/apps/web/lib/releases.ts b/apps/web/lib/releases.ts index 99a4109..47b8205 100644 --- a/apps/web/lib/releases.ts +++ b/apps/web/lib/releases.ts @@ -33,20 +33,48 @@ function pick(assets: readonly GithubAsset[], test: RegExp): string | null { return assets.find((asset) => test.test(asset.name))?.browser_download_url ?? null } +/** + * The last release we successfully read, kept so a refused fetch degrades to + * slightly stale instead of to nothing. + * + * Unauthenticated GitHub allows exactly 60 requests an hour per IP. This is a + * shared host, so that budget is not ours alone, and a burst of crawler traffic + * spends it: after the site was audited by fifteen engines plus a recursive + * link checker, every call was refused and `/download` had no release to show + * — while the artifacts were published and downloadable the whole time. + * + * A version a few minutes old is a far better answer than no version, so once + * we have read one we never go back to showing nothing. + */ +let lastKnownGood: ReleaseInfo | null = null + +/** + * A token lifts the ceiling from 60 requests an hour to 5,000. Optional on + * purpose: the site has to work without one, which is what the cache above is + * for. Set GITHUB_TOKEN on the service to stop relying on it. + */ +function githubHeaders(): Record { + const headers: Record = { Accept: 'application/vnd.github+json' } + const token = process.env.GITHUB_TOKEN + if (token) headers.Authorization = `Bearer ${token}` + return headers +} + export async function latestRelease(): Promise { try { const response = await fetch(ENDPOINT, { - headers: { Accept: 'application/vnd.github+json' }, + headers: githubHeaders(), next: { revalidate: 60 }, }) - // A repository with no tagged release yet answers 404. That is a normal - // state before launch, not an error worth surfacing to a visitor. - if (!response.ok) return null + // 404 means no tagged release yet — a normal pre-launch state. 403 with a + // spent budget means we are rate limited, which is not a fact about the + // project and must not be shown as one. Both fall back to what we last saw. + if (!response.ok) return lastKnownGood const release = (await response.json()) as GithubRelease - if (release.draft) return null + if (release.draft) return lastKnownGood - return { + const info: ReleaseInfo = { version: release.tag_name.replace(/^v/, ''), publishedAt: release.published_at, notesUrl: release.html_url, @@ -57,7 +85,9 @@ export async function latestRelease(): Promise { windowsExe: pick(release.assets, /\.(exe|msi)$/i), }, } + lastKnownGood = info + return info } catch { - return null + return lastKnownGood } }