diff --git a/.changeset/beta-stable-release-channels.md b/.changeset/beta-stable-release-channels.md new file mode 100644 index 0000000000..b64ae75a4e --- /dev/null +++ b/.changeset/beta-stable-release-channels.md @@ -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 `. +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`. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f6d1c9309f..622d0994ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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-.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/* diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index 800b9d3bc7..19fac7f499 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -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 diff --git a/RELEASING.md b/RELEASING.md index a12f2b8669..89f34fa364 100644 --- a/RELEASING.md +++ b/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 `. + +| 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 ` | Cross-compile for a specific platform | diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 92d321af87..5955622497 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -126,23 +126,34 @@ onboarding does not auto-launch. ## `fn update` -Check for and install the latest `@runfusion/fusion` CLI release from npm. + + +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@`). This is the default behavior. | +| `--json` | Output machine-readable status: `currentVersion`, `latestVersion`, `updateAvailable`, `updated`, `channel`. | +| `--channel ` | 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). --- diff --git a/docs/plans/2026-07-19-001-beta-stable-release-tracks-plan.md b/docs/plans/2026-07-19-001-beta-stable-release-tracks-plan.md new file mode 100644 index 0000000000..0a36d43ad6 --- /dev/null +++ b/docs/plans/2026-07-19-001-beta-stable-release-tracks-plan.md @@ -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 ` (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@`) 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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 72b5533365..d9721c66fb 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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: . | | `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. | diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 27bd580c37..a28dde080e 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -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 ] [--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 , --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 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; } diff --git a/packages/cli/src/commands/__tests__/update.test.ts b/packages/cli/src/commands/__tests__/update.test.ts index 5681ac0052..23dec8a5e5 100644 --- a/packages/cli/src/commands/__tests__/update.test.ts +++ b/packages/cli/src/commands/__tests__/update.test.ts @@ -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>(), })); 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; + 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; + 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"); + }); + }); }); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index a4477809ee..676257996c 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -158,6 +158,7 @@ type StartupUpdateStatus = { updateAvailable: true; latestVersion: string; currentVersion: string; + channel?: "stable" | "beta"; }; async function resolveCachedStartupUpdateStatus(importMetaUrl: string): Promise { @@ -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 { diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 1f47f4b156..35df2841df 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -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 ` 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 { +async function fetchChannelTargetVersion(channel: UpdateChannel): Promise { 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 { +async function installVersion(globalInstall: boolean, version: string, resolveBinaryPath: () => string | null = detectRunningBinaryPath): Promise { 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 { 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 { 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 { } } - 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 { latestVersion, updateAvailable, updated: false, + channel, }; if (jsonOutput) { @@ -275,12 +330,13 @@ export async function runUpdate(options: RunUpdateOptions = {}): Promise { 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 { } 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 { const updatedStatus: UpdateStatus = { currentVersion, latestVersion, - updateAvailable: true, + updateAvailable, updated: true, + channel, }; if (jsonOutput) { diff --git a/packages/cli/src/update-cache.ts b/packages/cli/src/update-cache.ts index ec087c4a8c..f2cbdb0fc2 100644 --- a/packages/cli/src/update-cache.ts +++ b/packages/cli/src/update-cache.ts @@ -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 { 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 ` both uses AND persists the +choice so the dashboard and desktop follow along. Absent/invalid = "stable". +*/ +export async function getConfiguredUpdateChannel(): Promise { + 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 { + const store = new GlobalSettingsStore(); + await store.init(); + const settings = await store.getSettings(); + if (settings.updateChannel === channel) return; + await store.updateSettings({ updateChannel: channel }); +} diff --git a/packages/core/src/__tests__/app-version.test.ts b/packages/core/src/__tests__/app-version.test.ts index a993a96f5c..1503105c9c 100644 --- a/packages/core/src/__tests__/app-version.test.ts +++ b/packages/core/src/__tests__/app-version.test.ts @@ -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(); + }); +}); diff --git a/packages/core/src/app-version.ts b/packages/core/src/app-version.ts index 7711998a5f..cb16fce96c 100644 --- a/packages/core/src/app-version.ts +++ b/packages/core/src/app-version.ts @@ -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; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ef64a83abe..678ad10397 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 28f5435544..47942e4305 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -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, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 33b05bb3ec..a533ba99c8 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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 diff --git a/packages/dashboard/app/components/settings/save-split.ts b/packages/dashboard/app/components/settings/save-split.ts index 6f582c868a..9c33d3bb2a 100644 --- a/packages/dashboard/app/components/settings/save-split.ts +++ b/packages/dashboard/app/components/settings/save-split.ts @@ -149,6 +149,7 @@ export const GLOBAL_SECTION_KEYS: Record> = { "fnBinaryCheckEnabled", "updateCheckEnabled", "updateCheckFrequency", + "updateChannel", "autoReloadOnVersionChange", ]), /* diff --git a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.search.ts b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.search.ts index 9aae662c17..e56da7fbcf 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.search.ts +++ b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.search.ts @@ -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", diff --git a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx index a8351e2567..98ecea0fa2 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx @@ -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. + */} + setForm((f) => ({ + ...f, + updateChannel: v as "stable" | "beta", + }))} + /> { 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) => { + 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"); + }); + }); }); diff --git a/packages/dashboard/src/routes/register-update-check-routes.ts b/packages/dashboard/src/routes/register-update-check-routes.ts index 1c1f019ac3..51ace54134 100644 --- a/packages/dashboard/src/routes/register-update-check-routes.ts +++ b/packages/dashboard/src/routes/register-update-check-routes.ts @@ -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) { diff --git a/packages/dashboard/src/update-check.ts b/packages/dashboard/src/update-check.ts index f8ad734ab0..56ec5e7f4f 100644 --- a/packages/dashboard/src/update-check.ts +++ b/packages/dashboard/src/update-check.ts @@ -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 { 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, }; } diff --git a/packages/desktop/src/native.ts b/packages/desktop/src/native.ts index 6d2928bf89..6e8dc62543 100644 --- a/packages/desktop/src/native.ts +++ b/packages/desktop/src/native.ts @@ -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; }; @@ -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 { + 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) { diff --git a/scripts/release.mjs b/scripts/release.mjs index e902abe624..ae1530e974 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -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 /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, "─────────────────────────────────────")); +}