feat(changelog): lead each release with highlights and fold the rest away

A release entry now opens with a short list of highlights above its first
section. The What's new sheet, the website changelog and the GitHub release
body show those and keep the sections, the full record, behind one control,
so a 100-bullet release reads as ten lines.

CHANGELOG.md stays plain Markdown because the app and the website render it
without HTML, including apps already installed. Each surface folds the
sections itself; only the release body gets a <details> block, written by
Release Notes Sync. A release with nothing before its first section shows
its sections as before.

Adds the 0.16.0-beta.1 entry in the new format, with its Chinese
translation, and the highlight writing rules in docs/release.md.
This commit is contained in:
Mohamed Boudra
2026-11-10 09:57:12 +01:00
parent 4c9c59ea2b
commit eda939b092
28 changed files with 852 additions and 97 deletions
+20
View File
@@ -47,6 +47,26 @@ 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 firstSection = bodyLines.findIndex((line) => sectionHeadingPattern.test(line));
const split = firstSection === -1 ? bodyLines.length : firstSection;
return {
highlightLines: trimBlankLines(bodyLines.slice(0, split)),
sectionLines: trimBlankLines(bodyLines.slice(split)),
};
}
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) {
+22
View 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(
+32 -11
View File
@@ -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");
});