mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
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:
@@ -1,18 +1,24 @@
|
||||
// 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
|
||||
// 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 sectionHeadingPattern = /^###\s+(.+?)\s*$/;
|
||||
const sectionHeadingPattern = /^ {0,3}###(?:[ \t]+(.*?))?[ \t]*$/;
|
||||
const fencePattern = /^\s*(`{3,}|~{3,})/;
|
||||
const bulletPattern = /^\s*[-*]\s+(.+?)\s*$/;
|
||||
const blockquotePattern = /^\s*>\s?(.*)$/;
|
||||
|
||||
// Returns entries newest-first, matching the order they appear in the file.
|
||||
export function parseChangelogEntries(changelogText) {
|
||||
const lines = changelogText.split(/\r?\n/);
|
||||
const fenced = markFencedLines(lines);
|
||||
const headings = [];
|
||||
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
@@ -51,7 +57,10 @@ export function parseChangelogEntries(changelogText) {
|
||||
// release's highlights; the sections after it are the full record. Either part is
|
||||
// empty when the entry has none.
|
||||
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;
|
||||
return {
|
||||
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) {
|
||||
let start = 0;
|
||||
let end = lines.length;
|
||||
@@ -154,7 +181,7 @@ export function parseChangelogBody(bodyLines) {
|
||||
if (current.bullets.length > 0) {
|
||||
sections.push(current);
|
||||
}
|
||||
current = { bullets: [], title: stripChangelogMarkup(sectionMatch[1]) };
|
||||
current = { bullets: [], title: stripChangelogMarkup(sectionMatch[1] ?? "") };
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -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": [""]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user