feat: AI Highlights and engagement X draft on release
Claude-author Highlights plus an engagement-oriented tweet (≤280) during changelog distillation, with scheme-free links, Fusion version openers that drop .0 patch, and a soft deterministic fallback when Claude is offline.
This commit is contained in:
7
.changeset/release-highlights-and-x-draft.md
Normal file
7
.changeset/release-highlights-and-x-draft.md
Normal file
@@ -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.
|
||||
@@ -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 <target>` | Cross-compile for a specific platform |
|
||||
|
||||
@@ -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?:\/\//);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<string, unknown>} */ (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 <version>:" (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<string, string | undefined>} [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<string, string | undefined>,
|
||||
* chatComplete?: (args: {system: string, user: string}) => Promise<string | null> | 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<typeof distillWithAi>[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);
|
||||
}
|
||||
|
||||
@@ -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, "─────────────────────────────────────"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user