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
|
- Consumes all pending changeset files
|
||||||
- Bumps package versions according to the changeset declarations
|
- Bumps package versions according to the changeset declarations
|
||||||
- Generates/updates `CHANGELOG.md` files for affected packages
|
- 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
|
### 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` | Add a new changeset |
|
||||||
| `pnpm changeset status` | Check pending changesets |
|
| `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 --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 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` | Build binary for current platform |
|
||||||
| `pnpm --filter @runfusion/fusion build:exe -- --target <target>` | Cross-compile for a specific 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 {
|
import {
|
||||||
distillDeterministic,
|
distillDeterministic,
|
||||||
|
distillReleaseNotes,
|
||||||
|
distillWithAi,
|
||||||
buildDistillationPrompt,
|
buildDistillationPrompt,
|
||||||
DISTILLATION_SYSTEM_PROMPT,
|
DISTILLATION_SYSTEM_PROMPT,
|
||||||
|
selectHighlights,
|
||||||
|
formatReleaseTweet,
|
||||||
|
formatTweetVersionLabel,
|
||||||
|
ensureFusionVersionPrefix,
|
||||||
|
buildChangelogUrl,
|
||||||
|
parseJsonFromLlm,
|
||||||
|
normalizeAiDistillResult,
|
||||||
|
fitTweetToBudget,
|
||||||
|
SHORT_RELEASE_URL,
|
||||||
|
HIGHLIGHTS_MAX,
|
||||||
} from "../lib/distill-release-notes.mjs";
|
} from "../lib/distill-release-notes.mjs";
|
||||||
|
|
||||||
// --- distillDeterministic ---
|
// --- distillDeterministic ---
|
||||||
@@ -19,10 +31,13 @@ test("groups entries by category in display order", () => {
|
|||||||
const { notes, source } = distillDeterministic(entries, "1.0.0");
|
const { notes, source } = distillDeterministic(entries, "1.0.0");
|
||||||
assert.equal(source, "deterministic");
|
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 featureIdx = notes.indexOf("### New");
|
||||||
const fixIdx = notes.indexOf("### Fixed");
|
const fixIdx = notes.indexOf("### Fixed");
|
||||||
const breakingIdx = notes.indexOf("### Breaking");
|
const breakingIdx = notes.indexOf("### Breaking");
|
||||||
|
assert.ok(highlightsIdx > -1);
|
||||||
|
assert.ok(highlightsIdx < featureIdx);
|
||||||
assert.ok(featureIdx > -1);
|
assert.ok(featureIdx > -1);
|
||||||
assert.ok(featureIdx < fixIdx);
|
assert.ok(featureIdx < fixIdx);
|
||||||
assert.ok(fixIdx < breakingIdx);
|
assert.ok(fixIdx < breakingIdx);
|
||||||
@@ -59,22 +74,25 @@ test("groups multiple entries in same category", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("handles empty entries array", () => {
|
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.equal(source, "deterministic");
|
||||||
assert.match(notes, /No changes in v1\.0\.0/);
|
assert.match(notes, /No changes in v1\.0\.0/);
|
||||||
|
assert.deepEqual(highlights, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles null/undefined entries", () => {
|
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.match(notes, /No changes/);
|
||||||
|
assert.deepEqual(highlights, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("single entry produces well-formed notes", () => {
|
test("single entry produces Highlights plus category section", () => {
|
||||||
const entries = [
|
const entries = [
|
||||||
{ summary: "Add cool feature.", category: "feature", legacy: false },
|
{ summary: "Add cool feature.", category: "feature", legacy: false },
|
||||||
];
|
];
|
||||||
const { notes } = distillDeterministic(entries, "2.0.0");
|
const { notes, highlights } = distillDeterministic(entries, "2.0.0");
|
||||||
assert.match(notes, /^### New\n\n- Add cool feature\.$/);
|
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", () => {
|
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 },
|
{ summary: "Internal cleanup.", category: "internal", legacy: false },
|
||||||
];
|
];
|
||||||
const { notes } = distillDeterministic(entries, "1.0.0");
|
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));
|
.map((h) => notes.indexOf(h));
|
||||||
// Each should be found and in ascending order.
|
// Each should be found and in ascending order.
|
||||||
for (let i = 0; i < order.length - 1; i++) {
|
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 ---
|
// --- buildDistillationPrompt ---
|
||||||
|
|
||||||
test("builds prompt with all entries", () => {
|
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: "Add feature.", category: "feature", legacy: false, dev: "Uses tool X." },
|
||||||
{ summary: "Fix bug.", category: "fix", legacy: false },
|
{ 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, /\[1\]/);
|
||||||
assert.match(prompt, /\[2\]/);
|
assert.match(prompt, /\[2\]/);
|
||||||
assert.match(prompt, /category: feature/);
|
assert.match(prompt, /category: feature/);
|
||||||
@@ -160,16 +304,181 @@ test("builds prompt without dev for entries lacking it", () => {
|
|||||||
const entries = [
|
const entries = [
|
||||||
{ summary: "Fix bug.", category: "fix", legacy: false },
|
{ 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:/);
|
assert.doesNotMatch(prompt, /dev:/);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- DISTILLATION_SYSTEM_PROMPT ---
|
// --- DISTILLATION_SYSTEM_PROMPT ---
|
||||||
|
|
||||||
test("system prompt contains key instructions", () => {
|
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, /operator/i);
|
||||||
|
assert.match(DISTILLATION_SYSTEM_PROMPT, /### Highlights/);
|
||||||
assert.match(DISTILLATION_SYSTEM_PROMPT, /### New/);
|
assert.match(DISTILLATION_SYSTEM_PROMPT, /### New/);
|
||||||
assert.match(DISTILLATION_SYSTEM_PROMPT, /### Fixed/);
|
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 { join } from "node:path";
|
||||||
|
|
||||||
import { parseChangesetBody } from "./lib/changeset-schema.mjs";
|
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";
|
import { extractVersionNotes, replaceVersionSection } from "./lib/extract-version-notes.mjs";
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
@@ -80,7 +80,12 @@ if (entries.length === 0) {
|
|||||||
process.exit(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 changelogContent = readFileSync(CHANGELOG_PATH, "utf8");
|
||||||
const updated = replaceVersionSection(changelogContent, version, distilledNotes);
|
const updated = replaceVersionSection(changelogContent, version, distilledNotes);
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +1,113 @@
|
|||||||
/*
|
/*
|
||||||
* FNXC:Changelog 2026-06-24-15:30:
|
* FNXC:Changelog 2026-06-24-15:30:
|
||||||
* Release-notes distillation module. Transforms parsed changeset entries
|
* Release-notes distillation module. Transforms parsed changeset entries
|
||||||
* into grouped, end-user-facing release notes. The deterministic fallback
|
* into grouped, end-user-facing release notes for Fusion operators.
|
||||||
* 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`.
|
|
||||||
*
|
*
|
||||||
* Audience is Fusion operators: behavior, fixes, what changed — minimal
|
* FNXC:Changelog 2026-07-13-15:45:
|
||||||
* internals. The `dev` field is preserved in per-package CHANGELOGs but
|
* Highlights + X draft are AI-authored via the local Claude CLI
|
||||||
* excluded from distilled release notes by default.
|
* (`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";
|
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`.
|
* Lower number = higher highlight priority (deterministic fallback only).
|
||||||
* Instructs the model to produce grouped markdown release notes for a
|
*/
|
||||||
* Fusion operator audience, using only the `summary` fields as input.
|
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 = [
|
export const DISTILLATION_SYSTEM_PROMPT = [
|
||||||
"You are a release-notes writer for Fusion, a model-agnostic AI agent orchestration product.",
|
"You are a release-notes writer and social copywriter for Fusion,",
|
||||||
"Your audience is Fusion operators — developers using the product, not its internals.",
|
"a model-agnostic AI agent orchestration product.",
|
||||||
"Produce clean, grouped markdown release notes from the provided changeset entries.",
|
"Audience: Fusion operators (developers who run Fusion), not internals.",
|
||||||
"Group under these headings (omit empty sections):",
|
"",
|
||||||
" ### New (features)",
|
"Return STRICT JSON only — no markdown fences, no preamble:",
|
||||||
" ### Fixed (bug fixes)",
|
'{ "highlights": string[3..5], "notes": string, "tweet": string }',
|
||||||
" ### Breaking (breaking changes)",
|
"",
|
||||||
" ### Security (security fixes)",
|
"highlights:",
|
||||||
" ### Performance (performance improvements)",
|
"- Top 3–5 user-facing changes (prefer breaking, security, features, then fixes).",
|
||||||
" ### Internal (internal-only changes)",
|
"- Punchy, benefit-led phrasing. Do not invent features not in the input.",
|
||||||
"Rules:",
|
"- One short phrase/sentence per item; no markdown inside the strings.",
|
||||||
"- Use the `summary` text verbatim or lightly edited for clarity and grouping.",
|
"",
|
||||||
"- Do NOT include internal class names, file paths, or implementation detail.",
|
"notes:",
|
||||||
"- Do NOT include the `dev` field content unless it is user-relevant migration guidance.",
|
"- Markdown body only (no version heading).",
|
||||||
"- Write one bullet per entry, prefixed with `- `.",
|
"- Start with ### Highlights using the same 3–5 items as `- ` bullets.",
|
||||||
"- Omit empty sections entirely.",
|
"- Then group under (omit empty): ### New, ### Fixed, ### Breaking, ### Security, ### Performance, ### Internal.",
|
||||||
"- Do NOT add a title or version heading — only the grouped sections.",
|
"- One `- ` bullet per entry; lightly edit for clarity; no file paths or class names.",
|
||||||
"- Respond with markdown only, no preamble or explanation.",
|
"",
|
||||||
|
"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");
|
].join("\n");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the user-facing prompt for AI distillation.
|
* 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 {Array<{summary: string, category: string, dev?: string, legacy?: boolean}>} entries
|
||||||
|
* @param {string} version
|
||||||
|
* @param {string} changelogUrl
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function buildDistillationPrompt(entries) {
|
export function buildDistillationPrompt(entries, version, changelogUrl) {
|
||||||
const lines = ["Produce release notes from these changeset entries:\n"];
|
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) => {
|
entries.forEach((entry, i) => {
|
||||||
const num = i + 1;
|
const num = i + 1;
|
||||||
lines.push(`[${num}]`);
|
lines.push(`[${num}]`);
|
||||||
@@ -63,27 +122,144 @@ export function buildDistillationPrompt(entries) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deterministic fallback: build category-grouped release notes directly
|
* Rank and pick the top 3–5 highlight summaries (deterministic fallback only).
|
||||||
* 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
|
|
||||||
*
|
*
|
||||||
* @param {Array<{summary: string, category: string, legacy?: boolean}>} entries
|
* @param {Array<{summary: string, category: string, legacy?: boolean}>} entries
|
||||||
* @param {string} version - Target version string (e.g. "0.47.0")
|
* @param {{min?: number, max?: number}} [opts]
|
||||||
* @returns {{notes: string, source: "deterministic"}}
|
* @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) {
|
export function distillDeterministic(entries, version) {
|
||||||
|
const changelogUrl = buildChangelogUrl(version);
|
||||||
|
|
||||||
if (!entries || entries.length === 0) {
|
if (!entries || entries.length === 0) {
|
||||||
return {
|
return {
|
||||||
notes: `No changes in v${version}.`,
|
notes: `No changes in v${version}.`,
|
||||||
source: "deterministic",
|
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();
|
const groups = new Map();
|
||||||
for (const cat of CATEGORIES) {
|
for (const cat of CATEGORIES) {
|
||||||
groups.set(cat, []);
|
groups.set(cat, []);
|
||||||
@@ -94,8 +270,13 @@ export function distillDeterministic(entries, version) {
|
|||||||
groups.get(cat).push(entry.summary);
|
groups.get(cat).push(entry.summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build sections in display order, omitting empty categories.
|
|
||||||
const sections = [];
|
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) {
|
for (const cat of CATEGORIES) {
|
||||||
const summaries = groups.get(cat);
|
const summaries = groups.get(cat);
|
||||||
if (summaries.length === 0) continue;
|
if (summaries.length === 0) continue;
|
||||||
@@ -108,5 +289,340 @@ export function distillDeterministic(entries, version) {
|
|||||||
return {
|
return {
|
||||||
notes: sections.join("\n\n"),
|
notes: sections.join("\n\n"),
|
||||||
source: "deterministic",
|
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";
|
} from "./lib/release-authorization-gate.mjs";
|
||||||
import { extractVersionNotes, replaceVersionSection } from "./lib/extract-version-notes.mjs";
|
import { extractVersionNotes, replaceVersionSection } from "./lib/extract-version-notes.mjs";
|
||||||
import { parseChangesetFile } from "./lib/changeset-schema.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 { shouldPromptForVersion } from "./lib/release-prompt-gate.mjs";
|
||||||
import {
|
import {
|
||||||
archivePointerLine,
|
archivePointerLine,
|
||||||
@@ -577,6 +577,23 @@ if (chosenVersion !== proposedVersion) {
|
|||||||
if (DRY_RUN) {
|
if (DRY_RUN) {
|
||||||
warn("--dry-run: stopping before version bump. No files modified, no commit, no publish, no tag.");
|
warn("--dry-run: stopping before version bump. No files modified, no commit, no publish, no tag.");
|
||||||
info(`Would release v${chosenVersion} (${releases.length} package(s) bumped).`);
|
info(`Would release v${chosenVersion} (${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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,18 +665,23 @@ syncRootChangelog();
|
|||||||
ok("Root CHANGELOG.md updated.");
|
ok("Root CHANGELOG.md updated.");
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* FNXC:Changelog 2026-06-24-16:30:
|
* FNXC:ReleaseScript 2026-07-13-15:45:
|
||||||
* Distill end-user-facing release notes from the captured changeset entries
|
* Claude CLI (`claude -p --model sonnet`) authors Highlights (top 3–5), full
|
||||||
* and replace the raw per-package aggregate in the root CHANGELOG for this
|
* notes, and an engagement-oriented X draft ≤280 chars. Soft deterministic
|
||||||
* version. Historical version sections are preserved untouched.
|
* fallback only if Claude is unreachable so release never blocks.
|
||||||
*/
|
*/
|
||||||
info("Distilling release notes…");
|
info("Distilling release notes with Claude (sonnet; soft fallback if unavailable)…");
|
||||||
const { notes: distilledNotes, source: distillSource } = distillDeterministic(capturedEntries, version);
|
const {
|
||||||
|
notes: distilledNotes,
|
||||||
|
source: distillSource,
|
||||||
|
highlights: releaseHighlights,
|
||||||
|
tweet: releaseTweet,
|
||||||
|
} = await distillReleaseNotes(capturedEntries, version);
|
||||||
const changelogBeforeDistill = readFileSync("CHANGELOG.md", "utf8");
|
const changelogBeforeDistill = readFileSync("CHANGELOG.md", "utf8");
|
||||||
const changelogAfterDistill = replaceVersionSection(changelogBeforeDistill, version, distilledNotes);
|
const changelogAfterDistill = replaceVersionSection(changelogBeforeDistill, version, distilledNotes);
|
||||||
if (changelogAfterDistill !== changelogBeforeDistill) {
|
if (changelogAfterDistill !== changelogBeforeDistill) {
|
||||||
writeFileSync("CHANGELOG.md", changelogAfterDistill);
|
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 {
|
} else {
|
||||||
warn(`Could not locate version section in CHANGELOG.md for distillation; leaving raw aggregate.`);
|
warn(`Could not locate version section in CHANGELOG.md for distillation; leaving raw aggregate.`);
|
||||||
}
|
}
|
||||||
@@ -753,3 +775,15 @@ if (githubReleaseStatus === "created") {
|
|||||||
} else {
|
} else {
|
||||||
ok(`Released v${version}. Published to npm, tag pushed. GitHub Release was not created (see warnings above).`);
|
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