Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions apps/web/lib/releases.test.ts
Original file line number Diff line number Diff line change
@@ -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',
})
})
})
44 changes: 37 additions & 7 deletions apps/web/lib/releases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
const headers: Record<string, string> = { Accept: 'application/vnd.github+json' }
const token = process.env.GITHUB_TOKEN
if (token) headers.Authorization = `Bearer ${token}`
return headers
}

export async function latestRelease(): Promise<ReleaseInfo | null> {
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,
Expand All @@ -57,7 +85,9 @@ export async function latestRelease(): Promise<ReleaseInfo | null> {
windowsExe: pick(release.assets, /\.(exe|msi)$/i),
},
}
lastKnownGood = info
return info
} catch {
return null
return lastKnownGood
}
}
Loading