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`. --- [](https://github.com/EveryInc/compound-engineering-plugin)  <!-- 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:
7
.changeset/beta-stable-release-channels.md
Normal file
7
.changeset/beta-stable-release-channels.md
Normal 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`.
|
||||
25
.github/workflows/release.yml
vendored
25
.github/workflows/release.yml
vendored
@@ -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/*
|
||||
|
||||
12
.github/workflows/version.yml
vendored
12
.github/workflows/version.yml
vendored
@@ -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
|
||||
|
||||
60
RELEASING.md
60
RELEASING.md
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
98
docs/plans/2026-07-19-001-beta-stable-release-tracks-plan.md
Normal file
98
docs/plans/2026-07-19-001-beta-stable-release-tracks-plan.md
Normal 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.
|
||||
@@ -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. |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -149,6 +149,7 @@ export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
|
||||
"fnBinaryCheckEnabled",
|
||||
"updateCheckEnabled",
|
||||
"updateCheckFrequency",
|
||||
"updateChannel",
|
||||
"autoReloadOnVersionChange",
|
||||
]),
|
||||
/*
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, "─────────────────────────────────────"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user