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
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
filters: .github/ci-paths.yml filters: .github/ci-paths.yml
- name: Validate CI contracts - name: Validate CI contracts
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/changelog-utils.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/sync-release-notes-from-changelog.test.mjs scripts/upload-release-assets.test.mjs
quality: quality:
name: quality name: quality
+1 -1
View File
@@ -594,7 +594,7 @@ Release notes depend on the changelog heading format. The heading **must** be st
No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or `## X.Y.Z-beta.N`) line for the pushed tag to extract the version. A malformed heading breaks the release-notes sync for that tag. No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or `## X.Y.Z-beta.N`) line for the pushed tag to extract the version. A malformed heading breaks the release-notes sync for that tag.
`CHANGELOG.md` on `main` is also what the app's **What's new** sheet fetches and renders, so the file is a shipped product surface, not just a release input. `##` starts a release and `###` starts a section; the app reads section titles from the document, so renaming or adding one needs no app change. Content between the release heading and the first `###` is the release's [highlights](#changelog-highlights). The What's new sheet, the website changelog and the GitHub release body show the highlights and keep the sections collapsed behind one control: Show all changes in the app and on the website, and an All changes `<details>` block that `Release Notes Sync` writes into the release body, so the file itself never needs HTML. A release with nothing before its first `###` shows its sections. Older entries that open with summary paragraphs are treated as highlights. Everything under a section is rendered as Markdown: prose, lists, links, inline code, fenced code, block quotes, tables, and images. Raw HTML does not render — the shared Markdown parser runs with `html: false`, so a `<video>`, `<iframe>` or `<embed>` tag reaches the reader as visible markup. Keep media out of the changelog, or link to it. A GitHub callout renders as a block quote with its `[!NOTE]` marker still in the text. A release entry is what a user reads on a phone the moment they are offered the update — write it for them. `CHANGELOG.md` on `main` is also what the app's **What's new** sheet fetches and renders, so the file is a shipped product surface, not just a release input. `##` starts a release and `###` starts a section; the app reads section titles from the document, so renaming or adding one needs no app change. Content between the release heading and the first `###` is the release's [highlights](#changelog-highlights). The What's new sheet, the website changelog and the GitHub release body show the highlights and keep the sections collapsed behind one control: Show all changes in the app and on the website, and an All changes `<details>` block that `Release Notes Sync` writes into the release body, so the file itself never needs HTML. A release with nothing before its first `###` shows its sections. Older entries that open with summary paragraphs are treated as highlights. The app, the website and the release scripts each parse the file with their own code, and installed apps keep the parser they shipped with, so those two heading rules are fixed: new kinds of content go inside the highlights or a section as Markdown, never behind a new heading level or marker. `scripts/test-support/changelog-structure.json` holds the cases all three parsers are tested against; change a structure rule there first. Everything under a section is rendered as Markdown: prose, lists, links, inline code, fenced code, block quotes, tables, and images. Raw HTML does not render — the shared Markdown parser runs with `html: false`, so a `<video>`, `<iframe>` or `<embed>` tag reaches the reader as visible markup. Keep media out of the changelog, or link to it. A GitHub callout renders as a block quote with its `[!NOTE]` marker still in the text. A release entry is what a user reads on a phone the moment they are offered the update — write it for them.
## Changelog policy ## Changelog policy
@@ -36,6 +36,25 @@ function isNotBlank(line: string): boolean {
return line.trim().length > 0; return line.trim().length > 0;
} }
function readRepositoryFile(path: string): string {
return readFileSync(fileURLToPath(new URL(`../../../../../${path}`, import.meta.url)), "utf8");
}
/** 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[];
sectionTitles: string[];
}[];
}
const structureCases: ChangelogStructureCases = JSON.parse(
readRepositoryFile("scripts/test-support/changelog-structure.json"),
);
describe("parseChangelog", () => { describe("parseChangelog", () => {
it("reads the shape the changelog is authored in today", () => { it("reads the shape the changelog is authored in today", () => {
const releases = parseChangelog( const releases = parseChangelog(
@@ -300,13 +319,39 @@ describe("formatChangelogDate", () => {
}); });
}); });
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));
expect(
releases.map((release) => ({
version: release.version,
date: release.date,
highlights: release.highlights.split("\n").filter(isNotBlank),
sectionTitles: release.sections.map(sectionTitle),
})),
).toEqual(
structureCases.releases.map((release) => ({
version: release.version,
date: release.date,
highlights: release.highlights.filter(isNotBlank),
sectionTitles: release.sectionTitles,
})),
);
});
}
});
describe("the repository's own CHANGELOG.md", () => { describe("the repository's own CHANGELOG.md", () => {
const markdown = readFileSync( const markdown = readRepositoryFile("CHANGELOG.md");
fileURLToPath(new URL("../../../../../CHANGELOG.md", import.meta.url)),
"utf8",
);
const releases = parseChangelog(markdown); const releases = parseChangelog(markdown);
it("finds a release for every release heading line", () => {
const headingLines = markdown.split("\n").filter((line) => line.startsWith("## "));
expect(releases).toHaveLength(headingLines.length);
});
it("parses every release heading", () => { it("parses every release heading", () => {
expect(releases.length).toBeGreaterThan(50); expect(releases.length).toBeGreaterThan(50);
for (const release of releases) { for (const release of releases) {
+41
View File
@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getChangelogReleaseGroups, localizeReleases, parseChangelog } from "~/changelog"; 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", () => { describe("localizeReleases", () => {
it("uses a translation whose version and date match the English entry", () => { 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"); 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[]; 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 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 { export function releaseAnchor(version: string): string {
return `release-${version}`; return `release-${version}`;
@@ -40,9 +44,20 @@ export function parseChangelog(markdown: string, locale: Locale): ChangelogRelea
const releases: ReleaseDraft[] = []; const releases: ReleaseDraft[] = [];
const versions = new Set<string>(); const versions = new Set<string>();
let currentRelease: ReleaseDraft | null = null; 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")) { for (const line of markdown.split(/\r?\n/)) {
const heading = line.match(releaseHeadingPattern); 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) { if (heading) {
const version = heading[1]; const version = heading[1];
if (versions.has(version)) { if (versions.has(version)) {
@@ -56,7 +71,8 @@ export function parseChangelog(markdown: string, locale: Locale): ChangelogRelea
} }
if (!currentRelease) continue; 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); currentRelease.sections.push(line);
} else { } else {
currentRelease.highlights.push(line); currentRelease.highlights.push(line);
@@ -67,12 +83,20 @@ export function parseChangelog(markdown: string, locale: Locale): ChangelogRelea
return releases.map((release) => ({ return releases.map((release) => ({
version: release.version, version: release.version,
date: release.date, date: release.date,
highlights: release.highlights.join("\n").trim(), highlights: joinWithoutBlankEdges(release.highlights),
sections: release.sections.join("\n").trim(), sections: joinWithoutBlankEdges(release.sections),
locale, 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 * 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 * only when it carries the same version and date as the English entry, so a stale translation never
+31 -4
View File
@@ -1,18 +1,24 @@
// One definition of how CHANGELOG.md is structured. Consumed by the GitHub release // One definition of how CHANGELOG.md is structured. Consumed by the GitHub release
// notes sync and by the F-Droid changelog sync, which need the same section bodies // notes sync and by the F-Droid changelog sync, which need the same section bodies
// rendered two different ways. // rendered two different ways.
//
// The app's What's new sheet and the website parse the same file with their own
// code. `test-support/changelog-structure.json` holds the cases all three must
// split the same way; a change to the structure rules starts there.
const headingPattern = /^##\s+\[?([^\]\s]+)\]?\s*-\s*([0-9]{4}-[0-9]{2}-[0-9]{2})\s*$/; const headingPattern = /^##\s+\[?([^\]\s]+)\]?\s*-\s*([0-9]{4}-[0-9]{2}-[0-9]{2})\s*$/;
const sectionHeadingPattern = /^###\s+(.+?)\s*$/; const sectionHeadingPattern = /^ {0,3}###(?:[ \t]+(.*?))?[ \t]*$/;
const fencePattern = /^\s*(`{3,}|~{3,})/;
const bulletPattern = /^\s*[-*]\s+(.+?)\s*$/; const bulletPattern = /^\s*[-*]\s+(.+?)\s*$/;
const blockquotePattern = /^\s*>\s?(.*)$/; const blockquotePattern = /^\s*>\s?(.*)$/;
// Returns entries newest-first, matching the order they appear in the file. // Returns entries newest-first, matching the order they appear in the file.
export function parseChangelogEntries(changelogText) { export function parseChangelogEntries(changelogText) {
const lines = changelogText.split(/\r?\n/); const lines = changelogText.split(/\r?\n/);
const fenced = markFencedLines(lines);
const headings = []; const headings = [];
for (let index = 0; index < lines.length; index += 1) { for (let index = 0; index < lines.length; index += 1) {
const match = lines[index].match(headingPattern); const match = fenced[index] ? null : lines[index].match(headingPattern);
if (!match) { if (!match) {
continue; continue;
} }
@@ -51,7 +57,10 @@ export function parseChangelogEntries(changelogText) {
// release's highlights; the sections after it are the full record. Either part is // release's highlights; the sections after it are the full record. Either part is
// empty when the entry has none. // empty when the entry has none.
export function splitChangelogHighlights(bodyLines) { export function splitChangelogHighlights(bodyLines) {
const firstSection = bodyLines.findIndex((line) => sectionHeadingPattern.test(line)); const fenced = markFencedLines(bodyLines);
const firstSection = bodyLines.findIndex(
(line, index) => !fenced[index] && sectionHeadingPattern.test(line),
);
const split = firstSection === -1 ? bodyLines.length : firstSection; const split = firstSection === -1 ? bodyLines.length : firstSection;
return { return {
highlightLines: trimBlankLines(bodyLines.slice(0, split)), highlightLines: trimBlankLines(bodyLines.slice(0, split)),
@@ -59,6 +68,24 @@ export function splitChangelogHighlights(bodyLines) {
}; };
} }
// Flags the lines that belong to a fenced code block, edges included. A `##` or
// `###` in there is an example, so it starts neither a release nor a section.
function markFencedLines(lines) {
let fence = null;
return lines.map((line) => {
const edge = line.match(fencePattern)?.[1];
if (!edge) {
return fence !== null;
}
if (fence === null) {
fence = edge;
} else if (edge[0] === fence[0] && edge.length >= fence.length) {
fence = null;
}
return true;
});
}
function trimBlankLines(lines) { function trimBlankLines(lines) {
let start = 0; let start = 0;
let end = lines.length; let end = lines.length;
@@ -154,7 +181,7 @@ export function parseChangelogBody(bodyLines) {
if (current.bullets.length > 0) { if (current.bullets.length > 0) {
sections.push(current); sections.push(current);
} }
current = { bullets: [], title: stripChangelogMarkup(sectionMatch[1]) }; current = { bullets: [], title: stripChangelogMarkup(sectionMatch[1] ?? "") };
continue; continue;
} }
+41
View File
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { parseChangelogEntries, splitChangelogHighlights } from "./changelog-utils.mjs";
// The cases the app, the website and the release scripts must all split the same way.
const structureCases = JSON.parse(
readFileSync(new URL("./test-support/changelog-structure.json", import.meta.url), "utf8"),
);
for (const lineEnding of ["\n", "\r\n"]) {
test(`splits the shared cases with ${JSON.stringify(lineEnding)} line endings`, () => {
const entries = parseChangelogEntries(structureCases.document.join(lineEnding));
assert.deepEqual(
entries.map((entry) => {
const { highlightLines, sectionLines } = splitChangelogHighlights(entry.bodyLines);
return {
version: entry.version,
date: entry.date,
highlights: highlightLines,
sections: sectionLines,
};
}),
structureCases.releases.map((release) => ({
version: release.version,
date: release.date,
highlights: release.highlights,
sections: release.sections,
})),
);
});
}
test("finds an entry for every release heading line in the repository's CHANGELOG.md", () => {
for (const file of ["CHANGELOG.md", "CHANGELOG.zh-CN.md"]) {
const changelog = readFileSync(new URL(`../${file}`, import.meta.url), "utf8");
const headingLines = changelog.split("\n").filter((line) => line.startsWith("## "));
assert.equal(parseChangelogEntries(changelog).length, headingLines.length, file);
}
});
@@ -0,0 +1,167 @@
{
"document": [
"# Changelog",
"",
"Text above the first release belongs to no release.",
"",
"## 4.0.0 - 2026-04-04",
"",
"- **First highlight:** One sentence. A second one. ([#1](https://example.com/1))",
"- **Nested content:** A highlight with a nested list.",
" - Nested item",
"",
" A second paragraph inside the item.",
"",
"- **Markdown example:** A fenced block whose contents look like headings.",
"",
" ```markdown",
" ## 9.9.9 - 2026-09-09",
" ### Not a section",
" ```",
"",
"```",
"## 9.9.8 - 2026-09-08",
"### Not a section either",
"```",
"",
"#### A deeper heading is not a section",
"",
"###Unspaced hashes are text",
"",
"### Added ",
"",
"- Added a thing ([#2](https://example.com/2))",
"",
"#### A sub-heading stays in its section",
"",
"~~~~",
"### Still code",
"~~~",
"## 9.9.7 - 2026-09-07",
"~~~~",
"",
"###\tChanged",
"",
"- Changed a thing",
"",
"### 修复",
"",
"- 修复了一个问题",
"",
"## 3.0.0 - 2026-03-03",
"",
"### Fixed",
"",
"- Fixed one thing",
"",
"## 3.0.0-beta.1 - 2026-03-01",
"",
"> **Notice**",
">",
"> A quoted notice opens this entry.",
"",
"A summary paragraph follows it.",
"",
"### Added",
"",
"- Added another thing",
"",
"## 2.0.0 - 2026-02-02",
"",
"- **Only a highlight:** No sections follow.",
"",
"## 1.0.0 - 2026-01-01",
"",
"Text before a heading with no title.",
"",
"###",
"",
"- The first section has no title",
""
],
"releases": [
{
"version": "4.0.0",
"date": "2026-04-04",
"highlights": [
"- **First highlight:** One sentence. A second one. ([#1](https://example.com/1))",
"- **Nested content:** A highlight with a nested list.",
" - Nested item",
"",
" A second paragraph inside the item.",
"",
"- **Markdown example:** A fenced block whose contents look like headings.",
"",
" ```markdown",
" ## 9.9.9 - 2026-09-09",
" ### Not a section",
" ```",
"",
"```",
"## 9.9.8 - 2026-09-08",
"### Not a section either",
"```",
"",
"#### A deeper heading is not a section",
"",
"###Unspaced hashes are text"
],
"sections": [
"### Added ",
"",
"- Added a thing ([#2](https://example.com/2))",
"",
"#### A sub-heading stays in its section",
"",
"~~~~",
"### Still code",
"~~~",
"## 9.9.7 - 2026-09-07",
"~~~~",
"",
"###\tChanged",
"",
"- Changed a thing",
"",
"### 修复",
"",
"- 修复了一个问题"
],
"sectionTitles": ["Added", "Changed", "修复"]
},
{
"version": "3.0.0",
"date": "2026-03-03",
"highlights": [],
"sections": ["### Fixed", "", "- Fixed one thing"],
"sectionTitles": ["Fixed"]
},
{
"version": "3.0.0-beta.1",
"date": "2026-03-01",
"highlights": [
"> **Notice**",
">",
"> A quoted notice opens this entry.",
"",
"A summary paragraph follows it."
],
"sections": ["### Added", "", "- Added another thing"],
"sectionTitles": ["Added"]
},
{
"version": "2.0.0",
"date": "2026-02-02",
"highlights": ["- **Only a highlight:** No sections follow."],
"sections": [],
"sectionTitles": []
},
{
"version": "1.0.0",
"date": "2026-01-01",
"highlights": ["Text before a heading with no title."],
"sections": ["###", "", "- The first section has no title"],
"sectionTitles": [""]
}
]
}