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:
@@ -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 (
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user