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

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add beta and stable release channels — pick your update track in Settings or with `fn update --channel <stable|beta>`.
category: feature
dev: New `updateChannel` global setting (default `stable`). Betas are cut from `main` via `pnpm release --channel beta` (changesets pre-mode) to the npm `beta` dist-tag as GitHub prereleases; stable releases are cut from the `release` branch to `latest`. CLI/dashboard/desktop update surfaces share `compareVersions`/`isVersionNewer`/`resolveUpdateTargetVersion` from `@fusion/core` (full SemVer precedence incl. prerelease); installs pin exact versions instead of `@latest`.

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

View File

@@ -62,12 +62,62 @@ When you merge the Version Packages PR:
- Generates SHA256 checksums for all binaries and Android artifacts
- Creates a **GitHub Release** with all binaries, Android artifacts, and checksums attached
## Release channels
## Release tracks: beta and stable
Fusion ships on two tracks. Users pick theirs with the `updateChannel` global setting (Settings → General → Release channel) or `fn update --channel <stable|beta>`.
| Track | Cut from | Version shape | npm dist-tag | GitHub Release | Homebrew |
|-------|----------|---------------|--------------|----------------|----------|
| beta | `main` | `X.Y.Z-beta.N` | `beta` | prerelease | — |
| stable | `release` branch | `X.Y.Z` | `latest` | latest | bumped |
### Beta release (from `main`)
```bash
pnpm release # prompts for the channel; beta is the default answer
pnpm release --channel beta # explicit, no prompt
```
The script auto-enters changesets pre-mode (`.changeset/pre.json`, tag `beta`) the first time, versions to the next `-beta.N`, publishes with an explicit `--tag beta`, tags `vX.Y.Z-beta.N` (the tag push builds binaries and marks the GitHub Release as a prerelease), and skips the Homebrew tap and X draft. Changeset `.md` files are *preserved* through beta versioning — pre-mode records them in `pre.json` so the eventual stable release aggregates everything.
### Promoting to stable
The easy path — run from `main` and let the script do the promotion:
```bash
pnpm release --channel stable # or answer "stable" at the channel prompt
```
When run from `main` with the stable channel, the script starts **assisted promotion**:
1. It proposes the newest `v*-beta*` tag reachable from HEAD as the promotion target (promote a tested beta, not main's tip); you can accept or type another tag/commit.
2. It verifies `release` fast-forwards cleanly to that commit (a diverged release branch — e.g. unmerged hotfixes — fails with instructions instead of guessing).
3. It creates a **temporary git worktree** on `release` at the target (bootstrapping the branch if it doesn't exist yet), installs dependencies there, and re-runs the whole stable release inside it — your checkout never leaves `main`. The Homebrew tap path is handed into the worktree via `FUSION_HOMEBREW_TAP_DIR`.
4. On success the temp worktree is removed; on failure it is kept for inspection.
The stable release itself exits pre-mode, versions to the clean `X.Y.Z` with the aggregated changelog, publishes to `latest`, marks the GitHub Release latest, and bumps the Homebrew tap.
Afterwards, **back-merge `release` into `main`** (the script prints the exact commands). This carries the consumed changesets, changelogs, version bump, and pre.json removal back — without it, the next beta double-releases old changesets.
Manual alternative: check out `release` in a worktree yourself, `git merge --ff-only vX.Y.Z-beta.N`, and run `pnpm release --channel stable` there.
### Hotfixes
Commit on `release` (or a worktree branched from it), add a changeset, run `pnpm release`, then cherry-pick the fix back to `main`.
### Channel semantics for users
- `stable` follows the npm `latest` dist-tag only — betas are invisible.
- `beta` follows the semver-max of `latest` and `beta`, so beta users are offered each promoted stable once it overtakes their prerelease.
- Switching beta → stable never downgrades; the user stays on the installed beta until the next stable passes it. `fn update --channel stable --force` is the explicit downgrade.
- Desktop beta builds emit `beta*.yml` electron-updater manifests; the desktop app selects them when `updateChannel` is `beta` (`allowPrerelease` + channel).
## Distribution channels
| Channel | Workflow | Trigger | Output |
|---------|----------|---------|--------|
| npm | `version.yml` | Push to `main` | npm packages with provenance |
| GitHub Release | `release.yml` | Version tag (`v*`) | Signed platform binaries, Android APK/AAB + checksums |
| npm | `version.yml` (stable only) or `pnpm release` | Manual | npm packages with provenance (CI) |
| GitHub Release | `release.yml` | Version tag (`v*`; `v*-beta.N` → prerelease) | Signed platform binaries, Android APK/AAB + checksums |
## Platform binaries
@@ -128,6 +178,10 @@ This will trigger `release.yml` to build binaries and create a GitHub Release. N
| `pnpm release` | Local interactive release: previews changesets, lets you accept or override the proposed version, then bumps + builds + publishes + tags; Claude authors Highlights + a ≤280-char engagement X draft (soft deterministic fallback if Claude is offline) |
| `pnpm release --yes` | Same, but auto-accepts the proposed version and skips the final confirmation |
| `pnpm release --dry-run` | Preview only — show changesets, proposed version, and Claude-authored X draft preview, then exit before any file/git/npm changes |
| `pnpm release --channel beta` | Beta release from `main`: pre-mode version `X.Y.Z-beta.N`, npm dist-tag `beta`, GitHub prerelease; no Homebrew/X draft |
| `pnpm release --channel stable` | Stable release: requires the `release` branch, publishes `latest`, marks the GitHub Release latest |
Without `--channel`, `pnpm release` prompts for the channel and **defaults to beta** (also the silent default with `--yes` or a non-interactive dry-run). Stable releases are always an explicit choice.
| `pnpm release:version` | Apply changesets and bump versions (used by CI) |
| `pnpm --filter @runfusion/fusion build:exe` | Build binary for current platform |
| `pnpm --filter @runfusion/fusion build:exe -- --target <target>` | Cross-compile for a specific platform |

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. |

View File

@@ -317,7 +317,8 @@ Usage:
fn desktop --dev Launch source-checkout desktop with hot-reload (connects to Vite dev server)
fn desktop --paused Launch with automation paused
fn desktop --no-auth Disable bearer-token auth for the embedded local dashboard
fn update [--check] [--global] [--json] Update Fusion to the latest version
fn update [--check] [--global] [--json] [--channel <stable|beta>] [--force]
Update Fusion on the selected release channel
fn upgrade Alias for fn update
fn task create [desc] [opts] Create a new task (goes to triage; supports --node <name>, --no-dedup)
fn task plan [description] [opts] Create task via AI-guided planning
@@ -934,10 +935,23 @@ async function main() {
case "update":
case "upgrade": {
// FNXC:UpdateChannels 2026-07-19-13:05: --channel <stable|beta> selects
// and persists the release track; --force installs the channel target
// even when not newer (the explicit beta → stable downgrade path).
// A bare trailing --channel (or one followed by another flag) errors
// instead of being silently ignored (PR #2345 review).
const channelFlagIndex = args.indexOf("--channel");
const channelValue = channelFlagIndex !== -1 ? args[channelFlagIndex + 1] : undefined;
if (channelFlagIndex !== -1 && (channelValue === undefined || channelValue.startsWith("--"))) {
console.error("Error: --channel requires a value: stable or beta.");
process.exit(1);
}
await runUpdate({
check: args.includes("--check"),
global: args.includes("--global") ? true : undefined,
json: args.includes("--json"),
channel: channelValue,
force: args.includes("--force"),
});
break;
}

View File

@@ -1,6 +1,6 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
const { execAsyncMock, existsSyncMock, readFileSyncMock, getCachedUpdateStatusMock } = vi.hoisted(() => ({
const { execAsyncMock, existsSyncMock, readFileSyncMock, getCachedUpdateStatusMock, getConfiguredUpdateChannelMock, persistUpdateChannelMock } = vi.hoisted(() => ({
execAsyncMock: vi.fn<(...args: unknown[]) => Promise<{ stdout: string; stderr: string }>>(),
existsSyncMock: vi.fn<(path: string) => boolean>(),
readFileSyncMock: vi.fn<(path: string, encoding: BufferEncoding) => string>(),
@@ -8,7 +8,10 @@ const { execAsyncMock, existsSyncMock, readFileSyncMock, getCachedUpdateStatusMo
updateAvailable: boolean;
latestVersion: string;
currentVersion: string;
channel?: "stable" | "beta";
} | null>(),
getConfiguredUpdateChannelMock: vi.fn<() => Promise<"stable" | "beta">>(),
persistUpdateChannelMock: vi.fn<(channel: "stable" | "beta") => Promise<void>>(),
}));
vi.mock("node:child_process", async () => {
@@ -25,8 +28,16 @@ vi.mock("node:fs", () => ({
vi.mock("../../update-cache.js", () => ({
getCachedUpdateStatus: getCachedUpdateStatusMock,
getConfiguredUpdateChannel: getConfiguredUpdateChannelMock,
persistUpdateChannel: persistUpdateChannelMock,
}));
// FNXC:UpdateChannels 2026-07-19-13:45: update.ts uses the REAL shared semver/
// channel helpers, but importing the @fusion/core barrel would drag core's
// git-binary through this file's node:child_process mock. Substitute the
// barrel with the actual app-version source module (the only part used here).
vi.mock("@fusion/core", async () => await vi.importActual("../../../../core/src/app-version.js"));
import { runUpdate } from "../update.js";
describe("runUpdate", () => {
@@ -41,6 +52,8 @@ describe("runUpdate", () => {
existsSyncMock.mockImplementation((path: string) => path.endsWith("package.json"));
readFileSyncMock.mockReturnValue(JSON.stringify({ name: "@runfusion/fusion", version: "1.2.3" }));
getCachedUpdateStatusMock.mockReturnValue(null);
getConfiguredUpdateChannelMock.mockResolvedValue("stable");
persistUpdateChannelMock.mockResolvedValue(undefined);
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
@@ -70,7 +83,8 @@ describe("runUpdate", () => {
await runUpdate();
expect(execAsyncMock).toHaveBeenCalledWith("npm install -g @runfusion/fusion@latest", expect.objectContaining({ timeout: 300_000 }));
// FNXC:UpdateChannels 2026-07-19-13:45: installs pin the exact resolved version, never a bare dist-tag.
expect(execAsyncMock).toHaveBeenCalledWith("npm install -g @runfusion/fusion@1.2.4", expect.objectContaining({ timeout: 300_000 }));
expect(logSpy).toHaveBeenCalledWith("Update complete.");
});
@@ -102,6 +116,7 @@ describe("runUpdate", () => {
latestVersion: "1.2.3",
updateAvailable: false,
updated: false,
channel: "stable",
});
});
@@ -124,7 +139,7 @@ describe("runUpdate", () => {
await runUpdate({ check: true });
expect(logSpy).toHaveBeenCalledWith("Warning: npm registry unreachable, using cached update metadata.");
expect(logSpy).toHaveBeenCalledWith("Latest version: 1.2.5");
expect(logSpy).toHaveBeenCalledWith("Latest stable version: 1.2.5");
expect(process.exitCode).toBe(1);
});
@@ -137,7 +152,7 @@ describe("runUpdate", () => {
await runUpdate();
expect(execAsyncMock).toHaveBeenCalledTimes(2);
expect((execAsyncMock.mock.calls[1] ?? [""])[0]).toContain("npm install --force -g @runfusion/fusion@latest");
expect((execAsyncMock.mock.calls[1] ?? [""])[0]).toContain("npm install --force -g @runfusion/fusion@1.2.4");
expect(errorSpy).toHaveBeenCalledWith("Detected legacy runfusion.ai bin symlinks; retrying update with --force.");
expect(logSpy).toHaveBeenCalledWith("Update complete.");
});
@@ -151,7 +166,7 @@ describe("runUpdate", () => {
await runUpdate({ global: false });
expect(execAsyncMock).toHaveBeenCalledTimes(2);
expect((execAsyncMock.mock.calls[1] ?? [""])[0]).toContain("npm install --force @runfusion/fusion@latest");
expect((execAsyncMock.mock.calls[1] ?? [""])[0]).toContain("npm install --force @runfusion/fusion@1.2.4");
expect((execAsyncMock.mock.calls[1] ?? [""])[0]).not.toContain(" -g ");
});
@@ -198,7 +213,7 @@ describe("runUpdate", () => {
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/timed out after 5 minutes.*terminal/i));
expect(errorSpy.mock.calls.flat().join("\n")).toContain(
"npm install -g @runfusion/fusion@latest",
"npm install -g @runfusion/fusion@1.2.4",
);
expect(errorSpy.mock.calls.flat().join("\n")).not.toContain("npm install --force");
expect(errorSpy.mock.calls.flat().join("\n")).not.toContain("deprecated");
@@ -220,7 +235,7 @@ describe("runUpdate", () => {
expect(execAsyncMock).toHaveBeenCalledTimes(2);
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/timed out after 5 minutes.*terminal/i));
expect(errorSpy.mock.calls.flat().join("\n")).toContain(
"npm install --force -g @runfusion/fusion@latest",
"npm install --force -g @runfusion/fusion@1.2.4",
);
expect(errorSpy.mock.calls.flat().join("\n")).not.toContain("deprecated");
});
@@ -316,4 +331,106 @@ describe("runUpdate", () => {
await runUpdate({ check: true });
expect(process.exitCode).toBe(1);
});
/*
FNXC:UpdateChannels 2026-07-19-13:45:
Channel behavior of `fn update`: --channel persists the track, beta resolves
semver-max(latest, beta), stable never sees betas, installs pin the exact
version, and --force is the only downgrade path (beta → stable).
*/
describe("release channels", () => {
it("rejects an invalid --channel value", async () => {
await expect(runUpdate({ channel: "nightly" })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: invalid --channel 'nightly'. Valid channels: stable, beta.");
expect(persistUpdateChannelMock).not.toHaveBeenCalled();
});
it("persists an explicit --channel choice and uses it for resolution", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.3", beta: "1.3.0-beta.1" } }) }));
execAsyncMock.mockResolvedValue({ stdout: "ok", stderr: "" });
await runUpdate({ channel: "beta" });
expect(persistUpdateChannelMock).toHaveBeenCalledWith("beta");
expect(execAsyncMock).toHaveBeenCalledWith("npm install -g @runfusion/fusion@1.3.0-beta.1", expect.any(Object));
});
it("beta channel from settings offers the beta dist-tag", async () => {
getConfiguredUpdateChannelMock.mockResolvedValue("beta");
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.3", beta: "1.3.0-beta.2" } }) }));
await runUpdate({ check: true, json: true });
const parsed = JSON.parse(logSpy.mock.calls[0]?.[0] as string) as Record<string, unknown>;
expect(parsed).toMatchObject({ latestVersion: "1.3.0-beta.2", updateAvailable: true, channel: "beta" });
expect(process.exitCode).toBe(1);
});
it("stable channel never offers the beta dist-tag", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.3", beta: "1.3.0-beta.2" } }) }));
await runUpdate({ check: true, json: true });
const parsed = JSON.parse(logSpy.mock.calls[0]?.[0] as string) as Record<string, unknown>;
expect(parsed).toMatchObject({ latestVersion: "1.2.3", updateAvailable: false, channel: "stable" });
expect(process.exitCode).toBe(0);
});
it("beta channel offers a promoted stable that overtakes the running beta", async () => {
getConfiguredUpdateChannelMock.mockResolvedValue("beta");
readFileSyncMock.mockReturnValue(JSON.stringify({ name: "@runfusion/fusion", version: "1.3.0-beta.2" }));
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.3.0", beta: "1.3.0-beta.2" } }) }));
execAsyncMock.mockResolvedValue({ stdout: "ok", stderr: "" });
await runUpdate();
expect(execAsyncMock).toHaveBeenCalledWith("npm install -g @runfusion/fusion@1.3.0", expect.any(Object));
});
it("switching beta → stable does not downgrade without --force", async () => {
readFileSyncMock.mockReturnValue(JSON.stringify({ name: "@runfusion/fusion", version: "1.3.0-beta.2" }));
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.3", beta: "1.3.0-beta.2" } }) }));
await runUpdate({ channel: "stable" });
expect(execAsyncMock).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith("Already up to date.");
});
it("--force installs the stable target below the running beta (explicit downgrade)", async () => {
readFileSyncMock.mockReturnValue(JSON.stringify({ name: "@runfusion/fusion", version: "1.3.0-beta.2" }));
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.3", beta: "1.3.0-beta.2" } }) }));
execAsyncMock.mockResolvedValue({ stdout: "ok", stderr: "" });
await runUpdate({ channel: "stable", force: true });
expect(execAsyncMock).toHaveBeenCalledWith("npm install -g @runfusion/fusion@1.2.3", expect.any(Object));
});
it("refuses to shell out with a non-semver registry version (injection hardening)", async () => {
// Only a poisoned dist-tag can produce this; --force is the one path
// that installs a target that isn't strictly newer.
readFileSyncMock.mockReturnValue(JSON.stringify({ name: "@runfusion/fusion", version: "1.2.3" }));
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4; rm -rf ~" } }) }));
await expect(runUpdate({ force: true })).rejects.toThrow("process.exit:1");
expect(execAsyncMock).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("is not a valid version string"));
});
it("ignores cached fallback metadata written for a different channel", async () => {
getConfiguredUpdateChannelMock.mockResolvedValue("beta");
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));
getCachedUpdateStatusMock.mockReturnValue({
updateAvailable: true,
currentVersion: "1.2.3",
latestVersion: "1.2.5",
channel: "stable",
});
await expect(runUpdate({ check: true })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error checking for updates: network down");
});
});
});

View File

@@ -158,6 +158,7 @@ type StartupUpdateStatus = {
updateAvailable: true;
latestVersion: string;
currentVersion: string;
channel?: "stable" | "beta";
};
async function resolveCachedStartupUpdateStatus(importMetaUrl: string): Promise<StartupUpdateStatus | null> {
@@ -183,6 +184,7 @@ async function resolveCachedStartupUpdateStatus(importMetaUrl: string): Promise<
updateAvailable: true,
currentVersion: cachedUpdate.currentVersion,
latestVersion: cachedUpdate.latestVersion,
channel: cachedUpdate.channel,
};
} catch {
return null;
@@ -194,7 +196,10 @@ function formatUpdateMessage(updateStatus: StartupUpdateStatus | null): string |
return null;
}
return `⬆ Update available: v${updateStatus.latestVersion} (current: v${updateStatus.currentVersion}). Run \`fn update\` for an installed CLI, or pull the source checkout.`;
// FNXC:UpdateChannels 2026-07-19-13:05: label beta-channel offers so an
// operator can tell a prerelease notice from a stable one at a glance.
const channelLabel = updateStatus.channel === "beta" ? " [beta channel]" : "";
return `⬆ Update available: v${updateStatus.latestVersion}${channelLabel} (current: v${updateStatus.currentVersion}). Run \`fn update\` for an installed CLI, or pull the source checkout.`;
}
export class StreamedLogBuffer {

View File

@@ -3,18 +3,36 @@ import { existsSync, readFileSync } from "node:fs";
import { promisify } from "node:util";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { getCachedUpdateStatus } from "../update-cache.js";
import { isVersionNewer, resolveUpdateTargetVersion } from "@fusion/core";
import type { UpdateChannel } from "@fusion/core";
import { getCachedUpdateStatus, getConfiguredUpdateChannel, persistUpdateChannel } from "../update-cache.js";
const execAsync = promisify(exec);
const REGISTRY_URL = "https://registry.npmjs.org/@runfusion%2Ffusion";
const INSTALL_COMMAND = "npm install -g @runfusion/fusion@latest";
const LOCAL_INSTALL_COMMAND = "npm install @runfusion/fusion@latest";
// FNXC:UpdateInstall 2026-07-19-09:50 (kept through channel merge): native npm
// dependencies can take >2min on Windows; installs get five minutes.
const INSTALL_TIMEOUT_MS = 300_000;
/*
FNXC:UpdateChannels 2026-07-19-13:00:
`fn update` is channel-aware. `stable` (default) follows the npm `latest`
dist-tag; `beta` follows the semver-max of `latest` and `beta` so beta users
also receive promoted stable releases. The install always pins the exact
resolved version (`@runfusion/fusion@X.Y.Z[-beta.N]`) — never a bare dist-tag —
so a beta-channel install can never silently land on the wrong track.
`--channel <stable|beta>` persists the choice to global settings (shared with
the dashboard and desktop updater). Switching beta → stable does not downgrade;
`--force` is the explicit escape hatch that installs the channel target even
when it is not newer than the current version.
*/
export type RunUpdateOptions = {
check?: boolean;
global?: boolean;
json?: boolean;
/** Raw --channel value; validated to "stable" | "beta". */
channel?: string;
/** Install the resolved channel target even when it is not newer (explicit downgrade). */
force?: boolean;
};
type UpdateStatus = {
@@ -22,6 +40,7 @@ type UpdateStatus = {
latestVersion: string;
updateAvailable: boolean;
updated: boolean;
channel: UpdateChannel;
};
function readOwnCliVersion(): string | undefined {
@@ -55,48 +74,40 @@ function readOwnCliVersion(): string | undefined {
return undefined;
}
function parseVersion(version: string): number[] {
return version
.split(".")
.slice(0, 3)
.map((part) => Number.parseInt(part, 10))
.map((part) => (Number.isFinite(part) ? part : 0));
}
function isRemoteNewer(remoteVersion: string, currentVersion: string): boolean {
const remote = parseVersion(remoteVersion);
const current = parseVersion(currentVersion);
const maxLength = Math.max(remote.length, current.length, 3);
for (let i = 0; i < maxLength; i += 1) {
const remotePart = remote[i] ?? 0;
const currentPart = current[i] ?? 0;
if (remotePart > currentPart) return true;
if (remotePart < currentPart) return false;
}
return false;
}
async function fetchLatestVersion(): Promise<string> {
async function fetchChannelTargetVersion(channel: UpdateChannel): Promise<string> {
const response = await fetch(REGISTRY_URL);
const payload = (await response.json()) as {
"dist-tags"?: {
latest?: string;
beta?: string;
};
};
const latestVersion = payload?.["dist-tags"]?.latest;
if (typeof latestVersion !== "string" || latestVersion.length === 0) {
throw new Error("Could not determine latest version from npm registry response.");
const targetVersion = resolveUpdateTargetVersion(channel, {
latest: payload?.["dist-tags"]?.latest,
beta: payload?.["dist-tags"]?.beta,
});
if (typeof targetVersion !== "string" || targetVersion.length === 0) {
throw new Error(`Could not determine ${channel} version from npm registry response.`);
}
return latestVersion;
return targetVersion;
}
function getInstallCommand(globalInstall: boolean, force = false): string {
const baseCommand = globalInstall ? INSTALL_COMMAND : LOCAL_INSTALL_COMMAND;
return force ? baseCommand.replace("npm install", "npm install --force") : baseCommand;
// FNXC:UpdateChannels 2026-07-19-16:20: the version comes from the npm
// registry's dist-tags and is interpolated into a shell-executed npm install;
// only a strict-semver-shaped string may pass (registry-poisoning hardening,
// PR #2345 review).
const SAFE_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
function getInstallCommand(globalInstall: boolean, version: string, force = false): string {
// Pin the exact resolved version so the installed build always matches the
// selected channel (installing `@latest` would drag a beta user to stable).
if (!SAFE_VERSION_RE.test(version)) {
throw new Error(`Refusing to install: '${version}' is not a valid version string.`);
}
const spec = `@runfusion/fusion@${version}`;
return `npm install${force ? " --force" : ""}${globalInstall ? " -g" : ""} ${spec}`;
}
type InstallError = Error & {
@@ -114,8 +125,10 @@ function isInstallTimeoutError(error: unknown): boolean {
return installError?.killed === true;
}
function installTimeoutError(globalInstall: boolean, force = false): Error {
const command = getInstallCommand(globalInstall, force);
function installTimeoutError(globalInstall: boolean, version: string, force = false): Error {
// Channel merge: the retry hint pins the resolved version like the install
// itself — suggesting @latest would cross release tracks for beta users.
const command = getInstallCommand(globalInstall, version, force);
return new Error(
`Update timed out after ${INSTALL_TIMEOUT_MS / 60_000} minutes. Retry from a terminal with: ${command}`,
);
@@ -161,16 +174,16 @@ function printCollisionRemediation(binaryPath: string | null): void {
}
}
async function installLatest(globalInstall: boolean, resolveBinaryPath: () => string | null = detectRunningBinaryPath): Promise<void> {
async function installVersion(globalInstall: boolean, version: string, resolveBinaryPath: () => string | null = detectRunningBinaryPath): Promise<void> {
try {
await execAsync(getInstallCommand(globalInstall), {
await execAsync(getInstallCommand(globalInstall, version), {
timeout: INSTALL_TIMEOUT_MS,
maxBuffer: 10 * 1024 * 1024,
});
return;
} catch (error) {
if (isInstallTimeoutError(error)) {
throw installTimeoutError(globalInstall);
throw installTimeoutError(globalInstall, version);
}
if (!isBinCollisionInstallError(error)) {
throw error;
@@ -179,14 +192,14 @@ async function installLatest(globalInstall: boolean, resolveBinaryPath: () => st
console.error("Detected legacy runfusion.ai bin symlinks; retrying update with --force.");
try {
await execAsync(getInstallCommand(globalInstall, true), {
await execAsync(getInstallCommand(globalInstall, version, true), {
timeout: INSTALL_TIMEOUT_MS,
maxBuffer: 10 * 1024 * 1024,
});
return;
} catch (forceError) {
if (isInstallTimeoutError(forceError)) {
throw installTimeoutError(globalInstall, true);
throw installTimeoutError(globalInstall, version, true);
}
printCollisionRemediation(resolveBinaryPath());
throw forceError;
@@ -195,11 +208,20 @@ async function installLatest(globalInstall: boolean, resolveBinaryPath: () => st
}
function printStatus(status: UpdateStatus, checkOnly: boolean): void {
console.log(`Channel: ${status.channel}`);
console.log(`Current version: ${status.currentVersion}`);
console.log(`Latest version: ${status.latestVersion}`);
console.log(`Latest ${status.channel} version: ${status.latestVersion}`);
if (!status.updateAvailable) {
if (status.updated) {
// --force path: installed the channel target even though it wasn't newer.
console.log("Installed channel version (forced).");
return;
}
console.log("Already up to date.");
if (status.channel === "stable" && isVersionNewer(status.currentVersion, status.latestVersion)) {
console.log("Current version is a beta ahead of stable. Use `fn update --force` to switch back to the stable build now, or stay until the next stable release overtakes it.");
}
return;
}
@@ -217,9 +239,12 @@ function printJson(status: UpdateStatus): void {
console.log(JSON.stringify(status));
}
function getLatestVersionFallback(currentVersion: string): string | null {
function getLatestVersionFallback(currentVersion: string, channel: UpdateChannel): string | null {
const cached = getCachedUpdateStatus(currentVersion);
if (!cached) return null;
// A cache written for another channel must not stand in for this one —
// e.g. a stable cache would hide the beta a freshly-switched user asked for.
if ((cached.channel ?? "stable") !== channel) return null;
return cached.latestVersion;
}
@@ -227,6 +252,32 @@ export async function runUpdate(options: RunUpdateOptions = {}): Promise<void> {
const checkOnly = options.check === true;
const globalInstall = options.global !== false;
const jsonOutput = options.json === true;
const force = options.force === true;
if (options.channel !== undefined && options.channel !== "stable" && options.channel !== "beta") {
console.error(`Error: invalid --channel '${options.channel}'. Valid channels: stable, beta.`);
process.exit(1);
return;
}
// FNXC:UpdateChannels 2026-07-19-13:00: an explicit --channel flag is
// persisted (even with --check) so every update surface follows the switch.
let channel: UpdateChannel;
if (options.channel === "stable" || options.channel === "beta") {
channel = options.channel;
try {
await persistUpdateChannel(channel);
if (!jsonOutput) {
console.log(`Update channel set to '${channel}'.`);
}
} catch {
if (!jsonOutput) {
console.log(`Warning: could not persist update channel '${channel}'; using it for this run only.`);
}
}
} else {
channel = await getConfiguredUpdateChannel();
}
const currentVersion = readOwnCliVersion();
if (!currentVersion) {
@@ -237,9 +288,9 @@ export async function runUpdate(options: RunUpdateOptions = {}): Promise<void> {
let latestVersion: string;
try {
latestVersion = await fetchLatestVersion();
latestVersion = await fetchChannelTargetVersion(channel);
} catch (error) {
const fallbackVersion = getLatestVersionFallback(currentVersion);
const fallbackVersion = getLatestVersionFallback(currentVersion, channel);
if (!fallbackVersion) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error checking for updates: ${message}`);
@@ -253,7 +304,10 @@ export async function runUpdate(options: RunUpdateOptions = {}): Promise<void> {
}
}
const updateAvailable = isRemoteNewer(latestVersion, currentVersion);
const updateAvailable = isVersionNewer(latestVersion, currentVersion);
// --force installs the channel target even when it isn't newer — the
// explicit beta → stable downgrade path. A same-version force is a no-op.
const shouldInstall = updateAvailable || (force && latestVersion !== currentVersion);
if (checkOnly) {
const checkStatus: UpdateStatus = {
@@ -261,6 +315,7 @@ export async function runUpdate(options: RunUpdateOptions = {}): Promise<void> {
latestVersion,
updateAvailable,
updated: false,
channel,
};
if (jsonOutput) {
@@ -275,12 +330,13 @@ export async function runUpdate(options: RunUpdateOptions = {}): Promise<void> {
return;
}
if (!updateAvailable) {
if (!shouldInstall) {
const status: UpdateStatus = {
currentVersion,
latestVersion,
updateAvailable: false,
updated: false,
channel,
};
if (jsonOutput) {
@@ -292,7 +348,7 @@ export async function runUpdate(options: RunUpdateOptions = {}): Promise<void> {
}
try {
await installLatest(globalInstall);
await installVersion(globalInstall, latestVersion);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error installing update: ${message}`);
@@ -303,8 +359,9 @@ export async function runUpdate(options: RunUpdateOptions = {}): Promise<void> {
const updatedStatus: UpdateStatus = {
currentVersion,
latestVersion,
updateAvailable: true,
updateAvailable,
updated: true,
channel,
};
if (jsonOutput) {

View File

@@ -1,17 +1,20 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { GlobalSettingsStore, resolveGlobalDir } from "@fusion/core";
import type { UpdateChannel } from "@fusion/core";
type CachedUpdateStatus = {
updateAvailable: boolean;
latestVersion: string;
currentVersion: string;
channel?: UpdateChannel;
};
type UpdateCachePayload = {
updateAvailable?: unknown;
latestVersion?: unknown;
currentVersion?: unknown;
channel?: unknown;
};
export function getCachedUpdateStatus(currentVersion?: string): CachedUpdateStatus | null {
@@ -39,6 +42,7 @@ export function getCachedUpdateStatus(currentVersion?: string): CachedUpdateStat
updateAvailable: true,
latestVersion: parsed.latestVersion,
currentVersion: parsed.currentVersion,
channel: parsed.channel === "beta" ? "beta" : parsed.channel === "stable" ? "stable" : undefined,
};
}
@@ -54,3 +58,28 @@ export async function isUpdateCheckEnabled(): Promise<boolean> {
const settings = await store.getSettings();
return settings.updateCheckEnabled !== false;
}
/*
FNXC:UpdateChannels 2026-07-19-13:00:
The persisted `updateChannel` global setting selects the release track for every
update surface. `fn update --channel <stable|beta>` both uses AND persists the
choice so the dashboard and desktop follow along. Absent/invalid = "stable".
*/
export async function getConfiguredUpdateChannel(): Promise<UpdateChannel> {
try {
const store = new GlobalSettingsStore();
await store.init();
const settings = await store.getSettings();
return settings.updateChannel === "beta" ? "beta" : "stable";
} catch {
return "stable";
}
}
export async function persistUpdateChannel(channel: UpdateChannel): Promise<void> {
const store = new GlobalSettingsStore();
await store.init();
const settings = await store.getSettings();
if (settings.updateChannel === channel) return;
await store.updateSettings({ updateChannel: channel });
}

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { readFileSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { getAppVersion, parseSemver } from "../app-version.js";
import { getAppVersion, parseSemver, compareVersions, isVersionNewer, resolveUpdateTargetVersion } from "../app-version.js";
describe("getAppVersion", () => {
it("should return a non-empty string", () => {
@@ -124,3 +124,70 @@ describe("parseSemver", () => {
});
});
});
/*
FNXC:UpdateChannels 2026-07-19-13:40:
Shared version ordering + channel resolution for every update surface.
Full SemVer 2.0.0 precedence, including prerelease identifiers — the class of
bug being prevented: comparators that ignore prerelease suffixes treat
0.73.0-beta.2, -beta.3, and 0.73.0 as equal.
*/
describe("compareVersions / isVersionNewer", () => {
it("orders plain releases", () => {
expect(compareVersions("1.2.3", "1.2.4")).toBeLessThan(0);
expect(compareVersions("1.3.0", "1.2.9")).toBeGreaterThan(0);
expect(compareVersions("1.2.3", "1.2.3")).toBe(0);
});
it("ranks a prerelease below its release", () => {
expect(compareVersions("0.73.0-beta.1", "0.73.0")).toBeLessThan(0);
expect(isVersionNewer("0.73.0", "0.73.0-beta.9")).toBe(true);
});
it("ranks a prerelease above lower releases", () => {
expect(isVersionNewer("0.73.0-beta.0", "0.72.5")).toBe(true);
});
it("orders prerelease iterations numerically", () => {
expect(isVersionNewer("0.73.0-beta.3", "0.73.0-beta.2")).toBe(true);
expect(isVersionNewer("0.73.0-beta.10", "0.73.0-beta.2")).toBe(true);
expect(compareVersions("0.73.0-beta.2", "0.73.0-beta.2")).toBe(0);
});
it("orders numeric prerelease identifiers below alphanumeric ones", () => {
// SemVer spec: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta
expect(compareVersions("1.0.0-alpha", "1.0.0-alpha.1")).toBeLessThan(0);
expect(compareVersions("1.0.0-alpha.1", "1.0.0-alpha.beta")).toBeLessThan(0);
expect(compareVersions("1.0.0-alpha.beta", "1.0.0-beta")).toBeLessThan(0);
});
it("ignores build metadata", () => {
expect(compareVersions("1.2.3+build.1", "1.2.3+build.2")).toBe(0);
});
it("sorts unparseable versions below parseable ones", () => {
expect(isVersionNewer("not-a-version", "0.0.1")).toBe(false);
expect(isVersionNewer("0.0.1", "not-a-version")).toBe(true);
});
});
describe("resolveUpdateTargetVersion", () => {
it("stable follows latest only and never sees beta", () => {
expect(resolveUpdateTargetVersion("stable", { latest: "0.72.0", beta: "0.73.0-beta.5" })).toBe("0.72.0");
expect(resolveUpdateTargetVersion(undefined, { latest: "0.72.0", beta: "0.73.0-beta.5" })).toBe("0.72.0");
});
it("beta resolves the semver-max of latest and beta", () => {
expect(resolveUpdateTargetVersion("beta", { latest: "0.72.0", beta: "0.73.0-beta.1" })).toBe("0.73.0-beta.1");
// A promoted stable overtakes its own betas.
expect(resolveUpdateTargetVersion("beta", { latest: "0.73.0", beta: "0.73.0-beta.4" })).toBe("0.73.0");
});
it("handles missing dist-tags", () => {
expect(resolveUpdateTargetVersion("beta", { latest: "0.72.0" })).toBe("0.72.0");
expect(resolveUpdateTargetVersion("beta", { beta: "0.73.0-beta.1" })).toBe("0.73.0-beta.1");
expect(resolveUpdateTargetVersion("stable", { beta: "0.73.0-beta.1" })).toBeNull();
expect(resolveUpdateTargetVersion("beta", {})).toBeNull();
expect(resolveUpdateTargetVersion("stable", { latest: "" })).toBeNull();
});
});

View File

@@ -61,3 +61,105 @@ export function parseSemver(version: string): { major: number; minor: number; pa
patch: parseInt(match[3], 10),
};
}
/*
FNXC:UpdateChannels 2026-07-19-12:30:
Fusion ships two release tracks: `stable` (npm dist-tag `latest`) and `beta`
(npm dist-tag `beta`, versions `X.Y.Z-beta.N` cut from `main`). All update
surfaces (CLI `fn update`, dashboard update check, desktop electron-updater)
share the helpers below so channel resolution and version ordering behave
identically everywhere. The previous per-surface `isRemoteNewer` compared only
major.minor.patch and treated `0.73.0-beta.2`, `-beta.3`, and `0.73.0` as
equal, which breaks the moment any prerelease exists — these helpers implement
full SemVer 2.0.0 precedence including prerelease identifiers.
*/
/** Release track a Fusion install follows for updates. */
export type UpdateChannel = "stable" | "beta";
/** npm dist-tags relevant to update resolution. */
export type UpdateDistTags = {
latest?: string | null;
beta?: string | null;
};
function parseVersionParts(version: string): {
release: number[];
prerelease: (string | number)[] | null;
} | null {
const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
if (!match) return null;
const prerelease = match[4]
? match[4].split(".").map((id) => (/^\d+$/.test(id) ? Number(id) : id))
: null;
return {
release: [Number(match[1]), Number(match[2]), Number(match[3])],
prerelease,
};
}
/**
* Full SemVer 2.0.0 precedence compare (build metadata ignored).
* Returns negative when `a < b`, 0 when equal, positive when `a > b`.
* Unparseable versions sort below every parseable one so a malformed remote
* value can never be offered as an "update".
*/
export function compareVersions(a: string, b: string): number {
const pa = parseVersionParts(a);
const pb = parseVersionParts(b);
if (!pa && !pb) return 0;
if (!pa) return -1;
if (!pb) return 1;
for (let i = 0; i < 3; i += 1) {
if (pa.release[i] !== pb.release[i]) return pa.release[i] - pb.release[i];
}
// SemVer: a version WITH a prerelease has lower precedence than the same
// release without one (0.73.0-beta.1 < 0.73.0).
if (pa.prerelease === null && pb.prerelease === null) return 0;
if (pa.prerelease === null) return 1;
if (pb.prerelease === null) return -1;
const len = Math.max(pa.prerelease.length, pb.prerelease.length);
for (let i = 0; i < len; i += 1) {
const ia = pa.prerelease[i];
const ib = pb.prerelease[i];
// A larger identifier set has higher precedence (beta.1.2 > beta.1).
if (ia === undefined) return -1;
if (ib === undefined) return 1;
if (typeof ia === "number" && typeof ib === "number") {
if (ia !== ib) return ia - ib;
} else if (typeof ia === "number") {
return -1; // Numeric identifiers sort below alphanumeric ones.
} else if (typeof ib === "number") {
return 1;
} else if (ia !== ib) {
return ia < ib ? -1 : 1;
}
}
return 0;
}
/** True when `remote` has strictly higher SemVer precedence than `current`. */
export function isVersionNewer(remote: string, current: string): boolean {
return compareVersions(remote, current) > 0;
}
/**
* Resolve the version a given update channel should offer.
* - `stable` follows the `latest` dist-tag only — betas are invisible.
* - `beta` follows the semver-max of `latest` and `beta`, so beta users are
* offered a newly promoted stable once it overtakes their prerelease.
* Returns null when the channel has no resolvable target.
*/
export function resolveUpdateTargetVersion(
channel: UpdateChannel | undefined,
distTags: UpdateDistTags,
): string | null {
const latest = typeof distTags.latest === "string" && distTags.latest.length > 0 ? distTags.latest : null;
if (channel !== "beta") return latest;
const beta = typeof distTags.beta === "string" && distTags.beta.length > 0 ? distTags.beta : null;
if (latest && beta) return compareVersions(beta, latest) > 0 ? beta : latest;
return beta ?? latest;
}

View File

@@ -1725,7 +1725,8 @@ export { NodeConnection } from "./node-connection.js";
export { NodeDiscovery } from "./node-discovery.js";
export { getAvailableMemoryBytes, getAvailableMemoryInfo, type AvailableMemoryReading } from "./available-memory.js";
export { collectSystemMetrics } from "./system-metrics.js";
export { getAppVersion, parseSemver } from "./app-version.js";
export { getAppVersion, parseSemver, compareVersions, isVersionNewer, resolveUpdateTargetVersion } from "./app-version.js";
export type { UpdateChannel, UpdateDistTags } from "./app-version.js";
export { DockerClientService } from "./docker-client.js";
export { MeshConfigGenerator } from "./mesh-config-generator.js";
export { DockerProvisioningService } from "./docker-provisioning.js";

View File

@@ -190,6 +190,9 @@ export const DEFAULT_GLOBAL_SETTINGS = {
updateCheckEnabled: true,
fnBinaryCheckEnabled: true,
updateCheckFrequency: "daily",
// FNXC:UpdateChannels 2026-07-19-12:30: release track for update surfaces;
// "stable" follows npm dist-tag `latest`, "beta" follows max(latest, beta).
updateChannel: "stable",
autoReloadOnVersionChange: true,
githubTrackingDefaultRepo: undefined,
gitlabEnabled: undefined,

View File

@@ -14,6 +14,11 @@ import type { StalePausedTodoSignal } from "./stale-paused-todo.js";
import type { StalledReviewSignal } from "./stalled-review-detector.js";
import type { TaskAgeStalenessSignal } from "./task-age-staleness.js";
import type { SecretScope } from "./secrets-store.js";
import type { UpdateChannel } from "./app-version.js";
// FNXC:UpdateChannels 2026-07-19-12:30: re-export type-only so browser-side
// dashboard code (whose "@fusion/core" vite alias resolves to types.ts, not the
// package barrel) can name the update channel union.
export type { UpdateChannel } from "./app-version.js";
export {
computeCapacityRisk,
@@ -2892,6 +2897,21 @@ export interface GlobalSettings {
* - `weekly`: 7-day cache TTL
*/
updateCheckFrequency?: "manual" | "on-startup" | "daily" | "weekly";
/**
* FNXC:UpdateChannels 2026-07-19-12:30:
* See `UpdateChannel` in app-version.ts for the channel semantics.
* Fusion ships on two release tracks: `stable` (npm dist-tag `latest`, GitHub
* releases marked latest) and `beta` (npm dist-tag `beta`, GitHub prereleases
* tagged `vX.Y.Z-beta.N`, cut from `main`). This setting selects which track
* every update surface (CLI `fn update`, dashboard update check, desktop
* electron-updater) offers. Channel resolution: `stable` sees only `latest`;
* `beta` sees the semver-max of `latest` and `beta` so beta users are moved
* forward when a promoted stable overtakes their prerelease. Switching
* beta → stable never offers a downgrade; the user stays on their beta build
* until the next stable release surpasses it (`fn update --channel stable --force`
* is the explicit downgrade escape hatch). Default: `stable`.
*/
updateChannel?: UpdateChannel;
/** When true (default), the dashboard automatically reloads when a new build
* version is detected via /version.json polling or service worker activation.
* Set to false to suppress automatic reloads — the user must manually

View File

@@ -149,6 +149,7 @@ export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
"fnBinaryCheckEnabled",
"updateCheckEnabled",
"updateCheckFrequency",
"updateChannel",
"autoReloadOnVersionChange",
]),
/*

View File

@@ -64,6 +64,16 @@ export const globalGeneralSearchEntries: SettingsSearchEntry[] = [
*/
keywords: ["update check", "cadence", "how often", "version check"],
},
{
sectionId: "global-general",
key: "updateChannel",
labelKey: "settings.globalGeneral.releaseChannel",
labelFallback: "Release channel",
helpKey: "settings.globalGeneral.releaseChannelHelp",
helpFallback:
" Stable follows official releases. Beta follows pre-releases cut from main (versions like 0.73.0-beta.2) and also picks up each stable release once it overtakes the beta. Switching back to Stable never downgrades; you stay on the installed beta until the next stable release passes it. Default: stable. ",
keywords: ["beta", "channel", "release track", "prerelease", "early access"],
},
{
sectionId: "global-general",
key: "autoReloadOnVersionChange",

View File

@@ -122,6 +122,31 @@ export function GlobalGeneralSection({ form, setForm }: GlobalGeneralSectionProp
updateCheckFrequency: v as "manual" | "on-startup" | "daily" | "weekly",
}))}
/>
{/*
FNXC:UpdateChannels 2026-07-19-12:50:
Release track selector. `stable` follows the npm `latest` dist-tag; `beta` follows
the semver-max of `latest` and `beta` so beta users also receive promoted stables.
Deliberately NOT disabled with auto-check off: the channel also governs manual
"Check now" and `fn update`. Switching beta → stable never downgrades — the install
stays on its beta until the next stable overtakes it.
*/}
<SettingsSelectRow
descriptor={{
key: "updateChannel",
label: t("settings.globalGeneral.releaseChannel", "Release channel"),
help: t("settings.globalGeneral.releaseChannelHelp", " Stable follows official releases. Beta follows pre-releases cut from main (versions like 0.73.0-beta.2) and also picks up each stable release once it overtakes the beta. Switching back to Stable never downgrades; you stay on the installed beta until the next stable release passes it. Default: stable. "),
scope: "global",
options: [
{ value: "stable", label: t("settings.globalGeneral.channelStable", "Stable (recommended)") },
{ value: "beta", label: t("settings.globalGeneral.channelBeta", "Beta — early builds from main") },
],
}}
value={form.updateChannel ?? "stable"}
onChange={(v) => setForm((f) => ({
...f,
updateChannel: v as "stable" | "beta",
}))}
/>
<SettingsToggleRow
descriptor={{
key: "autoReloadOnVersionChange",

View File

@@ -71,6 +71,7 @@ describe("update-check", () => {
latestVersion: "0.8.3",
updateAvailable: false,
lastChecked: expect.any(Number),
channel: "stable",
});
});
@@ -190,7 +191,9 @@ describe("update-check", () => {
const result = await performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir });
expect(execFake).toHaveBeenCalledWith("npm install -g @runfusion/fusion@latest", {
// FNXC:UpdateChannels 2026-07-19-13:40: installs pin the exact resolved
// version so a beta-channel install can never silently land on `latest`.
expect(execFake).toHaveBeenCalledWith("npm install -g @runfusion/fusion@2.0.0", {
timeout: 300_000,
maxBuffer: 10 * 1024 * 1024,
});
@@ -210,8 +213,8 @@ describe("update-check", () => {
const result = await performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir });
expect(execFake).toHaveBeenCalledTimes(2);
expect(execFake).toHaveBeenNthCalledWith(1, "npm install -g @runfusion/fusion@latest", expect.any(Object));
expect(execFake).toHaveBeenNthCalledWith(2, "npm install --force -g @runfusion/fusion@latest", expect.any(Object));
expect(execFake).toHaveBeenNthCalledWith(1, "npm install -g @runfusion/fusion@2.0.0", expect.any(Object));
expect(execFake).toHaveBeenNthCalledWith(2, "npm install --force -g @runfusion/fusion@2.0.0", expect.any(Object));
expect(result).toEqual({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true });
});
@@ -244,7 +247,7 @@ describe("update-check", () => {
updated: false,
error: expect.stringMatching(/timed out after 5 minutes.*terminal/i),
});
expect(result.error).toContain("npm install -g @runfusion/fusion@latest");
expect(result.error).toContain("npm install -g @runfusion/fusion@2.0.0");
expect(result.error).not.toContain("npm install --force");
expect(result.error).not.toContain("deprecated");
});
@@ -261,7 +264,7 @@ describe("update-check", () => {
expect(execFake).toHaveBeenCalledTimes(2);
expect(result.error).toMatch(/timed out after 5 minutes.*terminal/i);
expect(result.error).toContain("npm install --force -g @runfusion/fusion@latest");
expect(result.error).toContain("npm install --force -g @runfusion/fusion@2.0.0");
expect(result.error).not.toContain("deprecated");
});
@@ -407,6 +410,7 @@ describe("update-check", () => {
latestVersion: null,
updateAvailable: false,
lastChecked: expect.any(Number),
channel: "stable",
});
// force=true (used by /update-check/refresh) overrides manual.
@@ -462,4 +466,142 @@ describe("update-check", () => {
expect(JSON.parse(cachedRaw)).toEqual(result);
});
/*
FNXC:UpdateChannels 2026-07-19-13:40:
Channel behavior invariants across all update surfaces:
- stable NEVER sees the beta dist-tag;
- beta resolves semver-max(latest, beta) so a promoted stable overtakes a beta;
- prerelease ordering is real semver (0.73.0-beta.2 < -beta.3 < 0.73.0);
- a cache written for another channel is never served.
*/
describe("release channels", () => {
const stubTags = (tags: Record<string, string>) => {
const fetchSpy = vi.fn().mockResolvedValue({ json: async () => ({ "dist-tags": tags }) });
vi.stubGlobal("fetch", fetchSpy);
return fetchSpy;
};
it("stable channel ignores the beta dist-tag entirely", async () => {
stubTags({ latest: "0.72.0", beta: "0.73.0-beta.2" });
const result = await performUpdateCheck(fusionDir, "0.72.0", { channel: "stable", force: true });
expect(result.latestVersion).toBe("0.72.0");
expect(result.updateAvailable).toBe(false);
expect(result.channel).toBe("stable");
});
it("beta channel offers the beta when it is ahead of latest", async () => {
stubTags({ latest: "0.72.0", beta: "0.73.0-beta.2" });
const result = await performUpdateCheck(fusionDir, "0.72.0", { channel: "beta", force: true });
expect(result.latestVersion).toBe("0.73.0-beta.2");
expect(result.updateAvailable).toBe(true);
expect(result.channel).toBe("beta");
});
it("beta channel offers a promoted stable once it overtakes the beta", async () => {
stubTags({ latest: "0.73.0", beta: "0.73.0-beta.4" });
const result = await performUpdateCheck(fusionDir, "0.73.0-beta.4", { channel: "beta", force: true });
expect(result.latestVersion).toBe("0.73.0");
expect(result.updateAvailable).toBe(true);
});
it("orders prerelease identifiers numerically (beta.2 < beta.10)", async () => {
stubTags({ latest: "0.72.0", beta: "0.73.0-beta.10" });
const result = await performUpdateCheck(fusionDir, "0.73.0-beta.2", { channel: "beta", force: true });
expect(result.latestVersion).toBe("0.73.0-beta.10");
expect(result.updateAvailable).toBe(true);
});
it("does not offer a stable release below the running beta (no downgrade)", async () => {
stubTags({ latest: "0.72.0", beta: "0.73.0-beta.1" });
const result = await performUpdateCheck(fusionDir, "0.73.0-beta.1", { channel: "beta", force: true });
expect(result.latestVersion).toBe("0.73.0-beta.1");
expect(result.updateAvailable).toBe(false);
});
it("stable channel does not report a running beta as updatable to an older stable", async () => {
stubTags({ latest: "0.72.0", beta: "0.73.0-beta.1" });
const result = await performUpdateCheck(fusionDir, "0.73.0-beta.1", { channel: "stable", force: true });
expect(result.latestVersion).toBe("0.72.0");
expect(result.updateAvailable).toBe(false);
});
it("ignores a fresh cache written for a different channel", async () => {
const cached: UpdateCheckResult = {
currentVersion: "0.72.0",
latestVersion: "0.72.0",
updateAvailable: false,
lastChecked: Date.now(),
channel: "stable",
};
await writeFile(join(fusionDir, "update-check.json"), JSON.stringify(cached), "utf-8");
const fetchSpy = stubTags({ latest: "0.72.0", beta: "0.73.0-beta.1" });
const result = await performUpdateCheck(fusionDir, "0.72.0", { channel: "beta" });
expect(fetchSpy).toHaveBeenCalledOnce();
expect(result.latestVersion).toBe("0.73.0-beta.1");
expect(result.updateAvailable).toBe(true);
});
it("treats a channel-less legacy cache as stable", async () => {
const cached: UpdateCheckResult = {
currentVersion: "0.72.0",
latestVersion: "0.72.1",
updateAvailable: true,
lastChecked: Date.now(),
};
await writeFile(join(fusionDir, "update-check.json"), JSON.stringify(cached), "utf-8");
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const result = await performUpdateCheck(fusionDir, "0.72.0", { channel: "stable" });
expect(fetchSpy).not.toHaveBeenCalled();
expect(result).toEqual(cached);
});
it("pins the beta version in the install command", async () => {
const execFake = vi.fn().mockResolvedValue({ stdout: "", stderr: "" });
await performUpdateInstall("0.72.0", "0.73.0-beta.2", { exec: execFake, fusionDir });
expect(execFake).toHaveBeenCalledWith("npm install -g @runfusion/fusion@0.73.0-beta.2", expect.any(Object));
});
// FNXC:UpdateChannels 2026-07-19-16:20: the exec path is only reachable
// with a strict-semver-shaped target — no null fallback to @latest (which
// would cross release tracks) and no registry-poisoned shell input.
it("refuses to install when no target version resolved (no @latest fallback)", async () => {
const execFake = vi.fn();
const result = await performUpdateInstall("0.72.0", null, { exec: execFake, fusionDir });
expect(execFake).not.toHaveBeenCalled();
expect(result.updated).toBe(false);
expect(result.error).toContain("No valid update target version");
});
it("refuses to install a non-semver registry value (shell-injection hardening)", async () => {
const execFake = vi.fn();
const result = await performUpdateInstall("0.72.0", "1.2.3; rm -rf ~", { exec: execFake, fusionDir });
expect(execFake).not.toHaveBeenCalled();
expect(result.updated).toBe(false);
expect(result.error).toContain("No valid update target version");
});
});
});

View File

@@ -23,6 +23,7 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => {
const result = await performUpdateCheck(resolveGlobalDir(), cliPackageVersion, {
frequency: globalSettings.updateCheckFrequency,
channel: globalSettings.updateChannel,
});
res.json(result);
} catch (error) {
@@ -32,12 +33,14 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => {
router.post("/update-check/refresh", async (_req, res) => {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const fusionDir = resolveGlobalDir();
await clearUpdateCheckCache(fusionDir);
// Explicit `force: true` so a "manual" frequency setting doesn't short
// out the network fetch on the user's deliberate "Check now" click.
const result = await performUpdateCheck(fusionDir, cliPackageVersion, {
force: true,
channel: globalSettings.updateChannel,
});
res.json(result);
} catch (error) {
@@ -47,9 +50,11 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => {
router.post("/update-check/install", async (_req, res) => {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const fusionDir = resolveGlobalDir();
const updateCheck = await performUpdateCheck(fusionDir, cliPackageVersion, {
force: true,
channel: globalSettings.updateChannel,
});
if (!updateCheck.updateAvailable || !updateCheck.latestVersion) {

View File

@@ -3,12 +3,13 @@ import { readFileSync, realpathSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { resolveGlobalDir } from "@fusion/core";
import { resolveGlobalDir, isVersionNewer, resolveUpdateTargetVersion } from "@fusion/core";
import type { UpdateChannel } from "@fusion/core";
const CACHE_FILENAME = "update-check.json";
const REGISTRY_URL = "https://registry.npmjs.org/@runfusion%2Ffusion";
const INSTALL_COMMAND = "npm install -g @runfusion/fusion@latest";
const FORCE_INSTALL_COMMAND = "npm install --force -g @runfusion/fusion@latest";
// FNXC:UpdateInstall 2026-07-19-09:50 (kept through channel merge): native npm
// dependencies can take >2min on Windows; installs get five minutes.
const INSTALL_TIMEOUT_MS = 300_000;
const INSTALL_MAX_BUFFER = 10 * 1024 * 1024;
@@ -24,6 +25,8 @@ export type UpdateCheckResult = {
latestVersion: string | null;
updateAvailable: boolean;
lastChecked: number;
/** Release track this result was resolved for; absent in pre-channel caches (treated as "stable"). */
channel?: UpdateChannel;
error?: string;
};
@@ -68,27 +71,29 @@ function getCachePath(fusionDir: string): string {
return join(fusionDir, CACHE_FILENAME);
}
function parseVersion(version: string): number[] {
return version
.split(".")
.slice(0, 3)
.map((part) => Number.parseInt(part, 10))
.map((value) => (Number.isFinite(value) ? value : 0));
}
/*
FNXC:UpdateChannels 2026-07-19-12:40:
Version ordering and channel resolution moved to @fusion/core (`isVersionNewer`,
`resolveUpdateTargetVersion`) so the CLI, dashboard, and desktop agree. The old
local `isRemoteNewer` ignored prerelease identifiers, which is wrong the moment
a `-beta.N` version exists (0.73.0-beta.2 vs -beta.3 compared equal).
The install command pins the exact resolved version instead of `@latest` so a
beta-channel install never silently lands on the stable dist-tag.
function isRemoteNewer(remoteVersion: string, currentVersion: string): boolean {
const remote = parseVersion(remoteVersion);
const current = parseVersion(currentVersion);
const maxLength = Math.max(remote.length, current.length, 3);
FNXC:UpdateChannels 2026-07-19-16:20:
PR #2345 review hardening: the pinned version originates from the npm
registry's dist-tags and is interpolated into a shell-executed `npm install`.
`buildInstallCommand` therefore requires a strict-semver-shaped version and
throws otherwise — no `@latest` fallback (that would silently cross release
tracks) and no path for registry-poisoned strings to reach the shell.
*/
const SAFE_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
for (let i = 0; i < maxLength; i += 1) {
const remotePart = remote[i] ?? 0;
const currentPart = current[i] ?? 0;
if (remotePart > currentPart) return true;
if (remotePart < currentPart) return false;
function buildInstallCommand(version: string, force = false): string {
if (!SAFE_VERSION_RE.test(version)) {
throw new Error(`Refusing to install: '${version}' is not a valid version string.`);
}
return false;
return `npm install${force ? " --force" : ""} -g @runfusion/fusion@${version}`;
}
function isBinCollisionInstallError(error: unknown): boolean {
@@ -115,8 +120,10 @@ function isInstallTimeoutError(error: unknown): boolean {
return installError?.killed === true;
}
function getInstallTimeoutMessage(force = false): string {
const command = force ? FORCE_INSTALL_COMMAND : INSTALL_COMMAND;
function getInstallTimeoutMessage(version: string, force = false): string {
// Channel merge: the retry hint pins the resolved version like the install
// itself — suggesting @latest would cross release tracks for beta users.
const command = buildInstallCommand(version, force);
return (
`Update timed out after ${INSTALL_TIMEOUT_MS / 60_000} minutes. ` +
`Close Fusion and retry from a terminal with: ${command}`
@@ -208,6 +215,7 @@ function isValidResult(value: unknown): value is UpdateCheckResult {
(typeof candidate.latestVersion === "string" || candidate.latestVersion === null) &&
typeof candidate.updateAvailable === "boolean" &&
typeof candidate.lastChecked === "number" &&
(candidate.channel === undefined || candidate.channel === "stable" || candidate.channel === "beta") &&
(candidate.error === undefined || typeof candidate.error === "string")
);
}
@@ -247,8 +255,19 @@ export async function performUpdateInstall(
const runExec = options.exec ?? execAsync;
const fusionDir = options.fusionDir ?? resolveGlobalDir();
// No resolved target → nothing safe to install. Callers guard this today;
// the guard here keeps the exec path unreachable if one ever stops.
if (!latestVersion || !SAFE_VERSION_RE.test(latestVersion)) {
return {
currentVersion,
latestVersion,
updated: false,
error: `No valid update target version to install${latestVersion ? ` ('${latestVersion}')` : ""}.`,
};
}
try {
await runExec(INSTALL_COMMAND, getInstallOptions());
await runExec(buildInstallCommand(latestVersion), getInstallOptions());
await clearUpdateCheckCache(fusionDir);
return {
currentVersion,
@@ -265,7 +284,7 @@ export async function performUpdateInstall(
currentVersion,
latestVersion,
updated: false,
error: getInstallTimeoutMessage(),
error: getInstallTimeoutMessage(latestVersion),
};
}
@@ -291,7 +310,7 @@ export async function performUpdateInstall(
}
try {
await runExec(FORCE_INSTALL_COMMAND, getInstallOptions());
await runExec(buildInstallCommand(latestVersion, true), getInstallOptions());
await clearUpdateCheckCache(fusionDir);
return {
currentVersion,
@@ -304,7 +323,7 @@ export async function performUpdateInstall(
latestVersion,
updated: false,
error: isInstallTimeoutError(forceError)
? getInstallTimeoutMessage(true)
? getInstallTimeoutMessage(latestVersion, true)
: getInstallErrorMessage(forceError),
};
}
@@ -314,9 +333,12 @@ export async function performUpdateInstall(
export async function performUpdateCheck(
fusionDir: string,
currentVersion: string,
options: { frequency?: UpdateCheckFrequency; force?: boolean } = {},
options: { frequency?: UpdateCheckFrequency; force?: boolean; channel?: UpdateChannel } = {},
): Promise<UpdateCheckResult> {
const now = Date.now();
// FNXC:UpdateChannels 2026-07-19-12:40: normalize once; absent = stable so
// pre-channel callers and settings keep today's behavior.
const channel: UpdateChannel = options.channel === "beta" ? "beta" : "stable";
/*
* FNXC:DesktopUpdates 2026-07-03-15:35:
@@ -328,11 +350,15 @@ export async function performUpdateCheck(
latestVersion: null,
updateAvailable: false,
lastChecked: now,
channel,
error: "Current Fusion version is unavailable",
};
}
const cached = readCachedUpdateCheck(fusionDir);
const cacheMatchesCurrentVersion = !cached || cached.currentVersion === currentVersion;
// A cache written for another channel must not be served — switching
// stable → beta should surface the beta on the next check, not after TTL.
const cacheMatchesCurrentVersion =
!cached || (cached.currentVersion === currentVersion && (cached.channel ?? "stable") === channel);
const ttl = ttlForFrequency(options.frequency);
const cacheStillFresh = cached && cacheMatchesCurrentVersion && now - cached.lastChecked < ttl;
@@ -365,6 +391,7 @@ export async function performUpdateCheck(
latestVersion: null,
updateAvailable: false,
lastChecked: now,
channel,
}
);
}
@@ -374,17 +401,22 @@ export async function performUpdateCheck(
const payload = (await response.json()) as {
"dist-tags"?: {
latest?: string;
beta?: string;
};
};
const latestVersion = typeof payload?.["dist-tags"]?.latest === "string" ? payload["dist-tags"].latest : null;
const updateAvailable = latestVersion ? isRemoteNewer(latestVersion, currentVersion) : false;
const latestVersion = resolveUpdateTargetVersion(channel, {
latest: payload?.["dist-tags"]?.latest,
beta: payload?.["dist-tags"]?.beta,
});
const updateAvailable = latestVersion ? isVersionNewer(latestVersion, currentVersion) : false;
const result: UpdateCheckResult = {
currentVersion,
latestVersion,
updateAvailable,
lastChecked: now,
channel,
};
await mkdir(fusionDir, { recursive: true });
@@ -399,6 +431,7 @@ export async function performUpdateCheck(
latestVersion: null,
updateAvailable: false,
lastChecked: now,
channel,
error: message,
};
}

View File

@@ -215,6 +215,8 @@ export function showDesktopNotification(
type AutoUpdaterLike = {
autoDownload: boolean;
autoInstallOnAppQuit: boolean;
channel?: string | null;
allowPrerelease?: boolean;
on: (event: string, handler: (...args: unknown[]) => void) => unknown;
checkForUpdates: () => Promise<unknown>;
};
@@ -307,6 +309,36 @@ function bindAutoUpdaterListeners(autoUpdater: AutoUpdaterLike): void {
});
}
/*
FNXC:UpdateChannels 2026-07-19-13:15:
The desktop updater honors the shared `updateChannel` global setting. On the
beta channel we set electron-updater's `channel` to "beta" (so it reads the
`beta*.yml` manifests published by beta desktop builds) and `allowPrerelease`
(so GitHub prereleases are considered). Stable leaves electron-updater at its
defaults: the GitHub "latest" non-prerelease release only. Failures fall back
to stable — the updater must never break because settings are unreadable.
*/
async function applyConfiguredUpdateChannel(autoUpdater: AutoUpdaterLike): Promise<void> {
let channel: "stable" | "beta" = "stable";
try {
const { GlobalSettingsStore } = await import("@fusion/core");
const store = new GlobalSettingsStore();
await store.init();
const settings = await store.getSettings();
channel = settings.updateChannel === "beta" ? "beta" : "stable";
} catch (error) {
console.warn("[desktop/native] Could not read update channel setting; defaulting to stable", error);
}
if (channel === "beta") {
autoUpdater.channel = "beta";
autoUpdater.allowPrerelease = true;
} else {
autoUpdater.channel = null;
autoUpdater.allowPrerelease = false;
}
}
export function setupAutoUpdater(mainWindow?: BrowserWindow): void {
if (mainWindow) {
autoUpdaterWindow = mainWindow;
@@ -320,6 +352,7 @@ export function setupAutoUpdater(mainWindow?: BrowserWindow): void {
}
bindAutoUpdaterListeners(autoUpdater);
await applyConfiguredUpdateChannel(autoUpdater);
if (hasRunInitialUpdateCheck) {
return;
@@ -346,6 +379,9 @@ export async function triggerUpdateCheck(
}
try {
// Re-read the channel on every manual check so a settings change takes
// effect without an app restart.
await applyConfiguredUpdateChannel(autoUpdater);
await autoUpdater.checkForUpdates();
return { status: "checking" };
} catch (error) {

View File

@@ -7,7 +7,8 @@
// If you want provenance, run the workflow manually instead of this script.
//
// Requirements:
// - clean working tree on `main`, up to date with origin
// - clean working tree on the channel's branch (`main` for --channel beta,
// `release` for stable), up to date with origin
// - at least one pending changeset in .changeset/
// - `npm login` already completed (publish uses the active npm token)
// - real releases require a live operator to type the authorization phrase
@@ -20,6 +21,15 @@
// pnpm release --dry-run # preview only; non-interactive by default; no authorization or file/git/npm changes
// pnpm release --dry-run --interactive
// # preview only, but exercise the version prompt override
// pnpm release --channel beta # beta release from `main`: enters changesets pre-mode,
// # versions X.Y.Z-beta.N, publishes npm dist-tag `beta`,
// # GitHub prerelease; skips Homebrew tap + X draft
// pnpm release --channel stable # stable release from the `release` branch:
// # exits pre-mode if present, publishes dist-tag `latest`,
// # GitHub release marked latest, bumps Homebrew tap
//
// Without --channel, the script prompts for the channel; the default answer
// (and the silent default for --yes / non-interactive dry-runs) is BETA.
import { spawnSync } from "node:child_process";
import { readFileSync, readdirSync, writeFileSync, statSync, existsSync, unlinkSync, mkdtempSync, rmSync } from "node:fs";
@@ -44,7 +54,8 @@ import {
partitionVersionsByCutoff,
} from "./lib/changelog-archive.mjs";
const args = new Set(process.argv.slice(2));
const argv = process.argv.slice(2);
const args = new Set(argv);
/*
* FNXC:ReleaseScript 2026-06-14-23:08:
* `--dry-run` must not read stdin in the default agent-shell path; `--interactive` is the explicit maintainer override for prompt coverage while preserving real-release prompts.
@@ -53,6 +64,60 @@ const DRY_RUN = args.has("--dry-run");
const AUTO_YES = args.has("--yes") || args.has("-y");
const INTERACTIVE = args.has("--interactive");
/*
* FNXC:UpdateChannels 2026-07-19-13:20:
* Two release tracks (see docs/plans/2026-07-19-001-beta-stable-release-tracks-plan.md):
* - `--channel beta` runs on `main`, uses changesets pre-mode (auto `pre enter beta`),
* publishes to the npm `beta` dist-tag, tags vX.Y.Z-beta.N, and creates a GitHub
* PRERELEASE. Homebrew tap and the X draft are stable-only and skipped.
* - `--channel stable` runs on the long-lived `release` branch, exits
* pre-mode if `.changeset/pre.json` was merged in from main, publishes to `latest`,
* marks the GitHub Release latest, and bumps the Homebrew tap. After a stable
* release the operator back-merges `release` into `main` (commands are printed).
* The publish command ALWAYS passes an explicit `--tag`: a beta accidentally landing
* on `latest` is the one unrecoverable-embarrassing failure of this scheme.
*/
const channelFlagIndex = argv.indexOf("--channel");
let CHANNEL = channelFlagIndex !== -1 ? argv[channelFlagIndex + 1] : null;
if (CHANNEL !== null && CHANNEL !== "stable" && CHANNEL !== "beta") {
console.error(`✗ Invalid --channel '${CHANNEL ?? ""}'. Valid channels: stable, beta.`);
process.exit(1);
}
/*
* FNXC:UpdateChannels 2026-07-19-14:30:
* Without an explicit --channel, the operator is prompted to pick one, and the
* default is BETA: day-to-day releases are betas cut from main, while stable
* promotions are deliberate (release branch) and must be chosen explicitly
* (answer "stable" or pass --channel stable). The prompt obeys the same gate
* as the version prompt (shouldPromptForVersion): non-interactive dry-runs and
* --yes runs never read stdin and silently default to beta.
*/
if (CHANNEL === null) {
if (shouldPromptForVersion({ dryRun: DRY_RUN, autoYes: AUTO_YES, interactive: INTERACTIVE })) {
while (true) {
const answer = (await ask("Release channel — beta or stable? [beta]: ")).toLowerCase();
if (answer === "" || answer === "beta" || answer === "b") {
CHANNEL = "beta";
break;
}
if (answer === "stable" || answer === "s") {
CHANNEL = "stable";
break;
}
console.log(` Not a channel: '${answer}'. Answer 'beta' or 'stable'.`);
}
} else {
CHANNEL = "beta";
console.log("No --channel given; defaulting to the beta channel. Pass --channel stable for a stable release.");
}
}
const IS_BETA = CHANNEL === "beta";
const RELEASE_BRANCH = IS_BETA ? "main" : "release";
const NPM_DIST_TAG = IS_BETA ? "beta" : "latest";
const PRE_JSON_PATH = join(".changeset", "pre.json");
const color = (c, s) => `\x1b[${c}m${s}\x1b[0m`;
const info = (s) => console.log(color(36, "▶ ") + s);
const ok = (s) => console.log(color(32, "✓ ") + s);
@@ -444,7 +509,12 @@ function cleanupSmoke(dir) {
* can re-run the bump manually if needed; the release itself is already out.
*/
function bumpHomebrewTap(version) {
const formulaPath = join("homebrew-tap", "Formula", "fusion.rb");
// FNXC:UpdateChannels 2026-07-19-15:10: the tap clone is gitignored and only
// exists in the primary checkout. Assisted promotion runs this script from a
// temporary worktree and passes the primary checkout's tap path via
// FUSION_HOMEBREW_TAP_DIR so stable releases still bump the formula.
const tapDir = process.env.FUSION_HOMEBREW_TAP_DIR || "homebrew-tap";
const formulaPath = join(tapDir, "Formula", "fusion.rb");
if (!existsSync(formulaPath)) {
warn(`Homebrew tap formula not found at ${formulaPath} — skipping tap bump.`);
return;
@@ -488,7 +558,7 @@ function bumpHomebrewTap(version) {
// homebrew-tap is a sibling clone (gitignored in this repo) with its own git
// history; run git inside that working tree, not the main repo.
const tapCwd = "homebrew-tap";
const tapCwd = tapDir;
run(`git add Formula/fusion.rb`, { cwd: tapCwd });
const commit = run(
`git commit -m "chore(tap): bump fusion to v${version}" -m "Auto-bumped by scripts/release.mjs after npm publish."`,
@@ -524,21 +594,182 @@ function findPackageDir(name) {
return null;
}
// --- Stable promotion from main --------------------------------------------
/*
* FNXC:UpdateChannels 2026-07-19-15:10:
* Choosing the stable channel while on `main` triggers assisted promotion
* instead of a hard fail. The operator picks a commit to promote (default:
* the newest v*-beta* tag reachable from HEAD — promotion blesses a tested
* beta, not whatever main drifted to), the script verifies `release`
* fast-forwards to it, then creates a TEMPORARY git worktree on `release` and
* re-runs itself there with --channel stable. The primary checkout never
* leaves `main` (repo standing rule: branch work happens in worktrees).
* The tap clone lives at <primary-root>/homebrew-tap and is gitignored, so it
* does not exist inside the temp worktree — its path is handed to the child
* via FUSION_HOMEBREW_TAP_DIR so the stable tap bump still works.
* Dry-runs stop after reporting the promotion plan: creating the worktree
* would move the local `release` ref, and dry-run must mutate nothing.
*/
if (!IS_BETA && run("git rev-parse --abbrev-ref HEAD", { capture: true }).stdout === "main") {
info("Stable release requested from 'main' — starting assisted promotion to the 'release' branch.");
const latestBetaTag = run("git tag --list 'v*-beta*' --merged HEAD --sort=-v:refname", { capture: true })
.stdout.split("\n")[0]?.trim() ?? "";
let promoteTarget = latestBetaTag;
if (!promoteTarget) {
warn("No v*-beta* tag is reachable from HEAD; defaulting to HEAD (promoting an untested tip — prefer promoting a beta tag).");
promoteTarget = "HEAD";
}
if (shouldPromptForVersion({ dryRun: DRY_RUN, autoYes: AUTO_YES, interactive: INTERACTIVE })) {
const answer = await ask(`Promote which commit/tag to 'release'? [${promoteTarget}]: `);
if (answer !== "") promoteTarget = answer;
} else {
info(`Non-interactive: promoting ${promoteTarget}.`);
}
const targetSha = run(`git rev-parse --verify --quiet ${promoteTarget}^{commit}`, { capture: true, allowFail: true });
if (targetSha.status !== 0 || !targetSha.stdout) {
fail(`'${promoteTarget}' does not resolve to a commit.`);
}
const promoteSha = targetSha.stdout;
const originReleaseExists = run("git fetch origin release", { capture: true, allowFail: true }).status === 0;
const localReleaseExists = run("git show-ref --verify --quiet refs/heads/release", { capture: true, allowFail: true }).status === 0;
const releaseBase = localReleaseExists ? "release" : originReleaseExists ? "origin/release" : null;
if (releaseBase) {
const ff = run(`git merge-base --is-ancestor ${releaseBase} ${promoteSha}`, { capture: true, allowFail: true });
if (ff.status !== 0) {
fail(
`'${releaseBase}' does not fast-forward to ${promoteTarget} (${promoteSha.slice(0, 10)}).\n` +
" The release branch has commits (hotfixes?) that are not on main. Merge or rebase manually,\n" +
" then run the stable release from a 'release' worktree.",
);
}
} else {
warn("No 'release' branch exists yet (local or origin); it will be bootstrapped at the promoted commit.");
}
if (DRY_RUN) {
warn("--dry-run: stopping before promotion. No worktree created, no branch moved.");
info(`Would promote ${promoteTarget} (${promoteSha.slice(0, 10)}) to 'release' via a temporary worktree and run the stable release there.`);
process.exit(0);
}
const promoteDir = mkdtempSync(join(tmpdir(), "fusion-release-promote-"));
info(`Creating temporary release worktree at ${promoteDir}…`);
if (localReleaseExists) {
run(`git worktree add "${promoteDir}" release`);
run(`git merge --ff-only ${promoteSha}`, { cwd: promoteDir });
} else if (originReleaseExists) {
run(`git worktree add -b release "${promoteDir}" origin/release`);
run(`git merge --ff-only ${promoteSha}`, { cwd: promoteDir });
} else {
run(`git worktree add -b release "${promoteDir}" ${promoteSha}`);
}
info("Installing dependencies in the promotion worktree (fresh checkout)…");
run("pnpm install --prefer-offline", { cwd: promoteDir });
info("Re-running the release inside the promotion worktree (authorization prompts continue there)…");
const passThroughArgs = [
join("scripts", "release.mjs"),
"--channel", "stable",
...(AUTO_YES ? ["--yes"] : []),
...(INTERACTIVE ? ["--interactive"] : []),
];
const child = spawnSync(process.execPath, passThroughArgs, {
cwd: promoteDir,
stdio: "inherit",
env: { ...process.env, FUSION_HOMEBREW_TAP_DIR: resolve("homebrew-tap") },
});
if (child.status === 0) {
// node_modules makes the worktree "dirty" to git; --force is required and safe here.
const removed = run(`git worktree remove --force "${promoteDir}"`, { capture: true, allowFail: true });
if (removed.status !== 0) {
warn(`Could not remove promotion worktree; clean up manually: git worktree remove --force "${promoteDir}"`);
} else {
ok("Promotion worktree removed.");
}
info("Reminder: back-merge 'release' into 'main' from this checkout (commands were printed above).");
} else {
warn(`Stable release in the promotion worktree exited with status ${child.status ?? "unknown"}.`);
warn(`Worktree kept for inspection: ${promoteDir}`);
}
process.exit(child.status ?? 1);
}
// --- Preflight ------------------------------------------------------------
info("Preflight checks…");
info(`Preflight checks (${CHANNEL} channel)…`);
const branch = run("git rev-parse --abbrev-ref HEAD", { capture: true }).stdout;
if (branch !== "main") fail(`Must be on 'main' (currently '${branch}').`);
if (branch !== RELEASE_BRANCH) {
if (IS_BETA) {
fail(`Beta releases are cut from 'main' (currently '${branch}').`);
}
fail(
`Stable releases are cut from the '${RELEASE_BRANCH}' branch (currently '${branch}').\n` +
` To promote: merge/fast-forward '${RELEASE_BRANCH}' to the chosen beta commit on main, then release there.\n` +
` First-time bootstrap: git branch ${RELEASE_BRANCH} main && git push -u origin ${RELEASE_BRANCH}\n` +
` For a beta from main, run: pnpm release --channel beta`,
);
}
const dirty = run("git status --porcelain", { capture: true }).stdout;
if (dirty) fail("Working tree is not clean. Commit or stash first.");
run("git fetch origin main", { capture: true });
const ahead = run("git rev-list --count origin/main..HEAD", { capture: true }).stdout;
const behind = run("git rev-list --count HEAD..origin/main", { capture: true }).stdout;
if (behind !== "0") fail(`Local main is behind origin/main by ${behind} commit(s). Pull first.`);
if (ahead !== "0") warn(`Local main is ahead of origin/main by ${ahead} commit(s); they will be pushed.`);
// The remote release branch may not exist yet on the first stable promotion;
// fall back to a warning and let the final push create it with -u.
const fetchRemote = run(`git fetch origin ${RELEASE_BRANCH}`, { capture: true, allowFail: true });
let remoteBranchExists = fetchRemote.status === 0;
if (remoteBranchExists) {
const ahead = run(`git rev-list --count origin/${RELEASE_BRANCH}..HEAD`, { capture: true }).stdout;
const behind = run(`git rev-list --count HEAD..origin/${RELEASE_BRANCH}`, { capture: true }).stdout;
if (behind !== "0") fail(`Local ${RELEASE_BRANCH} is behind origin/${RELEASE_BRANCH} by ${behind} commit(s). Pull first.`);
if (ahead !== "0") warn(`Local ${RELEASE_BRANCH} is ahead of origin/${RELEASE_BRANCH} by ${ahead} commit(s); they will be pushed.`);
} else {
warn(`origin/${RELEASE_BRANCH} does not exist yet; the release push will create it.`);
}
/*
* FNXC:UpdateChannels 2026-07-19-13:20:
* Changesets pre-mode is the version engine for the beta track. In pre-mode,
* `changeset version` bumps to X.Y.Z-beta.N while PRESERVING the changeset
* .md files (recording them in pre.json), so the eventual stable release on
* the `release` branch aggregates every changeset across all betas after
* `changeset pre exit`. Beta auto-enters pre-mode here; stable auto-exits.
* Dry-runs revert whichever pre-mode mutation they made before exiting.
*/
let preModeMutation = "none"; // "entered" | "exited" | "none"
const preJsonExists = () => existsSync(PRE_JSON_PATH) && JSON.parse(readFileSync(PRE_JSON_PATH, "utf8")).mode === "pre";
if (IS_BETA) {
if (!preJsonExists()) {
info("Entering changesets pre-mode (beta)…");
run("pnpm changeset pre enter beta");
preModeMutation = "entered";
}
} else if (existsSync(PRE_JSON_PATH) && preJsonExists()) {
info("Exiting changesets pre-mode (promoting to stable)…");
run("pnpm changeset pre exit");
preModeMutation = "exited";
}
function revertDryRunPreModeMutation() {
if (preModeMutation === "entered") {
// `pre enter` only creates/rewrites pre.json; restore or remove it.
const tracked = run(`git ls-files --error-unmatch ${PRE_JSON_PATH}`, { capture: true, allowFail: true });
if (tracked.status === 0) {
run(`git checkout -- ${PRE_JSON_PATH}`);
} else {
try { unlinkSync(PRE_JSON_PATH); } catch { /* best-effort */ }
}
} else if (preModeMutation === "exited") {
run(`git checkout -- ${PRE_JSON_PATH}`);
}
}
const changesetSummaries = readChangesetSummaries();
if (changesetSummaries.length === 0) {
@@ -579,7 +810,7 @@ if (chosenVersion !== proposedVersion) {
if (DRY_RUN) {
warn("--dry-run: stopping before version bump. No files modified, no commit, no publish, no tag.");
info(`Would release v${chosenVersion} (${releases.length} package(s) bumped).`);
info(`Would release v${chosenVersion} on the ${CHANNEL} channel (npm dist-tag '${NPM_DIST_TAG}'${IS_BETA ? ", GitHub prerelease" : ", GitHub latest + Homebrew tap bump"}) with ${releases.length} package(s) bumped.`);
/*
* FNXC:ReleaseScript 2026-07-13-15:25:
* Dry-run previews the LLM-authored Highlights + X draft (falls back to
@@ -597,6 +828,9 @@ if (DRY_RUN) {
console.log(dryDistilled.tweet);
console.log(color(90, `(${dryDistilled.tweet.length}/280 chars; source: ${dryDistilled.source})`));
console.log(color(36, "──────────────────────────────────"));
// A dry-run must leave the tree exactly as it found it, including the
// pre-mode enter/exit performed to compute the channel's release plan.
revertDryRunPreModeMutation();
process.exit(0);
}
@@ -625,7 +859,7 @@ if (releaseAuthorization.mode === "requires-confirmation") {
}
}
if (!(await confirm(`Proceed with release v${chosenVersion} (build, publish, tag)?`))) {
if (!(await confirm(`Proceed with ${CHANNEL} release v${chosenVersion} (build, publish to npm tag '${NPM_DIST_TAG}', tag)?`))) {
warn("Aborted by user.");
process.exit(0);
}
@@ -716,13 +950,18 @@ ok("Pre-publish smoke passed.");
// --- Publish --------------------------------------------------------------
info("Publishing to npm (non-private packages only)…");
run("pnpm -r publish --access public --no-git-checks");
/*
* FNXC:UpdateChannels 2026-07-19-13:20:
* ALWAYS pass an explicit --tag. Relying on npm's implicit default (`latest`)
* is how a beta would pollute the stable track for every `fn update` user.
*/
info(`Publishing to npm dist-tag '${NPM_DIST_TAG}' (non-private packages only)…`);
run(`pnpm -r publish --access public --no-git-checks --tag ${NPM_DIST_TAG}`);
// --- Push + tag -----------------------------------------------------------
info("Pushing commit to origin/main…");
run("git push origin main");
info(`Pushing commit to origin/${RELEASE_BRANCH}…`);
run(remoteBranchExists ? `git push origin ${RELEASE_BRANCH}` : `git push -u origin ${RELEASE_BRANCH}`);
info(`Creating and pushing tag v${version}…`);
run(`git tag v${version}`);
@@ -731,8 +970,13 @@ run(`git push origin v${version}`);
// --- Homebrew tap bump ----------------------------------------------------
// Sync homebrew-tap/Formula/fusion.rb (url + sha256) to the new version so
// `brew install runfusion/tap/fusion` stays in lockstep with npm.
info("Bumping homebrew tap formula…");
bumpHomebrewTap(version);
// The tap tracks the STABLE channel only — betas never touch the formula.
if (IS_BETA) {
info("Beta channel: skipping Homebrew tap bump (tap tracks stable only).");
} else {
info("Bumping homebrew tap formula…");
bumpHomebrewTap(version);
}
// --- GitHub Release ------------------------------------------------------
@@ -741,9 +985,14 @@ const changelogContent = readFileSync("CHANGELOG.md", "utf8");
const releaseNotes = extractVersionNotes(changelogContent, version);
const ghCheck = spawnSync("gh", ["--version"], { stdio: "pipe" });
// Betas are GitHub PRERELEASES; only stable releases carry --latest so the
// desktop stable auto-updater (which follows the GitHub "latest" release) and
// the /releases/latest URL never see a beta.
const ghReleaseTypeFlag = IS_BETA ? "--prerelease" : "--latest";
if (ghCheck.status !== 0) {
githubReleaseStatus = "missing-gh";
warn(`⚠ gh CLI not found. Create the GitHub Release manually:\n gh release create v${version} --title "v${version}" --latest`);
warn(`⚠ gh CLI not found. Create the GitHub Release manually:\n gh release create v${version} --title "v${version}" ${ghReleaseTypeFlag}`);
} else {
let notesFile;
try {
@@ -753,7 +1002,7 @@ if (ghCheck.status !== 0) {
const ghCreate = spawnSync(
"gh",
["release", "create", `v${version}`, "--title", `v${version}`, "--notes-file", notesFile, "--latest"],
["release", "create", `v${version}`, "--title", `v${version}`, "--notes-file", notesFile, ghReleaseTypeFlag],
{ stdio: "inherit" }
);
@@ -772,21 +1021,41 @@ if (ghCheck.status !== 0) {
}
if (githubReleaseStatus === "created") {
ok(`Released v${version}. Published to npm, tag pushed, GitHub Release created.`);
ok(`Released v${version} (${CHANNEL}). Published to npm tag '${NPM_DIST_TAG}', tag pushed, GitHub ${IS_BETA ? "prerelease" : "Release"} created.`);
} else if (githubReleaseStatus === "missing-gh") {
ok(`Released v${version}. Published to npm, tag pushed. GitHub Release skipped (gh CLI not found).`);
ok(`Released v${version} (${CHANNEL}). Published to npm tag '${NPM_DIST_TAG}', tag pushed. GitHub Release skipped (gh CLI not found).`);
} else {
ok(`Released v${version}. Published to npm, tag pushed. GitHub Release was not created (see warnings above).`);
ok(`Released v${version} (${CHANNEL}). Published to npm tag '${NPM_DIST_TAG}', tag pushed. GitHub Release was not created (see warnings above).`);
}
/*
* FNXC:UpdateChannels 2026-07-19-13:20:
* A stable release consumes the changesets + pre.json state on the `release`
* branch; main must pick that up (consumed changesets, changelogs, version,
* deleted pre.json) or the next beta double-releases old changesets. The merge
* can conflict if main moved during promotion, so print the commands instead
* of force-running them — fail-soft by design.
*/
if (!IS_BETA) {
console.log("");
info("Next step — back-merge the release branch into main:");
console.log(` git checkout main && git pull origin main`);
console.log(` git merge ${RELEASE_BRANCH} -m "chore(release): back-merge v${version} from ${RELEASE_BRANCH}"`);
console.log(` git push origin main`);
console.log(color(90, " (The next beta on main will re-enter pre-mode automatically.)"));
}
/*
* FNXC:ReleaseScript 2026-07-13-15:25:
* After a successful publish/tag, print the LLM-authored X draft (≤280 chars)
* produced during distillation so the operator can copy-paste to X.
* FNXC:UpdateChannels 2026-07-19-13:20: stable-only — betas are not announced.
*/
console.log("");
console.log(color(36, "─── Draft post for X (copy-paste) ───"));
console.log(releaseTweet);
console.log(color(90, `(${releaseTweet.length}/280 chars; source: ${distillSource})`));
console.log(color(36, "─────────────────────────────────────"));
if (!IS_BETA) {
console.log("");
console.log(color(36, "─── Draft post for X (copy-paste) ───"));
console.log(releaseTweet);
console.log(color(90, `(${releaseTweet.length}/280 chars; source: ${distillSource})`));
console.log(color(36, "─────────────────────────────────────"));
}