mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
Lead each release with highlights in What's new, the website changelog and release notes (#9281)
Every release entry in CHANGELOG.md now leads with highlights: an unheaded list above the first `###`. The app's What's new sheet, the website changelog and the GitHub release body show the highlights and fold the sections, the full record, behind one control. An entry without highlights keeps its sections in view. docs/release.md carries the writing rules. Installed apps fetch CHANGELOG.md from main and parse it with the parser they shipped with, so the two heading rules are a compatibility surface: `##` starts a release, `###` starts a section, and headings inside fenced code are text. The app, website and release-script parsers now split an entry the same way and each runs the shared cases in scripts/test-support/changelog-structure.json. The release-script tests, which no CI job ran before, are part of "Validate CI contracts". Adds the 0.16.0-beta.1 entry (en and zh-CN) ahead of the release.
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;
|
||||
}
|
||||
@@ -47,6 +53,47 @@ export function parseChangelogEntries(changelogText) {
|
||||
});
|
||||
}
|
||||
|
||||
// Splits an entry body at its first `### ` section. What comes before it is the
|
||||
// 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 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)),
|
||||
sectionLines: trimBlankLines(bodyLines.slice(split)),
|
||||
};
|
||||
}
|
||||
|
||||
// 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;
|
||||
while (start < end && lines[start].trim() === "") start += 1;
|
||||
while (end > start && lines[end - 1].trim() === "") end -= 1;
|
||||
return lines.slice(start, end);
|
||||
}
|
||||
|
||||
// Turns `[label](url)` into `label` and drops the trailing PR/attribution
|
||||
// parenthetical, which is pure noise in a store changelog with a 500 char budget.
|
||||
export function stripChangelogMarkup(text) {
|
||||
@@ -134,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);
|
||||
}
|
||||
});
|
||||
@@ -275,6 +275,28 @@ test("preserves the standalone 0.1.96 Android notice", () => {
|
||||
assert.equal(contents.includes("_This release"), false);
|
||||
});
|
||||
|
||||
test("leads with an entry's highlights and spends the budget on them first", () => {
|
||||
const highlight = (name) =>
|
||||
`- **${name}:** ${"A highlight a user would name first. ".repeat(4).trim()} ([#1](https://github.com/getpaseo/paseo/pull/1))`;
|
||||
const contents = formatFdroidChangelog(
|
||||
[
|
||||
highlight("First"),
|
||||
highlight("Second"),
|
||||
highlight("Third"),
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed a crash",
|
||||
],
|
||||
{ limit: CHANGELOG_CHARACTER_LIMIT },
|
||||
);
|
||||
|
||||
assert.match(contents, /^- First: A highlight a user would name first\./);
|
||||
assert.match(contents, /\n- Second: /);
|
||||
assert.equal(contents.includes("Fixed a crash"), false);
|
||||
assert.ok(contents.length <= CHANGELOG_CHARACTER_LIMIT);
|
||||
});
|
||||
|
||||
test("--check fails when the generated files are missing or stale", () => {
|
||||
const changelog = "## 0.2.3 - 2026-07-27\n\n### Fixed\n\n- Something broke.\n";
|
||||
withTempRepo(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { execFileSync as nodeExecFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { parseChangelogEntries } from "./changelog-utils.mjs";
|
||||
import { parseChangelogEntries, splitChangelogHighlights } from "./changelog-utils.mjs";
|
||||
import { getGitHubRelease } from "./github-release.mjs";
|
||||
import {
|
||||
getReleaseInfoFromSourceTag,
|
||||
@@ -69,17 +69,38 @@ function parseArgs(argv) {
|
||||
}
|
||||
|
||||
function parseChangelog(changelogText) {
|
||||
return parseChangelogEntries(changelogText).map((entry) => {
|
||||
const notesParts = [`## ${entry.version} - ${entry.date}`];
|
||||
if (entry.bodyLines.length > 0) {
|
||||
notesParts.push("", ...entry.bodyLines);
|
||||
}
|
||||
|
||||
return Object.assign({}, entry, {
|
||||
return parseChangelogEntries(changelogText).map((entry) =>
|
||||
Object.assign({}, entry, {
|
||||
tag: `v${entry.version}`,
|
||||
notes: `${notesParts.join("\n").trim()}\n`,
|
||||
});
|
||||
});
|
||||
notes: formatReleaseNotes(entry),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// A release body leads with the entry's highlights and folds its sections, the
|
||||
// full record, into one collapsed block. GitHub renders `<details>` in release
|
||||
// bodies; CHANGELOG.md itself stays plain Markdown because the app and the
|
||||
// website render it without HTML. An entry without highlights keeps its sections
|
||||
// in view, since nothing stands in for them.
|
||||
function formatReleaseNotes(entry) {
|
||||
const { highlightLines, sectionLines } = splitChangelogHighlights(entry.bodyLines);
|
||||
const notesParts = [`## ${entry.version} - ${entry.date}`];
|
||||
if (highlightLines.length > 0 && sectionLines.length > 0) {
|
||||
notesParts.push(
|
||||
"",
|
||||
...highlightLines,
|
||||
"",
|
||||
"<details>",
|
||||
"<summary>All changes</summary>",
|
||||
"",
|
||||
...sectionLines,
|
||||
"",
|
||||
"</details>",
|
||||
);
|
||||
} else if (entry.bodyLines.length > 0) {
|
||||
notesParts.push("", ...entry.bodyLines);
|
||||
}
|
||||
return `${notesParts.join("\n").trim()}\n`;
|
||||
}
|
||||
|
||||
function runGh(args, execFileSync = nodeExecFileSync) {
|
||||
|
||||
@@ -193,17 +193,10 @@ test("creates missing beta releases as drafts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("converts contributor profile links to mentions in synced release notes", () => {
|
||||
const changelogText = [
|
||||
"## 0.1.60-beta.1 - 2026-04-20",
|
||||
"",
|
||||
"- Beta notes. ([#526](https://github.com/getpaseo/paseo/pull/526) by [@therainisme](https://github.com/therainisme))",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
// Syncs the v0.1.60-beta.1 entry of `changelogText` and returns the body GitHub would receive.
|
||||
function syncedReleaseBody(changelogText) {
|
||||
let syncedNotes = "";
|
||||
withTempChangelog(() => {
|
||||
let syncedNotes = "";
|
||||
|
||||
const execFileSync = (command, args) => {
|
||||
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases/tags/v0.1.60-beta.1") {
|
||||
return JSON.stringify({ id: 311163621, draft: false });
|
||||
@@ -222,8 +215,76 @@ test("converts contributor profile links to mentions in synced release notes", (
|
||||
syncReleaseNotes(["--repo", "getpaseo/paseo", "--tag", "v0.1.60-beta.1"], {
|
||||
execFileSync,
|
||||
});
|
||||
|
||||
assert.match(syncedNotes, /by @therainisme\)/);
|
||||
assert.doesNotMatch(syncedNotes, /\[@therainisme\]\(https:\/\/github\.com\/therainisme\)/);
|
||||
}, changelogText);
|
||||
return syncedNotes;
|
||||
}
|
||||
|
||||
test("converts contributor profile links to mentions in synced release notes", () => {
|
||||
const syncedNotes = syncedReleaseBody(
|
||||
[
|
||||
"## 0.1.60-beta.1 - 2026-04-20",
|
||||
"",
|
||||
"- Beta notes. ([#526](https://github.com/getpaseo/paseo/pull/526) by [@therainisme](https://github.com/therainisme))",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
assert.match(syncedNotes, /by @therainisme\)/);
|
||||
assert.doesNotMatch(syncedNotes, /\[@therainisme\]\(https:\/\/github\.com\/therainisme\)/);
|
||||
});
|
||||
|
||||
test("leads with the highlights and folds the sections into one collapsed block", () => {
|
||||
const syncedNotes = syncedReleaseBody(
|
||||
[
|
||||
"## 0.1.60-beta.1 - 2026-04-20",
|
||||
"",
|
||||
"- **Faster diffs:** Diffs load sooner.",
|
||||
"",
|
||||
"### Added",
|
||||
"",
|
||||
"- Added a picker",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed a crash",
|
||||
"",
|
||||
"## 0.1.59 - 2026-04-10",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed an older thing",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
syncedNotes,
|
||||
[
|
||||
"## 0.1.60-beta.1 - 2026-04-20",
|
||||
"",
|
||||
"- **Faster diffs:** Diffs load sooner.",
|
||||
"",
|
||||
"<details>",
|
||||
"<summary>All changes</summary>",
|
||||
"",
|
||||
"### Added",
|
||||
"",
|
||||
"- Added a picker",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed a crash",
|
||||
"",
|
||||
"</details>",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps the sections in view when an entry has no highlights", () => {
|
||||
const syncedNotes = syncedReleaseBody(
|
||||
"## 0.1.60-beta.1 - 2026-04-20\n\n### Fixed\n\n- Fixed a crash\n",
|
||||
);
|
||||
|
||||
assert.equal(syncedNotes, "## 0.1.60-beta.1 - 2026-04-20\n\n### Fixed\n\n- Fixed a crash\n");
|
||||
});
|
||||
|
||||
@@ -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