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

@@ -126,23 +126,34 @@ onboarding does not auto-launch.
## `fn update`
Check for and install the latest `@runfusion/fusion` CLI release from npm.
<!--
FNXC:UpdateChannels 2026-07-19-16:20:
User-facing update-channel contract: `--channel` persists the chosen track to the shared `updateChannel` global setting; stable resolves the npm `latest` dist-tag only while beta resolves the newer of `latest` and `beta`; switching beta → stable never downgrades and `--force` is the sole explicit downgrade path; installs always pin the exact resolved version, never a dist-tag.
Keep this comment in sync with packages/cli/src/commands/update.ts when the contract changes.
-->
Check for and install the latest `@runfusion/fusion` CLI release from npm, on the configured release channel.
```bash
fn update
fn update --check
fn update --global
fn update --json
fn update --channel beta # switch to the beta track and update onto it
fn update --channel stable # switch back to stable (no downgrade; see --force)
fn update --channel stable --force # explicit downgrade onto the current stable
fn upgrade
```
| Option | Description |
|---|---|
| `--check` | Check only. Does not install. Exit code `1` when an update is available. |
| `--global` | Explicitly install globally (`npm install -g @runfusion/fusion@latest`). This is the default behavior. |
| `--json` | Output machine-readable status: `currentVersion`, `latestVersion`, `updateAvailable`, `updated`. |
| `--global` | Explicitly install globally (`npm install -g @runfusion/fusion@<version>`). This is the default behavior. |
| `--json` | Output machine-readable status: `currentVersion`, `latestVersion`, `updateAvailable`, `updated`, `channel`. |
| `--channel <stable\|beta>` | Select the release track and persist it to global settings (`updateChannel`), shared with the dashboard and desktop updater. `stable` follows the npm `latest` dist-tag; `beta` follows the newer of `latest` and `beta`. |
| `--force` | Install the resolved channel target even when it is not newer than the current version — the explicit beta → stable downgrade path. |
`fn upgrade` is an alias for `fn update`.
`fn upgrade` is an alias for `fn update`. Installs always pin the exact resolved version rather than a dist-tag, so a beta-channel install can never silently land on stable (or vice versa).
---

View File

@@ -0,0 +1,98 @@
# Beta + Stable Release Tracks Plan
Date: 2026-07-19
Status: implemented (branch `feature/beta-stable-release-tracks-2`; Phases 1–3 landed, `release` branch bootstrap pending first stable promotion)
## Goal
Ship `@runfusion/fusion` on two tracks users can switch between:
- **beta** — cut from `main`, published to the npm `beta` dist-tag, tagged `vX.Y.Z-beta.N`, marked *prerelease* on GitHub.
- **stable** — cut from a long-lived `release` branch, published to `latest`, tagged `vX.Y.Z`, marked *latest* on GitHub, Homebrew tap bumped.
Users pick a channel via a new `updateChannel` setting consumed by all three update surfaces (CLI `fn update`, dashboard update check, desktop electron-updater).
## Current state (surveyed 2026-07-19)
- Everything publishes to `latest`: `scripts/release.mjs:719-720` (`pnpm -r publish --access public --no-git-checks`, no `--tag`), `version.yml:45`, and `release.mjs:756` hardcodes `gh release create … --latest`.
- `release.yml` (binary workflow, tag-push triggered) never sets `prerelease:` on `softprops/action-gh-release`.
- Changesets: single `fixed` group keeps all packages lockstep (currently 0.72.0); **pre-mode is never used** (no `.changeset/pre.json`).
- Update surfaces all hardcode `dist-tags.latest`:
- CLI: `packages/cli/src/commands/update.ts` (`fetchLatestVersion`, installs `@runfusion/fusion@latest`), cache in `packages/cli/src/update-cache.ts`, startup banner in `commands/dashboard.ts`.
- Dashboard: `packages/dashboard/src/update-check.ts` — note `isRemoteNewer` compares only major.minor.patch and **ignores prerelease identifiers**.
- Desktop: `packages/desktop/src/native.ts` `setupAutoUpdater()` with no `channel`/`allowPrerelease`; feed in `packages/desktop/deploy/electron-builder.yml`.
- No `releaseChannel`/`updateChannel` setting exists (`packages/core/src/settings-schema.ts` has only `updateCheckEnabled`, `updateCheckFrequency`).
## Design
### Branch & version model
- `main` — development + beta releases. Lives in changesets **pre-mode** (`.changeset/pre.json`, tag `beta`) whenever there is unreleased work; beta releases version to `X.Y.Z-beta.N`.
- `release` — new long-lived branch, stable releases only. Created once from current `main`.
Flows:
1. **Beta release (from `main`)**: `pnpm release --channel beta`. Script enters pre-mode if not already in it (`changeset pre enter beta`), runs `changeset version` (→ `0.73.0-beta.0`, `-beta.1`, …), publishes with `--tag beta`, tags `v0.73.0-beta.N`, creates a GitHub **prerelease**. No Homebrew bump, no tweet distill.
2. **Promotion to stable**: merge/fast-forward `release` to the chosen beta's commit on `main`, then on `release`: `pnpm release` (stable channel). Script runs `changeset pre exit`, `changeset version` (→ clean `0.73.0` with the aggregated changelog changesets accumulated across the betas), publishes to `latest`, tags `v0.73.0`, GitHub release `--latest`, bumps the Homebrew tap. Finally **back-merge `release` → `main`** so main picks up the consumed changesets, changelogs, version bump, and the pre.json deletion (next beta re-enters pre-mode automatically).
3. **Hotfix**: commit directly on `release` (or branch off it in a worktree per project rules), add a changeset, run a stable release there, cherry-pick the fix back to `main`.
Changesets pre-mode is exactly built for this: beta versions consume pending changesets but record them in `pre.json`, and the final `pre exit` + `version` produces one correctly-aggregated stable version and changelog. The `fixed` group keeps all `@fusion/*` packages lockstep as today.
### npm dist-tags
- Stable: `--tag latest` (explicit, both packages including the `runfusion.ai` alias).
- Beta: `--tag beta`. Publishing a beta must never move `latest`.
- Promotion publishes a fresh stable version; no `npm dist-tag add` gymnastics needed.
### GitHub releases & binary workflow
- `release.mjs`: `gh release create` gets `--prerelease` for beta, `--latest` for stable.
- `.github/workflows/release.yml` github-release job: set `prerelease: ${{ contains(github.ref_name, '-beta') }}` on `softprops/action-gh-release` (tag drives it, so tag-push-triggered binary builds do the right thing automatically).
- Desktop electron-builder: for beta builds pass `channel: beta` in the publish config so electron-updater manifests split into `beta*.yml` vs `latest*.yml`; electron-updater then selects by channel client-side.
## Implementation phases
### Phase 1 — publish side (`scripts/release.mjs`, workflows)
1. Add `--channel beta|stable` (default `stable`) to `release.mjs`:
- Preflight: beta requires branch `main`; stable requires branch `release` (keep the clean-tree / not-behind / pending-changeset checks; for stable in pre-mode, "pending" means pre.json has recorded changesets).
- Beta path: auto `changeset pre enter beta` when `.changeset/pre.json` absent → `pnpm release:version` → publish `pnpm -r publish --access public --no-git-checks --tag beta` → commit/tag `vX.Y.Z-beta.N` → `gh release create --prerelease` → **skip** `bumpHomebrewTap` and the tweet draft.
- Stable path: `changeset pre exit` if pre.json present → version → publish `--tag latest` → tag/GH release `--latest` → Homebrew bump → back-merge `release` into `main` (or print the exact command if the merge needs conflict resolution).
- `--dry-run` must work for both channels.
2. `release.yml`: prerelease flag on the GitHub release step keyed off the tag name; thread `channel: beta` into the electron-builder desktop legs for beta tags.
3. `version.yml` (CI npm publish, dispatch-only): add a `channel` input mirroring the same logic, or explicitly document it as stable-only until needed.
4. Create the `release` branch from current `main`; protect it like `main`.
### Phase 2 — `updateChannel` setting + channel-aware update surfaces
1. Core: add `updateChannel?: "stable" | "beta"` (default `"stable"`) to global settings (`packages/core/src/types.ts`, `settings-schema.ts`). Expose in the dashboard SettingsModal next to the existing update-check settings, and via `fn update --channel <stable|beta>` (persists the choice).
2. Shared resolution rule (implement once, reuse): the target version for channel *C* is
- stable: `dist-tags.latest`
- beta: semver-max(`dist-tags.latest`, `dist-tags.beta`) — so beta users are offered a newly promoted stable when it overtakes their beta.
3. CLI `packages/cli/src/commands/update.ts`: fetch both dist-tags, resolve per channel, and install the **explicit version** (`npm i -g @runfusion/fusion@<version>`) instead of `@latest`. Include the channel in `--check`/`--json` output and the startup banner (`commands/dashboard.ts`), and store it in the `update-check.json` cache so a channel switch invalidates the cache.
4. Dashboard `packages/dashboard/src/update-check.ts`: same resolution; **replace `isRemoteNewer` with a full semver comparison including prerelease ordering** (today `0.73.0-beta.2` vs `-beta.3` compare equal, and `0.73.0-beta.0` vs `0.73.0` would too). Same fix applies anywhere `parseSemver` (`packages/core/src/app-version.ts`) feeds an ordering decision.
5. Desktop `packages/desktop/src/native.ts`: when channel is beta, set `autoUpdater.channel = "beta"` and `autoUpdater.allowPrerelease = true` before `checkForUpdates()`.
6. Channel-switch semantics (document in `docs/settings-reference.md` and the CLI help):
- stable → beta: next check offers the current beta immediately.
- beta → stable: no downgrade offered; user stays on their beta until the next stable overtakes it. `fn update --channel stable --force` installs the current stable explicitly as an opt-in downgrade.
### Phase 3 — polish / optional
- Homebrew: tap stays stable-only. If beta demand appears, add a separate `fusion-beta` formula rather than making `fusion.rb` channel-aware.
- Dashboard banner copy distinguishes "Beta update available" vs stable.
- Docs: `docs/cli-reference.md` (`fn update --channel`), `docs/settings-reference.md` (`updateChannel`), `docs/contributing.md` (release runbook: beta cadence on main, promotion checklist, hotfix flow).
## Testing
- `release.mjs`: extend the existing dry-run coverage for both channels (branch preflight, dist-tag selection, prerelease flag, Homebrew skip, pre-mode enter/exit); no real publishes in tests.
- Unit tests for the channel resolution rule (stable/beta × ahead/behind/equal, prerelease ordering) in both `packages/cli` and `packages/dashboard` — file-scoped vitest per the verification standing rule.
- Full semver-compare tests for the `isRemoteNewer` replacement, including `X.Y.Z-beta.N < X.Y.Z`.
- First real beta: publish `0.73.0-beta.0`, verify `npm dist-tag ls @runfusion/fusion` shows `latest` unchanged, GitHub release shows *Pre-release*, `fn update --check` on a stable-channel install stays quiet, on beta channel offers it.
## Risks / gotchas
- **`latest` pollution is the one unrecoverable-embarrassing failure** — the publish command must always pass an explicit `--tag`; never rely on npm's default.
- Changesets pre-mode + the custom `sync-workspace-version.mjs` / changelog-distill pipeline haven't been exercised together; validate with `--dry-run` and a throwaway `0.73.0-beta.0` before trusting it.
- The prerelease-blind comparators (`isRemoteNewer`, `parseSemver` consumers) will misbehave the moment a `-beta.N` version exists anywhere — Phase 2 item 4 should land **before or with** the first published beta.
- Back-merge `release` → `main` can conflict on `CHANGELOG.md`/`package.json` if main moved during promotion; the script should fail soft with instructions rather than force it.
- Releases remain operator-only (`pnpm release`), per the standing rule — nothing here changes that; the interactive "authorized" gate stays for both channels.

View File

@@ -100,6 +100,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio
| `openrouterProviderPreferences` | `{ order?: string[]; ignore?: string[]; only?: string[]; allow_fallbacks?: boolean; sort?: "price" \| "throughput" \| "latency"; require_parameters?: boolean }` | `undefined` | Optional OpenRouter provider routing preferences forwarded via `compat.openRouterRouting` on chat-completion requests. See OpenRouter provider routing: <https://openrouter.ai/docs/features/provider-routing>. |
| `opencodeGoModelSync` | `boolean` | `true` | Sync opencode-go model catalog at startup via `opencode models opencode --refresh`, and re-run that refresh after saving an `opencode`/`opencode-go` API key in Dashboard Settings, normalizing discovered `opencode/...` IDs into the `opencode-go` provider surface used by `/api/models`. |
| `updateCheckEnabled` | `boolean` | `true` | When enabled, Fusion performs a daily npm registry check for new `@runfusion/fusion` versions and shows update notices in CLI/dashboard. |
| `updateChannel` | `"stable" \| "beta"` | `"stable"` | Release track for every update surface (CLI `fn update`, dashboard update check, desktop auto-updater). `stable` follows the npm `latest` dist-tag; `beta` follows the semver-max of `latest` and `beta`, so beta users also receive each promoted stable release. Switching beta → stable never downgrades — the install stays on its beta until the next stable overtakes it (`fn update --channel stable --force` downgrades explicitly). Dashboard location: **Settings → General → Release channel**. See `RELEASING.md` → "Release tracks". |
| `githubTrackingDefaultRepo` | `string` | `undefined` | Global fallback issue-tracking repo (`owner/repo`) used when task-level tracking is enabled and no project/task override is set. In Settings UI this is a detected-remote dropdown with a Custom fallback for manual entry. This key is dual-scope: global saves go through `PUT /api/settings/global` (Settings → Global General). |
| `gitlabEnabled` | `boolean` | `undefined` (effective `true`) | Global fallback enable switch for outbound GitLab integrations. Undefined preserves existing behavior; explicit `false` disables GitLab API fetch/import/comment/close/reconcile/refresh operations while leaving saved URL/token settings intact. Projects can override this key. Dashboard location: **Settings → Global General → GitLab Configuration** disclosure. |
| `gitlabInstanceUrl` | `string` | `undefined` (effective `https://gitlab.com`) | Global fallback GitLab web instance URL. Blank/unset defaults to GitLab.com. Values are trimmed and must be absolute `http://` or `https://` URLs without username/password userinfo; trailing slashes are normalized by `resolveGitlabConfig`. Projects can override this key. |