diff --git a/apps/web/features/landing/utils/github-release.test.ts b/apps/web/features/landing/utils/github-release.test.ts new file mode 100644 index 000000000..aad44aa1c --- /dev/null +++ b/apps/web/features/landing/utils/github-release.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchLatestRelease } from "./github-release"; + +const SAMPLE_LATEST_ASSET = { + name: "multica-desktop-0.2.14-mac-arm64.dmg", + browser_download_url: + "https://github.com/multica-ai/multica/releases/download/v0.2.14/multica-desktop-0.2.14-mac-arm64.dmg", +}; + +const SAMPLE_PREV_ASSET = { + name: "multica-desktop-0.2.13-mac-arm64.dmg", + browser_download_url: + "https://github.com/multica-ai/multica/releases/download/v0.2.13/multica-desktop-0.2.13-mac-arm64.dmg", +}; + +function releasePayload(overrides: { + tag: string; + publishedMinutesAgo?: number; + asset?: { name: string; browser_download_url: string }; + prerelease?: boolean; + draft?: boolean; +}) { + const published = new Date( + Date.now() - (overrides.publishedMinutesAgo ?? 0) * 60_000, + ).toISOString(); + return { + tag_name: overrides.tag, + published_at: published, + html_url: `https://github.com/multica-ai/multica/releases/tag/${overrides.tag}`, + prerelease: overrides.prerelease ?? false, + draft: overrides.draft ?? false, + assets: overrides.asset ? [overrides.asset] : [], + }; +} + +function mockFetchWithReleases(releases: unknown[]) { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(releases), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("fetchLatestRelease", () => { + it("uses previous release when latest was published within the fresh window", async () => { + mockFetchWithReleases([ + releasePayload({ + tag: "v0.2.14", + publishedMinutesAgo: 10, + asset: SAMPLE_LATEST_ASSET, + }), + releasePayload({ + tag: "v0.2.13", + publishedMinutesAgo: 60 * 24, + asset: SAMPLE_PREV_ASSET, + }), + ]); + + const result = await fetchLatestRelease(); + expect(result.version).toBe("v0.2.13"); + expect(result.assets.macArm64Dmg).toBe(SAMPLE_PREV_ASSET.browser_download_url); + }); + + it("uses latest release once it is older than the fresh window", async () => { + mockFetchWithReleases([ + releasePayload({ + tag: "v0.2.14", + publishedMinutesAgo: 120, + asset: SAMPLE_LATEST_ASSET, + }), + releasePayload({ + tag: "v0.2.13", + publishedMinutesAgo: 60 * 24, + asset: SAMPLE_PREV_ASSET, + }), + ]); + + const result = await fetchLatestRelease(); + expect(result.version).toBe("v0.2.14"); + expect(result.assets.macArm64Dmg).toBe(SAMPLE_LATEST_ASSET.browser_download_url); + }); + + it("falls back to latest when there is no previous release", async () => { + mockFetchWithReleases([ + releasePayload({ + tag: "v0.0.1", + publishedMinutesAgo: 5, + asset: SAMPLE_LATEST_ASSET, + }), + ]); + + const result = await fetchLatestRelease(); + expect(result.version).toBe("v0.0.1"); + }); + + it("skips prereleases and drafts in the candidate list", async () => { + mockFetchWithReleases([ + releasePayload({ + tag: "v0.2.15-rc.1", + publishedMinutesAgo: 30, + prerelease: true, + }), + releasePayload({ + tag: "v0.2.14", + publishedMinutesAgo: 120, + asset: SAMPLE_LATEST_ASSET, + }), + ]); + + const result = await fetchLatestRelease(); + expect(result.version).toBe("v0.2.14"); + }); + + it("returns an empty release shape when the API errors", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response("rate limited", { status: 403 }), + ); + vi.stubGlobal("fetch", fetchMock); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const result = await fetchLatestRelease(); + expect(result).toEqual({ + version: null, + publishedAt: null, + htmlUrl: null, + assets: {}, + }); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("returns an empty release shape when all candidates are filtered out", async () => { + mockFetchWithReleases([ + releasePayload({ tag: "v0.2.15-rc.1", prerelease: true }), + releasePayload({ tag: "v0.2.14-draft", draft: true }), + ]); + + const result = await fetchLatestRelease(); + expect(result.version).toBeNull(); + expect(result.assets).toEqual({}); + }); +}); diff --git a/apps/web/features/landing/utils/github-release.ts b/apps/web/features/landing/utils/github-release.ts index d225e12a2..1def12f0b 100644 --- a/apps/web/features/landing/utils/github-release.ts +++ b/apps/web/features/landing/utils/github-release.ts @@ -9,6 +9,15 @@ import { * Next.js fetch cache for 5 minutes (Vercel ISR) so hitting /download * costs at most one GitHub API call per region per 5 minutes. * + * Desktop assets don't all land at the same time: CI uploads Linux + * and Windows within a minute of each other, but macOS is packaged + * manually (notarization credentials aren't wired into CI yet) and + * lands tens of minutes later. To avoid showing the half-filled + * mid-flight state on /download, the fetcher pulls the two most + * recent releases and falls back to the previous one for the first + * hour after publish. Empirically full desktop uploads complete in + * ~20 min; 1 h gives 3x buffer for commonly-variable manual steps. + * * On any failure (network, rate limit, malformed payload) returns a * `null`-shaped result and logs — the page degrades to a "version * unavailable" view rather than 500ing. @@ -21,15 +30,19 @@ export interface LatestRelease { assets: DownloadAssets; } -const GITHUB_LATEST_URL = - "https://api.github.com/repos/multica-ai/multica/releases/latest"; +const GITHUB_RELEASES_URL = + "https://api.github.com/repos/multica-ai/multica/releases?per_page=2"; const REVALIDATE_SECONDS = 300; +const FRESH_RELEASE_WINDOW_MS = 60 * 60 * 1000; + interface GitHubReleasePayload { tag_name?: string; published_at?: string; html_url?: string; + prerelease?: boolean; + draft?: boolean; assets?: Array<{ name: string; browser_download_url: string }>; } @@ -50,27 +63,52 @@ export async function fetchLatestRelease(): Promise { } try { - const res = await fetch(GITHUB_LATEST_URL, { + const res = await fetch(GITHUB_RELEASES_URL, { next: { revalidate: REVALIDATE_SECONDS }, headers, }); if (!res.ok) { throw new Error(`GitHub API responded ${res.status}`); } - const data = (await res.json()) as GitHubReleasePayload; + const data = (await res.json()) as GitHubReleasePayload[]; + + // Defensive filter — Multica doesn't publish prereleases or drafts + // today, but the endpoint returns them if that ever changes. A + // prerelease shadowing a stable version on /download would be a + // regression. + const stable = data.filter((r) => !r.prerelease && !r.draft); + const latest = stable[0]; + if (!latest) { + return emptyRelease(); + } + const previous = stable[1]; + const chosen = + previous && isWithinFreshWindow(latest) ? previous : latest; + return { - version: data.tag_name ?? null, - publishedAt: data.published_at ?? null, - htmlUrl: data.html_url ?? null, - assets: parseReleaseAssets(data.assets ?? []), + version: chosen.tag_name ?? null, + publishedAt: chosen.published_at ?? null, + htmlUrl: chosen.html_url ?? null, + assets: parseReleaseAssets(chosen.assets ?? []), }; } catch (err) { console.warn("[download] fetchLatestRelease failed:", err); - return { - version: null, - publishedAt: null, - htmlUrl: null, - assets: {}, - }; + return emptyRelease(); } } + +function isWithinFreshWindow(release: GitHubReleasePayload): boolean { + if (!release.published_at) return false; + const publishedAt = Date.parse(release.published_at); + if (Number.isNaN(publishedAt)) return false; + return Date.now() - publishedAt < FRESH_RELEASE_WINDOW_MS; +} + +function emptyRelease(): LatestRelease { + return { + version: null, + publishedAt: null, + htmlUrl: null, + assets: {}, + }; +}