feat: add beta release channel

This commit is contained in:
Mohamed Boudra
2026-04-20 20:38:56 +07:00
parent 831d990ef7
commit dfc814147a
32 changed files with 442 additions and 256 deletions

View File

@@ -4,9 +4,9 @@ on:
push:
tags:
- 'v*'
- '!v*-rc.*'
- '!v*-beta.*'
- 'app-v*'
- '!app-v*-rc.*'
- '!app-v*-beta.*'
workflow_dispatch:
jobs:

View File

@@ -145,6 +145,7 @@ jobs:
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
@@ -154,7 +155,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: mac-manifest-${{ matrix.electron_arch }}
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-mac.yml
retention-days: 1
finalize-mac-manifest:
@@ -227,8 +228,9 @@ jobs:
return out;
}
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
const manifestName = `${process.env.RELEASE_CHANNEL}-mac.yml`;
const arm64Text = fs.readFileSync(`mac-manifest-arm64/${manifestName}`, 'utf8');
const x64Text = fs.readFileSync(`mac-manifest-x64/${manifestName}`, 'utf8');
const arm64 = parseManifest(arm64Text);
const x64 = parseManifest(x64Text);
@@ -243,7 +245,7 @@ jobs:
};
const output = toYaml(merged);
fs.writeFileSync('latest-mac.yml', output);
fs.writeFileSync(manifestName, output);
console.log('Merged manifest:\n' + output);
NODE
@@ -251,7 +253,7 @@ jobs:
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
run: gh release upload "$RELEASE_TAG" "$RELEASE_CHANNEL-mac.yml" --clobber --repo "${{ github.repository }}"
publish-linux:
needs: [create-release]
@@ -316,6 +318,7 @@ jobs:
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
@@ -391,6 +394,7 @@ jobs:
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"

View File

@@ -1,5 +1,10 @@
# Changelog
## 0.1.60-beta.1 - 2026-04-20
### Added
- Beta release channel for desktop updates, with a Settings toggle for receiving beta builds before they are promoted to stable.
## 0.1.59 - 2026-04-16
### Added

View File

@@ -51,7 +51,7 @@ Stable tag pushes like `v0.1.0` trigger:
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
### Useful commands

View File

@@ -7,7 +7,7 @@ All workspaces share one version and release together.
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. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
2. **Beta flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
## Standard release (patch)
@@ -37,31 +37,32 @@ npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
```
## Release candidate flow
## Beta flow
```bash
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
npm run release:beta:patch # Bump to X.Y.Z-beta.1, push commit + tag
# ... test desktop and APK prerelease assets from GitHub Releases ...
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
```
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
- Betas publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
- Desktop assets now come from the Electron package at `packages/desktop`
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
- **Do create a changelog entry for betas.** The beta entry is temporary and gets updated in place until promotion.
Use the RC path when you need to:
Use the beta path when you need to:
- test a build manually in a Linux or Windows VM
- send a build to a user who is hitting a specific problem
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
- iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
## Website behavior
- The website download page points to GitHub's latest published **stable** release.
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
- The website only moves when you publish the final stable release tag like `v0.1.41`.
## Fixing a failed release build
@@ -88,13 +89,13 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
# Android APK
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
# RC
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
# Beta
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
```
This ensures the checkout ref matches the actual code on `main` with the fix included.
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
- `android-vX.Y.Z` rebuilds the Android APK release only
@@ -105,24 +106,26 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target.
- The website uses GitHub's latest published release API for download links, so published beta prereleases do not replace the stable download target.
## Changelog format
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed:
Release notes depend on the changelog heading format. The heading **must** be strictly followed:
```
## X.Y.Z - YYYY-MM-DD
## X.Y.Z-beta.N - YYYY-MM-DD
```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
## Changelog policy
- `CHANGELOG.md` is for **final stable releases only**.
- Do not add or edit changelog entries while iterating on RCs.
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
- Between stable releases, keep changelog work out of the repo until the final release is ready.
- `CHANGELOG.md` includes stable releases and the current beta line.
- The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
- The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
- Stable promotion updates that same entry in place, for example from `0.1.60-beta.2` to `0.1.60`.
- Do not create duplicate entries for each beta on the same version line.
## Changelog ownership
@@ -178,7 +181,7 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
## Pre-release sanity check
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
Before cutting any release (beta or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
@@ -196,10 +199,10 @@ The agent's job is a deep sanity check, not a full code review. If it flags anyt
The changelog always covers **stable-to-HEAD**:
- **RC release**: the diff and release notes cover `latest stable tag HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC.
- **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
- **Stable release**: the same changelog entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next.
In other words, betas are checkpoints along the way; the changelog entry remains the single record for the final jump from one stable version to the next.
## Completion checklist

38
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -37303,16 +37303,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.60-rc.3",
"@getpaseo/highlight": "0.1.60-rc.3",
"@getpaseo/server": "0.1.60-rc.3",
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
"@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-beta.1",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -37456,11 +37456,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.60-rc.3",
"@getpaseo/server": "0.1.60-rc.3",
"@getpaseo/relay": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37501,11 +37501,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.60-rc.3",
"@getpaseo/server": "0.1.60-rc.3",
"@getpaseo/cli": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-beta.1",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -37539,7 +37539,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -37740,7 +37740,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -37766,7 +37766,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37782,14 +37782,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.60-rc.3",
"@getpaseo/relay": "0.1.60-rc.3",
"@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/relay": "0.1.60-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-coding-agent": "^0.67.68",
"@modelcontextprotocol/sdk": "^1.20.1",
@@ -38543,7 +38543,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
@@ -47,19 +47,19 @@
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
"version:all:major": "node scripts/set-release-version.mjs --mode major",
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
"version:all:beta:patch": "node scripts/set-release-version.mjs --mode beta-patch",
"version:all:beta:minor": "node scripts/set-release-version.mjs --mode beta-minor",
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs",
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
"release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:push",
"release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:push",
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"private": true,
"scripts": {
"start": "APP_VARIANT=development expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.60-rc.3",
"@getpaseo/highlight": "0.1.60-rc.3",
"@getpaseo/server": "0.1.60-rc.3",
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
"@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-beta.1",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",

View File

@@ -35,12 +35,13 @@ export function SegmentedControl<T extends string>({
testID,
}: SegmentedControlProps<T>) {
const { theme } = useUnistyles();
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md;
return (
<View style={[styles.container, style]} testID={testID}>
<View style={[styles.container, containerSizeStyle, style]} testID={testID}>
{options.map((option) => {
const isSelected = option.value === value;
const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted;
@@ -89,39 +90,46 @@ export function SegmentedControl<T extends string>({
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "center",
alignItems: "stretch",
maxWidth: "100%",
gap: 4,
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
},
containerSm: {
padding: 2,
},
containerMd: {
padding: 3,
},
segment: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: "transparent",
borderRadius: theme.borderRadius.lg,
gap: theme.spacing[1],
},
segmentSm: {
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
},
segmentMd: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[6],
},
segmentSelected: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 2,
elevation: 1,
},
segmentHover: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
},
segmentPressed: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
},
segmentDisabled: {
opacity: theme.opacity[50],

View File

@@ -17,6 +17,8 @@ export interface DesktopAppUpdateInstallResult {
message: string;
}
export type DesktopReleaseChannel = "stable" | "beta";
export interface LocalDaemonUpdateResult {
exitCode: number;
stdout: string;
@@ -69,8 +71,12 @@ export async function getLocalDaemonVersion(): Promise<LocalDaemonVersionResult>
return parseLocalDaemonVersionResult(result);
}
export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResult> {
const result = await invokeDesktopCommand<unknown>("check_app_update");
export async function checkDesktopAppUpdate({
releaseChannel,
}: {
releaseChannel: DesktopReleaseChannel;
}): Promise<DesktopAppUpdateCheckResult> {
const result = await invokeDesktopCommand<unknown>("check_app_update", { releaseChannel });
if (!isRecord(result)) {
throw new Error("Unexpected response while checking desktop updates.");
}
@@ -85,8 +91,12 @@ export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResu
};
}
export async function installDesktopAppUpdate(): Promise<DesktopAppUpdateInstallResult> {
const result = await invokeDesktopCommand<unknown>("install_app_update");
export async function installDesktopAppUpdate({
releaseChannel,
}: {
releaseChannel: DesktopReleaseChannel;
}): Promise<DesktopAppUpdateInstallResult> {
const result = await invokeDesktopCommand<unknown>("install_app_update", { releaseChannel });
if (!isRecord(result)) {
throw new Error("Unexpected response while installing desktop update.");
}

View File

@@ -7,6 +7,7 @@ import {
type DesktopAppUpdateCheckResult,
type DesktopAppUpdateInstallResult,
} from "@/desktop/updates/desktop-updates";
import { useAppSettings } from "@/hooks/use-settings";
export type DesktopAppUpdateStatus =
| "idle"
@@ -83,6 +84,8 @@ function formatStatusText(input: {
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const isDesktopApp = shouldShowDesktopUpdateSection();
const { settings } = useAppSettings();
const releaseChannel = settings.releaseChannel;
const requestVersionRef = useRef(0);
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
@@ -105,7 +108,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(null);
try {
const result = await checkDesktopAppUpdate();
const result = await checkDesktopAppUpdate({ releaseChannel });
if (requestVersion !== requestVersionRef.current) {
return result;
}
@@ -140,9 +143,17 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
return null;
}
},
[isDesktopApp],
[isDesktopApp, releaseChannel],
);
useEffect(() => {
if (!isDesktopApp) {
return;
}
void checkForUpdates({ silent: true });
}, [checkForUpdates, isDesktopApp]);
useEffect(() => {
if (!isDesktopApp || status !== "pending") {
return undefined;
@@ -166,7 +177,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(null);
try {
const result = await installDesktopAppUpdate();
const result = await installDesktopAppUpdate({ releaseChannel });
setLastCheckedAt(Date.now());
if (result.installed) {
@@ -186,7 +197,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(message);
return null;
}
}, [isDesktopApp]);
}, [isDesktopApp, releaseChannel]);
return {
isDesktopApp,

View File

@@ -40,6 +40,16 @@ describe("use-settings", () => {
expect(result.theme).toBe("auto");
});
it("defaults release channel to stable when storage is empty", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result.releaseChannel).toBe("stable");
});
it("loads persisted built-in daemon management state", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
@@ -58,7 +68,24 @@ describe("use-settings", () => {
theme: "light",
manageBuiltInDaemon: false,
sendBehavior: "interrupt",
releaseChannel: "stable",
});
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
});
it("loads persisted beta release channel", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
return JSON.stringify({
releaseChannel: "beta",
});
}
return null;
});
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result.releaseChannel).toBe("beta");
});
});

View File

@@ -9,19 +9,23 @@ const APP_SETTINGS_QUERY_KEY = ["app-settings"];
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
export type SendBehavior = "interrupt" | "queue";
export type ReleaseChannel = "stable" | "beta";
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
const VALID_RELEASE_CHANNELS = new Set<string>(["stable", "beta"]);
export interface AppSettings {
theme: ThemeName | "auto";
manageBuiltInDaemon: boolean;
sendBehavior: SendBehavior;
releaseChannel: ReleaseChannel;
}
export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "auto",
manageBuiltInDaemon: true,
sendBehavior: "interrupt",
releaseChannel: "stable",
};
export interface UseAppSettingsReturn {
@@ -85,6 +89,9 @@ export async function loadSettingsFromStorage(): Promise<AppSettings> {
if (parsed.theme && !VALID_THEMES.has(parsed.theme)) {
parsed.theme = DEFAULT_APP_SETTINGS.theme;
}
if (parsed.releaseChannel && !VALID_RELEASE_CHANNELS.has(parsed.releaseChannel)) {
parsed.releaseChannel = DEFAULT_APP_SETTINGS.releaseChannel;
}
return { ...DEFAULT_APP_SETTINGS, ...parsed };
}
@@ -115,6 +122,9 @@ function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<App
if (typeof legacy.manageBuiltInDaemon === "boolean") {
result.manageBuiltInDaemon = legacy.manageBuiltInDaemon;
}
if (legacy.releaseChannel === "stable" || legacy.releaseChannel === "beta") {
result.releaseChannel = legacy.releaseChannel;
}
return result;
}

View File

@@ -281,6 +281,7 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
}
function DesktopAppUpdateRow() {
const { settings, updateSettings } = useAppSettings();
const {
isDesktopApp,
statusText,
@@ -309,6 +310,13 @@ function DesktopAppUpdateRow() {
void checkForUpdates();
}, [checkForUpdates, isDesktopApp]);
const handleReleaseChannelChange = useCallback(
(releaseChannel: AppSettings["releaseChannel"]) => {
void updateSettings({ releaseChannel });
},
[updateSettings],
);
const handleInstallUpdate = useCallback(() => {
if (!isDesktopApp) {
return;
@@ -337,40 +345,59 @@ function DesktopAppUpdateRow() {
}
return (
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>App updates</Text>
<Text style={settingsStyles.rowHint}>{statusText}</Text>
{availableUpdate?.latestVersion ? (
<>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Release channel</Text>
<Text style={settingsStyles.rowHint}>
Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)}
Switch to Beta to get updates sooner and help shape them.
</Text>
) : null}
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
</View>
<View style={styles.aboutUpdateActions}>
<Button
variant="outline"
</View>
<SegmentedControl
size="sm"
onPress={handleCheckForUpdates}
disabled={isChecking || isInstalling}
>
{isChecking ? "Checking..." : "Check"}
</Button>
<Button
variant="default"
size="sm"
onPress={handleInstallUpdate}
disabled={isChecking || isInstalling || !availableUpdate}
>
{isInstalling
? "Installing..."
: availableUpdate?.latestVersion
? `Update to ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
: "Update"}
</Button>
value={settings.releaseChannel}
onValueChange={handleReleaseChannelChange}
options={[
{ value: "stable", label: "Stable" },
{ value: "beta", label: "Beta" },
]}
/>
</View>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>App updates</Text>
<Text style={settingsStyles.rowHint}>{statusText}</Text>
{availableUpdate?.latestVersion ? (
<Text style={settingsStyles.rowHint}>
Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)}
</Text>
) : null}
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
</View>
<View style={styles.aboutUpdateActions}>
<Button
variant="outline"
size="sm"
onPress={handleCheckForUpdates}
disabled={isChecking || isInstalling}
>
{isChecking ? "Checking..." : "Check"}
</Button>
<Button
variant="default"
size="sm"
onPress={handleInstallUpdate}
disabled={isChecking || isInstalling || !availableUpdate}
>
{isInstalling
? "Installing..."
: availableUpdate?.latestVersion
? `Update to ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
: "Update"}
</Button>
</View>
</View>
</>
);
}
@@ -858,9 +885,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
layout="desktop"
/>
<View style={desktopStyles.contentPane}>
{detailHeader ? (
<ScreenHeader
left={
<ScreenHeader
borderless={!detailHeader}
left={
detailHeader ? (
<>
<HeaderIconBadge>
<detailHeader.Icon
@@ -873,10 +901,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
</ScreenTitle>
{detailHeader.titleAccessory}
</>
}
leftStyle={desktopStyles.detailLeft}
/>
) : null}
) : null
}
leftStyle={desktopStyles.detailLeft}
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{ paddingBottom: insets.bottom }}

View File

@@ -19,58 +19,66 @@ type RawWindowControlsPadding = {
type WindowControlsPaddingRole = "sidebar" | "header" | "tabRow" | "explorerSidebar";
// Module-level cache so hook remounts (e.g., on navigation) don't briefly
// fall back to the default `false` while the async fullscreen check resolves.
// Without this, in fullscreen the sidebar flashes with traffic-light padding
// on first frame and then snaps to 0 once the async read completes.
let cachedIsFullscreen = false;
const fullscreenSubscribers = new Set<(value: boolean) => void>();
let fullscreenSubscriptionStarted = false;
function setCachedFullscreen(value: boolean) {
if (cachedIsFullscreen === value) return;
cachedIsFullscreen = value;
for (const sub of fullscreenSubscribers) {
sub(value);
}
}
function startFullscreenSubscription() {
if (fullscreenSubscriptionStarted) return;
if (isNative || !getIsElectronRuntime()) return;
fullscreenSubscriptionStarted = true;
void (async () => {
const win = getDesktopWindow();
if (!win) return;
if (typeof win.isFullscreen === "function") {
try {
setCachedFullscreen(await win.isFullscreen());
} catch (error) {
console.warn("[DesktopWindow] Failed to read fullscreen state", error);
}
}
if (typeof win.onResized !== "function") return;
try {
await win.onResized(async () => {
if (typeof win.isFullscreen !== "function") return;
try {
setCachedFullscreen(await win.isFullscreen());
} catch (error) {
console.warn("[DesktopWindow] Failed to read fullscreen state", error);
}
});
} catch (error) {
console.warn("[DesktopWindow] Failed to subscribe to resize", error);
}
})();
}
function useRawWindowControlsPadding(): RawWindowControlsPadding {
const [isFullscreen, setIsFullscreen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(cachedIsFullscreen);
useEffect(() => {
if (isNative || !getIsElectronRuntime()) return;
let disposed = false;
let cleanup: (() => void) | undefined;
let didCleanup = false;
function runCleanup() {
if (!cleanup || didCleanup) return;
didCleanup = true;
try {
void Promise.resolve(cleanup()).catch((error) => {
console.warn("[DesktopWindow] Failed to remove resize listener", error);
});
} catch (error) {
console.warn("[DesktopWindow] Failed to remove resize listener", error);
}
}
async function setup() {
const win = getDesktopWindow();
if (!win) return;
const fullscreen = typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
if (disposed) return;
setIsFullscreen(fullscreen);
if (typeof win.onResized !== "function") {
return;
}
const unlisten = await win.onResized(async () => {
if (disposed) return;
const fs = typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
if (disposed) return;
setIsFullscreen(fs);
});
cleanup = unlisten;
if (disposed) {
runCleanup();
}
}
void setup();
startFullscreenSubscription();
// Sync to any value that resolved between render and effect.
setIsFullscreen(cachedIsFullscreen);
fullscreenSubscribers.add(setIsFullscreen);
return () => {
disposed = true;
runCleanup();
fullscreenSubscribers.delete(setIsFullscreen);
};
}, []);

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.60-rc.3",
"@getpaseo/server": "0.1.60-rc.3",
"@getpaseo/relay": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -13,8 +13,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.60-rc.3",
"@getpaseo/server": "0.1.60-rc.3",
"@getpaseo/cli": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-beta.1",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"

View File

@@ -11,7 +11,11 @@ import {
readManagedFileBase64,
writeAttachmentBase64,
} from "../features/attachments.js";
import { checkForAppUpdate, downloadAndInstallUpdate } from "../features/auto-updater.js";
import {
checkForAppUpdate,
downloadAndInstallUpdate,
type AppReleaseChannel,
} from "../features/auto-updater.js";
import {
installCli,
getCliInstallStatus,
@@ -65,6 +69,10 @@ type DesktopPairingOffer = {
type DesktopCommandHandler = (args?: Record<string, unknown>) => Promise<unknown> | unknown;
function parseReleaseChannel(args: Record<string, unknown> | undefined): AppReleaseChannel {
return args?.releaseChannel === "beta" ? "beta" : "stable";
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
@@ -483,15 +491,18 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
: "";
if (sessionId) closeLocalTransportSession(sessionId);
},
check_app_update: async () => {
check_app_update: async (args) => {
const currentVersion = await resolveCurrentUpdateVersion();
return checkForAppUpdate(currentVersion);
return checkForAppUpdate({ currentVersion, releaseChannel: parseReleaseChannel(args) });
},
install_app_update: async () => {
install_app_update: async (args) => {
const currentVersion = await resolveCurrentUpdateVersion();
return downloadAndInstallUpdate(currentVersion, async () => {
await stopDaemon();
});
return downloadAndInstallUpdate(
{ currentVersion, releaseChannel: parseReleaseChannel(args) },
async () => {
await stopDaemon();
},
);
},
get_local_daemon_version: () => getLocalDaemonVersion(),
install_cli: () => installCli(),

View File

@@ -20,6 +20,8 @@ export type AppUpdateInstallResult = {
message: string;
};
export type AppReleaseChannel = "stable" | "beta";
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
@@ -28,18 +30,29 @@ let cachedUpdateInfo: UpdateInfo | null = null;
let downloadedUpdateVersion: string | null = null;
let downloading = false;
let autoUpdaterConfigured = false;
let configuredReleaseChannel: AppReleaseChannel | null = null;
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
function configureAutoUpdater(): void {
function configureAutoUpdater(releaseChannel: AppReleaseChannel): void {
// Download updates in the background and only prompt once they are ready to install.
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
// Suppress built-in dialogs; the renderer handles UI.
autoUpdater.autoRunAppAfterInstall = true;
autoUpdater.allowPrerelease = releaseChannel === "beta";
autoUpdater.channel = releaseChannel === "beta" ? "beta" : "latest";
autoUpdater.allowDowngrade = false;
if (configuredReleaseChannel !== releaseChannel) {
cachedUpdateInfo = null;
downloadedUpdateVersion = null;
downloading = false;
configuredReleaseChannel = releaseChannel;
}
if (autoUpdaterConfigured) {
return;
@@ -109,7 +122,13 @@ function scheduleQuitAndInstall(onBeforeQuit?: () => Promise<void>): void {
// Public API
// ---------------------------------------------------------------------------
export async function checkForAppUpdate(currentVersion: string): Promise<AppUpdateCheckResult> {
export async function checkForAppUpdate({
currentVersion,
releaseChannel,
}: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
}): Promise<AppUpdateCheckResult> {
if (!app.isPackaged) {
return buildCheckResult({
currentVersion,
@@ -118,7 +137,7 @@ export async function checkForAppUpdate(currentVersion: string): Promise<AppUpda
});
}
configureAutoUpdater();
configureAutoUpdater(releaseChannel);
const cachedVersion = cachedUpdateInfo?.version ?? null;
if (cachedVersion && cachedVersion !== currentVersion) {
@@ -176,7 +195,13 @@ export async function checkForAppUpdate(currentVersion: string): Promise<AppUpda
}
export async function downloadAndInstallUpdate(
currentVersion: string,
{
currentVersion,
releaseChannel,
}: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
},
onBeforeQuit?: () => Promise<void>,
): Promise<AppUpdateInstallResult> {
if (!app.isPackaged) {
@@ -195,7 +220,7 @@ export async function downloadAndInstallUpdate(
};
}
configureAutoUpdater();
configureAutoUpdater(releaseChannel);
const readyVersion = cachedUpdateInfo.version;
if (isReadyToInstallVersion(readyVersion)) {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"type": "module",
"publishConfig": {
"access": "public"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -60,8 +60,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.60-rc.3",
"@getpaseo/relay": "0.1.60-rc.3",
"@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/relay": "0.1.60-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@mariozechner/pi-coding-agent": "^0.67.68",

View File

@@ -234,7 +234,7 @@ const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50";
function isAppVersionAtLeast(appVersion: string | null, minVersion: string): boolean {
if (!appVersion) return false;
// Strip RC/prerelease suffix: "0.1.45-rc.4" "0.1.45"
// Strip prerelease suffix: "0.1.45-beta.4" -> "0.1.45"
const base = appVersion.replace(/-.*$/, "");
const parts = base.split(".").map(Number);
const minParts = minVersion.split(".").map(Number);

View File

@@ -1089,7 +1089,7 @@ describe("slugify", () => {
});
it("keeps very long names within the hostname label length limit", () => {
const result = slugify("Release Candidate ".repeat(12));
const result = slugify("Beta Build ".repeat(12));
expect(result.length).toBeLessThanOrEqual(63);
expectValidHostnameLabel(result);
@@ -1107,7 +1107,7 @@ describe("slugify", () => {
"feature/cool stuff",
" Café Launch ",
"__bar__",
"Release Candidate ".repeat(12),
"Beta Build ".repeat(12),
"release***candidate",
];

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"private": true,
"type": "module",
"scripts": {

View File

@@ -38,8 +38,9 @@ const entries = [
["RELEASE_BASE_VERSION", info.baseVersion],
["RELEASE_PRERELEASE", info.prerelease ?? ""],
["IS_PRERELEASE", info.isPrerelease ? "true" : "false"],
["IS_RELEASE_CANDIDATE", info.isReleaseCandidate ? "true" : "false"],
["IS_BETA", info.isBeta ? "true" : "false"],
["RELEASE_TYPE", info.releaseType],
["RELEASE_CHANNEL", info.releaseChannel],
["DESKTOP_VERSION", info.version],
["IS_SMOKE_TAG", info.isSmokeTag ? "true" : "false"],
];

View File

@@ -14,7 +14,7 @@ export function parseReleaseVersion(version) {
const match = trimmed.match(versionPattern);
if (!match?.groups) {
throw new Error(
`Unsupported release version "${version}". Expected semver like 0.1.41 or 0.1.41-rc.1.`,
`Unsupported release version "${version}". Expected semver like 0.1.41 or 0.1.41-beta.1.`,
);
}
@@ -22,14 +22,20 @@ export function parseReleaseVersion(version) {
const minor = Number.parseInt(match.groups.minor, 10);
const patch = Number.parseInt(match.groups.patch, 10);
const prerelease = match.groups.prerelease ?? null;
const rcMatch = prerelease?.match(/^rc\.(?<rc>\d+)$/) ?? null;
const rcNumber = rcMatch?.groups?.rc ? Number.parseInt(rcMatch.groups.rc, 10) : null;
const betaMatch = prerelease?.match(/^beta\.(?<beta>\d+)$/) ?? null;
const betaNumber = betaMatch?.groups?.beta ? Number.parseInt(betaMatch.groups.beta, 10) : null;
if (prerelease !== null && betaNumber === null) {
throw new Error(
`Unsupported release version "${version}". Expected beta prerelease versions like 0.1.41-beta.1.`,
);
}
assertInteger(major, "major version");
assertInteger(minor, "minor version");
assertInteger(patch, "patch version");
if (rcNumber !== null) {
assertInteger(rcNumber, "release candidate number");
if (betaNumber !== null) {
assertInteger(betaNumber, "beta number");
}
return {
@@ -40,8 +46,8 @@ export function parseReleaseVersion(version) {
prerelease,
baseVersion: `${major}.${minor}.${patch}`,
isPrerelease: prerelease !== null,
isReleaseCandidate: rcNumber !== null,
rcNumber,
isBeta: betaNumber !== null,
betaNumber,
};
}
@@ -57,7 +63,7 @@ export function normalizeReleaseTag(rawTag) {
const match = trimmed.match(sourceTagPattern);
if (!match?.groups?.version) {
throw new Error(
`Unsupported release tag "${rawTag}". Expected vX.Y.Z, vX.Y.Z-rc.N, desktop-v..., or android-v...`,
`Unsupported release tag "${rawTag}". Expected vX.Y.Z, vX.Y.Z-beta.N, desktop-v..., or android-v...`,
);
}
return `v${match.groups.version}`;
@@ -73,9 +79,10 @@ export function getReleaseInfoFromSourceTag(sourceTag) {
baseVersion: parsed.baseVersion,
prerelease: parsed.prerelease,
isPrerelease: parsed.isPrerelease,
isReleaseCandidate: parsed.isReleaseCandidate,
rcNumber: parsed.rcNumber,
isBeta: parsed.isBeta,
betaNumber: parsed.betaNumber,
releaseType: parsed.isPrerelease ? "prerelease" : "release",
releaseChannel: parsed.isBeta ? "beta" : "latest",
isSmokeTag: sourceTag.includes("gha-smoke"),
};
}
@@ -110,54 +117,54 @@ export function computeNextReleaseVersion(currentVersion, mode) {
});
}
if (mode === "rc-patch" || mode === "rc-minor" || mode === "rc-major") {
if (mode === "beta-patch" || mode === "beta-minor" || mode === "beta-major") {
if (parsed.isPrerelease) {
throw new Error(
`Cannot start a new RC line from prerelease version ${currentVersion}. Use rc-next or promote.`,
`Cannot start a new beta line from prerelease version ${currentVersion}. Use beta-next or promote.`,
);
}
if (mode === "rc-patch") {
if (mode === "beta-patch") {
return formatReleaseVersion({
major: parsed.major,
minor: parsed.minor,
patch: parsed.patch + 1,
prerelease: "rc.1",
prerelease: "beta.1",
});
}
if (mode === "rc-minor") {
if (mode === "beta-minor") {
return formatReleaseVersion({
major: parsed.major,
minor: parsed.minor + 1,
patch: 0,
prerelease: "rc.1",
prerelease: "beta.1",
});
}
return formatReleaseVersion({
major: parsed.major + 1,
minor: 0,
patch: 0,
prerelease: "rc.1",
prerelease: "beta.1",
});
}
if (mode === "rc-next") {
if (!parsed.isReleaseCandidate || parsed.rcNumber === null) {
if (mode === "beta-next") {
if (!parsed.isBeta || parsed.betaNumber === null) {
throw new Error(
`Cannot advance RC number from ${currentVersion}. Expected a version like 0.1.41-rc.1.`,
`Cannot advance beta number from ${currentVersion}. Expected a version like 0.1.41-beta.1.`,
);
}
return formatReleaseVersion({
major: parsed.major,
minor: parsed.minor,
patch: parsed.patch,
prerelease: `rc.${parsed.rcNumber + 1}`,
prerelease: `beta.${parsed.betaNumber + 1}`,
});
}
if (mode === "promote") {
if (!parsed.isReleaseCandidate) {
if (!parsed.isBeta) {
throw new Error(
`Cannot promote ${currentVersion}. Expected a release candidate version like 0.1.41-rc.1.`,
`Cannot promote ${currentVersion}. Expected a beta version like 0.1.41-beta.1.`,
);
}
return parsed.baseVersion;

View File

@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
computeNextReleaseVersion,
getReleaseInfoFromSourceTag,
parseReleaseVersion,
} from "./release-version-utils.mjs";
test("computes the next beta patch from a stable version", () => {
assert.equal(computeNextReleaseVersion("0.1.59", "beta-patch"), "0.1.60-beta.1");
});
test("advances beta versions", () => {
assert.equal(computeNextReleaseVersion("0.1.60-beta.1", "beta-next"), "0.1.60-beta.2");
});
test("promotes beta versions to stable", () => {
assert.equal(computeNextReleaseVersion("0.1.60-beta.2", "promote"), "0.1.60");
});
test("parses beta release metadata", () => {
assert.deepEqual(parseReleaseVersion("0.1.60-beta.1"), {
version: "0.1.60-beta.1",
major: 0,
minor: 1,
patch: 60,
prerelease: "beta.1",
baseVersion: "0.1.60",
isPrerelease: true,
isBeta: true,
betaNumber: 1,
});
});
test("emits beta release info from tags", () => {
assert.deepEqual(getReleaseInfoFromSourceTag("v0.1.60-beta.1"), {
sourceTag: "v0.1.60-beta.1",
releaseTag: "v0.1.60-beta.1",
version: "0.1.60-beta.1",
baseVersion: "0.1.60",
prerelease: "beta.1",
isPrerelease: true,
isBeta: true,
betaNumber: 1,
releaseType: "prerelease",
releaseChannel: "beta",
isSmokeTag: false,
});
});
test("rejects non-beta prerelease versions", () => {
assert.throws(() => parseReleaseVersion("0.1.60-canary.1"), /Expected beta prerelease versions/);
});

View File

@@ -11,7 +11,7 @@ const rootPackagePath = path.join(rootDir, "package.json");
function usageAndExit(code = 1) {
process.stderr.write(`Usage: node scripts/set-release-version.mjs --mode <mode> [--print]\n`);
process.stderr.write(
"Modes: patch, minor, major, rc-patch, rc-minor, rc-major, rc-next, promote\n",
"Modes: patch, minor, major, beta-patch, beta-minor, beta-major, beta-next, promote\n",
);
process.exit(code);
}

View File

@@ -139,24 +139,6 @@ function updateReleaseNotes({ releaseId, repo, notesPath }, execFileSync = nodeE
);
}
function buildPrereleaseNotes({ releaseTag, version }) {
const today = new Date().toISOString().slice(0, 10);
const lines = [
`## ${version} - ${today}`,
"",
"Release candidate build for testing from `main`.",
"",
`- Tag: \`${releaseTag}\``,
];
if (process.env.GITHUB_SHA) {
lines.push(`- Commit: \`${process.env.GITHUB_SHA}\``);
}
lines.push("- This prerelease does not replace the website's latest stable download.");
return `${lines.join("\n")}\n`;
}
export function syncReleaseNotes(argv = process.argv.slice(2), deps = {}) {
const execFileSync = deps.execFileSync ?? nodeExecFileSync;
const args = parseArgs(argv);
@@ -170,13 +152,6 @@ export function syncReleaseNotes(argv = process.argv.slice(2), deps = {}) {
let notes = targetEntry?.notes ?? null;
if (!notes && releaseInfo.isPrerelease) {
notes = buildPrereleaseNotes({
releaseTag: releaseInfo.releaseTag,
version: releaseInfo.version,
});
}
if (!notes) {
console.log(`No matching changelog section found for ${targetTag}. Skipping.`);
return;

View File

@@ -9,7 +9,7 @@ function withTempChangelog(fn) {
const previousCwd = process.cwd();
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-release-notes-test-"));
process.chdir(tempDir);
writeFileSync("CHANGELOG.md", "## 0.1.60 - 2026-04-20\n\n- Stable notes.\n");
writeFileSync("CHANGELOG.md", "## 0.1.60-beta.1 - 2026-04-20\n\n- Beta notes.\n");
try {
fn();
@@ -26,7 +26,7 @@ test("updates an existing release body through the release id API", () => {
const execFileSync = (command, args, options) => {
calls.push({ args, command, options });
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases/tags/v0.1.60-rc.3") {
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases/tags/v0.1.60-beta.1") {
return JSON.stringify({ id: 311163621 });
}
@@ -34,14 +34,14 @@ test("updates an existing release body through the release id API", () => {
const notesArg = args.find((arg) => arg.startsWith("body=@"));
assert.ok(notesArg, "PATCH should send the notes body from a file");
const notesPath = notesArg.slice("body=@".length);
assert.match(notesPath, /v0\.1\.60-rc\.3-notes\.md$/);
assert.match(notesPath, /v0\.1\.60-beta\.1-notes\.md$/);
return "";
}
throw new Error(`Unexpected gh call: ${command} ${args.join(" ")}`);
};
syncReleaseNotes(["--repo", "getpaseo/paseo", "--tag", "v0.1.60-rc.3"], {
syncReleaseNotes(["--repo", "getpaseo/paseo", "--tag", "v0.1.60-beta.1"], {
execFileSync,
});