From 1c43e4eea1de4e0bb6ade7b471fbfa857d9550b0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 5 Aug 2026 16:39:44 +0200 Subject: [PATCH] feat(website): offer the beta channel on the download page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beta testers had to find prerelease assets on GitHub by hand. The download page now carries a Stable/Beta switch, with stable staying the default and the canonical URL. The channel lives in the URL (?channel=beta) rather than client state so maintainers can link someone straight to it. That link outlives the beta it was shared for, so the switch only renders while the newest prerelease leads stable on its core version, and a stale ?channel=beta falls back to stable instead of serving a promoted version twice. "What's new" deep-links the minor group anchor (/changelog#release-0.3), not the exact entry: promotion rewrites the -beta.N entry in place, so that anchor dies while the group survives — and the group is the stable-to-beta diff a beta downloader came to read. A version missing from the bundled changelog links plain /changelog rather than a dead anchor, which covers the window between a tag going public and the site redeploying. Homebrew, the app stores, and the web app have no beta, so the Beta view drops those rows instead of showing inert placeholders. --- docs/release.md | 12 +- packages/website/src/changelog.ts | 89 +++++++ packages/website/src/latest-release.test.ts | 65 ++++- packages/website/src/latest-release.ts | 75 +++++- packages/website/src/release.ts | 4 +- packages/website/src/routes/__root.tsx | 35 +-- packages/website/src/routes/changelog.tsx | 85 +----- packages/website/src/routes/download.tsx | 271 +++++++++++++------- 8 files changed, 428 insertions(+), 208 deletions(-) create mode 100644 packages/website/src/changelog.ts diff --git a/docs/release.md b/docs/release.md index f35296c42..ebf257958 100644 --- a/docs/release.md +++ b/docs/release.md @@ -36,7 +36,7 @@ Rules that apply to both steps: There are two supported ways to ship from `main`: 1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately. -2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never move the website download target off the latest stable. +2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never become the website's default download — they sit behind the Stable/Beta switch on `/download`. Paseo has one linear release track even though npm dist-tags are independent pointers. The npm invariant is: @@ -317,10 +317,12 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w ## Website behavior -- The website download page points to GitHub's latest published **stable** release. -- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target. -- The download target only moves when you publish the final stable release tag like `v0.1.41`. -- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them. +- The website download page defaults to GitHub's latest published **stable** release. +- A published beta prerelease is offered behind the Stable/Beta switch on `/download` (`?channel=beta`), never as the default. The switch only appears while the newest prerelease leads stable on its core version, so promoting `X.Y.Z-beta.N` to `X.Y.Z` retires the beta channel from the page until the next beta line opens. +- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`. +- The default download target only moves when you publish the final stable release tag like `v0.1.41`. +- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them. +- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: promotion rewrites the `-beta.N` entry in place, so that anchor dies while the group survives. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor. - The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`. ## Fixing a failed release build diff --git a/packages/website/src/changelog.ts b/packages/website/src/changelog.ts new file mode 100644 index 000000000..1e638d7c8 --- /dev/null +++ b/packages/website/src/changelog.ts @@ -0,0 +1,89 @@ +import changelogMarkdown from "../../../CHANGELOG.md?raw"; + +export interface ChangelogRelease { + version: string; + date: string; + markdown: string; +} + +export interface ChangelogReleaseGroup { + version: string; + releases: ChangelogRelease[]; +} + +const releaseHeadingPattern = /^## (.+?) - (\d{4}-\d{2}-\d{2})$/; + +export function releaseAnchor(version: string): string { + return `release-${version}`; +} + +function minorVersion(version: string): string { + return version.split(".").slice(0, 2).join("."); +} + +function parseChangelog(markdown: string): ChangelogRelease[] { + const releases: ChangelogRelease[] = []; + const versions = new Set(); + let currentRelease: ChangelogRelease | null = null; + + for (const line of markdown.split("\n")) { + const heading = line.match(releaseHeadingPattern); + if (heading) { + const version = heading[1]; + if (versions.has(version)) { + throw new Error(`Duplicate changelog version: ${version}`); + } + versions.add(version); + + if (currentRelease) releases.push(currentRelease); + currentRelease = { + version, + date: heading[2], + markdown: "", + }; + continue; + } + + if (currentRelease) currentRelease.markdown += `${line}\n`; + } + + if (currentRelease) releases.push(currentRelease); + return releases; +} + +function groupChangelogReleases(releases: ChangelogRelease[]): ChangelogReleaseGroup[] { + const groups: ChangelogReleaseGroup[] = []; + + for (const release of releases) { + const version = minorVersion(release.version); + const currentGroup = groups.at(-1); + if (currentGroup?.version === version) { + currentGroup.releases.push(release); + } else { + groups.push({ version, releases: [release] }); + } + } + + return groups; +} + +const changelogReleases = parseChangelog(changelogMarkdown); + +export const changelogReleaseGroups = groupChangelogReleases(changelogReleases); + +/** + * Deep link from a downloadable version to its notes. + * + * Links the minor group rather than the exact entry: a `-beta.N` entry is + * rewritten in place on promotion, so its anchor disappears while the group + * anchor survives — and the group is the stable-to-beta diff a beta downloader + * came to read. Versions the bundled changelog doesn't know about (a tag whose + * changelog commit hasn't been deployed yet) get the plain changelog, never a + * dead anchor. + */ +export function changelogLink(version: string): { to: "/changelog"; hash?: string } { + const known = changelogReleases.some((release) => release.version === version); + return known + ? { to: "/changelog", hash: releaseAnchor(minorVersion(version)) } + : { to: "/changelog" }; +} diff --git a/packages/website/src/latest-release.test.ts b/packages/website/src/latest-release.test.ts index e10c26446..997478d05 100644 --- a/packages/website/src/latest-release.test.ts +++ b/packages/website/src/latest-release.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { getLatestAndroidVersionFromReleases, type GitHubRelease } from "./latest-release"; +import { + getLatestAndroidVersionFromReleases, + selectReleaseChannels, + type GitHubRelease, +} from "./latest-release"; function release({ version, @@ -19,6 +23,28 @@ function release({ }; } +function desktopRelease({ + version, + prerelease = false, + draft = false, +}: { + version: string; + prerelease?: boolean; + draft?: boolean; +}): GitHubRelease { + return { + tag_name: `v${version}`, + assets: [ + { name: `Paseo-${version}-arm64.dmg` }, + { name: "Paseo-x86_64.AppImage" }, + { name: `Paseo-Setup-${version}-x64.exe` }, + { name: `Paseo-Setup-${version}-arm64.exe` }, + ], + prerelease, + draft, + }; +} + describe("getLatestAndroidVersionFromReleases", () => { it("selects the latest stable release that contains an Android APK", () => { const releases = [ @@ -30,3 +56,40 @@ describe("getLatestAndroidVersionFromReleases", () => { expect(getLatestAndroidVersionFromReleases(releases)).toBe("0.1.107"); }); }); + +describe("selectReleaseChannels", () => { + it("offers the newest prerelease when it leads stable", () => { + const channels = selectReleaseChannels([ + desktopRelease({ version: "0.3.0-beta.2", prerelease: true }), + desktopRelease({ version: "0.3.0-beta.1", prerelease: true }), + desktopRelease({ version: "0.2.5" }), + ]); + + expect(channels.stable.version).toBe("0.2.5"); + expect(channels.beta?.version).toBe("0.3.0-beta.2"); + expect(channels.beta?.windowsArm64Asset).toBe("Paseo-Setup-0.3.0-beta.2-arm64.exe"); + }); + + it("retires the beta channel once stable ships the same version", () => { + const channels = selectReleaseChannels([ + desktopRelease({ version: "0.3.0" }), + desktopRelease({ version: "0.3.0-beta.2", prerelease: true }), + ]); + + expect(channels.stable.version).toBe("0.3.0"); + expect(channels.beta).toBeNull(); + }); + + it("skips drafts and releases whose desktop assets are still uploading", () => { + const channels = selectReleaseChannels([ + desktopRelease({ version: "0.3.0-beta.3", prerelease: true, draft: true }), + release({ version: "0.3.0-beta.2", hasApk: true, prerelease: true }), + desktopRelease({ version: "0.3.0-beta.1", prerelease: true }), + release({ version: "0.2.5", hasApk: true }), + desktopRelease({ version: "0.2.4" }), + ]); + + expect(channels.stable.version).toBe("0.2.4"); + expect(channels.beta?.version).toBe("0.3.0-beta.1"); + }); +}); diff --git a/packages/website/src/latest-release.ts b/packages/website/src/latest-release.ts index afdbf6b56..8187d89e0 100644 --- a/packages/website/src/latest-release.ts +++ b/packages/website/src/latest-release.ts @@ -18,6 +18,12 @@ export interface ReleaseInfo { windowsArm64Asset: string | null; } +export interface ReleaseChannels { + stable: ReleaseInfo; + /** The newest prerelease, or null when stable has caught up with it. */ + beta: ReleaseInfo | null; +} + const LINUX_APPIMAGE_ASSET_PATTERN = /^Paseo-(?:\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)-)?x86_64\.AppImage$/; @@ -28,7 +34,7 @@ const REQUIRED_ASSET_PATTERNS = [ ]; const GITHUB_RELEASES_URL = "https://api.github.com/repos/getpaseo/paseo/releases?per_page=10"; -const RELEASE_CACHE_KEY = "github-release:v1"; +const RELEASE_CACHE_KEY = "github-release:v2"; const ANDROID_RELEASE_CACHE_KEY = "github-android-release:v1"; function hasRequiredAssets(release: GitHubRelease): boolean { @@ -77,25 +83,60 @@ async function fetchGitHubReleases(): Promise { return (await response.json()) as GitHubRelease[]; } -async function fetchLatestReadyRelease(): Promise { - const releases = await fetchGitHubReleases(); - const ready = releases.find( - (release) => !release.prerelease && !release.draft && hasRequiredAssets(release), - ); - if (!ready) throw new Error("no ready GitHub release found"); +function toReleaseInfo(release: GitHubRelease): ReleaseInfo | null { + if (release.draft || !hasRequiredAssets(release)) return null; - const windowsAssets = pickWindowsAssets(ready.assets); - const linuxAppImageAsset = pickLinuxAppImageAsset(ready.assets); - if (!linuxAppImageAsset) throw new Error("ready release missing Linux AppImage asset"); + const linuxAppImageAsset = pickLinuxAppImageAsset(release.assets); + if (!linuxAppImageAsset) return null; + const windowsAssets = pickWindowsAssets(release.assets); return { - version: versionFromTag(ready.tag_name), + version: versionFromTag(release.tag_name), linuxAppImageAsset, windowsX64Asset: windowsAssets.x64, windowsArm64Asset: windowsAssets.arm64, }; } +function coreVersion(version: string): number[] { + return version.split("-")[0].split(".").map(Number); +} + +/** + * A beta is only worth offering while its core version is ahead of stable. + * Promotion ships the same core as a stable release, which retires the beta + * channel until the next beta line opens. + */ +function leadsStable(betaVersion: string, stableVersion: string): boolean { + const beta = coreVersion(betaVersion); + const stable = coreVersion(stableVersion); + for (let index = 0; index < Math.max(beta.length, stable.length); index++) { + const betaPart = beta[index] ?? 0; + const stablePart = stable[index] ?? 0; + if (betaPart !== stablePart) return betaPart > stablePart; + } + return false; +} + +export function selectReleaseChannels(releases: GitHubRelease[]): ReleaseChannels { + const stable = releases + .filter((release) => !release.prerelease) + .map(toReleaseInfo) + .find((release) => release !== null); + if (!stable) throw new Error("no ready GitHub release found"); + + const beta = releases + .filter((release) => release.prerelease) + .map(toReleaseInfo) + .find((release) => release !== null); + + return { stable, beta: beta && leadsStable(beta.version, stable.version) ? beta : null }; +} + +async function fetchReleaseChannels(): Promise { + return selectReleaseChannels(await fetchGitHubReleases()); +} + export function getLatestAndroidVersionFromReleases(releases: GitHubRelease[]): string { const release = releases.find((candidate) => { if (candidate.prerelease || candidate.draft) return false; @@ -141,12 +182,18 @@ function isReleaseInfo(value: unknown): value is ReleaseInfo { ); } -export async function getLatestReleaseInfo(context: WebsiteCacheContext): Promise { +function isReleaseChannels(value: unknown): value is ReleaseChannels { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return isReleaseInfo(record.stable) && (record.beta === null || isReleaseInfo(record.beta)); +} + +export async function getReleaseChannels(context: WebsiteCacheContext): Promise { return getBlockingColdCache({ context, key: RELEASE_CACHE_KEY, - isValue: isReleaseInfo, - fetchFresh: fetchLatestReadyRelease, + isValue: isReleaseChannels, + fetchFresh: fetchReleaseChannels, }); } diff --git a/packages/website/src/release.ts b/packages/website/src/release.ts index 09a759cbf..51c817bb0 100644 --- a/packages/website/src/release.ts +++ b/packages/website/src/release.ts @@ -1,7 +1,7 @@ import { createServerFn } from "@tanstack/react-start"; import { getWebsiteCacheContext } from "./cloudflare-cache"; -import { getLatestReleaseInfo } from "./latest-release"; +import { getReleaseChannels } from "./latest-release"; export const getLatestRelease = createServerFn({ method: "GET" }).handler(async () => { - return getLatestReleaseInfo(getWebsiteCacheContext()); + return getReleaseChannels(getWebsiteCacheContext()); }); diff --git a/packages/website/src/routes/__root.tsx b/packages/website/src/routes/__root.tsx index 664ddd453..82b9de513 100644 --- a/packages/website/src/routes/__root.tsx +++ b/packages/website/src/routes/__root.tsx @@ -1,25 +1,22 @@ import type { ReactNode } from "react"; import { createContext, useContext } from "react"; import { Outlet, createRootRoute, HeadContent, Scripts } from "@tanstack/react-router"; +import type { ReleaseChannels, ReleaseInfo } from "~/latest-release"; import { getLatestRelease } from "~/release"; import { getStarCount } from "~/stars"; -interface ReleaseContext { - version: string; - linuxAppImageAsset: string; - windowsX64Asset: string | null; - windowsArm64Asset: string | null; -} - interface StarsContext { stars: string; } -const ReleaseCtx = createContext({ - version: "", - linuxAppImageAsset: "", - windowsX64Asset: null, - windowsArm64Asset: null, +const ReleaseCtx = createContext({ + stable: { + version: "", + linuxAppImageAsset: "", + windowsX64Asset: null, + windowsArm64Asset: null, + }, + beta: null, }); const StarsCtx = createContext({ stars: "" }); @@ -27,8 +24,14 @@ const PLAUSIBLE_INIT_SCRIPT = { __html: `window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init()`, }; -export function useRelease(): ReleaseContext { - return useContext(ReleaseCtx); +/** The latest stable release. Everything on the site points here by default. */ +export function useRelease(): ReleaseInfo { + return useContext(ReleaseCtx).stable; +} + +/** The current beta, or null when there is no beta ahead of stable. */ +export function useBetaRelease(): ReleaseInfo | null { + return useContext(ReleaseCtx).beta; } export function useStars(): StarsContext { @@ -38,7 +41,7 @@ export function useStars(): StarsContext { export const Route = createRootRoute({ loader: async () => { const [release, stars] = await Promise.all([getLatestRelease(), getStarCount()]); - return { ...release, ...stars }; + return { release, ...stars }; }, head: () => ({ meta: [ @@ -63,7 +66,7 @@ export const Route = createRootRoute({ function RootComponent() { const data = Route.useLoaderData(); return ( - + diff --git a/packages/website/src/routes/changelog.tsx b/packages/website/src/routes/changelog.tsx index feeee8768..6548326e0 100644 --- a/packages/website/src/routes/changelog.tsx +++ b/packages/website/src/routes/changelog.tsx @@ -1,21 +1,14 @@ import { createFileRoute } from "@tanstack/react-router"; import ReactMarkdown, { type Components } from "react-markdown"; -import changelogMarkdown from "../../../../CHANGELOG.md?raw"; +import { + changelogReleaseGroups, + releaseAnchor, + type ChangelogRelease, + type ChangelogReleaseGroup, +} from "~/changelog"; import { SiteShell } from "~/components/site-shell"; import { pageMeta } from "~/meta"; -interface ChangelogRelease { - version: string; - date: string; - markdown: string; -} - -interface ChangelogReleaseGroup { - version: string; - releases: ChangelogRelease[]; -} - -const releaseHeadingPattern = /^## (.+?) - (\d{4}-\d{2}-\d{2})$/; const patchMarkdownComponents: Components = { h3: "h4" }; export const Route = createFileRoute("/changelog")({ @@ -38,66 +31,10 @@ function formatDate(date: string): string { }).format(new Date(Date.UTC(year, month - 1, day))); } -function parseChangelog(markdown: string): ChangelogRelease[] { - const releases: ChangelogRelease[] = []; - const versions = new Set(); - let currentRelease: ChangelogRelease | null = null; - - for (const line of markdown.split("\n")) { - const heading = line.match(releaseHeadingPattern); - if (heading) { - const version = heading[1]; - if (versions.has(version)) { - throw new Error(`Duplicate changelog version: ${version}`); - } - versions.add(version); - - if (currentRelease) releases.push(currentRelease); - currentRelease = { - version, - date: heading[2], - markdown: "", - }; - continue; - } - - if (currentRelease) currentRelease.markdown += `${line}\n`; - } - - if (currentRelease) releases.push(currentRelease); - return releases; -} - -const changelogReleases = parseChangelog(changelogMarkdown); - -function minorVersion(version: string): string { - return version.split(".").slice(0, 2).join("."); -} - -function groupChangelogReleases(releases: ChangelogRelease[]): ChangelogReleaseGroup[] { - const groups: ChangelogReleaseGroup[] = []; - - for (const release of releases) { - const version = minorVersion(release.version); - const currentGroup = groups.at(-1); - if (currentGroup?.version === version) { - currentGroup.releases.push(release); - } else { - groups.push({ version, releases: [release] }); - } - } - - return groups; -} - -const changelogReleaseGroups = groupChangelogReleases(changelogReleases); - function HeadingAnchor({ version }: { version: string }) { - const anchor = `release-${version}`; - return ( @@ -107,11 +44,9 @@ function HeadingAnchor({ version }: { version: string }) { } function PatchRelease({ release }: { release: ChangelogRelease }) { - const anchor = `release-${release.version}`; - return (
-
+

{release.version}

@@ -109,55 +126,45 @@ function Download() {
- {/* Android */} -
-
- - Android -
-
- + + + {!onBeta && } -
-
+ + - {/* iOS */} -
-
- - iOS -
-
- -
-
+ {!onBeta && ( + + + + + + )}
{/* Web */} -
-
-
-

Web

-

- Connect to a server from any browser -

+ {!onBeta && ( +
+
+
+

Web

+

+ Connect to a server from any browser +

+
+
- -
-
-
-
- - Web App -
-
- -
+
+ + + + +
-
-
+ + )} {/* Server */}
@@ -172,21 +179,21 @@ function Download() {
-
-
- - npm -
- npm install -g @getpaseo/cli && paseo -
+ + + {onBeta + ? "npm install -g @getpaseo/cli@beta && paseo" + : "npm install -g @getpaseo/cli && paseo"} + + -
-
- - Nix -
- nix run github:getpaseo/paseo -
+ + + {onBeta + ? `nix run github:getpaseo/paseo/v${version}` + : "nix run github:getpaseo/paseo"} + +
@@ -206,6 +213,82 @@ function Download() { ); } +function ChannelSwitch({ onBeta }: { onBeta: boolean }) { + return ( +
+ + +
+ ); +} + +function ChannelOption({ + label, + active, + search, +}: { + label: string; + active: boolean; + search: DownloadSearch; +}) { + return ( + + {label} + + ); +} + +function BetaNotice() { + return ( +
+

Beta builds ship ahead of stable and can break.

+

+ Already running the desktop app? Set{" "} + Settings → Release channel → Beta and it updates + itself from here on. +

+
+ ); +} + +function PlatformRow({ + icon: Icon, + label, + children, +}: { + icon: (props: React.SVGProps) => ReactNode; + label: string; + children: ReactNode; +}) { + return ( +
+
+ + {label} +
+ {children} +
+ ); +} + +function PillGroup({ children }: { children: ReactNode }) { + return
{children}
; +} + function DownloadPill({ href, label,