## Summary Fusion can now ship on two release tracks. Betas are cut from `main` as `vX.Y.Z-beta.N` (npm dist-tag `beta`, GitHub prerelease), stable releases are promoted to a long-lived `release` branch and published to `latest`, and users pick their track with the new `updateChannel` global setting — via **Settings → General → Release channel** or `fn update --channel <stable|beta>`. Previously everything was single-track: every publish landed on `latest` and every update surface could only see it. | | beta | stable | |---|---|---| | Cut from | `main` | `release` branch | | Version | `X.Y.Z-beta.N` (changesets pre-mode) | `X.Y.Z` | | npm dist-tag | `beta` | `latest` | | GitHub Release | prerelease | latest | | Homebrew tap / X draft | skipped | bumped / printed | ## How releasing works now `pnpm release` prompts for the channel and **defaults to beta**, so day-to-day releases are betas; stable is always an explicit choice. Choosing stable from `main` triggers assisted promotion: the script proposes the newest beta tag reachable from HEAD, verifies `release` fast-forwards to it, then runs the whole stable release inside a temporary git worktree on `release` — the primary checkout never leaves `main`. Changesets pre-mode preserves changeset files across betas, so the promoted stable release aggregates every changeset since the last stable into one clean changelog entry. ## Design decisions - **Every publish path names an explicit `--tag`.** A beta accidentally landing on `latest` is the one unrecoverable failure of a dual-track scheme, so nothing relies on npm's implicit default (`release.mjs`, `version.yml`). - **Beta channel resolves to semver-max of `latest` and `beta`**, so beta users are offered each promoted stable once it overtakes their prerelease. Switching beta → stable never downgrades; `fn update --channel stable --force` is the explicit escape hatch. - **One comparator instead of three.** CLI, dashboard, and desktop each had their own `isRemoteNewer` that ignored prerelease identifiers — `0.73.0-beta.2`, `-beta.3`, and `0.73.0` all compared equal, which breaks the moment any beta exists. They now share full SemVer-precedence helpers (`compareVersions`, `resolveUpdateTargetVersion`) from `@fusion/core`. - **Installs pin exact versions** (`@runfusion/fusion@0.73.0-beta.2`), never a dist-tag, so an install can't silently land on the wrong track. - **Desktop channels via electron-updater manifests.** Beta tags build desktop artifacts with `publish.channel=beta` (emitting `beta*.yml`); the app sets `channel`/`allowPrerelease` from the shared setting, re-read on every manual check. - **Update caches are channel-stamped** — a cache written for one channel is never served to the other, so switching tracks takes effect on the next check instead of after TTL. ## Test plan - New unit coverage: SemVer precedence + channel resolution in `@fusion/core` (30), channel behavior of the dashboard update check (28, incl. 9 new) and `fn update` (16, incl. 8 new: persist `--channel`, no-downgrade, `--force`, cache channel mismatch). - `pnpm verify:fast` green (scoped typecheck, builds, CLI build, boot smoke); desktop + settings-section suites green. - `release.mjs` dry-run matrix exercised by hand: channel prompt (default/override/invalid), branch preflights per channel, assisted-promotion target selection, fast-forward guard against a diverged `release` branch, and bootstrap when no `release` branch exists. - Not exercised live: an end-to-end publish (needs TTY authorization + real npm publish). First real run is the first `pnpm release --channel beta`. --- [](https://github.com/EveryInc/compound-engineering-plugin)  <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added beta and stable release channels across CLI, dashboard, and desktop updates. * Users can select a channel via Settings or `fn update --channel <stable|beta>` (stored as a global default). * Desktop beta releases now generate beta update manifests and publish as prereleases. * **Documentation** * Expanded release-track, settings, and CLI references to explain channel semantics and workflows. * **Bug Fixes** * Updates now pin the resolved version per channel, improve version comparison, and prevent unintended cross-channel downgrades unless `--force` is used. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
import { exec } from "node:child_process";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { promisify } from "node:util";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
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";
|
|
// FNXC:UpdateInstall 2026-07-19-09:50 (kept through channel merge): native npm
|
|
// dependencies can take >2min on Windows; installs get five minutes.
|
|
const INSTALL_TIMEOUT_MS = 300_000;
|
|
|
|
/*
|
|
FNXC:UpdateChannels 2026-07-19-13:00:
|
|
`fn update` is channel-aware. `stable` (default) follows the npm `latest`
|
|
dist-tag; `beta` follows the semver-max of `latest` and `beta` so beta users
|
|
also receive promoted stable releases. The install always pins the exact
|
|
resolved version (`@runfusion/fusion@X.Y.Z[-beta.N]`) — never a bare dist-tag —
|
|
so a beta-channel install can never silently land on the wrong track.
|
|
`--channel <stable|beta>` persists the choice to global settings (shared with
|
|
the dashboard and desktop updater). Switching beta → stable does not downgrade;
|
|
`--force` is the explicit escape hatch that installs the channel target even
|
|
when it is not newer than the current version.
|
|
*/
|
|
export type RunUpdateOptions = {
|
|
check?: boolean;
|
|
global?: boolean;
|
|
json?: boolean;
|
|
/** Raw --channel value; validated to "stable" | "beta". */
|
|
channel?: string;
|
|
/** Install the resolved channel target even when it is not newer (explicit downgrade). */
|
|
force?: boolean;
|
|
};
|
|
|
|
type UpdateStatus = {
|
|
currentVersion: string;
|
|
latestVersion: string;
|
|
updateAvailable: boolean;
|
|
updated: boolean;
|
|
channel: UpdateChannel;
|
|
};
|
|
|
|
function readOwnCliVersion(): string | undefined {
|
|
let currentDir: string;
|
|
try {
|
|
currentDir = dirname(fileURLToPath(import.meta.url));
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
|
|
for (let i = 0; i < 8; i += 1) {
|
|
const pkgPath = resolve(currentDir, "package.json");
|
|
if (existsSync(pkgPath)) {
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(pkgPath, "utf-8")) as { name?: string; version?: string };
|
|
if (parsed.name === "@runfusion/fusion" && typeof parsed.version === "string") {
|
|
return parsed.version;
|
|
}
|
|
} catch {
|
|
// Ignore parse errors and keep walking.
|
|
}
|
|
}
|
|
|
|
const parentDir = resolve(currentDir, "..");
|
|
if (parentDir === currentDir) {
|
|
break;
|
|
}
|
|
currentDir = parentDir;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
async function fetchChannelTargetVersion(channel: UpdateChannel): Promise<string> {
|
|
const response = await fetch(REGISTRY_URL);
|
|
const payload = (await response.json()) as {
|
|
"dist-tags"?: {
|
|
latest?: string;
|
|
beta?: string;
|
|
};
|
|
};
|
|
|
|
const 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 targetVersion;
|
|
}
|
|
|
|
// 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 & {
|
|
stdout?: string;
|
|
stderr?: string;
|
|
killed?: boolean;
|
|
};
|
|
|
|
function isInstallTimeoutError(error: unknown): boolean {
|
|
const installError = error as InstallError;
|
|
/*
|
|
FNXC:UpdateInstall 2026-07-19-09:50:
|
|
Native npm dependencies can take longer than two minutes to install on Windows. Allow five minutes, and classify only an exec-killed process as that ceiling so registry ETIMEDOUT failures retain their real diagnosis.
|
|
*/
|
|
return installError?.killed === true;
|
|
}
|
|
|
|
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}`,
|
|
);
|
|
}
|
|
|
|
function isBinCollisionInstallError(error: unknown): boolean {
|
|
const installError = error as InstallError;
|
|
const message = [installError?.message, installError?.stderr, installError?.stdout]
|
|
.filter((part): part is string => typeof part === "string" && part.length > 0)
|
|
.join("\n");
|
|
|
|
const hasBinHint = /\/(fn|fusion)\b|runfusion\.ai/i.test(message);
|
|
if (!hasBinHint) return false;
|
|
|
|
return /EEXIST|ENOENT|File exists/i.test(message);
|
|
}
|
|
|
|
function detectRunningBinaryPath(): string | null {
|
|
const argvPath = process.argv[1];
|
|
if (typeof argvPath === "string" && argvPath.length > 0) {
|
|
return argvPath;
|
|
}
|
|
return typeof process.execPath === "string" ? process.execPath : null;
|
|
}
|
|
|
|
function shouldSuggestHomebrewFix(binaryPath: string | null): boolean {
|
|
if (!binaryPath) return false;
|
|
return (
|
|
binaryPath.startsWith("/opt/homebrew/") ||
|
|
binaryPath.startsWith("/usr/local/Homebrew/") ||
|
|
binaryPath.startsWith("/home/linuxbrew/")
|
|
);
|
|
}
|
|
|
|
function printCollisionRemediation(binaryPath: string | null): void {
|
|
console.error("Legacy runfusion.ai bin links blocked automatic update. Run:");
|
|
console.error(" npm uninstall -g runfusion.ai");
|
|
console.error(" rm -f $(command -v fn) $(command -v fusion)");
|
|
console.error(" npm install -g @runfusion/fusion@latest");
|
|
if (shouldSuggestHomebrewFix(binaryPath)) {
|
|
console.error("If installed via Homebrew, reinstall with:");
|
|
console.error(" brew uninstall fusion && brew install runfusion/tap/fusion");
|
|
}
|
|
}
|
|
|
|
async function installVersion(globalInstall: boolean, version: string, resolveBinaryPath: () => string | null = detectRunningBinaryPath): Promise<void> {
|
|
try {
|
|
await execAsync(getInstallCommand(globalInstall, version), {
|
|
timeout: INSTALL_TIMEOUT_MS,
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
});
|
|
return;
|
|
} catch (error) {
|
|
if (isInstallTimeoutError(error)) {
|
|
throw installTimeoutError(globalInstall, version);
|
|
}
|
|
if (!isBinCollisionInstallError(error)) {
|
|
throw error;
|
|
}
|
|
|
|
console.error("Detected legacy runfusion.ai bin symlinks; retrying update with --force.");
|
|
|
|
try {
|
|
await execAsync(getInstallCommand(globalInstall, version, true), {
|
|
timeout: INSTALL_TIMEOUT_MS,
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
});
|
|
return;
|
|
} catch (forceError) {
|
|
if (isInstallTimeoutError(forceError)) {
|
|
throw installTimeoutError(globalInstall, version, true);
|
|
}
|
|
printCollisionRemediation(resolveBinaryPath());
|
|
throw forceError;
|
|
}
|
|
}
|
|
}
|
|
|
|
function printStatus(status: UpdateStatus, checkOnly: boolean): void {
|
|
console.log(`Channel: ${status.channel}`);
|
|
console.log(`Current version: ${status.currentVersion}`);
|
|
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;
|
|
}
|
|
|
|
if (checkOnly) {
|
|
console.log("Update available.");
|
|
return;
|
|
}
|
|
|
|
if (status.updated) {
|
|
console.log("Update complete.");
|
|
}
|
|
}
|
|
|
|
function printJson(status: UpdateStatus): void {
|
|
console.log(JSON.stringify(status));
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export async function runUpdate(options: RunUpdateOptions = {}): Promise<void> {
|
|
const checkOnly = options.check === true;
|
|
const globalInstall = options.global !== false;
|
|
const jsonOutput = options.json === true;
|
|
const force = options.force === true;
|
|
|
|
if (options.channel !== undefined && options.channel !== "stable" && options.channel !== "beta") {
|
|
console.error(`Error: invalid --channel '${options.channel}'. Valid channels: stable, beta.`);
|
|
process.exit(1);
|
|
return;
|
|
}
|
|
|
|
// FNXC:UpdateChannels 2026-07-19-13:00: an explicit --channel flag is
|
|
// persisted (even with --check) so every update surface follows the switch.
|
|
let channel: UpdateChannel;
|
|
if (options.channel === "stable" || options.channel === "beta") {
|
|
channel = options.channel;
|
|
try {
|
|
await persistUpdateChannel(channel);
|
|
if (!jsonOutput) {
|
|
console.log(`Update channel set to '${channel}'.`);
|
|
}
|
|
} catch {
|
|
if (!jsonOutput) {
|
|
console.log(`Warning: could not persist update channel '${channel}'; using it for this run only.`);
|
|
}
|
|
}
|
|
} else {
|
|
channel = await getConfiguredUpdateChannel();
|
|
}
|
|
|
|
const currentVersion = readOwnCliVersion();
|
|
if (!currentVersion) {
|
|
console.error("Error: Could not determine current Fusion CLI version.");
|
|
process.exit(1);
|
|
return;
|
|
}
|
|
|
|
let latestVersion: string;
|
|
try {
|
|
latestVersion = await fetchChannelTargetVersion(channel);
|
|
} catch (error) {
|
|
const fallbackVersion = getLatestVersionFallback(currentVersion, channel);
|
|
if (!fallbackVersion) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(`Error checking for updates: ${message}`);
|
|
process.exit(1);
|
|
return;
|
|
}
|
|
|
|
latestVersion = fallbackVersion;
|
|
if (!jsonOutput) {
|
|
console.log("Warning: npm registry unreachable, using cached update metadata.");
|
|
}
|
|
}
|
|
|
|
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 = {
|
|
currentVersion,
|
|
latestVersion,
|
|
updateAvailable,
|
|
updated: false,
|
|
channel,
|
|
};
|
|
|
|
if (jsonOutput) {
|
|
printJson(checkStatus);
|
|
} else {
|
|
printStatus(checkStatus, true);
|
|
}
|
|
|
|
if (updateAvailable) {
|
|
process.exitCode = 1;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!shouldInstall) {
|
|
const status: UpdateStatus = {
|
|
currentVersion,
|
|
latestVersion,
|
|
updateAvailable: false,
|
|
updated: false,
|
|
channel,
|
|
};
|
|
|
|
if (jsonOutput) {
|
|
printJson(status);
|
|
} else {
|
|
printStatus(status, false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await installVersion(globalInstall, latestVersion);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(`Error installing update: ${message}`);
|
|
process.exit(1);
|
|
return;
|
|
}
|
|
|
|
const updatedStatus: UpdateStatus = {
|
|
currentVersion,
|
|
latestVersion,
|
|
updateAvailable,
|
|
updated: true,
|
|
channel,
|
|
};
|
|
|
|
if (jsonOutput) {
|
|
printJson(updatedStatus);
|
|
return;
|
|
}
|
|
|
|
printStatus(updatedStatus, false);
|
|
}
|