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>
This commit is contained in:
Naiyuan Qing
2026-04-22 19:05:36 +08:00
committed by GitHub
parent dc8096fb6e
commit 101da19b02
2 changed files with 201 additions and 14 deletions

View File

@@ -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({});
});
});

View File

@@ -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<LatestRelease> {
}
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: {},
};
}