From 1ae2f9280e2e3fba9c4b73f1469e9cabab30d395 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 20:41:12 +0700 Subject: [PATCH] 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. --- .../website/src/components/landing-page.tsx | 6 +- packages/website/src/downloads.tsx | 80 ++++++++++--------- packages/website/src/release.ts | 69 ++++++++++++++++ packages/website/src/routes/__root.tsx | 24 +++++- packages/website/src/routes/download.tsx | 28 +++---- packages/website/vite.config.ts | 21 +---- 6 files changed, 152 insertions(+), 76 deletions(-) create mode 100644 packages/website/src/release.ts diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index 6d2b21ffe..967601ea7 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -6,13 +6,14 @@ import { appStoreUrl, playStoreUrl, webAppUrl, - downloadOptions, + getDownloadOptions, useDetectedPlatform, AppleIcon, AndroidIcon, TerminalIcon, GlobeIcon, } from "~/downloads"; +import { useRelease } from "~/routes/__root"; import { Mic } from "lucide-react"; import { HeroMockup } from "~/components/hero-mockup"; import { ClaudeIcon } from "~/components/mockup"; @@ -772,8 +773,9 @@ function GetStarted() { } function DownloadButton() { + const { version } = useRelease(); const detectedPlatform = useDetectedPlatform(); - const primary = downloadOptions.find((o) => o.platform === detectedPlatform)!; + const primary = getDownloadOptions(version).find((o) => o.platform === detectedPlatform)!; const PrimaryIcon = primary.icon; return ( diff --git a/packages/website/src/downloads.tsx b/packages/website/src/downloads.tsx index 379cb8590..aae15a88d 100644 --- a/packages/website/src/downloads.tsx +++ b/packages/website/src/downloads.tsx @@ -1,16 +1,21 @@ 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 = - typeof __DESKTOP_VERSION__ !== "undefined" ? __DESKTOP_VERSION__ : websitePackage.version; -export const releaseBase = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}`; -export const macAppleSiliconDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-arm64.dmg`; -export const macIntelDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-x64.dmg`; -export const linuxAppImageDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-x86_64.AppImage`; -export const linuxDebDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-amd64.deb`; -export const linuxRpmDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-x86_64.rpm`; +export function downloadUrls(version: string) { + const base = releaseBase(version); + return { + macAppleSilicon: `${base}/Paseo-${version}-arm64.dmg`, + macIntel: `${base}/Paseo-${version}-x64.dmg`, + linuxAppImage: `${base}/Paseo-${version}-x86_64.AppImage`, + linuxDeb: `${base}/Paseo-${version}-amd64.deb`, + 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 playStoreUrl = "https://play.google.com/store/apps/details?id=sh.paseo"; @@ -25,32 +30,35 @@ export interface DownloadOption { icon: (props: React.SVGProps) => React.ReactElement; } -export const downloadOptions: DownloadOption[] = [ - { - platform: "mac-silicon", - label: "Mac", - href: macAppleSiliconDownloadUrl, - icon: AppleIcon, - }, - { - platform: "mac-intel", - label: "Mac Intel", - href: macIntelDownloadUrl, - icon: AppleIcon, - }, - { - platform: "windows", - label: "Windows", - href: `${releaseBase}/Paseo-Setup-${desktopVersion}.exe`, - icon: WindowsIcon, - }, - { - platform: "linux", - label: "Linux", - href: linuxAppImageDownloadUrl, - icon: LinuxIcon, - }, -]; +export function getDownloadOptions(version: string): DownloadOption[] { + const urls = downloadUrls(version); + return [ + { + platform: "mac-silicon", + label: "Mac", + href: urls.macAppleSilicon, + icon: AppleIcon, + }, + { + platform: "mac-intel", + label: "Mac Intel", + href: urls.macIntel, + icon: AppleIcon, + }, + { + platform: "windows", + label: "Windows", + href: urls.windowsExe, + icon: WindowsIcon, + }, + { + platform: "linux", + label: "Linux", + href: urls.linuxAppImage, + icon: LinuxIcon, + }, + ]; +} export function useDetectedPlatform(): Platform { const [platform, setPlatform] = React.useState("mac-silicon"); diff --git a/packages/website/src/release.ts b/packages/website/src/release.ts new file mode 100644 index 000000000..ac925cd5c --- /dev/null +++ b/packages/website/src/release.ts @@ -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 { + 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 }; + }, +); diff --git a/packages/website/src/routes/__root.tsx b/packages/website/src/routes/__root.tsx index fe7db8cb2..e887ac222 100644 --- a/packages/website/src/routes/__root.tsx +++ b/packages/website/src/routes/__root.tsx @@ -1,7 +1,22 @@ import type { ReactNode } from "react"; +import { createContext, useContext } from "react"; import { Outlet, createRootRoute, HeadContent, Scripts } from "@tanstack/react-router"; +import { getLatestRelease } from "~/release"; + +interface ReleaseContext { + version: string; +} + +const ReleaseCtx = createContext({ version: "" }); + +export function useRelease(): ReleaseContext { + return useContext(ReleaseCtx); +} export const Route = createRootRoute({ + loader: async () => { + return getLatestRelease(); + }, head: () => ({ meta: [ { charSet: "utf-8" }, @@ -23,10 +38,13 @@ export const Route = createRootRoute({ }); function RootComponent() { + const release = Route.useLoaderData(); return ( - - - + + + + + ); } diff --git a/packages/website/src/routes/download.tsx b/packages/website/src/routes/download.tsx index aebd3fff9..e28acbed2 100644 --- a/packages/website/src/routes/download.tsx +++ b/packages/website/src/routes/download.tsx @@ -2,13 +2,7 @@ import { Link, createFileRoute } from "@tanstack/react-router"; import { CommandDialog } from "~/components/command-dialog"; import { pageMeta } from "~/meta"; import { - desktopVersion, - linuxAppImageDownloadUrl, - linuxDebDownloadUrl, - linuxRpmDownloadUrl, - releaseBase, - macAppleSiliconDownloadUrl, - macIntelDownloadUrl, + downloadUrls, appStoreUrl, playStoreUrl, webAppUrl, @@ -19,6 +13,7 @@ import { TerminalIcon, GlobeIcon, } from "~/downloads"; +import { useRelease } from "~/routes/__root"; import "~/styles.css"; export const Route = createFileRoute("/download")({ @@ -32,6 +27,9 @@ export const Route = createFileRoute("/download")({ }); function Download() { + const { version } = useRelease(); + const urls = downloadUrls(version); + return (
@@ -93,7 +91,7 @@ function Download() {

Download

- v{desktopVersion} + v{version}

{/* Desktop */} @@ -111,8 +109,8 @@ function Download() { macOS
- - + + @@ -133,7 +131,7 @@ function Download() {
@@ -147,11 +145,11 @@ function Download() {
- - + +
@@ -178,7 +176,7 @@ function Download() { external /> diff --git a/packages/website/vite.config.ts b/packages/website/vite.config.ts index 3ff066e67..648e5e533 100644 --- a/packages/website/vite.config.ts +++ b/packages/website/vite.config.ts @@ -28,27 +28,8 @@ const sitemapPages = [ path: routePath, })); -async function fetchLatestReleaseVersion(): Promise { - 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 => { - const latestVersion = await fetchLatestReleaseVersion(); - +export default defineConfig((): UserConfig => { return { - define: { - ...(latestVersion && { __DESKTOP_VERSION__: JSON.stringify(latestVersion) }), - }, server: { port: 8082, fs: {