From 084dd76d64a96e3eb0d871124746f84d33fc846f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 24 Jul 2026 23:15:36 -0700 Subject: [PATCH] feat(release): write release copy with opus and draft tweets for betas too - distillation runs on opus (env-overridable) with a 4-minute budget - highlights must name the surface and outcome; vague filler is banned - tweets target 200-280 chars with concrete changes and varied structure - betas get their own tester-facing draft carrying `fn update --channel beta` - prerelease openers read as "Fusion 0.74 beta:" instead of "Fusion 0.74-beta.0" --- .../__tests__/distill-release-notes.test.mjs | 88 ++++++++++++- scripts/lib/distill-release-notes.mjs | 122 +++++++++++++++--- scripts/release.mjs | 30 +++-- 3 files changed, 205 insertions(+), 35 deletions(-) diff --git a/scripts/__tests__/distill-release-notes.test.mjs b/scripts/__tests__/distill-release-notes.test.mjs index 9e48663e82..11b4066a9f 100644 --- a/scripts/__tests__/distill-release-notes.test.mjs +++ b/scripts/__tests__/distill-release-notes.test.mjs @@ -17,6 +17,12 @@ import { fitTweetToBudget, SHORT_RELEASE_URL, HIGHLIGHTS_MAX, + BETA_TWEET_GUIDANCE, + DEFAULT_CLAUDE_MODEL, + RELEASE_LLM_TIMEOUT_MS, + TWEET_MAX_CHARS, + TWEET_TARGET_MIN_CHARS, + resolveClaudeDistillConfig, } from "../lib/distill-release-notes.mjs"; // --- distillDeterministic --- @@ -316,8 +322,13 @@ test("system prompt contains key instructions", () => { assert.match(DISTILLATION_SYSTEM_PROMPT, /### Highlights/); assert.match(DISTILLATION_SYSTEM_PROMPT, /### New/); assert.match(DISTILLATION_SYSTEM_PROMPT, /### Fixed/); - assert.match(DISTILLATION_SYSTEM_PROMPT, /engagement/i); assert.match(DISTILLATION_SYSTEM_PROMPT, /280/); + // FNXC:Changelog 2026-07-24-11:05 — the copy contract that keeps drafts usable: + // spend the character budget, stay concrete, and skip stock launch hype. + assert.match(DISTILLATION_SYSTEM_PROMPT, /USE THE BUDGET/); + assert.match(DISTILLATION_SYSTEM_PROMPT, /Banned as vague filler/); + assert.match(DISTILLATION_SYSTEM_PROMPT, /excited to announce/); + assert.match(DISTILLATION_SYSTEM_PROMPT, /SURFACE/); }); // --- AI path (injected chatComplete; no live Claude required) --- @@ -482,3 +493,78 @@ test("normalizeAiDistillResult uses short URL when AI tweet is over budget", () assert.ok(normalized.tweet.includes(SHORT_RELEASE_URL)); assert.doesNotMatch(normalized.tweet, /https?:\/\//); }); + +/* +FNXC:Changelog 2026-07-24-11:05: +Release copy is written by Opus, aims at the full 280-char budget, and is +produced for BOTH channels — a beta draft must read as a call for testers with +the real opt-in command instead of a GA announcement. +*/ + +test("release copy is authored by opus, with an env override", () => { + assert.equal(DEFAULT_CLAUDE_MODEL, "opus"); + assert.equal(resolveClaudeDistillConfig({}).model, "opus"); + assert.equal( + resolveClaudeDistillConfig({ FUSION_RELEASE_CLAUDE_MODEL: "sonnet" }).model, + "sonnet", + ); + // Opus is slower than the sonnet-era budget; a too-tight timeout silently + // demotes every release to the deterministic fallback. + assert.ok(RELEASE_LLM_TIMEOUT_MS >= 180_000); +}); + +test("the tweet budget band asks for most of the 280 characters", () => { + assert.equal(TWEET_MAX_CHARS, 280); + assert.ok(TWEET_TARGET_MIN_CHARS >= 180 && TWEET_TARGET_MIN_CHARS < TWEET_MAX_CHARS); +}); + +test("beta prompts carry tester-facing guidance and the opt-in command", () => { + const entries = [{ summary: "Board scrolls smoothly with 200+ tasks.", category: "performance" }]; + const beta = buildDistillationPrompt(entries, "0.74.0-beta.0", "example.com/CHANGELOG.md", { + channel: "beta", + }); + assert.match(beta, /Channel: beta/); + assert.ok(beta.includes(BETA_TWEET_GUIDANCE)); + assert.match(beta, /fn update --channel beta/); + assert.match(BETA_TWEET_GUIDANCE, /never imply general availability/i); +}); + +test("stable prompts omit the beta guidance", () => { + const entries = [{ summary: "Board scrolls smoothly with 200+ tasks.", category: "performance" }]; + const stable = buildDistillationPrompt(entries, "0.74.0", "example.com/CHANGELOG.md", { + channel: "stable", + }); + assert.match(stable, /Channel: stable/); + assert.ok(!stable.includes(BETA_TWEET_GUIDANCE)); + assert.doesNotMatch(stable, /fn update --channel beta/); + // No channel passed at all behaves like stable rather than throwing. + const defaulted = buildDistillationPrompt(entries, "0.74.0", "example.com/CHANGELOG.md"); + assert.match(defaulted, /Channel: stable/); +}); + +test("prerelease tweet openers read as a beta, not a mangled patch version", () => { + assert.equal(formatTweetVersionLabel("0.74.0-beta.0"), "Fusion 0.74 beta"); + assert.equal(formatTweetVersionLabel("v0.74.0-beta.3"), "Fusion 0.74 beta"); + assert.equal(formatTweetVersionLabel("0.74.1-beta.0"), "Fusion 0.74.1 beta"); + assert.equal(formatTweetVersionLabel("0.74.0-rc.1"), "Fusion 0.74 rc"); + // Stable labels are unchanged. + assert.equal(formatTweetVersionLabel("0.74.0"), "Fusion 0.74"); +}); + +test("distillWithAi forwards the channel into the prompt", async () => { + const entries = [{ summary: "Board scrolls smoothly with 200+ tasks.", category: "performance" }]; + let seen = null; + await distillWithAi(entries, "0.74.0-beta.0", { + channel: "beta", + chatComplete: ({ user }) => { + seen = user; + return JSON.stringify({ + highlights: ["Board scrolls smoothly with 200+ tasks"], + notes: "### Highlights\n\n- Board scrolls smoothly with 200+ tasks", + tweet: "Fusion 0.74 beta: board scrolls smoothly with 200+ tasks. fn update --channel beta", + }); + }, + }); + assert.match(seen, /Channel: beta/); + assert.match(seen, /fn update --channel beta/); +}); diff --git a/scripts/lib/distill-release-notes.mjs b/scripts/lib/distill-release-notes.mjs index f553dbb33b..21813081c5 100644 --- a/scripts/lib/distill-release-notes.mjs +++ b/scripts/lib/distill-release-notes.mjs @@ -5,9 +5,14 @@ * * FNXC:Changelog 2026-07-13-15:45: * Highlights + X draft are AI-authored via the local Claude CLI - * (`claude -p --model sonnet`). Each release gets a fresh engagement-oriented + * (`claude -p --model `). Each release gets a fresh engagement-oriented * tweet (not a fixed template). Deterministic ranking remains only as a soft * fallback so offline/CI releases without Claude never block. + * + * FNXC:Changelog 2026-07-24-11:05: + * Authored by Opus (see DEFAULT_CLAUDE_MODEL), and channel-aware: BOTH channels + * get an X draft, with beta drafts written as a call for testers carrying the + * `fn update --channel beta` opt-in. */ import { spawnSync } from "node:child_process"; @@ -17,11 +22,24 @@ import { CATEGORIES, CATEGORY_HEADINGS } from "./changeset-schema.mjs"; export const HIGHLIGHTS_MIN = 3; export const HIGHLIGHTS_MAX = 5; +/* + * FNXC:Changelog 2026-07-24-11:05: + * Release copy is authored by OPUS, not sonnet: highlights and the X draft are + * the only release artifacts a human reads verbatim, they are written once per + * release, and the quality gap shows. Opus is slower, so the CLI budget triples + * to 4 minutes; the deterministic fallback still covers a timeout. + * `FUSION_RELEASE_CLAUDE_MODEL` still overrides the model. + */ + /** Wall-clock budget for the Claude CLI call. */ -export const RELEASE_LLM_TIMEOUT_MS = 90_000; +export const RELEASE_LLM_TIMEOUT_MS = 240_000; /** Default Claude model alias for release distillation. */ -export const DEFAULT_CLAUDE_MODEL = "sonnet"; +export const DEFAULT_CLAUDE_MODEL = "opus"; + +/** Tweet budget: X's hard cap, and the length below which a draft reads thin. */ +export const TWEET_MAX_CHARS = 280; +export const TWEET_TARGET_MIN_CHARS = 200; /** * Lower number = higher highlight priority (deterministic fallback only). @@ -63,31 +81,77 @@ export const DISTILLATION_SYSTEM_PROMPT = [ "Return STRICT JSON only — no markdown fences, no preamble:", '{ "highlights": string[3..5], "notes": string, "tweet": string }', "", + /* + * FNXC:Changelog 2026-07-24-11:05: + * Highlights are the section operators actually read, so they must name the + * surface and the outcome. Vague filler ("various improvements", "enhanced + * reliability") is what made past Highlights sections skimmable-but-useless. + */ "highlights:", - "- Top 3–5 user-facing changes (prefer breaking, security, features, then fixes).", - "- Punchy, benefit-led phrasing. Do not invent features not in the input.", - "- One short phrase/sentence per item; no markdown inside the strings.", + "- The 3–5 changes an operator would most want to know about. Rank by impact:", + " breaking > security > the release's headline feature > widely-hit fixes > performance.", + "- Each item names the SURFACE it lands on (board, Command Center, task detail, CLI,", + " dashboard, engine, mobile, desktop, updater…) and the OUTCOME for the operator.", + "- Lead with the outcome, not the mechanism: \"Board scrolling stays smooth with 200+ tasks\",", + " not \"Refactored the kanban virtualization layer\".", + "- Be concrete. Keep real numbers, versions, limits, and names from the input.", + "- Merge duplicates: several entries about one area become ONE highlight.", + "- Banned as vague filler: 'various', 'several improvements', 'under the hood',", + " 'enhanced', 'better overall', 'general polish', 'misc'.", + "- Never invent a change that is not in the input. Never name a file, class, or symbol.", + "- ≤ 100 characters each, sentence case, no trailing period, no markdown.", "", "notes:", "- Markdown body only (no version heading).", "- Start with ### Highlights using the same 3–5 items as `- ` bullets.", "- Then group under (omit empty): ### New, ### Fixed, ### Breaking, ### Security, ### Performance, ### Internal.", "- One `- ` bullet per entry; lightly edit for clarity; no file paths or class names.", + "- Within a group, most operator-visible first.", "", + /* + * FNXC:Changelog 2026-07-24-11:05: + * The X draft must EARN the 280 characters: earlier drafts came back at ~120 + * chars of generic hype and wasted more than half the budget. Specifics from + * the release are the engagement driver, so the prompt demands named changes + * and a target length band, and bans the stock launch-announcement voice. + */ "tweet:", - "- Ready to post on X. Hard max 280 characters including spaces and the URL.", - "- Goal: drive engagement (curiosity, replies, clicks) — not a dry changelog dump.", - "- Vary tone per release (excited, wry, bold, founder-voice). Never reuse a fixed template.", + `- Ready to post on X. Hard max ${TWEET_MAX_CHARS} characters including spaces and the URL.`, + `- USE THE BUDGET: aim for ${TWEET_TARGET_MIN_CHARS}–${TWEET_MAX_CHARS} characters. A short, vague tweet is a failure;`, + " spend the room on specifics instead of adjectives. Count characters before answering.", "- Open with Fusion + version + colon, no leading v — e.g. \"Fusion 0.58: …\" (drop .0 patch when patch is 0; keep 0.58.1 as-is). Never \"v0.58.0:\" alone.", + "- After that opener, earn the read: a hook line, then 2–4 CONCRETE changes with their", + " real numbers/surfaces, then the link. Line breaks are fine and improve scannability.", + "- Structure must vary release to release — pick one and commit: sharp one-liner + detail,", + " before→after contrast, a stat or limit that surprises, a pointed question, a bold claim", + " you then back up, or a tight list. Never reuse the previous release's shape.", + "- Voice: a technical founder shipping to peers. Confident, specific, a little opinionated.", + "- Banned openers and filler: 'excited to announce', 'we've been busy', 'thrilled',", + " 'a lot to unpack', 'game-changer', 'supercharged', 'and much more', 'ships with'.", + "- Do not enumerate every change — pick the ones a skeptical developer would stop for.", "- Include a link: prefer the static GitHub changelog path when it fits.", "- Link form: no https:// scheme — always github.com/Runfusion/Fusion/blob/main/CHANGELOG.md (not a version tag).", - "- If that still exceeds 280 chars, use runfusion.ai instead.", - "- Weave 2–4 of the highlights into a scroll-stopping hook; questions, contrast, or a bold claim are fine.", - "- Plain text only. At most one hashtag. Emoji optional and sparse.", + "- If that still exceeds the limit, use runfusion.ai instead.", + "- Plain text only. At most one hashtag. At most one emoji, and only if it earns its place.", "", "JSON only.", ].join("\n"); +/* + * FNXC:Changelog 2026-07-24-11:05: + * Betas get their own X draft too (previously stable-only). A beta post is a + * call for testers, not a launch: it must say it is a beta and give the real + * opt-in command (`fn update --channel beta`), so nobody reads a prerelease as + * generally available. + */ +export const BETA_TWEET_GUIDANCE = [ + "This is a BETA prerelease, not a stable launch. Adjust the tweet accordingly:", + "- Say plainly that it is a beta; never imply general availability.", + "- Frame it as a call for testers: what to try, what feedback is useful.", + "- Include the opt-in command `fn update --channel beta` (it counts toward the character budget).", + "- Still specific and still ≤ the character limit; keep the same anti-hype rules.", +].join("\n"); + /** * Build the user-facing prompt for AI distillation. * @@ -96,18 +160,25 @@ export const DISTILLATION_SYSTEM_PROMPT = [ * @param {string} changelogUrl * @returns {string} */ -export function buildDistillationPrompt(entries, version, changelogUrl) { +export function buildDistillationPrompt(entries, version, changelogUrl, options = {}) { + const channel = options.channel === "beta" ? "beta" : "stable"; const opener = `${formatTweetVersionLabel(version)}:`; const lines = [ `Version: ${version}`, + `Channel: ${channel}`, `Tweet opener (required form): ${opener}`, - `Changelog URL (prefer in the tweet when it fits ≤280): ${changelogUrl}`, + `Changelog URL (prefer in the tweet when it fits ≤${TWEET_MAX_CHARS}): ${changelogUrl}`, `Short link (use if the full changelog URL won't fit): ${SHORT_RELEASE_URL}`, "", "Write fresh, engagement-driving release copy from these changeset entries.", `Start the tweet with "${opener}" (no leading v; omit trailing .0 patch when patch is 0).`, - "Do not use a stock \"X is out!\" opener every time — earn the click after that prefix.\n", + "Do not use a stock \"X is out!\" opener every time — earn the click after that prefix.", + `Target ${TWEET_TARGET_MIN_CHARS}–${TWEET_MAX_CHARS} characters for the tweet; count them before answering.`, ]; + if (channel === "beta") { + lines.push("", BETA_TWEET_GUIDANCE); + } + lines.push(""); entries.forEach((entry, i) => { const num = i + 1; lines.push(`[${num}]`); @@ -180,10 +251,18 @@ export function formatTweetVersionLabel(version) { const m = bare.match(/^(\d+)\.(\d+)\.(\d+)(.*)$/); if (m) { const [, major, minor, patch, rest] = m; - const display = Number(patch) === 0 - ? `${major}.${minor}${rest}` - : `${major}.${minor}.${patch}${rest}`; - return `Fusion ${display}`; + const core = Number(patch) === 0 ? `${major}.${minor}` : `${major}.${minor}.${patch}`; + /* + * FNXC:Changelog 2026-07-24-11:05: + * Prereleases now get their own opener. Reusing the stable rule produced + * "Fusion 0.74-beta.0" (patch dropped mid-identifier, unreadable); a beta + * post reads as "Fusion 0.74 beta:" — the beta counter is noise on X. + */ + if (rest) { + const preTag = rest.replace(/^-/, "").split(".")[0]; + return `Fusion ${core} ${preTag || "pre"}`; + } + return `Fusion ${core}`; } return `Fusion ${bare}`; } @@ -568,6 +647,7 @@ export function chatViaClaudeCli(opts) { * env?: Record, * chatComplete?: (args: {system: string, user: string}) => Promise | string | null, * allowClaudeCli?: boolean, + * channel?: "beta" | "stable", * timeoutMs?: number, * spawnImpl?: typeof spawnSync, * }} [options] @@ -578,7 +658,9 @@ export async function distillWithAi(entries, version, options = {}) { const changelogUrl = options.changelogUrl || buildChangelogUrl(version); const system = DISTILLATION_SYSTEM_PROMPT; - const user = buildDistillationPrompt(entries, version, changelogUrl); + const user = buildDistillationPrompt(entries, version, changelogUrl, { + channel: options.channel, + }); let raw = null; diff --git a/scripts/release.mjs b/scripts/release.mjs index 1206c1c6da..df39dd71a1 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -23,7 +23,7 @@ // # 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 +// # GitHub prerelease; prints a tester-facing X draft; skips Homebrew tap // 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 @@ -82,7 +82,8 @@ const INTERACTIVE = args.has("--interactive"); * 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. + * PRERELEASE. Homebrew tap is stable-only; the X draft is printed for both + * channels (betas get tester-facing copy). * - `--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 @@ -963,8 +964,8 @@ if (DRY_RUN) { const raw = readFileSync(join(".changeset", file), "utf8"); return parseChangesetFile(raw).parsed; }).filter(Boolean); - info("Distilling release notes with Claude (sonnet; soft fallback if unavailable)…"); - const dryDistilled = await distillReleaseNotes(dryEntries, chosenVersion); + info("Distilling release notes with Claude (opus; soft fallback if unavailable)…"); + const dryDistilled = await distillReleaseNotes(dryEntries, chosenVersion, { channel: CHANNEL }); console.log(""); console.log(color(36, "─── Draft post for X (preview) ───")); console.log(dryDistilled.tweet); @@ -1054,13 +1055,13 @@ ok("Root CHANGELOG.md updated."); * notes, and an engagement-oriented X draft ≤280 chars. Soft deterministic * fallback only if Claude is unreachable so release never blocks. */ -info("Distilling release notes with Claude (sonnet; soft fallback if unavailable)…"); +info("Distilling release notes with Claude (opus; soft fallback if unavailable)…"); const { notes: distilledNotes, source: distillSource, highlights: releaseHighlights, tweet: releaseTweet, -} = await distillReleaseNotes(capturedEntries, version); +} = await distillReleaseNotes(capturedEntries, version, { channel: CHANNEL }); const changelogBeforeDistill = readFileSync("CHANGELOG.md", "utf8"); const changelogAfterDistill = replaceVersionSection(changelogBeforeDistill, version, distilledNotes); if (changelogAfterDistill !== changelogBeforeDistill) { @@ -1196,13 +1197,14 @@ if (!IS_BETA) { * 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. + * FNXC:Changelog 2026-07-24-11:05: BOTH channels get a draft now (it was + * stable-only). Betas ship to testers who opt in, and that audience only hears + * about a prerelease if it is posted; the beta draft is written as a call for + * testers carrying `fn update --channel beta`, never as a GA announcement. */ -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, "─────────────────────────────────────")); -} +console.log(""); +console.log(color(36, `─── Draft post for X (${CHANNEL}, copy-paste) ───`)); +console.log(releaseTweet); +console.log(color(90, `(${releaseTweet.length}/280 chars; source: ${distillSource})`)); +console.log(color(36, "──────────────────────────────────────────────"));