mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
149
apps/web/features/landing/utils/github-release.test.ts
Normal file
149
apps/web/features/landing/utils/github-release.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user