feat: add beta/stable release tracks with switchable update channel (#2345)

## Summary

Fusion can now ship on two release tracks. Betas are cut from `main` as
`vX.Y.Z-beta.N` (npm dist-tag `beta`, GitHub prerelease), stable
releases are promoted to a long-lived `release` branch and published to
`latest`, and users pick their track with the new `updateChannel` global
setting — via **Settings → General → Release channel** or `fn update
--channel <stable|beta>`. Previously everything was single-track: every
publish landed on `latest` and every update surface could only see it.

| | beta | stable |
|---|---|---|
| Cut from | `main` | `release` branch |
| Version | `X.Y.Z-beta.N` (changesets pre-mode) | `X.Y.Z` |
| npm dist-tag | `beta` | `latest` |
| GitHub Release | prerelease | latest |
| Homebrew tap / X draft | skipped | bumped / printed |

## How releasing works now

`pnpm release` prompts for the channel and **defaults to beta**, so
day-to-day releases are betas; stable is always an explicit choice.
Choosing stable from `main` triggers assisted promotion: the script
proposes the newest beta tag reachable from HEAD, verifies `release`
fast-forwards to it, then runs the whole stable release inside a
temporary git worktree on `release` — the primary checkout never leaves
`main`. Changesets pre-mode preserves changeset files across betas, so
the promoted stable release aggregates every changeset since the last
stable into one clean changelog entry.

## Design decisions

- **Every publish path names an explicit `--tag`.** A beta accidentally
landing on `latest` is the one unrecoverable failure of a dual-track
scheme, so nothing relies on npm's implicit default (`release.mjs`,
`version.yml`).
- **Beta channel resolves to semver-max of `latest` and `beta`**, so
beta users are offered each promoted stable once it overtakes their
prerelease. Switching beta → stable never downgrades; `fn update
--channel stable --force` is the explicit escape hatch.
- **One comparator instead of three.** CLI, dashboard, and desktop each
had their own `isRemoteNewer` that ignored prerelease identifiers —
`0.73.0-beta.2`, `-beta.3`, and `0.73.0` all compared equal, which
breaks the moment any beta exists. They now share full SemVer-precedence
helpers (`compareVersions`, `resolveUpdateTargetVersion`) from
`@fusion/core`.
- **Installs pin exact versions** (`@runfusion/fusion@0.73.0-beta.2`),
never a dist-tag, so an install can't silently land on the wrong track.
- **Desktop channels via electron-updater manifests.** Beta tags build
desktop artifacts with `publish.channel=beta` (emitting `beta*.yml`);
the app sets `channel`/`allowPrerelease` from the shared setting,
re-read on every manual check.
- **Update caches are channel-stamped** — a cache written for one
channel is never served to the other, so switching tracks takes effect
on the next check instead of after TTL.

## Test plan

- New unit coverage: SemVer precedence + channel resolution in
`@fusion/core` (30), channel behavior of the dashboard update check (28,
incl. 9 new) and `fn update` (16, incl. 8 new: persist `--channel`,
no-downgrade, `--force`, cache channel mismatch).
- `pnpm verify:fast` green (scoped typecheck, builds, CLI build, boot
smoke); desktop + settings-section suites green.
- `release.mjs` dry-run matrix exercised by hand: channel prompt
(default/override/invalid), branch preflights per channel,
assisted-promotion target selection, fast-forward guard against a
diverged `release` branch, and bootstrap when no `release` branch
exists.
- Not exercised live: an end-to-end publish (needs TTY authorization +
real npm publish). First real run is the first `pnpm release --channel
beta`.

---

[![Compound
Engineering](https://img.shields.io/badge/Built_with-Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
![Claude
Code](https://img.shields.io/badge/Fable_5-D97757?logo=claude&logoColor=white)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added beta and stable release channels across CLI, dashboard, and
desktop updates.
* Users can select a channel via Settings or `fn update --channel
<stable|beta>` (stored as a global default).
* Desktop beta releases now generate beta update manifests and publish
as prereleases.
* **Documentation**
* Expanded release-track, settings, and CLI references to explain
channel semantics and workflows.
* **Bug Fixes**
* Updates now pin the resolved version per channel, improve version
comparison, and prevent unintended cross-channel downgrades unless
`--force` is used.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-19 13:34:25 -07:00
committed by GitHub
parent 048ae909fd
commit 2302fb8a3d
25 changed files with 1270 additions and 138 deletions

View File

@@ -194,7 +194,10 @@ jobs:
# Use `exec electron-builder` rather than the `dist:win` script: pnpm leaks
# the `--` separator into script args (electron-builder then stops parsing
# at `--` and ignores `--publish never`, auto-publishing to the wrong repo).
run: pnpm --filter @fusion/desktop exec electron-builder --projectDir deploy --win --publish never
# FNXC:UpdateChannels 2026-07-19-13:30: beta tags (v*-beta.N) build with
# publish.channel=beta so electron-builder emits beta*.yml update manifests;
# beta-channel desktop installs read those, stable installs keep latest*.yml.
run: pnpm --filter @fusion/desktop exec electron-builder --projectDir deploy --win --publish never ${{ contains(github.ref_name, '-beta') && '-c.publish.channel=beta' || '' }}
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -226,6 +229,7 @@ jobs:
packages/desktop/dist-electron/Fusion-*-win-*.exe.sha256
packages/desktop/dist-electron/Fusion-*-win-*.exe.blockmap
packages/desktop/dist-electron/latest.yml
packages/desktop/dist-electron/beta.yml
# ── Build macOS desktop artifacts ────────────────────────────────────
build-desktop-macos:
@@ -252,7 +256,8 @@ jobs:
- name: Package signed macOS desktop DMG/ZIP
if: ${{ env.APPLE_CERTIFICATE_BASE64 != '' }}
run: pnpm --filter @fusion/desktop exec electron-builder --projectDir deploy --mac --publish never
# FNXC:UpdateChannels 2026-07-19-13:30: beta tags emit beta*.yml manifests (see Windows leg).
run: pnpm --filter @fusion/desktop exec electron-builder --projectDir deploy --mac --publish never ${{ contains(github.ref_name, '-beta') && '-c.publish.channel=beta' || '' }}
env:
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
@@ -265,7 +270,7 @@ jobs:
- name: Package unsigned macOS desktop DMG/ZIP
if: ${{ env.APPLE_CERTIFICATE_BASE64 == '' }}
run: pnpm --filter @fusion/desktop exec electron-builder --projectDir deploy --mac --publish never -c.mac.notarize=false
run: pnpm --filter @fusion/desktop exec electron-builder --projectDir deploy --mac --publish never -c.mac.notarize=false ${{ contains(github.ref_name, '-beta') && '-c.publish.channel=beta' || '' }}
env:
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
CSC_IDENTITY_AUTO_DISCOVERY: "false"
@@ -330,6 +335,7 @@ jobs:
packages/desktop/dist-electron/Fusion-*-mac-*.zip.sha256
packages/desktop/dist-electron/Fusion-*-mac-*.blockmap
packages/desktop/dist-electron/latest-mac.yml
packages/desktop/dist-electron/beta-mac.yml
# ── Build Linux desktop artifacts ────────────────────────────────────
build-desktop-linux:
@@ -356,7 +362,8 @@ jobs:
- name: Package Linux desktop artifacts
# Linux desktop code-signing is deferred to FN-5605; Linux ARM64 CLI binaries are tracked in FN-5606.
run: pnpm --filter @fusion/desktop exec electron-builder --projectDir deploy --linux --x64 --arm64 --publish never
# FNXC:UpdateChannels 2026-07-19-13:30: beta tags emit beta*.yml manifests (see Windows leg).
run: pnpm --filter @fusion/desktop exec electron-builder --projectDir deploy --linux --x64 --arm64 --publish never ${{ contains(github.ref_name, '-beta') && '-c.publish.channel=beta' || '' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -425,6 +432,7 @@ jobs:
packages/desktop/dist-electron/Fusion-*-linux-*.tar.gz.sha256
packages/desktop/dist-electron/Fusion-*-linux-*.tar.gz.asc
packages/desktop/dist-electron/latest-linux.yml
packages/desktop/dist-electron/beta-linux.yml
# ── Build Android APK/AAB artifacts ──────────────────────────────────
@@ -619,7 +627,7 @@ jobs:
# The self-contained fn-cli-<platform>.tar.gz (+ .sha256) matches the
# existing *.tar.gz globs and reaches the release alongside the bare
# fn-cli-* binaries.
find artifacts \( -path "*/runtime/*" -o -path "*/migrations/*" \) -prune -o -type f \( -name "fn-*" -o -name "*.sha256" -o -name "*.asc" -o -name "*.exe" -o -name "*.exe.sha256" -o -name "*.blockmap" -o -name "*.dmg" -o -name "*.dmg.sha256" -o -name "*.zip" -o -name "*.zip.sha256" -o -name "*.apk" -o -name "*.aab" -o -name "*.AppImage" -o -name "*.AppImage.sha256" -o -name "*.deb" -o -name "*.deb.sha256" -o -name "*.tar.gz" -o -name "*.tar.gz.sha256" -o -name "latest*.yml" \) -print -exec cp {} release-files/ \;
find artifacts \( -path "*/runtime/*" -o -path "*/migrations/*" \) -prune -o -type f \( -name "fn-*" -o -name "*.sha256" -o -name "*.asc" -o -name "*.exe" -o -name "*.exe.sha256" -o -name "*.blockmap" -o -name "*.dmg" -o -name "*.dmg.sha256" -o -name "*.zip" -o -name "*.zip.sha256" -o -name "*.apk" -o -name "*.aab" -o -name "*.AppImage" -o -name "*.AppImage.sha256" -o -name "*.deb" -o -name "*.deb.sha256" -o -name "*.tar.gz" -o -name "*.tar.gz.sha256" -o -name "latest*.yml" -o -name "beta*.yml" \) -print -exec cp {} release-files/ \;
ls -la release-files/
count=$(find release-files -type f | wc -l | tr -d ' ')
echo "count=$count" >> "$GITHUB_OUTPUT"
@@ -660,10 +668,17 @@ jobs:
")
echo "$NOTES" > /tmp/release-notes.md
# FNXC:UpdateChannels 2026-07-19-13:30:
# Beta tags (vX.Y.Z-beta.N, cut from main by `pnpm release --channel beta`)
# must be GitHub PRERELEASES: the desktop stable auto-updater and the
# /releases/latest URL follow the "latest" release, which GitHub only
# assigns to non-prerelease releases. The tag name is the single source
# of truth so tag-push-triggered binary builds do the right thing.
- name: Create GitHub Release
if: ${{ steps.collect.outputs.count != '0' }}
uses: softprops/action-gh-release@v2
with:
body_path: /tmp/release-notes.md
fail_on_unmatched_files: true
prerelease: ${{ contains(github.ref_name, '-beta') }}
files: release-files/*

View File

@@ -2,6 +2,15 @@
#
# Uses npm OIDC trusted publishing — no NPM_TOKEN secret needed.
# Requires npm 11.5.1+ for OIDC support.
#
# FNXC:UpdateChannels 2026-07-19-13:30:
# STABLE-CHANNEL ONLY. This workflow publishes with npm's implicit `latest`
# dist-tag and must never run for a beta: betas are cut from `main` by
# `pnpm release --channel beta`, which publishes with an explicit `--tag beta`
# (see scripts/release.mjs and docs/plans/2026-07-19-001-beta-stable-release-tracks-plan.md).
# If beta publishing ever moves to CI, this workflow needs a channel input that
# threads `--tag beta` into the publish command — do not dispatch it as-is from
# a pre-mode (.changeset/pre.json) checkout.
name: Version & Release
@@ -42,7 +51,8 @@ jobs:
uses: changesets/action@v1
with:
version: pnpm release:version
publish: pnpm -r publish --provenance --access public
# Explicit --tag latest: every publish path names its dist-tag (see channel note above).
publish: pnpm -r publish --provenance --access public --tag latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_CONFIG_PROVENANCE: true