diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ab8ae1a..0031d45 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -41,8 +41,6 @@ branch-name ──PR──► main ──auto-deploy──► Vercel production | `npm run format` | Prettier-format the whole project. | | `npm run format:check` | Prettier check (no writes); fails on drift. | ---- - ## For repo & Vercel owners You own the guardrails that make the developer workflow safe. The rules below are authoritative. @@ -93,8 +91,35 @@ Also in `Settings → General → Pull Requests`: - **Environment variables**: managed in the Vercel dashboard. - Production secrets must be scoped **Production only** so PR previews can't read them. - Preview-safe variables can be scoped to Preview + Development. + - `CRON_SECRET` is required (see [Release data freshness](#release-data-freshness)). Vercel sends it as `Authorization: Bearer $CRON_SECRET` on cron invocations. Generate a random value; never commit it. - **GitHub integration**: the Vercel GitHub App must have access to this repo so it can post deployment statuses (these become required checks in branch protection). +### Release data freshness + +`/download` is **prerendered**, not fetched in the browser. `components/DownloadButton.tsx` is a server component that resolves GitHub releases at render time via `lib/releases.ts`, then hands the data to `DownloadButtonClient.tsx` for the interactive bits. Visitors never call GitHub, so the page costs one upstream request per day instead of one per visitor — comfortably under GitHub's 60/hour unauthenticated limit. + +Refresh is driven by the cron in `vercel.json`: + +``` +0 0 * * * → /api/revalidate-releases +``` + +At midnight UTC it purges the `releases` cache tag and the prerendered `/download` page; the next visitor triggers one fresh fetch. **A new release therefore takes up to a day to appear on the site.** That delay is intentional — a grace period to pull a release that turns out to be problematic before the website advertises it. + +Two safety nets: + +- `export const revalidate` on `app/download/page.tsx` (24h) refreshes the page even if the cron stops firing. It duplicates `RELEASES_REVALIDATE_SECONDS` in `lib/releases.ts` because Next requires a literal there — change both together. +- If GitHub is down when a refresh runs, the last good render keeps being served; visitors see nothing wrong. + +To publish a release immediately, either redeploy or call the endpoint by hand: + +```bash +curl -H "Authorization: Bearer $CRON_SECRET" https:///api/revalidate-releases +``` + +> [!NOTE] +> Vercel cron schedules are UTC, and on Hobby plans they run at most once a day and fire within the hour of the scheduled time. Both are fine for a daily grace period. + ### Rollback Production is `main`. Two paths: diff --git a/app/api/releases/route.ts b/app/api/releases/route.ts deleted file mode 100644 index 5ef663c..0000000 --- a/app/api/releases/route.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { NextResponse } from "next/server"; -import type { - GitHubRelease, - GitHubAsset, - GroupedReleases, - LatestRelease, - OperatingSystem, - ReleaseAsset, - ReleasesAPIResponse, -} from "../../../types/releases"; -import { fakeGitHubReleases } from "../../../lib/fakeReleases"; - -const OS_EXTENSIONS: Record = { - macos: /\.(dmg|pkg)$/i, - windows: /\.(exe|msi)$/i, - linux: /\.(AppImage|deb|rpm)$/i, -}; - -function detectAssetOS(name: string): OperatingSystem | null { - for (const os of Object.keys(OS_EXTENSIONS) as OperatingSystem[]) { - if (OS_EXTENSIONS[os].test(name)) return os; - } - return null; -} - -function parseVersion(tag: string): string | null { - const match = tag.match(/^v?(\d+\.\d+\.\d+)/); - return match ? match[1] : null; -} - -function compareVersions(v1: string, v2: string): number { - const parts1 = v1.split(".").map(Number); - const parts2 = v2.split(".").map(Number); - for (let i = 0; i < 3; i++) { - if (parts1[i] > parts2[i]) return 1; - if (parts1[i] < parts2[i]) return -1; - } - return 0; -} - -function toReleaseAsset(asset: GitHubAsset): ReleaseAsset { - return { - name: asset.name, - url: asset.browser_download_url, - size: asset.size, - }; -} - -export async function GET() { - try { - let releases: GitHubRelease[]; - - if (process.env.USE_FAKE_RELEASES === "true") { - releases = fakeGitHubReleases; - } else { - const owner = process.env.GITHUB_OWNER; - const repo = process.env.GITHUB_REPO; - - if (!owner || !repo) { - return NextResponse.json( - { error: "GITHUB_OWNER or GITHUB_REPO is not configured" }, - { status: 500 }, - ); - } - - const response = await fetch( - `https://api.github.com/repos/${owner}/${repo}/releases`, - { - headers: { Accept: "application/vnd.github+json" }, - next: { revalidate: 5000 }, - }, - ); - - if (!response.ok) { - const errorText = await response.text(); - console.error("GitHub API error:", response.status, errorText); - return NextResponse.json( - { error: `Failed to fetch releases from GitHub: ${response.status}` }, - { status: response.status }, - ); - } - - releases = await response.json(); - } - - const grouped: GroupedReleases = {}; - - for (const release of releases) { - if (release.draft) continue; - const version = parseVersion(release.tag_name); - if (!version) continue; - - const byOS: Partial> = {}; - for (const asset of release.assets) { - const os = detectAssetOS(asset.name); - if (!os) continue; - (byOS[os] ??= []).push(toReleaseAsset(asset)); - } - - for (const os of Object.keys(byOS) as OperatingSystem[]) { - const assets = byOS[os]!; - const current = grouped[os]; - if (!current || compareVersions(version, current.version) > 0) { - const next: LatestRelease = { - version, - tag: release.tag_name, - publishedAt: release.published_at, - htmlUrl: release.html_url, - prerelease: release.prerelease, - assets, - }; - grouped[os] = next; - } - } - } - - const apiResponse: ReleasesAPIResponse = { - releases: grouped, - lastUpdated: new Date().toISOString(), - }; - - return NextResponse.json(apiResponse); - } catch (error) { - console.error("Error fetching releases:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); - } -} diff --git a/app/api/revalidate-releases/route.ts b/app/api/revalidate-releases/route.ts new file mode 100644 index 0000000..9ef73f8 --- /dev/null +++ b/app/api/revalidate-releases/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { revalidatePath, revalidateTag } from "next/cache"; +import { RELEASES_CACHE_TAG } from "../../../lib/releases"; + +/** + * Cron target, wired to midnight UTC daily in `vercel.json`. + * + * Drops both the cached GitHub response and the prerendered download page, so + * the first visitor after midnight triggers one fresh fetch. New releases are + * therefore picked up with up to a day's delay — a deliberate grace period in + * case a release turns out to be problematic. + * + * Vercel signs cron requests with `Authorization: Bearer $CRON_SECRET`. + */ +export async function GET(request: Request) { + const secret = process.env.CRON_SECRET; + + if (!secret) { + console.error("CRON_SECRET is not configured; refusing to revalidate."); + return NextResponse.json( + { error: "CRON_SECRET is not configured" }, + { status: 500 }, + ); + } + + if (request.headers.get("authorization") !== `Bearer ${secret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + revalidateTag(RELEASES_CACHE_TAG, "max"); + revalidatePath("/download"); + + return NextResponse.json({ + revalidated: true, + at: new Date().toISOString(), + }); +} diff --git a/app/download/page.tsx b/app/download/page.tsx index 927162e..821e415 100644 --- a/app/download/page.tsx +++ b/app/download/page.tsx @@ -1,7 +1,18 @@ -"use client"; +import type { Metadata } from "next"; import { Footer, Navbar } from "../../layout"; import DownloadButton from "../../components/DownloadButton"; +export const metadata: Metadata = { + title: "Download VoxKit", + description: + "Download the latest VoxKit release for macOS, Windows, or Linux. Built for speech pathology researchers, no command line required.", +}; + +// Fallback only: the nightly cron at /api/revalidate-releases is what normally +// refreshes this page. Keep in sync with RELEASES_REVALIDATE_SECONDS (24h) -- +// Next requires a literal here, so it cannot be imported. +export const revalidate = 86400; + export default function DownloadPage() { return (
diff --git a/components/DownloadButton.tsx b/components/DownloadButton.tsx index 20d53e2..7ee06a4 100644 --- a/components/DownloadButton.tsx +++ b/components/DownloadButton.tsx @@ -1,257 +1,29 @@ -import { useEffect, useState } from "react"; -import { Download, AlertTriangle } from "lucide-react"; -import { AppleIcon, WindowsIcon, LinuxIcon } from "./OSIcons"; -import { PRE_RELEASE_DISCLAIMER } from "../lib/fakeReleases"; -import GridButton from "./GridButton"; -import type { - ReleaseAsset, - ReleasesAPIResponse, - OperatingSystem, - LatestRelease, -} from "../types/releases"; - -type OS = OperatingSystem | null; - -const detectOS = (): OS => { - if (typeof window === "undefined") return null; - const userAgent = window.navigator.userAgent.toLowerCase(); - if (userAgent.includes("win")) return "windows"; - if (userAgent.includes("mac")) return "macos"; - if (userAgent.includes("linux")) return "linux"; - return null; -}; - -const isPreReleaseVersion = (version: string): boolean => { - const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)/); - if (!match) return false; - const major = parseInt(match[1], 10); - return major < 1; -}; - -export default function DownloadButton() { - const [releasesData, setReleasesData] = useState( - null, - ); - const [detectedOS] = useState(detectOS); - const [selectedOS, setSelectedOS] = useState(detectOS); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - fetch("/api/releases") - .then((res) => { - if (!res.ok) throw new Error("Failed to fetch releases"); - return res.json(); - }) - .then((data: ReleasesAPIResponse) => { - setReleasesData(data); - setLoading(false); - }) - .catch((err) => { - console.error(err); - setError("Unable to load release information"); - setLoading(false); - }); - }, []); - - const getReleaseForOS = (): LatestRelease | undefined => { - if (!releasesData?.releases || !selectedOS) return undefined; - return releasesData.releases[selectedOS]; - }; - - const selectBestAsset = ( - assets: ReleaseAsset[], - os: OS, - ): ReleaseAsset | null => { - if (!assets || assets.length === 0) return null; - if (assets.length === 1) return assets[0]; - - const priorities: Record = { - macos: [/\.dmg$/i, /\.pkg$/i], - windows: [/\.exe$/i, /\.msi$/i], - linux: [/\.AppImage$/i, /\.deb$/i, /\.rpm$/i], - }; - - if (!os || !priorities[os]) return assets[0]; - - for (const pattern of priorities[os]) { - const match = assets.find((asset) => pattern.test(asset.name)); - if (match) return match; - } - - return assets[0]; - }; - - const handleDownload = (asset: ReleaseAsset) => { - if (asset?.url) { - window.open(asset.url, "_blank"); - } - }; - - const formatFileSize = (bytes: number) => { - const mb = bytes / (1024 * 1024); - return `${mb.toFixed(1)} MB`; - }; - - const getOSDisplayName = (os: OS) => { - if (!os) return ""; - return os === "macos" ? "macOS" : os === "windows" ? "Windows" : "Linux"; - }; - - if (loading) { - return ( -
-
- Loading release information... -
-
- ); - } - - if (error || !releasesData) { +import { getReleases } from "../lib/releases"; +import DownloadButtonClient from "./DownloadButtonClient"; + +/** + * Server component. Resolves release data during render so the panel arrives + * fully populated in the initial HTML: no client fetch, no loading flash, and + * the version numbers are visible to crawlers. + * + * Freshness is controlled by the page's `revalidate` plus the nightly cron in + * `app/api/revalidate-releases/route.ts`, not by this component. + */ +export default async function DownloadButton() { + const result = await getReleases(); + + if (!result.ok) { return (
-
- {error || "No release data available"} -
+
{result.error}
); } - const currentRelease = getReleaseForOS(); - const availableOSes = Object.keys(releasesData.releases); - const hasPreReleaseVersion = - !!currentRelease && - (currentRelease.prerelease || isPreReleaseVersion(currentRelease.version)); - const bestAsset = currentRelease - ? selectBestAsset(currentRelease.assets, selectedOS) - : null; - return ( - <> - {hasPreReleaseVersion && ( -
- -

{PRE_RELEASE_DISCLAIMER}

-
- )} - -
-

- - Download VoxKit -

-

- Select your operating system -

- - {/* OS Selection */} -
- {(["windows", "macos", "linux"] as const).map((os) => { - const isSelected = selectedOS === os; - const isDetected = detectedOS === os; - const isAvailable = availableOSes.includes(os); - - return ( - setSelectedOS(os)} - disabled={!isAvailable} - className={` - px-6 py-4 text-sm font-medium transition-all - ${isAvailable ? "" : "opacity-50 cursor-not-allowed"} - ${ - isSelected - ? "bg-gradient-to-r from-blue-600 to-blue-700 text-white border border-blue-400/50" - : "bg-slate-700/50 text-slate-300 border border-slate-600" - } - `} - > -
-
- {os === "windows" && } - {os === "macos" && } - {os === "linux" && } -
-
{getOSDisplayName(os)}
- {isDetected && ( -
Detected
- )} - {!isAvailable && ( -
- Not Available -
- )} -
-
- ); - })} -
- - {/* Release Download */} - {currentRelease ? ( -
-
-
-
- {getOSDisplayName(selectedOS)} • v{currentRelease.version} -
-

- Released{" "} - {new Date(currentRelease.publishedAt).toLocaleDateString()} -

- {bestAsset && ( -

- {bestAsset.name} • {formatFileSize(bestAsset.size)} -

- )} -
-
- {bestAsset ? ( - handleDownload(bestAsset)} - className="text-small px-8 py-4 rounded-lg bg-gradient-to-r from-blue-500 to-blue-600 text-white border-white" - rippleColor="rgba(255, 255, 255, 0.5)" - > - - Download - - ) : ( - - No assets - - )} - window.open(currentRelease.htmlUrl, "_blank")} - className="text-small px-8 py-4 rounded-lg text-cyan-400 border-cyan-400" - > - View Release - -
-
-
- ) : ( -
- No release available for {getOSDisplayName(selectedOS) || "this OS"} - . -
- )} - - {/* Footer */} -
-

- Last updated: {new Date(releasesData.lastUpdated).toLocaleString()} -

- - View all releases on GitHub - -
-
- + ); } diff --git a/components/DownloadButtonClient.tsx b/components/DownloadButtonClient.tsx new file mode 100644 index 0000000..fd17246 --- /dev/null +++ b/components/DownloadButtonClient.tsx @@ -0,0 +1,270 @@ +"use client"; +import { useState, useSyncExternalStore } from "react"; +import { Download, AlertTriangle } from "lucide-react"; +import { AppleIcon, WindowsIcon, LinuxIcon } from "./OSIcons"; +import { PRE_RELEASE_DISCLAIMER } from "../lib/fakeReleases"; +import GridButton from "./GridButton"; +import type { + ReleaseAsset, + GroupedReleases, + OperatingSystem, + LatestRelease, +} from "../types/releases"; + +type OS = OperatingSystem | null; + +type DownloadButtonClientProps = { + releases: GroupedReleases; + /** ISO timestamp of the server render that produced `releases`. */ + lastUpdated: string; +}; + +/** Left-to-right order of the OS tiles. */ +const OS_DISPLAY_ORDER = ["macos", "windows", "linux"] as const; + +/** + * Which OS the panel falls back to when detection fails or the visitor's OS + * has no build. Deliberately independent of the tile order above, so moving a + * tile does not change what the prerendered HTML shows. + */ +const OS_FALLBACK_ORDER = ["windows", "macos", "linux"] as const; + +// Dates are formatted in a fixed locale and timezone so the server render and +// the hydrated client render agree. `toLocaleDateString()` would resolve +// against the visitor's timezone and mismatch the prerendered HTML. +const RELEASE_DATE_FORMAT = new Intl.DateTimeFormat("en-US", { + timeZone: "UTC", + year: "numeric", + month: "short", + day: "numeric", +}); + +const LAST_UPDATED_FORMAT = new Intl.DateTimeFormat("en-US", { + timeZone: "UTC", + dateStyle: "medium", + timeStyle: "short", +}); + +const detectOS = (): OS => { + if (typeof window === "undefined") return null; + const userAgent = window.navigator.userAgent.toLowerCase(); + if (userAgent.includes("win")) return "windows"; + if (userAgent.includes("mac")) return "macos"; + if (userAgent.includes("linux")) return "linux"; + return null; +}; + +// There is no user agent to sniff during SSR, so detection is modeled as an +// external store: React renders `null` on the server and again while +// hydrating, then re-renders with the real value. The user agent never changes +// mid-session, so there is nothing to subscribe to. +const subscribeToOS = () => () => {}; +const getServerOS = (): OS => null; + +const isPreReleaseVersion = (version: string): boolean => { + const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)/); + if (!match) return false; + const major = parseInt(match[1], 10); + return major < 1; +}; + +export default function DownloadButtonClient({ + releases, + lastUpdated, +}: DownloadButtonClientProps) { + const detectedOS = useSyncExternalStore(subscribeToOS, detectOS, getServerOS); + const [pickedOS, setPickedOS] = useState(null); + + // Derived rather than stored, so the server and the hydrated client agree: + // an explicit pick wins, then the detected OS if we ship builds for it, then + // the first OS with a release at all. + const selectedOS: OS = + pickedOS ?? + (detectedOS && releases[detectedOS] + ? detectedOS + : (OS_FALLBACK_ORDER.find((os) => releases[os]) ?? null)); + + const getReleaseForOS = (): LatestRelease | undefined => { + if (!selectedOS) return undefined; + return releases[selectedOS]; + }; + + const selectBestAsset = ( + assets: ReleaseAsset[], + os: OS, + ): ReleaseAsset | null => { + if (!assets || assets.length === 0) return null; + if (assets.length === 1) return assets[0]; + + const priorities: Record = { + macos: [/\.dmg$/i, /\.pkg$/i], + windows: [/\.exe$/i, /\.msi$/i], + linux: [/\.AppImage$/i, /\.deb$/i, /\.rpm$/i], + }; + + if (!os || !priorities[os]) return assets[0]; + + for (const pattern of priorities[os]) { + const match = assets.find((asset) => pattern.test(asset.name)); + if (match) return match; + } + + return assets[0]; + }; + + const handleDownload = (asset: ReleaseAsset) => { + if (asset?.url) { + window.open(asset.url, "_blank"); + } + }; + + const formatFileSize = (bytes: number) => { + const mb = bytes / (1024 * 1024); + return `${mb.toFixed(1)} MB`; + }; + + const getOSDisplayName = (os: OS) => { + if (!os) return ""; + return os === "macos" ? "macOS" : os === "windows" ? "Windows" : "Linux"; + }; + + const currentRelease = getReleaseForOS(); + const availableOSes = Object.keys(releases); + const hasPreReleaseVersion = + !!currentRelease && + (currentRelease.prerelease || isPreReleaseVersion(currentRelease.version)); + const bestAsset = currentRelease + ? selectBestAsset(currentRelease.assets, selectedOS) + : null; + + return ( + <> + {hasPreReleaseVersion && ( +
+ +

{PRE_RELEASE_DISCLAIMER}

+
+ )} + +
+

+ + Download VoxKit +

+

+ Select your operating system +

+ + {/* OS Selection */} +
+ {OS_DISPLAY_ORDER.map((os) => { + const isSelected = selectedOS === os; + const isDetected = detectedOS === os; + const isAvailable = availableOSes.includes(os); + + return ( + setPickedOS(os)} + disabled={!isAvailable} + className={` + px-6 py-4 text-sm font-medium transition-all + ${isAvailable ? "" : "opacity-50 cursor-not-allowed"} + ${ + isSelected + ? "bg-gradient-to-r from-blue-600 to-blue-700 text-white border border-blue-400/50" + : "bg-slate-700/50 text-slate-300 border border-slate-600" + } + `} + > +
+
+ {os === "windows" && } + {os === "macos" && } + {os === "linux" && } +
+
{getOSDisplayName(os)}
+ {isDetected && ( +
Detected
+ )} + {!isAvailable && ( +
+ Not Available +
+ )} +
+
+ ); + })} +
+ + {/* Release Download */} + {currentRelease ? ( +
+
+
+
+ {getOSDisplayName(selectedOS)} • v{currentRelease.version} +
+

+ Released{" "} + {RELEASE_DATE_FORMAT.format( + new Date(currentRelease.publishedAt), + )} +

+ {bestAsset && ( +

+ {bestAsset.name} • {formatFileSize(bestAsset.size)} +

+ )} +
+
+ {bestAsset ? ( + handleDownload(bestAsset)} + className="text-small px-8 py-4 rounded-lg bg-gradient-to-r from-blue-500 to-blue-600 text-white border-white" + rippleColor="rgba(255, 255, 255, 0.5)" + > + + Download + + ) : ( + + No assets + + )} + window.open(currentRelease.htmlUrl, "_blank")} + className="text-small px-8 py-4 rounded-lg text-cyan-400 border-cyan-400" + > + View Release + +
+
+
+ ) : ( +
+ No release available for {getOSDisplayName(selectedOS) || "this OS"} + . +
+ )} + + {/* Footer */} +
+

+ Last updated: {LAST_UPDATED_FORMAT.format(new Date(lastUpdated))}{" "} + UTC +

+ + View all releases on GitHub + +
+
+ + ); +} diff --git a/lib/releases.ts b/lib/releases.ts new file mode 100644 index 0000000..806d353 --- /dev/null +++ b/lib/releases.ts @@ -0,0 +1,156 @@ +import type { + GitHubAsset, + GitHubRelease, + GroupedReleases, + LatestRelease, + OperatingSystem, + ReleaseAsset, + ReleasesAPIResponse, +} from "../types/releases"; +import { fakeGitHubReleases } from "./fakeReleases"; + +/** + * Server-side resolution of the latest VoxKit release per OS. + * + * This runs during render (see `components/DownloadButton.tsx`), not in the + * browser, so a visitor never pays for a GitHub round trip and the download + * panel ships fully populated in the initial HTML. + */ + +/** Cache tag on the GitHub fetch. Purged nightly by `/api/revalidate-releases`. */ +export const RELEASES_CACHE_TAG = "releases"; + +/** + * Fallback TTL for the tag above. The nightly cron is what normally refreshes + * the data; this keeps the site from serving indefinitely stale releases if + * the cron ever stops firing. + */ +export const RELEASES_REVALIDATE_SECONDS = 60 * 60 * 24; + +const OS_EXTENSIONS: Record = { + macos: /\.(dmg|pkg)$/i, + windows: /\.(exe|msi)$/i, + linux: /\.(AppImage|deb|rpm)$/i, +}; + +function detectAssetOS(name: string): OperatingSystem | null { + for (const os of Object.keys(OS_EXTENSIONS) as OperatingSystem[]) { + if (OS_EXTENSIONS[os].test(name)) return os; + } + return null; +} + +function parseVersion(tag: string): string | null { + const match = tag.match(/^v?(\d+\.\d+\.\d+)/); + return match ? match[1] : null; +} + +function compareVersions(v1: string, v2: string): number { + const parts1 = v1.split(".").map(Number); + const parts2 = v2.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if (parts1[i] > parts2[i]) return 1; + if (parts1[i] < parts2[i]) return -1; + } + return 0; +} + +function toReleaseAsset(asset: GitHubAsset): ReleaseAsset { + return { + name: asset.name, + url: asset.browser_download_url, + size: asset.size, + }; +} + +/** Reduces the full release list to the newest release carrying assets per OS. */ +export function groupReleasesByOS(releases: GitHubRelease[]): GroupedReleases { + const grouped: GroupedReleases = {}; + + for (const release of releases) { + if (release.draft) continue; + const version = parseVersion(release.tag_name); + if (!version) continue; + + const byOS: Partial> = {}; + for (const asset of release.assets) { + const os = detectAssetOS(asset.name); + if (!os) continue; + (byOS[os] ??= []).push(toReleaseAsset(asset)); + } + + for (const os of Object.keys(byOS) as OperatingSystem[]) { + const assets = byOS[os]!; + const current = grouped[os]; + if (!current || compareVersions(version, current.version) > 0) { + const next: LatestRelease = { + version, + tag: release.tag_name, + publishedAt: release.published_at, + htmlUrl: release.html_url, + prerelease: release.prerelease, + assets, + }; + grouped[os] = next; + } + } + } + + return grouped; +} + +export type ReleasesResult = + | { ok: true; data: ReleasesAPIResponse } + | { ok: false; error: string }; + +async function fetchGitHubReleases(): Promise { + if (process.env.USE_FAKE_RELEASES === "true") return fakeGitHubReleases; + + const owner = process.env.GITHUB_OWNER; + const repo = process.env.GITHUB_REPO; + + if (!owner || !repo) { + throw new Error("GITHUB_OWNER or GITHUB_REPO is not configured"); + } + + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/releases`, + { + headers: { Accept: "application/vnd.github+json" }, + next: { + revalidate: RELEASES_REVALIDATE_SECONDS, + tags: [RELEASES_CACHE_TAG], + }, + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("GitHub API error:", response.status, errorText); + throw new Error(`Failed to fetch releases from GitHub: ${response.status}`); + } + + return response.json(); +} + +/** + * Never throws. A GitHub outage must not fail the build or blank the download + * page, so the failure is returned for the caller to render. On an already + * prerendered page, a failed background revalidation leaves the last good + * render in place and visitors see nothing at all. + */ +export async function getReleases(): Promise { + try { + const releases = await fetchGitHubReleases(); + + const data: ReleasesAPIResponse = { + releases: groupReleasesByOS(releases), + lastUpdated: new Date().toISOString(), + }; + + return { ok: true, data }; + } catch (error) { + console.error("Error fetching releases:", error); + return { ok: false, error: "Unable to load release information" }; + } +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..193969c --- /dev/null +++ b/vercel.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "crons": [ + { + "path": "/api/revalidate-releases", + "schedule": "0 0 * * *" + } + ] +}