diff --git a/.changeset/release-highlights-and-x-draft.md b/.changeset/release-highlights-and-x-draft.md new file mode 100644 index 0000000000..40559cb527 --- /dev/null +++ b/.changeset/release-highlights-and-x-draft.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Release notes open with AI Highlights, and the release script prints a ready-to-post engagement tweet. +category: feature +dev: distillReleaseNotes calls `claude -p --model sonnet` for Highlights + notes + ≤280-char X draft (engagement-oriented, varies per release); soft deterministic fallback if Claude is offline. release.mjs prints the draft after publish and on --dry-run. diff --git a/RELEASING.md b/RELEASING.md index 22194a7403..a12f2b8669 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -46,7 +46,7 @@ When changesets are merged to `main`, the `version.yml` workflow automatically o - 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` +- 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 @@ -125,9 +125,9 @@ This will trigger `release.yml` to build binaries and create a GitHub Release. N |--------|-------------| | `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 | +| `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 prompt for override, then exit before any file/git/npm changes | +| `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: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/scripts/__tests__/distill-release-notes.test.mjs b/scripts/__tests__/distill-release-notes.test.mjs index a646332237..9e48663e82 100644 --- a/scripts/__tests__/distill-release-notes.test.mjs +++ b/scripts/__tests__/distill-release-notes.test.mjs @@ -3,8 +3,20 @@ import assert from "node:assert/strict"; import { distillDeterministic, + distillReleaseNotes, + distillWithAi, buildDistillationPrompt, DISTILLATION_SYSTEM_PROMPT, + selectHighlights, + formatReleaseTweet, + formatTweetVersionLabel, + ensureFusionVersionPrefix, + buildChangelogUrl, + parseJsonFromLlm, + normalizeAiDistillResult, + fitTweetToBudget, + SHORT_RELEASE_URL, + HIGHLIGHTS_MAX, } from "../lib/distill-release-notes.mjs"; // --- distillDeterministic --- @@ -19,10 +31,13 @@ test("groups entries by category in display order", () => { const { notes, source } = distillDeterministic(entries, "1.0.0"); assert.equal(source, "deterministic"); - // Feature section comes first. + // Feature section comes first among category sections; Highlights lead the doc. + const highlightsIdx = notes.indexOf("### Highlights"); const featureIdx = notes.indexOf("### New"); const fixIdx = notes.indexOf("### Fixed"); const breakingIdx = notes.indexOf("### Breaking"); + assert.ok(highlightsIdx > -1); + assert.ok(highlightsIdx < featureIdx); assert.ok(featureIdx > -1); assert.ok(featureIdx < fixIdx); assert.ok(fixIdx < breakingIdx); @@ -59,22 +74,25 @@ test("groups multiple entries in same category", () => { }); test("handles empty entries array", () => { - const { notes, source } = distillDeterministic([], "1.0.0"); + const { notes, source, highlights } = distillDeterministic([], "1.0.0"); assert.equal(source, "deterministic"); assert.match(notes, /No changes in v1\.0\.0/); + assert.deepEqual(highlights, []); }); test("handles null/undefined entries", () => { - const { notes } = distillDeterministic(null, "1.0.0"); + const { notes, highlights } = distillDeterministic(null, "1.0.0"); assert.match(notes, /No changes/); + assert.deepEqual(highlights, []); }); -test("single entry produces well-formed notes", () => { +test("single entry produces Highlights plus category section", () => { const entries = [ { summary: "Add cool feature.", category: "feature", legacy: false }, ]; - const { notes } = distillDeterministic(entries, "2.0.0"); - assert.match(notes, /^### New\n\n- Add cool feature\.$/); + const { notes, highlights } = distillDeterministic(entries, "2.0.0"); + assert.deepEqual(highlights, ["Add cool feature."]); + assert.match(notes, /^### Highlights\n\n- Add cool feature\.\n\n### New\n\n- Add cool feature\.$/); }); test("includes internal category when entries exist", () => { @@ -132,7 +150,7 @@ test("multiple categories render in correct order", () => { { summary: "Internal cleanup.", category: "internal", legacy: false }, ]; const { notes } = distillDeterministic(entries, "1.0.0"); - const order = ["### New", "### Fixed", "### Breaking", "### Security", "### Performance", "### Internal"] + const order = ["### Highlights", "### New", "### Fixed", "### Breaking", "### Security", "### Performance", "### Internal"] .map((h) => notes.indexOf(h)); // Each should be found and in ascending order. for (let i = 0; i < order.length - 1; i++) { @@ -141,6 +159,129 @@ test("multiple categories render in correct order", () => { } }); +test("returns highlights alongside notes", () => { + const entries = [ + { summary: "Breaking API.", category: "breaking", legacy: false }, + { summary: "New widget.", category: "feature", legacy: false }, + { summary: "Bug fix.", category: "fix", legacy: false }, + ]; + const { notes, highlights } = distillDeterministic(entries, "1.0.0"); + assert.equal(highlights.length, 3); + assert.equal(highlights[0], "Breaking API."); + assert.match(notes, /### Highlights/); + for (const h of highlights) { + assert.ok(notes.includes(`- ${h}`)); + } +}); + +// --- selectHighlights --- + +test("selectHighlights prefers breaking and security over features", () => { + const entries = [ + { summary: "Feature A.", category: "feature", legacy: false }, + { summary: "Security fix.", category: "security", legacy: false }, + { summary: "Breaking change.", category: "breaking", legacy: false }, + { summary: "Feature B.", category: "feature", legacy: false }, + ]; + const highlights = selectHighlights(entries); + assert.equal(highlights[0], "Breaking change."); + assert.equal(highlights[1], "Security fix."); + assert.ok(highlights.includes("Feature A.")); +}); + +test("selectHighlights caps at HIGHLIGHTS_MAX", () => { + const entries = Array.from({ length: 12 }, (_, i) => ({ + summary: `Feature ${i}.`, + category: "feature", + legacy: false, + })); + const highlights = selectHighlights(entries); + assert.equal(highlights.length, HIGHLIGHTS_MAX); +}); + +test("selectHighlights returns fewer than min when release is small", () => { + const entries = [ + { summary: "Only one.", category: "feature", legacy: false }, + { summary: "Only two.", category: "fix", legacy: false }, + ]; + const highlights = selectHighlights(entries); + assert.equal(highlights.length, 2); +}); + +test("selectHighlights prefers non-internal when enough user-facing exist", () => { + const entries = [ + { summary: "Internal A.", category: "internal", legacy: false }, + { summary: "Feature A.", category: "feature", legacy: false }, + { summary: "Feature B.", category: "feature", legacy: false }, + { summary: "Feature C.", category: "feature", legacy: false }, + { summary: "Internal B.", category: "internal", legacy: false }, + ]; + const highlights = selectHighlights(entries); + assert.ok(highlights.every((h) => !h.startsWith("Internal"))); + assert.equal(highlights.length, 3); +}); + +test("selectHighlights falls back to internal when no user-facing entries", () => { + const entries = [ + { summary: "Internal A.", category: "internal", legacy: false }, + { summary: "Internal B.", category: "internal", legacy: false }, + ]; + const highlights = selectHighlights(entries); + assert.deepEqual(highlights, ["Internal A.", "Internal B."]); +}); + +// --- formatReleaseTweet --- + +test("formatReleaseTweet stays within 280 characters", () => { + const highlights = [ + "Add an interactive worktree-rooted Terminal tab to the task detail view.", + "Agents can now add files to a task's File Scope while working.", + "Agents save screenshots, videos, HTML mockups, and PDFs as artifacts.", + "Add a persistent Advanced settings toggle for uncommon Settings sections.", + "Guide repeatable Compound Engineering cycles from product grounding.", + ]; + const tweet = formatReleaseTweet({ version: "0.58.0", highlights }); + assert.ok(tweet.length <= 280, `tweet length ${tweet.length} exceeds 280`); + assert.match(tweet, /^Fusion 0\.58:/); + assert.match(tweet, /github\.com\/Runfusion\/Fusion\/blob\/main\/CHANGELOG\.md/); + assert.doesNotMatch(tweet, /https?:\/\//); +}); + +test("formatReleaseTweet includes version and changelog link with no highlights", () => { + const tweet = formatReleaseTweet({ version: "1.2.3", highlights: [] }); + assert.ok(tweet.length <= 280); + assert.match(tweet, /^Fusion 1\.2\.3:/); + assert.match(tweet, /github\.com\/Runfusion\/Fusion\/blob\/main\/CHANGELOG\.md/); + assert.doesNotMatch(tweet, /•/); +}); + +test("formatReleaseTweet truncates when a single highlight is very long", () => { + const long = "A".repeat(400); + const tweet = formatReleaseTweet({ version: "9.9.9", highlights: [long] }); + assert.ok(tweet.length <= 280, `tweet length ${tweet.length}`); + assert.match(tweet, /^Fusion 9\.9\.9:/); + assert.match(tweet, /…/); + assert.match(tweet, /github\.com\/Runfusion\/Fusion\/blob\/main\/CHANGELOG\.md/); +}); + +test("formatReleaseTweet drops excess highlights to stay under budget", () => { + const highlights = Array.from({ length: 5 }, (_, i) => + `This is a fairly long highlight number ${i} describing a user-facing change in detail.`, + ); + const tweet = formatReleaseTweet({ version: "0.1.0", highlights }); + assert.ok(tweet.length <= 280); + // At least one bullet should survive when space allows. + assert.match(tweet, /•/); +}); + +test("buildChangelogUrl is always main CHANGELOG without https", () => { + assert.equal( + buildChangelogUrl("0.58.0"), + "github.com/Runfusion/Fusion/blob/main/CHANGELOG.md", + ); + assert.equal(buildChangelogUrl("9.9.9"), buildChangelogUrl("0.1.0")); +}); + // --- buildDistillationPrompt --- test("builds prompt with all entries", () => { @@ -148,7 +289,10 @@ test("builds prompt with all entries", () => { { summary: "Add feature.", category: "feature", legacy: false, dev: "Uses tool X." }, { summary: "Fix bug.", category: "fix", legacy: false }, ]; - const prompt = buildDistillationPrompt(entries); + const prompt = buildDistillationPrompt(entries, "1.0.0", "https://example.com/CHANGELOG.md"); + assert.match(prompt, /Version: 1\.0\.0/); + assert.match(prompt, /Tweet opener \(required form\): Fusion 1\.0:/); + assert.match(prompt, /Changelog URL/); assert.match(prompt, /\[1\]/); assert.match(prompt, /\[2\]/); assert.match(prompt, /category: feature/); @@ -160,16 +304,181 @@ test("builds prompt without dev for entries lacking it", () => { const entries = [ { summary: "Fix bug.", category: "fix", legacy: false }, ]; - const prompt = buildDistillationPrompt(entries); + const prompt = buildDistillationPrompt(entries, "1.0.0", "https://example.com/CHANGELOG.md"); assert.doesNotMatch(prompt, /dev:/); }); // --- DISTILLATION_SYSTEM_PROMPT --- test("system prompt contains key instructions", () => { - assert.match(DISTILLATION_SYSTEM_PROMPT, /release notes/i); + assert.match(DISTILLATION_SYSTEM_PROMPT, /release-notes/i); assert.match(DISTILLATION_SYSTEM_PROMPT, /operator/i); + assert.match(DISTILLATION_SYSTEM_PROMPT, /### Highlights/); assert.match(DISTILLATION_SYSTEM_PROMPT, /### New/); assert.match(DISTILLATION_SYSTEM_PROMPT, /### Fixed/); - assert.match(DISTILLATION_SYSTEM_PROMPT, /omit empty sections/i); + assert.match(DISTILLATION_SYSTEM_PROMPT, /engagement/i); + assert.match(DISTILLATION_SYSTEM_PROMPT, /280/); +}); + +// --- AI path (injected chatComplete; no live Claude required) --- + +test("distillWithAi uses chatComplete and returns source ai", async () => { + const entries = [ + { summary: "Add Terminal tab on tasks.", category: "feature", legacy: false }, + { summary: "Agents can expand File Scope mid-run.", category: "feature", legacy: false }, + { summary: "Artifacts gallery for screenshots and videos.", category: "feature", legacy: false }, + ]; + const aiPayload = { + highlights: [ + "Task Terminal tab rooted in the worktree", + "Agents expand File Scope as they work", + "Artifacts gallery for media deliverables", + ], + notes: [ + "### Highlights", + "", + "- Task Terminal tab rooted in the worktree", + "- Agents expand File Scope as they work", + "- Artifacts gallery for media deliverables", + "", + "### New", + "", + "- Add Terminal tab on tasks.", + "- Agents can expand File Scope mid-run.", + "- Artifacts gallery for screenshots and videos.", + ].join("\n"), + tweet: + "Fusion 0.58: Terminals in the worktree, File Scope that grows with the agent, and an Artifacts gallery that actually shows the work.\ngithub.com/Runfusion/Fusion/blob/main/CHANGELOG.md", + }; + const result = await distillWithAi(entries, "0.58.0", { + chatComplete: async () => JSON.stringify(aiPayload), + }); + assert.ok(result); + assert.equal(result.source, "ai"); + assert.equal(result.highlights.length, 3); + assert.ok(result.tweet.length <= 280); + assert.match(result.notes, /### Highlights/); + assert.match(result.tweet, /Fusion 0\.58:/); +}); + +test("distillReleaseNotes falls back when AI is unavailable", async () => { + const entries = [ + { summary: "Add feature X.", category: "feature", legacy: false }, + ]; + const result = await distillReleaseNotes(entries, "1.2.3", { + chatComplete: async () => null, + }); + assert.equal(result.source, "deterministic"); + assert.match(result.notes, /### Highlights/); + assert.ok(result.tweet.length <= 280); +}); + +test("parseJsonFromLlm strips fences", () => { + const parsed = parseJsonFromLlm('```json\n{"highlights":["A"],"notes":"### Highlights\\n\\n- A","tweet":"hi"}\n```'); + assert.deepEqual(parsed.highlights, ["A"]); +}); + +test("normalizeAiDistillResult injects missing Highlights heading", () => { + const changelogUrl = "github.com/Runfusion/Fusion/blob/main/CHANGELOG.md"; + const normalized = normalizeAiDistillResult( + { + highlights: ["One thing", "Two thing", "Three thing"], + notes: "### New\n\n- One thing", + tweet: `Fusion v1.0.0 shipped three things.\n${changelogUrl}`, + }, + "1.0.0", + changelogUrl, + ); + assert.ok(normalized); + assert.match(normalized.notes, /^### Highlights/); +}); + +test("formatTweetVersionLabel drops leading v and .0 patch", () => { + assert.equal(formatTweetVersionLabel("0.58.0"), "Fusion 0.58"); + assert.equal(formatTweetVersionLabel("v0.58.0"), "Fusion 0.58"); + assert.equal(formatTweetVersionLabel("0.58.1"), "Fusion 0.58.1"); + assert.equal(formatTweetVersionLabel("1.0.0"), "Fusion 1.0"); +}); + +test("ensureFusionVersionPrefix rewrites bare vX openers", () => { + assert.equal( + ensureFusionVersionPrefix("v0.58.0: terminals in task detail", "0.58.0"), + "Fusion 0.58: terminals in task detail", + ); + assert.equal( + ensureFusionVersionPrefix("Fusion v0.58.0 is out! terminals", "0.58.0"), + "Fusion 0.58: terminals", + ); + assert.equal( + ensureFusionVersionPrefix("Fusion 0.58.1: patch release", "0.58.1"), + "Fusion 0.58.1: patch release", + ); +}); + +test("fitTweetToBudget keeps GitHub changelog path when under 280", () => { + const changelogUrl = "github.com/Runfusion/Fusion/blob/main/CHANGELOG.md"; + const tweet = `Fusion 0.58: Terminals in task detail.\n${changelogUrl}`; + const fit = fitTweetToBudget(tweet, { + version: "0.58.0", + highlights: ["Terminals in task detail"], + changelogUrl, + }); + assert.ok(fit.length <= 280); + assert.ok(fit.includes(changelogUrl)); + assert.match(fit, /^Fusion 0\.58:/); + assert.doesNotMatch(fit, /https?:\/\//); +}); + +test("fitTweetToBudget strips https and version tags to static main path", () => { + const changelogUrl = "github.com/Runfusion/Fusion/blob/main/CHANGELOG.md"; + const tweet = + "Fusion v0.58.0: Terminals in task detail.\nhttps://github.com/Runfusion/Fusion/blob/v0.58.0/CHANGELOG.md"; + const fit = fitTweetToBudget(tweet, { + version: "0.58.0", + highlights: ["Terminals in task detail"], + changelogUrl, + }); + assert.ok(fit.includes(changelogUrl)); + assert.doesNotMatch(fit, /https?:\/\//); + assert.doesNotMatch(fit, /blob\/v0\.58\.0\//); +}); + +test("fitTweetToBudget swaps to runfusion.ai when full changelog URL is too long", () => { + const changelogUrl = "github.com/Runfusion/Fusion/blob/main/CHANGELOG.md"; + // Long prose + full changelog path intentionally over 280. + const body = + "Fusion 0.58: your agents can now open a real terminal in the task itself, grab extra files into scope mid-run instead of stranding edits, and stash screenshots/videos/PDFs in a proper artifact gallery. Grok CLI joins as a bundled runtime plugin too."; + const tweet = `${body}\n${changelogUrl}`; + assert.ok(tweet.length > 280, "precondition: over budget"); + const fit = fitTweetToBudget(tweet, { + version: "0.58.0", + highlights: ["Terminal", "File Scope", "Artifacts"], + changelogUrl, + }); + assert.ok(fit.length <= 280, `fit length ${fit.length}`); + assert.match(fit, /^Fusion 0\.58:/); + assert.ok(fit.includes(SHORT_RELEASE_URL), "should use short product link"); + assert.ok(!fit.includes(changelogUrl), "should drop full changelog URL"); + assert.doesNotMatch(fit, /https?:\/\//); +}); + +test("normalizeAiDistillResult uses short URL when AI tweet is over budget", () => { + const changelogUrl = "github.com/Runfusion/Fusion/blob/main/CHANGELOG.md"; + const longTweet = + "v0.58.0 ships a worktree Terminal tab, mid-task File Scope expansion so edits never strand at merge, a full artifact gallery for screenshots videos and PDFs, plus Grok CLI as a bundled plugin with thinking-level controls everywhere. " + + changelogUrl; + assert.ok(longTweet.length > 280); + const normalized = normalizeAiDistillResult( + { + highlights: ["Terminal tab", "File Scope expansion", "Artifact gallery"], + notes: "### Highlights\n\n- Terminal tab\n- File Scope expansion\n- Artifact gallery", + tweet: longTweet, + }, + "0.58.0", + changelogUrl, + ); + assert.ok(normalized); + assert.ok(normalized.tweet.length <= 280); + assert.ok(normalized.tweet.includes(SHORT_RELEASE_URL)); + assert.doesNotMatch(normalized.tweet, /https?:\/\//); }); diff --git a/scripts/ci-distill-release-notes.mjs b/scripts/ci-distill-release-notes.mjs index f0019836af..97eb967507 100644 --- a/scripts/ci-distill-release-notes.mjs +++ b/scripts/ci-distill-release-notes.mjs @@ -17,7 +17,7 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { parseChangesetBody } from "./lib/changeset-schema.mjs"; -import { distillDeterministic } from "./lib/distill-release-notes.mjs"; +import { distillReleaseNotes } from "./lib/distill-release-notes.mjs"; import { extractVersionNotes, replaceVersionSection } from "./lib/extract-version-notes.mjs"; const args = process.argv.slice(2); @@ -80,7 +80,12 @@ if (entries.length === 0) { process.exit(0); } -const { notes: distilledNotes, source } = distillDeterministic(entries, version); +/* + * FNXC:Changelog 2026-07-13-15:45: + * Prefer Claude CLI distillation (`claude -p --model sonnet`) when available + * in the environment; otherwise soft-fallback deterministic. Never blocks. + */ +const { notes: distilledNotes, source } = await distillReleaseNotes(entries, version); const changelogContent = readFileSync(CHANGELOG_PATH, "utf8"); const updated = replaceVersionSection(changelogContent, version, distilledNotes); diff --git a/scripts/lib/distill-release-notes.mjs b/scripts/lib/distill-release-notes.mjs index 3c42d4274b..f553dbb33b 100644 --- a/scripts/lib/distill-release-notes.mjs +++ b/scripts/lib/distill-release-notes.mjs @@ -1,54 +1,113 @@ /* * FNXC:Changelog 2026-06-24-15:30: * Release-notes distillation module. Transforms parsed changeset entries - * into grouped, end-user-facing release notes. The deterministic fallback - * builds a category-grouped bullet list directly from the structured - * `summary` fields — no model call. When a model is available, the prompt - * and system prompt defined here can be used to produce curated, polished - * notes via `createFnAgent`. + * into grouped, end-user-facing release notes for Fusion operators. * - * Audience is Fusion operators: behavior, fixes, what changed — minimal - * internals. The `dev` field is preserved in per-package CHANGELOGs but - * excluded from distilled release notes by default. + * 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 + * tweet (not a fixed template). Deterministic ranking remains only as a soft + * fallback so offline/CI releases without Claude never block. */ +import { spawnSync } from "node:child_process"; import { CATEGORIES, CATEGORY_HEADINGS } from "./changeset-schema.mjs"; +/** Preferred min/max size of the Highlights section when enough entries exist. */ +export const HIGHLIGHTS_MIN = 3; +export const HIGHLIGHTS_MAX = 5; + +/** Wall-clock budget for the Claude CLI call. */ +export const RELEASE_LLM_TIMEOUT_MS = 90_000; + +/** Default Claude model alias for release distillation. */ +export const DEFAULT_CLAUDE_MODEL = "sonnet"; + /** - * System prompt for AI distillation via `createFnAgent`. - * Instructs the model to produce grouped markdown release notes for a - * Fusion operator audience, using only the `summary` fields as input. + * Lower number = higher highlight priority (deterministic fallback only). + */ +export const HIGHLIGHT_PRIORITY = { + breaking: 0, + security: 1, + feature: 2, + fix: 3, + performance: 4, + internal: 5, +}; + +/* + * FNXC:Changelog 2026-07-13-16:10: + * Tweet links omit the https:// scheme to save characters on X + * (github.com/.../CHANGELOG.md, or runfusion.ai when still too long). + * + * FNXC:Changelog 2026-07-13-16:25: + * The tweet changelog CTA is always the main-branch file path (not a version tag) + * so operators land on the live CHANGELOG.md: github.com/Runfusion/Fusion/blob/main/CHANGELOG.md + */ +/** Static scheme-free changelog path used in release tweets. */ +export const STATIC_CHANGELOG_URL = + "github.com/Runfusion/Fusion/blob/main/CHANGELOG.md"; + +/** Short product link used in tweets when the full changelog URL is too long. */ +export const SHORT_RELEASE_URL = "runfusion.ai"; + +/** + * System prompt for Claude distillation. + * Produces highlights, full notes, and an engagement-driving tweet. */ export const DISTILLATION_SYSTEM_PROMPT = [ - "You are a release-notes writer for Fusion, a model-agnostic AI agent orchestration product.", - "Your audience is Fusion operators — developers using the product, not its internals.", - "Produce clean, grouped markdown release notes from the provided changeset entries.", - "Group under these headings (omit empty sections):", - " ### New (features)", - " ### Fixed (bug fixes)", - " ### Breaking (breaking changes)", - " ### Security (security fixes)", - " ### Performance (performance improvements)", - " ### Internal (internal-only changes)", - "Rules:", - "- Use the `summary` text verbatim or lightly edited for clarity and grouping.", - "- Do NOT include internal class names, file paths, or implementation detail.", - "- Do NOT include the `dev` field content unless it is user-relevant migration guidance.", - "- Write one bullet per entry, prefixed with `- `.", - "- Omit empty sections entirely.", - "- Do NOT add a title or version heading — only the grouped sections.", - "- Respond with markdown only, no preamble or explanation.", + "You are a release-notes writer and social copywriter for Fusion,", + "a model-agnostic AI agent orchestration product.", + "Audience: Fusion operators (developers who run Fusion), not internals.", + "", + "Return STRICT JSON only — no markdown fences, no preamble:", + '{ "highlights": string[3..5], "notes": string, "tweet": string }', + "", + "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.", + "", + "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.", + "", + "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.", + "- 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.", + "- 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.", + "", + "JSON only.", ].join("\n"); /** * Build the user-facing prompt for AI distillation. - * Lists each entry as `[N] category: X / summary: Y / dev: Z`. * * @param {Array<{summary: string, category: string, dev?: string, legacy?: boolean}>} entries + * @param {string} version + * @param {string} changelogUrl * @returns {string} */ -export function buildDistillationPrompt(entries) { - const lines = ["Produce release notes from these changeset entries:\n"]; +export function buildDistillationPrompt(entries, version, changelogUrl) { + const opener = `${formatTweetVersionLabel(version)}:`; + const lines = [ + `Version: ${version}`, + `Tweet opener (required form): ${opener}`, + `Changelog URL (prefer in the tweet when it fits ≤280): ${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", + ]; entries.forEach((entry, i) => { const num = i + 1; lines.push(`[${num}]`); @@ -63,27 +122,144 @@ export function buildDistillationPrompt(entries) { } /** - * Deterministic fallback: build category-grouped release notes directly - * from the structured `summary` fields — no model call. - * - * Used when: - * - The model call fails, times out, or returns unparseable output - * - No model is configured (CI without model secret) - * - As a pre-model preview in dry-runs + * Rank and pick the top 3–5 highlight summaries (deterministic fallback only). * * @param {Array<{summary: string, category: string, legacy?: boolean}>} entries - * @param {string} version - Target version string (e.g. "0.47.0") - * @returns {{notes: string, source: "deterministic"}} + * @param {{min?: number, max?: number}} [opts] + * @returns {string[]} + */ +export function selectHighlights(entries, opts = {}) { + const min = opts.min ?? HIGHLIGHTS_MIN; + const max = opts.max ?? HIGHLIGHTS_MAX; + + if (!entries || entries.length === 0) { + return []; + } + + const ranked = entries + .map((entry, index) => ({ + entry, + index, + priority: HIGHLIGHT_PRIORITY[entry.category] ?? HIGHLIGHT_PRIORITY.internal, + })) + .sort((a, b) => { + if (a.priority !== b.priority) return a.priority - b.priority; + return a.index - b.index; + }); + + const nonInternal = ranked.filter((r) => r.entry.category !== "internal"); + const pool = nonInternal.length >= Math.min(min, ranked.length) ? nonInternal : ranked; + + const count = Math.min(max, pool.length); + return pool.slice(0, count).map((r) => r.entry.summary); +} + +/** + * Static changelog path for release tweets (scheme-free, always main). + * @param {string} [_version] - unused; kept for call-site compatibility + * @returns {string} + */ +export function buildChangelogUrl(_version) { + return STATIC_CHANGELOG_URL; +} + +/** + * Deterministic tweet formatter (soft fallback only). + * + * @param {{version: string, highlights: string[], changelogUrl?: string}} opts + * @returns {string} + */ +/** + * Canonical tweet version label: "Fusion 0.58" (drop patch when it is 0). + * e.g. 0.58.0 → "Fusion 0.58", 0.58.1 → "Fusion 0.58.1". No leading v. + * @param {string} version + * @returns {string} + */ +export function formatTweetVersionLabel(version) { + const bare = String(version || "").replace(/^v/i, ""); + 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}`; + } + return `Fusion ${bare}`; +} + +/** + * Bare version string for tweet matching (patch dropped when 0). + * @param {string} version + * @returns {string} + */ +export function formatTweetVersionBare(version) { + return formatTweetVersionLabel(version).replace(/^Fusion\s+/i, ""); +} + +export function formatReleaseTweet({ version, highlights = [], changelogUrl }) { + const url = changelogUrl || buildChangelogUrl(version); + const header = `${formatTweetVersionLabel(version)}:`; + const footer = url; + + const fit = (items) => { + const body = items.length === 0 + ? "" + : `\n\n${items.map((h) => `• ${h}`).join("\n")}`; + return `${header}${body}\n\n${footer}`; + }; + + let items = [...highlights]; + let tweet = fit(items); + while (tweet.length > 280 && items.length > 1) { + items = items.slice(0, -1); + tweet = fit(items); + } + + if (tweet.length > 280 && items.length === 1) { + const prefix = `${header}\n\n• `; + const suffix = `\n\n${footer}`; + const maxSummary = 280 - prefix.length - suffix.length; + if (maxSummary >= 12) { + let summary = items[0]; + if (summary.length > maxSummary) { + summary = `${summary.slice(0, maxSummary - 1).trimEnd()}…`; + } + tweet = `${prefix}${summary}${suffix}`; + } + } + + if (tweet.length > 280) { + tweet = `${header}\n\n${footer}`; + } + if (tweet.length > 280) { + tweet = tweet.slice(0, 280); + } + + return tweet; +} + +/** + * Deterministic fallback when Claude is unavailable. + * + * @param {Array<{summary: string, category: string, legacy?: boolean}>} entries + * @param {string} version + * @returns {{notes: string, source: "deterministic", highlights: string[], tweet: string}} */ export function distillDeterministic(entries, version) { + const changelogUrl = buildChangelogUrl(version); + if (!entries || entries.length === 0) { return { notes: `No changes in v${version}.`, source: "deterministic", + highlights: [], + tweet: formatReleaseTweet({ version, highlights: [], changelogUrl }), }; } - // Group entries by category, preserving entry order within each group. + const highlights = selectHighlights(entries); + const groups = new Map(); for (const cat of CATEGORIES) { groups.set(cat, []); @@ -94,8 +270,13 @@ export function distillDeterministic(entries, version) { groups.get(cat).push(entry.summary); } - // Build sections in display order, omitting empty categories. const sections = []; + + if (highlights.length > 0) { + const bullets = highlights.map((s) => `- ${s}`).join("\n"); + sections.push(`### Highlights\n\n${bullets}`); + } + for (const cat of CATEGORIES) { const summaries = groups.get(cat); if (summaries.length === 0) continue; @@ -108,5 +289,340 @@ export function distillDeterministic(entries, version) { return { notes: sections.join("\n\n"), source: "deterministic", + highlights, + tweet: formatReleaseTweet({ version, highlights, changelogUrl }), }; } + +/** + * @param {string} raw + * @returns {unknown | null} + */ +export function parseJsonFromLlm(raw) { + if (!raw || typeof raw !== "string") return null; + let text = raw.trim(); + const fence = text.match(/^```(?:json)?\s*([\s\S]*?)```$/i); + if (fence) text = fence[1].trim(); + const start = text.indexOf("{"); + const end = text.lastIndexOf("}"); + if (start === -1 || end === -1 || end <= start) return null; + try { + return JSON.parse(text.slice(start, end + 1)); + } catch { + return null; + } +} + +/** + * @param {unknown} parsed + * @param {string} version + * @param {string} changelogUrl + * @returns {{highlights: string[], notes: string, tweet: string} | null} + */ +export function normalizeAiDistillResult(parsed, version, changelogUrl) { + if (!parsed || typeof parsed !== "object") return null; + const obj = /** @type {Record} */ (parsed); + + const highlightsRaw = Array.isArray(obj.highlights) ? obj.highlights : null; + if (!highlightsRaw || highlightsRaw.length === 0) return null; + + const highlights = highlightsRaw + .filter((h) => typeof h === "string" && h.trim()) + .map((h) => h.trim()) + .slice(0, HIGHLIGHTS_MAX); + if (highlights.length === 0) return null; + + const notes = typeof obj.notes === "string" ? obj.notes.trim() : ""; + if (!notes) return null; + + let notesOut = notes; + if (!/^###\s+Highlights\b/m.test(notesOut)) { + const bullets = highlights.map((s) => `- ${s}`).join("\n"); + notesOut = `### Highlights\n\n${bullets}\n\n${notesOut}`; + } + + let tweet = typeof obj.tweet === "string" ? obj.tweet.trim() : ""; + if (!tweet) { + tweet = formatReleaseTweet({ version, highlights, changelogUrl }); + } + + tweet = fitTweetToBudget(tweet, { + version, + highlights, + changelogUrl, + }); + + return { highlights, notes: notesOut, tweet }; +} + +/** + * Ensure a tweet is ≤280 chars. Prefer the full changelog URL; if too long, + * swap to runfusion.ai (no scheme), then trim prose before the link if needed. + * + * @param {string} tweet + * @param {{version: string, highlights: string[], changelogUrl: string}} ctx + * @returns {string} + */ +export function fitTweetToBudget(tweet, { version, highlights, changelogUrl }) { + let out = (tweet || "").trim(); + if (!out) { + return formatReleaseTweet({ version, highlights, changelogUrl }); + } + + // Normalize any https:// forms the model may still emit. + out = stripTweetLinkSchemes(out, changelogUrl); + // Prefer "Fusion 0.58.0:" over bare "v0.58.0:" openers. + out = ensureFusionVersionPrefix(out, version); + + const hasFull = out.includes(changelogUrl); + const hasShort = out.includes(SHORT_RELEASE_URL); + + // Prefer attaching the GitHub changelog path when missing and it still fits. + if (!hasFull && !hasShort && out.length + 1 + changelogUrl.length <= 280) { + out = `${out}\n${changelogUrl}`; + } + + if (out.length <= 280) { + return out; + } + + // Too long with the GitHub path — swap to the short product link. + if (out.includes(changelogUrl)) { + out = out.split(changelogUrl).join(SHORT_RELEASE_URL); + } else if (!out.includes(SHORT_RELEASE_URL)) { + out = out.replace( + /(?:https?:\/\/)?github\.com\/Runfusion\/Fusion\/[^\s]+/g, + SHORT_RELEASE_URL, + ); + } + + if (out.length <= 280) { + return out; + } + + // Still over: keep the short URL, trim prose before it (never mid-link). + const shortIdx = out.indexOf(SHORT_RELEASE_URL); + if (shortIdx > 0) { + const bodyBudget = 280 - SHORT_RELEASE_URL.length - 1; + let body = out.slice(0, shortIdx).trimEnd(); + if (body.length > bodyBudget) { + body = `${body.slice(0, Math.max(0, bodyBudget - 1)).trimEnd()}…`; + } + out = `${body}\n${SHORT_RELEASE_URL}`; + } else if (out.length + 1 + SHORT_RELEASE_URL.length <= 280) { + out = `${out}\n${SHORT_RELEASE_URL}`; + } else { + // Last resort: rebuild with short link so the CTA still fits. + out = formatReleaseTweet({ + version, + highlights, + changelogUrl: SHORT_RELEASE_URL, + }); + } + + if (out.length > 280) { + out = formatReleaseTweet({ + version, + highlights, + changelogUrl: SHORT_RELEASE_URL, + }); + } + + return out; +} + +/** + * Drop https:// from known release links so tweets stay scheme-free. + * @param {string} text + * @param {string} changelogUrl - scheme-free github.com/... path + * @returns {string} + */ +export function stripTweetLinkSchemes(text, changelogUrl) { + let out = text; + out = out.replace(/https?:\/\/(?:www\.)?runfusion\.ai\/?/gi, SHORT_RELEASE_URL); + // Any GitHub CHANGELOG link (scheme or version tag) → static main path. + out = out.replace( + /(?:https?:\/\/)?github\.com\/Runfusion\/Fusion\/blob\/(?:main|v[\w.-]+)\/CHANGELOG\.md/g, + changelogUrl || STATIC_CHANGELOG_URL, + ); + return out; +} + +/** + * Ensure the tweet opens with "Fusion :" (no leading v on the number). + * Rewrites common "vX.Y.Z:" / "Fusion vX.Y.Z" openers. + * + * @param {string} text + * @param {string} version + * @returns {string} + */ +export function ensureFusionVersionPrefix(text, version) { + const label = formatTweetVersionLabel(version); + const bareFull = String(version || "").replace(/^v/i, ""); + const bareDisplay = formatTweetVersionBare(version); + // Match full semver or display form (with/without .0 patch). + const versionAlt = bareFull === bareDisplay + ? escapeRegExp(bareFull) + : `(?:${escapeRegExp(bareFull)}|${escapeRegExp(bareDisplay)})`; + let out = (text || "").trim(); + if (!out) return `${label}:`; + + // "Fusion v0.58.0" / "Fusion 0.58 is out" → "Fusion 0.58:" + out = out.replace( + new RegExp(`^Fusion\\s+v?${versionAlt}(?:\\s+is\\s+out!?)?\\s*:?\\s*`, "i"), + `${label}: `, + ); + // Bare "v0.58.0:" / "0.58:" opener → "Fusion 0.58:" + out = out.replace( + new RegExp(`^v?${versionAlt}\\s*:\\s*`, "i"), + `${label}: `, + ); + + if (!new RegExp(`^Fusion\\s+${escapeRegExp(bareDisplay)}\\s*:`, "i").test(out)) { + out = `${label}: ${out}`; + } + + // Collapse accidental double spaces after the colon. + return out.replace(/^([^:]+:)\s+/, "$1 ").trim(); +} + +function escapeRegExp(s) { + return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Resolve Claude CLI binary + model from env. + * @param {Record} [env] + * @returns {{claudeBin: string, model: string}} + */ +export function resolveClaudeDistillConfig(env = process.env) { + return { + claudeBin: env.FUSION_RELEASE_CLAUDE_BIN || "claude", + model: env.FUSION_RELEASE_CLAUDE_MODEL || DEFAULT_CLAUDE_MODEL, + }; +} + +/** + * Call local Claude CLI headless: `claude -p --model sonnet`. + * Tools disabled so this is a pure text completion. + * + * @param {{ + * system: string, + * user: string, + * claudeBin?: string, + * model?: string, + * timeoutMs?: number, + * spawnImpl?: typeof spawnSync, + * }} opts + * @returns {string | null} + */ +export function chatViaClaudeCli(opts) { + const { + system, + user, + claudeBin = "claude", + model = DEFAULT_CLAUDE_MODEL, + timeoutMs = RELEASE_LLM_TIMEOUT_MS, + spawnImpl = spawnSync, + } = opts; + + // Combined prompt: Claude --system-prompt can be long; keep it simple and reliable. + const prompt = `${system}\n\n---\n\n${user}`; + + const args = [ + "-p", + prompt, + "--model", + model, + "--output-format", + "text", + // No tools: pure generation for release copy. + "--tools", + "", + "--permission-mode", + "dontAsk", + ]; + + const r = spawnImpl(claudeBin, args, { + encoding: "utf8", + timeout: timeoutMs, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 4 * 1024 * 1024, + }); + + if (r.error || r.status !== 0) { + return null; + } + const out = (r.stdout || "").trim(); + return out || null; +} + +/** + * Attempt Claude AI distillation. Returns null when Claude is unavailable or output is invalid. + * + * @param {Array<{summary: string, category: string, dev?: string, legacy?: boolean}>} entries + * @param {string} version + * @param {{ + * changelogUrl?: string, + * env?: Record, + * chatComplete?: (args: {system: string, user: string}) => Promise | string | null, + * allowClaudeCli?: boolean, + * timeoutMs?: number, + * spawnImpl?: typeof spawnSync, + * }} [options] + * @returns {Promise<{notes: string, source: "ai", highlights: string[], tweet: string} | null>} + */ +export async function distillWithAi(entries, version, options = {}) { + if (!entries || entries.length === 0) return null; + + const changelogUrl = options.changelogUrl || buildChangelogUrl(version); + const system = DISTILLATION_SYSTEM_PROMPT; + const user = buildDistillationPrompt(entries, version, changelogUrl); + + let raw = null; + + if (typeof options.chatComplete === "function") { + raw = await options.chatComplete({ system, user }); + } else if (options.allowClaudeCli !== false) { + const cfg = resolveClaudeDistillConfig(options.env || process.env); + raw = chatViaClaudeCli({ + system, + user, + claudeBin: cfg.claudeBin, + model: cfg.model, + timeoutMs: options.timeoutMs ?? RELEASE_LLM_TIMEOUT_MS, + spawnImpl: options.spawnImpl, + }); + } + + if (!raw) return null; + const parsed = parseJsonFromLlm(raw); + const normalized = normalizeAiDistillResult(parsed, version, changelogUrl); + if (!normalized) return null; + + return { + notes: normalized.notes, + highlights: normalized.highlights, + tweet: normalized.tweet, + source: "ai", + }; +} + +/** + * Distill release notes: Claude AI first, deterministic soft fallback. + * + * @param {Array<{summary: string, category: string, dev?: string, legacy?: boolean}>} entries + * @param {string} version + * @param {Parameters[2]} [options] + * @returns {Promise<{notes: string, source: "ai" | "deterministic", highlights: string[], tweet: string}>} + */ +export async function distillReleaseNotes(entries, version, options = {}) { + try { + const ai = await distillWithAi(entries, version, options); + if (ai) return ai; + } catch { + // Soft fallback — release must never fail on distillation. + } + return distillDeterministic(entries, version); +} diff --git a/scripts/release.mjs b/scripts/release.mjs index 18f64c812d..93956013ba 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -35,7 +35,7 @@ import { } from "./lib/release-authorization-gate.mjs"; import { extractVersionNotes, replaceVersionSection } from "./lib/extract-version-notes.mjs"; import { parseChangesetFile } from "./lib/changeset-schema.mjs"; -import { distillDeterministic } from "./lib/distill-release-notes.mjs"; +import { distillReleaseNotes } from "./lib/distill-release-notes.mjs"; import { shouldPromptForVersion } from "./lib/release-prompt-gate.mjs"; import { archivePointerLine, @@ -577,6 +577,23 @@ 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).`); + /* + * FNXC:ReleaseScript 2026-07-13-15:25: + * Dry-run previews the LLM-authored Highlights + X draft (falls back to + * deterministic if no model is reachable) so operators can review the post + * without authorizing a real publish. + */ + const dryEntries = changesetSummaries.map(({ file }) => { + 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); + console.log(""); + console.log(color(36, "─── Draft post for X (preview) ───")); + console.log(dryDistilled.tweet); + console.log(color(90, `(${dryDistilled.tweet.length}/280 chars; source: ${dryDistilled.source})`)); + console.log(color(36, "──────────────────────────────────")); process.exit(0); } @@ -648,18 +665,23 @@ syncRootChangelog(); ok("Root CHANGELOG.md updated."); /* - * FNXC:Changelog 2026-06-24-16:30: - * Distill end-user-facing release notes from the captured changeset entries - * and replace the raw per-package aggregate in the root CHANGELOG for this - * version. Historical version sections are preserved untouched. + * FNXC:ReleaseScript 2026-07-13-15:45: + * Claude CLI (`claude -p --model sonnet`) authors Highlights (top 3–5), full + * 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…"); -const { notes: distilledNotes, source: distillSource } = distillDeterministic(capturedEntries, version); +info("Distilling release notes with Claude (sonnet; soft fallback if unavailable)…"); +const { + notes: distilledNotes, + source: distillSource, + highlights: releaseHighlights, + tweet: releaseTweet, +} = await distillReleaseNotes(capturedEntries, version); const changelogBeforeDistill = readFileSync("CHANGELOG.md", "utf8"); const changelogAfterDistill = replaceVersionSection(changelogBeforeDistill, version, distilledNotes); if (changelogAfterDistill !== changelogBeforeDistill) { writeFileSync("CHANGELOG.md", changelogAfterDistill); - ok(`Root CHANGELOG.md updated with distilled notes (source: ${distillSource}).`); + ok(`Root CHANGELOG.md updated with distilled notes (source: ${distillSource}; ${releaseHighlights.length} highlight(s)).`); } else { warn(`Could not locate version section in CHANGELOG.md for distillation; leaving raw aggregate.`); } @@ -753,3 +775,15 @@ if (githubReleaseStatus === "created") { } else { ok(`Released v${version}. Published to npm, tag pushed. GitHub Release was not created (see warnings above).`); } + +/* + * 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. + */ +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, "─────────────────────────────────────")); +