From dfc814147a7e479089507a868cac043ddec5ae33 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 20 Apr 2026 20:38:56 +0700 Subject: [PATCH] feat: add beta release channel --- .github/workflows/deploy-app.yml | 4 +- .github/workflows/desktop-release.yml | 14 ++- CHANGELOG.md | 5 + docs/ANDROID.md | 2 +- docs/RELEASE.md | 53 ++++----- package-lock.json | 38 +++---- package.json | 18 +-- packages/app/package.json | 8 +- .../src/components/ui/segmented-control.tsx | 40 ++++--- .../src/desktop/updates/desktop-updates.ts | 18 ++- .../updates/use-desktop-app-updater.ts | 19 +++- packages/app/src/hooks/use-settings.test.ts | 27 +++++ packages/app/src/hooks/use-settings.ts | 10 ++ packages/app/src/screens/settings-screen.tsx | 102 ++++++++++------- packages/app/src/utils/desktop-window.ts | 104 ++++++++++-------- packages/cli/package.json | 6 +- packages/desktop/package.json | 6 +- packages/desktop/src/daemon/daemon-manager.ts | 25 +++-- packages/desktop/src/features/auto-updater.ts | 35 +++++- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 6 +- packages/server/src/server/session.ts | 2 +- packages/server/src/utils/worktree.test.ts | 4 +- packages/website/package.json | 2 +- scripts/emit-release-env.mjs | 3 +- scripts/release-version-utils.mjs | 53 +++++---- scripts/release-version-utils.test.mjs | 53 +++++++++ scripts/set-release-version.mjs | 2 +- scripts/sync-release-notes-from-changelog.mjs | 25 ----- ...sync-release-notes-from-changelog.test.mjs | 8 +- 32 files changed, 442 insertions(+), 256 deletions(-) create mode 100644 scripts/release-version-utils.test.mjs diff --git a/.github/workflows/deploy-app.yml b/.github/workflows/deploy-app.yml index 2c25b909d..5a8b47a70 100644 --- a/.github/workflows/deploy-app.yml +++ b/.github/workflows/deploy-app.yml @@ -4,9 +4,9 @@ on: push: tags: - 'v*' - - '!v*-rc.*' + - '!v*-beta.*' - 'app-v*' - - '!app-v*-rc.*' + - '!app-v*-beta.*' workflow_dispatch: jobs: diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 29badd266..298dacfd3 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -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[@]}" diff --git a/CHANGELOG.md b/CHANGELOG.md index c136e6ae1..85e79ae29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/ANDROID.md b/docs/ANDROID.md index fd6fa1db2..2c432b418 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -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 diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 6a838f66e..16c5ad183 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -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 diff --git a/package-lock.json b/package-lock.json index eb29ebcdb..1aa1a8f61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b41bf64b7..6aa78cd6a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/app/package.json b/packages/app/package.json index e70d56555..472f52960 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/components/ui/segmented-control.tsx b/packages/app/src/components/ui/segmented-control.tsx index 0af7efe5f..24d1c5f0e 100644 --- a/packages/app/src/components/ui/segmented-control.tsx +++ b/packages/app/src/components/ui/segmented-control.tsx @@ -35,12 +35,13 @@ export function SegmentedControl({ testID, }: SegmentedControlProps) { 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 ( - + {options.map((option) => { const isSelected = option.value === value; const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted; @@ -89,39 +90,46 @@ export function SegmentedControl({ 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], diff --git a/packages/app/src/desktop/updates/desktop-updates.ts b/packages/app/src/desktop/updates/desktop-updates.ts index 482a5de81..4216b5b48 100644 --- a/packages/app/src/desktop/updates/desktop-updates.ts +++ b/packages/app/src/desktop/updates/desktop-updates.ts @@ -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 return parseLocalDaemonVersionResult(result); } -export async function checkDesktopAppUpdate(): Promise { - const result = await invokeDesktopCommand("check_app_update"); +export async function checkDesktopAppUpdate({ + releaseChannel, +}: { + releaseChannel: DesktopReleaseChannel; +}): Promise { + const result = await invokeDesktopCommand("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 { - const result = await invokeDesktopCommand("install_app_update"); +export async function installDesktopAppUpdate({ + releaseChannel, +}: { + releaseChannel: DesktopReleaseChannel; +}): Promise { + const result = await invokeDesktopCommand("install_app_update", { releaseChannel }); if (!isRecord(result)) { throw new Error("Unexpected response while installing desktop update."); } diff --git a/packages/app/src/desktop/updates/use-desktop-app-updater.ts b/packages/app/src/desktop/updates/use-desktop-app-updater.ts index 65fe922eb..53c27494e 100644 --- a/packages/app/src/desktop/updates/use-desktop-app-updater.ts +++ b/packages/app/src/desktop/updates/use-desktop-app-updater.ts @@ -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("idle"); const [availableUpdate, setAvailableUpdate] = useState(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, diff --git a/packages/app/src/hooks/use-settings.test.ts b/packages/app/src/hooks/use-settings.test.ts index 84eae3479..e8a9d4914 100644 --- a/packages/app/src/hooks/use-settings.test.ts +++ b/packages/app/src/hooks/use-settings.test.ts @@ -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"); + }); }); diff --git a/packages/app/src/hooks/use-settings.ts b/packages/app/src/hooks/use-settings.ts index 1c3793e49..e88ab2327 100644 --- a/packages/app/src/hooks/use-settings.ts +++ b/packages/app/src/hooks/use-settings.ts @@ -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([...Object.keys(THEME_TO_UNISTYLES), "auto"]); +const VALID_RELEASE_CHANNELS = new Set(["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 { 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): Partial { + void updateSettings({ releaseChannel }); + }, + [updateSettings], + ); + const handleInstallUpdate = useCallback(() => { if (!isDesktopApp) { return; @@ -337,40 +345,59 @@ function DesktopAppUpdateRow() { } return ( - - - App updates - {statusText} - {availableUpdate?.latestVersion ? ( + <> + + + Release channel - Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)} + Switch to Beta to get updates sooner and help shape them. - ) : null} - {errorMessage ? {errorMessage} : null} - - - - + value={settings.releaseChannel} + onValueChange={handleReleaseChannelChange} + options={[ + { value: "stable", label: "Stable" }, + { value: "beta", label: "Beta" }, + ]} + /> - + + + App updates + {statusText} + {availableUpdate?.latestVersion ? ( + + Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)} + + ) : null} + {errorMessage ? {errorMessage} : null} + + + + + + + ); } @@ -858,9 +885,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { layout="desktop" /> - {detailHeader ? ( - {detailHeader.titleAccessory} - } - leftStyle={desktopStyles.detailLeft} - /> - ) : null} + ) : null + } + leftStyle={desktopStyles.detailLeft} + /> 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); }; }, []); diff --git a/packages/cli/package.json b/packages/cli/package.json index 96c6767b7..9b9110ad6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index c70a6d742..57efa7e41 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -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" diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 2d1496138..57571503b 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -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) => Promise | unknown; +function parseReleaseChannel(args: Record | undefined): AppReleaseChannel { + return args?.releaseChannel === "beta" ? "beta" : "stable"; +} + // --------------------------------------------------------------------------- // Utilities // --------------------------------------------------------------------------- @@ -483,15 +491,18 @@ export function createDaemonCommandHandlers(): Record { + 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(), diff --git a/packages/desktop/src/features/auto-updater.ts b/packages/desktop/src/features/auto-updater.ts index 61ecee1d9..455677334 100644 --- a/packages/desktop/src/features/auto-updater.ts +++ b/packages/desktop/src/features/auto-updater.ts @@ -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 { // Public API // --------------------------------------------------------------------------- -export async function checkForAppUpdate(currentVersion: string): Promise { +export async function checkForAppUpdate({ + currentVersion, + releaseChannel, +}: { + currentVersion: string; + releaseChannel: AppReleaseChannel; +}): Promise { if (!app.isPackaged) { return buildCheckResult({ currentVersion, @@ -118,7 +137,7 @@ export async function checkForAppUpdate(currentVersion: string): Promise Promise, ): Promise { if (!app.isPackaged) { @@ -195,7 +220,7 @@ export async function downloadAndInstallUpdate( }; } - configureAutoUpdater(); + configureAutoUpdater(releaseChannel); const readyVersion = cachedUpdateInfo.version; if (isReadyToInstallVersion(readyVersion)) { diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 9bfc8c417..3f45fd485 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -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", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index a0b6526b8..e4827d7bb 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.60-rc.3", + "version": "0.1.60-beta.1", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index 5aa71a598..f068ccb5b 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -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": { diff --git a/packages/server/package.json b/packages/server/package.json index 74b2a5c28..c7a93b899 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 35c5e090b..218e43f34 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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); diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 87d024d1b..50dfb9e85 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -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", ]; diff --git a/packages/website/package.json b/packages/website/package.json index 79ccc34a8..29a7db11d 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.60-rc.3", + "version": "0.1.60-beta.1", "private": true, "type": "module", "scripts": { diff --git a/scripts/emit-release-env.mjs b/scripts/emit-release-env.mjs index ca4bd60f3..50327cade 100644 --- a/scripts/emit-release-env.mjs +++ b/scripts/emit-release-env.mjs @@ -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"], ]; diff --git a/scripts/release-version-utils.mjs b/scripts/release-version-utils.mjs index a21c40365..a38d7ae48 100644 --- a/scripts/release-version-utils.mjs +++ b/scripts/release-version-utils.mjs @@ -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\.(?\d+)$/) ?? null; - const rcNumber = rcMatch?.groups?.rc ? Number.parseInt(rcMatch.groups.rc, 10) : null; + const betaMatch = prerelease?.match(/^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; diff --git a/scripts/release-version-utils.test.mjs b/scripts/release-version-utils.test.mjs new file mode 100644 index 000000000..6a435f6dd --- /dev/null +++ b/scripts/release-version-utils.test.mjs @@ -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/); +}); diff --git a/scripts/set-release-version.mjs b/scripts/set-release-version.mjs index 03908f49e..0b6976ca8 100644 --- a/scripts/set-release-version.mjs +++ b/scripts/set-release-version.mjs @@ -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 [--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); } diff --git a/scripts/sync-release-notes-from-changelog.mjs b/scripts/sync-release-notes-from-changelog.mjs index c83d77b5a..891b87713 100644 --- a/scripts/sync-release-notes-from-changelog.mjs +++ b/scripts/sync-release-notes-from-changelog.mjs @@ -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; diff --git a/scripts/sync-release-notes-from-changelog.test.mjs b/scripts/sync-release-notes-from-changelog.test.mjs index 6c7720649..534ec9df6 100644 --- a/scripts/sync-release-notes-from-changelog.test.mjs +++ b/scripts/sync-release-notes-from-changelog.test.mjs @@ -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, });