feat(website): offer the beta channel on the download page

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.
This commit is contained in:
Mohamed Boudra
2026-08-05 16:39:59 +02:00
parent aae9a474c2
commit 1c43e4eea1
8 changed files with 428 additions and 208 deletions
+61 -14
View File
@@ -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<GitHubRelease[]> {
return (await response.json()) as GitHubRelease[];
}
async function fetchLatestReadyRelease(): Promise<ReleaseInfo> {
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<ReleaseChannels> {
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<ReleaseInfo> {
function isReleaseChannels(value: unknown): value is ReleaseChannels {
if (typeof value !== "object" || value === null) return false;
const record = value as Record<string, unknown>;
return isReleaseInfo(record.stable) && (record.beta === null || isReleaseInfo(record.beta));
}
export async function getReleaseChannels(context: WebsiteCacheContext): Promise<ReleaseChannels> {
return getBlockingColdCache({
context,
key: RELEASE_CACHE_KEY,
isValue: isReleaseInfo,
fetchFresh: fetchLatestReadyRelease,
isValue: isReleaseChannels,
fetchFresh: fetchReleaseChannels,
});
}