feat: add beta/stable release tracks with switchable update channel (#2345)

## Summary

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

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

## How releasing works now

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

## Design decisions

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

## Test plan

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

---

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


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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-19 13:34:25 -07:00
committed by GitHub
parent 048ae909fd
commit 2302fb8a3d
25 changed files with 1270 additions and 138 deletions

View File

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