Files
fusion/scripts/lib/changelog-sync.mjs
gsxdsm 7911fdb9b1 fix(release): preserve distilled changelog summaries across releases
syncRootChangelog rewrote every prior release from raw package notes, so only
the latest distilled Highlights view survived. Re-emit already-distilled bodies
on sync, keep the archive pointer outside version sections, and restore wiped
summaries from release history.
2026-07-21 20:29:46 -07:00

156 lines
5.1 KiB
JavaScript

/*
* FNXC:ReleaseChangelog 2026-07-21-20:22:
* Root CHANGELOG.md is fully regenerated on every release from package changelogs.
* Distillation only rewrites the current version, so without preservation prior
* releases lose their user-facing Highlights/New/Fixed summary on the next sync.
* Detect distilled bodies and re-emit them instead of the raw package aggregate.
*/
/** Category / highlights headings written by distill-release-notes. */
const DISTILLED_HEADING_RE =
/^### (Highlights|New|Fixed|Breaking|Security|Performance|Internal)\b/m;
/** Raw package-aggregate sections always use `### @scope/name` package headings. */
const PACKAGE_HEADING_RE = /^### @[\w.-]+\//m;
/**
* Parse a changeset-format CHANGELOG into { versions, order }.
* Splits on top-level `## ` headings; the version key is the heading text
* verbatim (e.g. "0.2.5", or "0.4.0 (pre-release, unpublished)").
*
* @param {string} raw
* @returns {{ versions: Map<string, string>, order: string[] }}
*/
export function parseChangelog(raw) {
const versions = new Map();
const order = [];
if (!raw) {
return { versions, order };
}
// Strip out the first-line title and any horizontal rules so they don't
// pollute the first version section.
const stripped = raw.replace(/^# [^\n]*\n?/, "").replace(/^---\s*$/gm, "");
const sections = stripped.split(/^## /m).slice(1); // drop pre-first-version preamble
for (const section of sections) {
const nl = section.indexOf("\n");
const key = (nl === -1 ? section : section.slice(0, nl)).trim();
const body = nl === -1 ? "" : section.slice(nl + 1).trim();
if (!versions.has(key)) {
versions.set(key, body);
order.push(key);
}
}
return { versions, order };
}
/**
* Strip the archive pointer line that syncRootChangelog appends after the last
* current-file version so it is not treated as part of that version's body.
*
* @param {string} body
* @returns {string}
*/
export function stripArchivePointerFromBody(body) {
if (!body) return "";
return body
.replace(/\n*> Older releases \(before [^)]+\) are archived in \[[^\]]+\]\([^)]+\)\.?\s*$/m, "")
.trim();
}
/**
* True when a version section body is distilled end-user notes rather than the
* raw per-package changeset aggregate produced by syncRootChangelog.
*
* @param {string} body
* @returns {boolean}
*/
export function isDistilledChangelogBody(body) {
const cleaned = stripArchivePointerFromBody(body);
if (!cleaned) return false;
// Raw aggregates always nest package sections under ### @scope/name.
if (PACKAGE_HEADING_RE.test(cleaned)) return false;
// Distilled notes use Highlights and/or category group headings.
return DISTILLED_HEADING_RE.test(cleaned);
}
/**
* Collect distilled version bodies from one or more existing root changelog files.
* First-seen distilled body wins (call with current file before archive).
*
* @param {Array<string|null|undefined>} changelogContents
* @returns {Map<string, string>} version key → distilled body markdown
*/
export function collectDistilledBodies(changelogContents = []) {
const out = new Map();
for (const content of changelogContents) {
if (!content) continue;
const { versions } = parseChangelog(content);
for (const [version, body] of versions) {
if (out.has(version)) continue;
const cleaned = stripArchivePointerFromBody(body);
if (isDistilledChangelogBody(cleaned)) {
out.set(version, cleaned);
}
}
}
return out;
}
/**
* Build root changelog lines for the given version order.
* When a version has a preserved distilled body, emit that instead of the
* per-package aggregate so prior releases keep their summarized view.
*
* @param {{
* title: string,
* banner: string,
* parsed: Array<{ pkgName: string, versions: Map<string, string> }>,
* versionOrder: string[],
* preservedBodies?: Map<string, string>,
* }} opts
* @returns {string[]}
*/
export function buildRootChangelogLines({
title,
banner,
parsed,
versionOrder,
preservedBodies = new Map(),
}) {
const lines = [title, "", banner, ""];
for (const version of versionOrder) {
lines.push(`## ${version}`, "");
const preserved = preservedBodies.get(version);
if (preserved && isDistilledChangelogBody(preserved)) {
lines.push(preserved.trim(), "");
continue;
}
// Sort packages alphabetically within a version for deterministic output.
const pkgsForVersion = parsed
.filter((p) => p.versions.has(version))
.sort((a, b) => a.pkgName.localeCompare(b.pkgName));
for (const p of pkgsForVersion) {
const body = p.versions.get(version).trim();
if (!body) continue;
lines.push(`### ${p.pkgName}`, "");
// Bump heading levels by one so package sub-sections nest cleanly.
const bumped = body.replace(/^(#{1,5}) /gm, (_m, hashes) => `${hashes}# `);
lines.push(bumped, "");
}
}
return lines;
}
/**
* Join changelog lines and collapse excessive blank lines.
* @param {string[]} lines
* @returns {string}
*/
export function normalizeChangelogLines(lines) {
return lines.join("\n").replace(/\n{3,}/g, "\n\n");
}