Files
multica/apps/web/features/landing/utils/github-release.ts
Naiyuan Qing 101da19b02 feat(download): fall back to previous release within 1h freshness window (#1514)
New /download visitors were seeing grayed-out macOS buttons in the 20-ish
minutes after a tag push because CI only builds Linux/Windows — Mac is
still packaged manually and uploads tens of minutes later. Swap the
`/releases/latest` fetch for `/releases?per_page=2` and, when the latest
release is under an hour old, render the previous (fully-populated)
release instead. After the freshness window, page auto-switches to latest.

Frontend-only change — GitHub "latest" marker, electron-updater, and
homebrew paths are untouched.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:05:36 +08:00

115 lines
3.8 KiB
TypeScript

import {
parseReleaseAssets,
type DownloadAssets,
} from "./parse-release-assets";
/**
* Server-side fetcher for the latest Multica release, designed to
* run inside a Next.js server component. Response is cached by the
* 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.
*/
export interface LatestRelease {
version: string | null;
publishedAt: string | null;
htmlUrl: string | null;
assets: DownloadAssets;
}
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 }>;
}
export async function fetchLatestRelease(): Promise<LatestRelease> {
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};
// Optional PAT for local development and self-hosted deploys where
// the shared outbound IP keeps hitting the 60-requests/hour
// unauthenticated limit. Vercel's fetch cache is shared across all
// regions so production rarely needs this — but the env var lets
// anyone running the site locally avoid the rate-limit dance. Never
// prefix this with `NEXT_PUBLIC_`; the token must stay server-side.
const token = process.env.GITHUB_TOKEN;
if (token) {
headers.Authorization = `Bearer ${token}`;
}
try {
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[];
// 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: 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 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: {},
};
}