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,