Files
fusion/RELEASING.md
gsxdsm 2302fb8a3d feat: add beta/stable release tracks with switchable update channel (#2345)
## Summary

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

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

## How releasing works now

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

## Design decisions

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

## Test plan

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

---

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


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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:34:25 -07:00

12 KiB
Raw Blame History

Releasing

This project uses changesets for automated versioning and release management. Releases are distributed through two channels:

  1. npm packages — published automatically via version.yml using changesets
  2. GitHub Release with platform binaries — built and uploaded via release.yml when a version tag is pushed

How it works

1. Add a changeset

When you make a change that should be included in a release, add a changeset:

pnpm changeset

This will prompt you to:

  • Select which packages are affected
  • Choose the semver bump type (patch, minor, major)
  • Write a summary of the change

Then edit the created changeset file to use the structured body format:

---
"@runfusion/fusion": minor
---

summary: Add a Command Center productivity control for LOC backfills.
category: feature
dev: Uses the new `fn_backfill_loc` tool; settings key `commandCenter.locBackfill`.

Fields:

  • summary (required) — one line, user-facing, max 120 chars.
  • category (required) — one of: feature, fix, breaking, security, performance, internal.
  • dev (optional) — developer/migration detail.

A markdown file will be created in the .changeset/ directory. Commit this file along with your code changes. Validate with pnpm check:changesets.

2. Version PR is created automatically

When changesets are merged to main, the version.yml workflow automatically opens (or updates) a "Version Packages" pull request. This PR:

  • Consumes all pending changeset files
  • Bumps package versions according to the changeset declarations
  • Generates/updates CHANGELOG.md files for affected packages
  • Distills the version's changeset summaries into grouped, end-user-facing release notes in the root CHANGELOG.md via Claude (claude -p --model sonnet): a Highlights section (top 3–5) plus category groups, and prints an engagement-oriented X draft (≤280 chars) after a local release

3. Merge the Version PR to release

When you merge the Version Packages PR:

  • The version.yml workflow detects that all changesets have been consumed
  • It builds all packages and publishes them to npm with provenance attestation
  • It creates a git tag v{version} based on the kb CLI package version
  • The tag push triggers release.yml, which:
    • Builds platform-specific binaries for Linux x64, macOS x64, macOS arm64, and Windows x64
    • Builds Android release assets: signed fusion-android-release.apk + fusion-android-release.aab when Android signing secrets are configured, otherwise unsigned debug fusion-android.apk
    • Signs macOS binaries (codesign + notarization), Windows binaries (Authenticode), and Android release artifacts when keystore secrets are available
    • Generates SHA256 checksums for all binaries and Android artifacts
    • Creates a GitHub Release with all binaries, Android artifacts, and checksums attached

Release tracks: beta and stable

Fusion ships on two tracks. Users pick theirs with the updateChannel global setting (Settings → General → Release channel) or fn update --channel <stable|beta>.

Track Cut from Version shape npm dist-tag GitHub Release Homebrew
beta main X.Y.Z-beta.N beta prerelease —
stable release branch X.Y.Z latest latest bumped

Beta release (from main)

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:

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

Platform Binary name Signed
Linux x64 fusion-linux-x64 —
macOS arm64 fusion-darwin-arm64 ✓ (codesign + notarization)
Windows x64 fusion-windows-x64.exe ✓ (Authenticode)
Android fusion-android-release.apk, fusion-android-release.aab ✓ when Android keystore secrets are configured
Android fallback fusion-android.apk — (debug/unsigned APK when Android keystore secrets are absent)

macOS Intel (darwin-x64) is intentionally not shipped: the CLI is Apple-Silicon-only because macos-13 GitHub runners are too scarce to build reliably. The desktop macOS DMG/ZIP remains universal.

Android release signing

release.yml and the tag-less test-release.yml rehearsal workflow publish signed Android release artifacts when all Android signing secrets are configured:

  • ANDROID_KEYSTORE_BASE64 — base64-encoded .jks / .keystore file
  • ANDROID_KEYSTORE_PASSWORD
  • ANDROID_KEY_ALIAS
  • ANDROID_KEY_PASSWORD

Encode the keystore before saving it as a GitHub Actions secret:

base64 -w0 release.keystore

The Android native project under packages/mobile/android/ is generated and gitignored, so CI does not commit signing configuration into Gradle files. Instead, the release job injects signing at build time with Android Gradle Plugin android.injected.signing.* properties, builds assembleRelease and bundleRelease, verifies the APK signature, and uploads fusion-android-release.apk, fusion-android-release.aab, and matching .sha256 files. If ANDROID_KEYSTORE_BASE64 is absent, the workflow preserves the secret-free path by building the unsigned debug APK as fusion-android.apk with fusion-android.apk.sha256.

Automated Play Store / Play Console upload is intentionally out of scope for the release pipeline right now. It needs a Google service-account JSON secret, a published Play listing, and fastlane or r0adkll/upload-google-play wiring; that work is tracked separately in FN-7043 while Fusion remains sideload-first for pre-1.0 Android distribution.

Testing binary builds

Use the Test Release workflow (test-release.yml) to manually test binary builds without creating a real release:

  1. Go to Actions → Test Release → Run workflow
  2. The workflow builds all 4 platform binaries plus the Android APK/AAB path (signed when Android signing secrets are available, unsigned debug APK otherwise), runs smoke tests, and uploads artifacts
  3. Download the all-binaries artifact to inspect the output

Manual release (fallback)

If you need to release manually, you can still push a version tag directly:

git tag v0.2.0
git push origin v0.2.0

This will trigger release.yml to build binaries and create a GitHub Release. Note: npm publishing is handled separately by version.yml and won't be triggered by a manual tag push.

Available scripts

Script Description
pnpm changeset Add a new changeset
pnpm changeset status Check pending changesets
pnpm release Local interactive release: previews changesets, lets you accept or override the proposed version, then bumps + builds + publishes + tags; Claude authors Highlights + a ≤280-char engagement X draft (soft deterministic fallback if Claude is offline)
pnpm release --yes Same, but auto-accepts the proposed version and skips the final confirmation
pnpm release --dry-run Preview only — show changesets, proposed version, and Claude-authored X draft preview, then exit before any file/git/npm changes
pnpm release --channel beta Beta release from main: pre-mode version X.Y.Z-beta.N, npm dist-tag beta, GitHub prerelease; no Homebrew/X draft
pnpm release --channel stable Stable release: requires the release branch, publishes latest, marks the GitHub Release latest

Without --channel, pnpm release prompts for the channel and defaults to beta (also the silent default with --yes or a non-interactive dry-run). Stable releases are always an explicit choice. | pnpm release:version | Apply changesets and bump versions (used by CI) | | pnpm --filter @runfusion/fusion build:exe | Build binary for current platform | | pnpm --filter @runfusion/fusion build:exe -- --target <target> | Cross-compile for a specific platform | | pnpm --filter @runfusion/fusion build:exe:all | Build binaries for all platforms |

Tips

  • Every user-facing change should have a changeset — CI will remind you if one is missing
  • You can add multiple changesets per PR if you're making changes to multiple packages
  • Changeset files are automatically deleted when versions are bumped
  • CI verifies binary compilation on every push/PR to catch build regressions early
  • If your project enables completionDocumentationMode: "changeset", triage specs will explicitly require .changeset/*.md completion artifacts for relevant tasks; keep this aligned with your repo's release convention.

Internal packages

The following packages are internal and are not published to npm:

  • @fusion/core — Core domain model and task store
  • @fusion/dashboard — Web UI and API server
  • @fusion/engine — AI agents and orchestration
  • @fusion/plugin-sdk — Plugin development SDK
  • @fusion-plugin-examples/* — Example plugins

These packages have private: true in their package.json and are listed in the .changeset/config.json ignore array to prevent accidental publishing. Only the @runfusion/fusion package is published to npm.