fix(changelog): make every surface split a release the same way

The app, the website and the release scripts each parse CHANGELOG.md with
their own code, and only the app skipped fenced code blocks and read CRLF.
A `###` or a release heading inside a fenced example in a highlight would
have ended the highlights early on the website and in the GitHub release
body, or started a phantom release, while the app showed it correctly.

The website and script parsers now follow the app's rules, and one case
file, scripts/test-support/changelog-structure.json, is run by all three
suites so a rule cannot change in one place only.

The release-notes script tests were not part of any CI job; they now run
with the other script contract tests.
This commit is contained in:
Mohamed Boudra
2026-11-10 10:04:33 +01:00
parent eda939b092
commit ccdca942e9
8 changed files with 361 additions and 16 deletions
+41
View File
@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { getChangelogReleaseGroups, localizeReleases, parseChangelog } from "~/changelog";
@@ -52,6 +54,45 @@ describe("parseChangelog", () => {
});
});
/** The cases the app, the website and the release scripts must all split the same way. */
interface ChangelogStructureCases {
document: string[];
releases: { version: string; date: string; highlights: string[]; sections: string[] }[];
}
const structureCases: ChangelogStructureCases = JSON.parse(
readFileSync(
fileURLToPath(
new URL("../../../scripts/test-support/changelog-structure.json", import.meta.url),
),
"utf8",
),
);
describe("the structure every changelog surface shares", () => {
for (const lineEnding of ["\n", "\r\n"]) {
it(`splits the shared cases with ${JSON.stringify(lineEnding)} line endings`, () => {
const releases = parseChangelog(structureCases.document.join(lineEnding), "en");
expect(
releases.map((release) => ({
version: release.version,
date: release.date,
highlights: release.highlights,
sections: release.sections,
})),
).toEqual(
structureCases.releases.map((release) => ({
version: release.version,
date: release.date,
highlights: release.highlights.join("\n"),
sections: release.sections.join("\n"),
})),
);
});
}
});
describe("localizeReleases", () => {
it("uses a translation whose version and date match the English entry", () => {
const chinese = parseChangelog("## 0.1.0 - 2026-05-01\n\n- 旧\n", "zh-CN");
+30 -6
View File
@@ -18,8 +18,12 @@ export interface ChangelogReleaseGroup {
releases: ChangelogRelease[];
}
// The app's What's new sheet and the release scripts parse the same file with their own code.
// `scripts/test-support/changelog-structure.json` holds the cases all three must split the same
// way; a change to the structure rules starts there.
const releaseHeadingPattern = /^## (.+?) - (\d{4}-\d{2}-\d{2})$/;
const sectionHeadingPattern = /^### /;
const sectionHeadingPattern = /^ {0,3}###(?:[ \t]|$)/;
const fencePattern = /^\s*(`{3,}|~{3,})/;
export function releaseAnchor(version: string): string {
return `release-${version}`;
@@ -40,9 +44,20 @@ export function parseChangelog(markdown: string, locale: Locale): ChangelogRelea
const releases: ReleaseDraft[] = [];
const versions = new Set<string>();
let currentRelease: ReleaseDraft | null = null;
// A `##` or `###` inside a fenced code block is an example, so it starts nothing.
let fence: string | null = null;
for (const line of markdown.split("\n")) {
const heading = line.match(releaseHeadingPattern);
for (const line of markdown.split(/\r?\n/)) {
const fenceEdge = line.match(fencePattern)?.[1];
if (fenceEdge) {
if (!fence) {
fence = fenceEdge;
} else if (fenceEdge[0] === fence[0] && fenceEdge.length >= fence.length) {
fence = null;
}
}
const heading = fence ? null : line.match(releaseHeadingPattern);
if (heading) {
const version = heading[1];
if (versions.has(version)) {
@@ -56,7 +71,8 @@ export function parseChangelog(markdown: string, locale: Locale): ChangelogRelea
}
if (!currentRelease) continue;
if (currentRelease.sections.length > 0 || sectionHeadingPattern.test(line)) {
const startsSection = !fence && sectionHeadingPattern.test(line);
if (currentRelease.sections.length > 0 || startsSection) {
currentRelease.sections.push(line);
} else {
currentRelease.highlights.push(line);
@@ -67,12 +83,20 @@ export function parseChangelog(markdown: string, locale: Locale): ChangelogRelea
return releases.map((release) => ({
version: release.version,
date: release.date,
highlights: release.highlights.join("\n").trim(),
sections: release.sections.join("\n").trim(),
highlights: joinWithoutBlankEdges(release.highlights),
sections: joinWithoutBlankEdges(release.sections),
locale,
}));
}
function joinWithoutBlankEdges(lines: string[]): string {
let start = 0;
let end = lines.length;
while (start < end && lines[start].trim() === "") start += 1;
while (end > start && lines[end - 1].trim() === "") end -= 1;
return lines.slice(start, end).join("\n");
}
/**
* The English releases, each replaced by its translation when one exists. A translation counts
* only when it carries the same version and date as the English entry, so a stale translation never