feat(FN-3311): add version notes extractor for GitHub releases

Merges FN-3311 work into the release pipeline: adds a version notes extractor with tests and integrates GitHub release creation into the release script, plus a minor CSS design token consistency fix in the dashboard.

Fusion-Task-Id: FN-3311
This commit is contained in:
Fusion
2026-05-04 14:29:18 -07:00
committed by gsxdsm
parent 496c000e99
commit a56f22026e
6 changed files with 167 additions and 18 deletions

View File

@@ -0,0 +1,71 @@
import test from "node:test";
import assert from "node:assert/strict";
import { extractVersionNotes } from "../lib/extract-version-notes.mjs";
const changelog = `# Fusion changelog
## 1.2.0
### @runfusion/fusion
#### Patch Changes
- Added release integration.
## 1.1.0
### @runfusion/fusion
#### Patch Changes
- Fixed parser bug.
## 1.0.0
### @runfusion/fusion
#### Patch Changes
- Initial release.
`;
test("extracts correct section for known version", () => {
const notes = extractVersionNotes(changelog, "1.1.0");
assert.match(notes, /Fixed parser bug\./);
assert.doesNotMatch(notes, /Added release integration\./);
});
test("returns full multiline body including sub-headings", () => {
const notes = extractVersionNotes(changelog, "1.2.0");
assert.match(notes, /^### @runfusion\/fusion/m);
assert.match(notes, /^#### Patch Changes/m);
assert.match(notes, /- Added release integration\./);
});
test("returns fallback when version not found", () => {
const notes = extractVersionNotes(changelog, "9.9.9");
assert.equal(notes, "Release v9.9.9");
});
test("returns fallback when changelog content is empty", () => {
const notes = extractVersionNotes("", "1.2.0");
assert.equal(notes, "Release v1.2.0");
});
test("handles version as last section with no trailing heading", () => {
const notes = extractVersionNotes(changelog, "1.0.0");
assert.match(notes, /Initial release\./);
});
test("handles single-version changelog", () => {
const single = `# Changelog\n\n## 2.0.0\n\n### pkg\n\n#### Patch Changes\n\n- Solo entry.\n`;
const notes = extractVersionNotes(single, "2.0.0");
assert.match(notes, /Solo entry\./);
});
test("does not bleed into adjacent version sections", () => {
const notes = extractVersionNotes(changelog, "1.1.0");
assert.doesNotMatch(notes, /Initial release\./);
assert.doesNotMatch(notes, /Added release integration\./);
});

View File

@@ -0,0 +1,32 @@
/**
* Extract the changelog section for a specific version from the root CHANGELOG.md content.
* @param {string} content - Full CHANGELOG.md content (as formatted by syncRootChangelog)
* @param {string} version - Bare version string (e.g. "0.16.0"), NOT "v"-prefixed
* @returns {string} Release notes body, or a fallback like "Release v{version}" if not found
*/
export function extractVersionNotes(content, version) {
const fallback = `Release v${version}`;
if (!content || !version) {
return fallback;
}
const lines = content.split(/\r?\n/);
const header = `## ${version}`;
const startIndex = lines.findIndex((line) => line.trim() === header);
if (startIndex === -1) {
return fallback;
}
let endIndex = lines.length;
for (let i = startIndex + 1; i < lines.length; i += 1) {
if (lines[i].startsWith("## ")) {
endIndex = i;
break;
}
}
const body = lines.slice(startIndex + 1, endIndex).join("\n").trim();
return body || fallback;
}

View File

@@ -22,6 +22,8 @@ import { tmpdir } from "node:os";
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { extractVersionNotes } from "./lib/extract-version-notes.mjs";
const args = new Set(process.argv.slice(2));
const DRY_RUN = args.has("--dry-run");
const AUTO_YES = args.has("--yes") || args.has("-y");
@@ -523,4 +525,47 @@ info(`Creating and pushing tag v${version}…`);
run(`git tag v${version}`);
run(`git push origin v${version}`);
ok(`Released v${version}. The 'v${version}' tag will trigger release.yml for binary builds.`);
// --- GitHub Release ------------------------------------------------------
let githubReleaseStatus = "not-created";
const changelogContent = readFileSync("CHANGELOG.md", "utf8");
const releaseNotes = extractVersionNotes(changelogContent, version);
const ghCheck = spawnSync("gh", ["--version"], { stdio: "pipe" });
if (ghCheck.status !== 0) {
githubReleaseStatus = "missing-gh";
warn(`⚠ gh CLI not found. Create the GitHub Release manually:\n gh release create v${version} --title "v${version}" --latest`);
} else {
let notesFile;
try {
const notesDir = mkdtempSync(join(tmpdir(), "fusion-release-notes-"));
notesFile = join(notesDir, `v${version}-notes.md`);
writeFileSync(notesFile, `${releaseNotes}\n`, "utf8");
const ghCreate = spawnSync(
"gh",
["release", "create", `v${version}`, "--title", `v${version}`, "--notes-file", notesFile, "--latest"],
{ stdio: "inherit" }
);
if (ghCreate.status !== 0) {
warn(`GitHub Release creation failed for v${version}. You can retry manually with gh release create.`);
} else {
githubReleaseStatus = "created";
}
} catch (error) {
warn(`GitHub Release creation failed for v${version}: ${error instanceof Error ? error.message : String(error)}`);
} finally {
if (notesFile && existsSync(notesFile)) {
unlinkSync(notesFile);
}
}
}
if (githubReleaseStatus === "created") {
ok(`Released v${version}. Published to npm, tag pushed, GitHub Release created.`);
} else if (githubReleaseStatus === "missing-gh") {
ok(`Released v${version}. Published to npm, tag pushed. GitHub Release skipped (gh CLI not found).`);
} else {
ok(`Released v${version}. Published to npm, tag pushed. GitHub Release was not created (see warnings above).`);
}