fix(website): fetch release version at runtime with asset validation

Move release version resolution from build-time (vite.config.ts) to
runtime (server function on Cloudflare Workers). The server function
fetches GitHub releases, filters out prereleases/drafts, and only
returns a version that has all required assets (Mac DMG, Linux AppImage,
Windows exe). Uses Cloudflare's cf.cacheEverything with a 60s TTL to
avoid hitting GitHub on every request.

This fixes a race condition where the website would deploy and show
download links for a new release before the desktop build assets were
actually uploaded, resulting in 404s for users.
This commit is contained in:
Mohamed Boudra
2026-04-03 20:41:26 +07:00
parent 49402b854f
commit 1ae2f9280e
6 changed files with 152 additions and 76 deletions
@@ -6,13 +6,14 @@ import {
appStoreUrl, appStoreUrl,
playStoreUrl, playStoreUrl,
webAppUrl, webAppUrl,
downloadOptions, getDownloadOptions,
useDetectedPlatform, useDetectedPlatform,
AppleIcon, AppleIcon,
AndroidIcon, AndroidIcon,
TerminalIcon, TerminalIcon,
GlobeIcon, GlobeIcon,
} from "~/downloads"; } from "~/downloads";
import { useRelease } from "~/routes/__root";
import { Mic } from "lucide-react"; import { Mic } from "lucide-react";
import { HeroMockup } from "~/components/hero-mockup"; import { HeroMockup } from "~/components/hero-mockup";
import { ClaudeIcon } from "~/components/mockup"; import { ClaudeIcon } from "~/components/mockup";
@@ -772,8 +773,9 @@ function GetStarted() {
} }
function DownloadButton() { function DownloadButton() {
const { version } = useRelease();
const detectedPlatform = useDetectedPlatform(); const detectedPlatform = useDetectedPlatform();
const primary = downloadOptions.find((o) => o.platform === detectedPlatform)!; const primary = getDownloadOptions(version).find((o) => o.platform === detectedPlatform)!;
const PrimaryIcon = primary.icon; const PrimaryIcon = primary.icon;
return ( return (
+44 -36
View File
@@ -1,16 +1,21 @@
import * as React from "react"; import * as React from "react";
import websitePackage from "../package.json";
declare const __DESKTOP_VERSION__: string | undefined; export function releaseBase(version: string) {
return `https://github.com/getpaseo/paseo/releases/download/v${version}`;
}
export const desktopVersion = export function downloadUrls(version: string) {
typeof __DESKTOP_VERSION__ !== "undefined" ? __DESKTOP_VERSION__ : websitePackage.version; const base = releaseBase(version);
export const releaseBase = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}`; return {
export const macAppleSiliconDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-arm64.dmg`; macAppleSilicon: `${base}/Paseo-${version}-arm64.dmg`,
export const macIntelDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-x64.dmg`; macIntel: `${base}/Paseo-${version}-x64.dmg`,
export const linuxAppImageDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-x86_64.AppImage`; linuxAppImage: `${base}/Paseo-${version}-x86_64.AppImage`,
export const linuxDebDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-amd64.deb`; linuxDeb: `${base}/Paseo-${version}-amd64.deb`,
export const linuxRpmDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-x86_64.rpm`; linuxRpm: `${base}/Paseo-${version}-x86_64.rpm`,
windowsExe: `${base}/Paseo-Setup-${version}.exe`,
androidApk: `${base}/paseo-v${version}-android.apk`,
};
}
export const appStoreUrl = "https://apps.apple.com/app/paseo-pocket-engineer/id6758887924"; export const appStoreUrl = "https://apps.apple.com/app/paseo-pocket-engineer/id6758887924";
export const playStoreUrl = "https://play.google.com/store/apps/details?id=sh.paseo"; export const playStoreUrl = "https://play.google.com/store/apps/details?id=sh.paseo";
@@ -25,32 +30,35 @@ export interface DownloadOption {
icon: (props: React.SVGProps<SVGSVGElement>) => React.ReactElement; icon: (props: React.SVGProps<SVGSVGElement>) => React.ReactElement;
} }
export const downloadOptions: DownloadOption[] = [ export function getDownloadOptions(version: string): DownloadOption[] {
{ const urls = downloadUrls(version);
platform: "mac-silicon", return [
label: "Mac", {
href: macAppleSiliconDownloadUrl, platform: "mac-silicon",
icon: AppleIcon, label: "Mac",
}, href: urls.macAppleSilicon,
{ icon: AppleIcon,
platform: "mac-intel", },
label: "Mac Intel", {
href: macIntelDownloadUrl, platform: "mac-intel",
icon: AppleIcon, label: "Mac Intel",
}, href: urls.macIntel,
{ icon: AppleIcon,
platform: "windows", },
label: "Windows", {
href: `${releaseBase}/Paseo-Setup-${desktopVersion}.exe`, platform: "windows",
icon: WindowsIcon, label: "Windows",
}, href: urls.windowsExe,
{ icon: WindowsIcon,
platform: "linux", },
label: "Linux", {
href: linuxAppImageDownloadUrl, platform: "linux",
icon: LinuxIcon, label: "Linux",
}, href: urls.linuxAppImage,
]; icon: LinuxIcon,
},
];
}
export function useDetectedPlatform(): Platform { export function useDetectedPlatform(): Platform {
const [platform, setPlatform] = React.useState<Platform>("mac-silicon"); const [platform, setPlatform] = React.useState<Platform>("mac-silicon");
+69
View File
@@ -0,0 +1,69 @@
import { createServerFn } from "@tanstack/react-start";
import websitePackage from "../package.json";
interface GitHubAsset {
name: string;
}
interface GitHubRelease {
tag_name: string;
assets: GitHubAsset[];
prerelease: boolean;
draft: boolean;
}
const REQUIRED_ASSET_PATTERNS = [
/Paseo-.*-arm64\.dmg$/, // Mac Apple Silicon
/Paseo-.*-x86_64\.AppImage$/, // Linux AppImage
/Paseo-Setup-.*\.exe$/, // Windows
];
function hasRequiredAssets(release: GitHubRelease): boolean {
return REQUIRED_ASSET_PATTERNS.every((pattern) =>
release.assets.some((asset) => pattern.test(asset.name)),
);
}
function versionFromTag(tag: string): string {
return tag.replace(/^v/, "");
}
const GITHUB_RELEASES_URL =
"https://api.github.com/repos/getpaseo/paseo/releases?per_page=10";
async function fetchLatestReadyRelease(): Promise<string> {
const fallback = websitePackage.version.replace(/-.*$/, "");
try {
const res = await fetch(GITHUB_RELEASES_URL, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "paseo-website",
},
// Cloudflare Workers: cache the upstream response with stale-while-revalidate.
// Fresh for 60s, serve stale for up to 300s while revalidating in background.
// In non-Workers environments this is ignored.
cf: {
cacheEverything: true,
cacheTtl: 60,
cacheKey: "github-releases-latest",
},
} as RequestInit);
if (!res.ok) return fallback;
const releases = (await res.json()) as GitHubRelease[];
const ready = releases.find(
(r) => !r.prerelease && !r.draft && hasRequiredAssets(r),
);
return ready ? versionFromTag(ready.tag_name) : fallback;
} catch {
return fallback;
}
}
export const getLatestRelease = createServerFn({ method: "GET" }).handler(
async () => {
const version = await fetchLatestReadyRelease();
return { version };
},
);
+21 -3
View File
@@ -1,7 +1,22 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { createContext, useContext } from "react";
import { Outlet, createRootRoute, HeadContent, Scripts } from "@tanstack/react-router"; import { Outlet, createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
import { getLatestRelease } from "~/release";
interface ReleaseContext {
version: string;
}
const ReleaseCtx = createContext<ReleaseContext>({ version: "" });
export function useRelease(): ReleaseContext {
return useContext(ReleaseCtx);
}
export const Route = createRootRoute({ export const Route = createRootRoute({
loader: async () => {
return getLatestRelease();
},
head: () => ({ head: () => ({
meta: [ meta: [
{ charSet: "utf-8" }, { charSet: "utf-8" },
@@ -23,10 +38,13 @@ export const Route = createRootRoute({
}); });
function RootComponent() { function RootComponent() {
const release = Route.useLoaderData();
return ( return (
<RootDocument> <ReleaseCtx value={release}>
<Outlet /> <RootDocument>
</RootDocument> <Outlet />
</RootDocument>
</ReleaseCtx>
); );
} }
+13 -15
View File
@@ -2,13 +2,7 @@ import { Link, createFileRoute } from "@tanstack/react-router";
import { CommandDialog } from "~/components/command-dialog"; import { CommandDialog } from "~/components/command-dialog";
import { pageMeta } from "~/meta"; import { pageMeta } from "~/meta";
import { import {
desktopVersion, downloadUrls,
linuxAppImageDownloadUrl,
linuxDebDownloadUrl,
linuxRpmDownloadUrl,
releaseBase,
macAppleSiliconDownloadUrl,
macIntelDownloadUrl,
appStoreUrl, appStoreUrl,
playStoreUrl, playStoreUrl,
webAppUrl, webAppUrl,
@@ -19,6 +13,7 @@ import {
TerminalIcon, TerminalIcon,
GlobeIcon, GlobeIcon,
} from "~/downloads"; } from "~/downloads";
import { useRelease } from "~/routes/__root";
import "~/styles.css"; import "~/styles.css";
export const Route = createFileRoute("/download")({ export const Route = createFileRoute("/download")({
@@ -32,6 +27,9 @@ export const Route = createFileRoute("/download")({
}); });
function Download() { function Download() {
const { version } = useRelease();
const urls = downloadUrls(version);
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<div className="max-w-3xl mx-auto p-6 md:p-12"> <div className="max-w-3xl mx-auto p-6 md:p-12">
@@ -93,7 +91,7 @@ function Download() {
<h1 className="text-3xl md:text-4xl font-semibold tracking-tight mb-2">Download</h1> <h1 className="text-3xl md:text-4xl font-semibold tracking-tight mb-2">Download</h1>
<p className="text-muted-foreground mb-10"> <p className="text-muted-foreground mb-10">
v{desktopVersion} v{version}
</p> </p>
{/* Desktop */} {/* Desktop */}
@@ -111,8 +109,8 @@ function Download() {
<span className="font-medium">macOS</span> <span className="font-medium">macOS</span>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<DownloadPill href={macAppleSiliconDownloadUrl} label="Apple Silicon" /> <DownloadPill href={urls.macAppleSilicon} label="Apple Silicon" />
<DownloadPill href={macIntelDownloadUrl} label="Intel" /> <DownloadPill href={urls.macIntel} label="Intel" />
<CommandDialog <CommandDialog
trigger={ trigger={
<span className="inline-flex items-center justify-center rounded-full bg-foreground px-4 py-1.5 text-sm font-medium text-background hover:bg-foreground/85 transition-colors"> <span className="inline-flex items-center justify-center rounded-full bg-foreground px-4 py-1.5 text-sm font-medium text-background hover:bg-foreground/85 transition-colors">
@@ -133,7 +131,7 @@ function Download() {
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<DownloadPill <DownloadPill
href={`${releaseBase}/Paseo-Setup-${desktopVersion}.exe`} href={urls.windowsExe}
label="Download" label="Download"
/> />
</div> </div>
@@ -147,11 +145,11 @@ function Download() {
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<DownloadPill <DownloadPill
href={linuxAppImageDownloadUrl} href={urls.linuxAppImage}
label="AppImage" label="AppImage"
/> />
<DownloadPill href={linuxDebDownloadUrl} label="DEB" /> <DownloadPill href={urls.linuxDeb} label="DEB" />
<DownloadPill href={linuxRpmDownloadUrl} label="RPM" /> <DownloadPill href={urls.linuxRpm} label="RPM" />
</div> </div>
</div> </div>
</div> </div>
@@ -178,7 +176,7 @@ function Download() {
external external
/> />
<DownloadPill <DownloadPill
href={`${releaseBase}/paseo-v${desktopVersion}-android.apk`} href={urls.androidApk}
label="APK" label="APK"
/> />
</div> </div>
+1 -20
View File
@@ -28,27 +28,8 @@ const sitemapPages = [
path: routePath, path: routePath,
})); }));
async function fetchLatestReleaseVersion(): Promise<string | undefined> { export default defineConfig((): UserConfig => {
try {
const res = await fetch(
"https://api.github.com/repos/getpaseo/paseo/releases/latest",
{ headers: { Accept: "application/vnd.github+json" }, signal: AbortSignal.timeout(5000) },
);
if (!res.ok) return undefined;
const data = (await res.json()) as { tag_name: string };
return data.tag_name.replace(/^v/, "");
} catch {
return undefined;
}
}
export default defineConfig(async (): Promise<UserConfig> => {
const latestVersion = await fetchLatestReleaseVersion();
return { return {
define: {
...(latestVersion && { __DESKTOP_VERSION__: JSON.stringify(latestVersion) }),
},
server: { server: {
port: 8082, port: 8082,
fs: { fs: {