mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 21:23:04 +00:00
Fastlane metadata format for F-Droid releases (#2501)
* add f-droid metadata support * share version code calculation with website * preserve blockquoted sections in changelogs * refactor(android): simplify stable F-Droid metadata Generate store changelogs only for stable releases and keep native version-code ownership in the existing release module. * test(android): run F-Droid changelog suite in CI --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
4a5716360b
commit
f6288629ff
@@ -0,0 +1,163 @@
|
||||
// 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.
|
||||
const headingPattern = /^##\s+\[?([^\]\s]+)\]?\s*-\s*([0-9]{4}-[0-9]{2}-[0-9]{2})\s*$/;
|
||||
const sectionHeadingPattern = /^###\s+(.+?)\s*$/;
|
||||
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 headings = [];
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const match = lines[index].match(headingPattern);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
headings.push({
|
||||
version: match[1],
|
||||
date: match[2],
|
||||
headingLineIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
if (headings.length === 0) {
|
||||
throw new Error(
|
||||
"No release headings found in CHANGELOG.md. Expected headings like `## 0.1.14 - 2026-02-19`.",
|
||||
);
|
||||
}
|
||||
|
||||
return headings.map((heading, index) => {
|
||||
const nextHeading = headings[index + 1];
|
||||
const bodyStart = heading.headingLineIndex + 1;
|
||||
const bodyEnd = nextHeading ? nextHeading.headingLineIndex : lines.length;
|
||||
|
||||
const bodyLines = lines.slice(bodyStart, bodyEnd);
|
||||
while (bodyLines.length > 0 && bodyLines[0].trim() === "") {
|
||||
bodyLines.shift();
|
||||
}
|
||||
while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1].trim() === "") {
|
||||
bodyLines.pop();
|
||||
}
|
||||
|
||||
return Object.assign({}, heading, { bodyLines });
|
||||
});
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return text
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/\s*\((?:[^()]*(?:#\d+|by @)[^()]*)\)\s*$/, "")
|
||||
.replace(/`([^`]*)`/g, "$1")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||||
.replace(/_([^_]+)_/g, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Collapses one blockquote into plain text, keeping paragraph breaks as newlines.
|
||||
// `> **Important update notice**` + `>` + `> body` becomes a heading line and a
|
||||
// body line, which is how it should read in a plain-text store changelog.
|
||||
function formatQuotedNote(quotedLines) {
|
||||
const paragraphs = [];
|
||||
let current = [];
|
||||
|
||||
for (const line of quotedLines) {
|
||||
if (line.trim() === "") {
|
||||
if (current.length > 0) {
|
||||
paragraphs.push(current.join(" "));
|
||||
current = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current.push(line);
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
paragraphs.push(current.join(" "));
|
||||
}
|
||||
|
||||
return paragraphs
|
||||
.map((paragraph) => stripChangelogMarkup(paragraph))
|
||||
.filter((paragraph) => paragraph.length > 0)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// Splits an entry body into blockquoted notes and `### Heading` sections of
|
||||
// plain-text bullets. Bullets before any section heading land in a leading
|
||||
// untitled section. Standalone prose is kept as a note: releases occasionally
|
||||
// use it for a short, important notice without a blockquote.
|
||||
//
|
||||
// Notes are parsed separately rather than dropped because releases use
|
||||
// blockquotes for action-required notices — 0.1.109's "you must reinstall
|
||||
// manually" warning is the canonical example, and it matters more than any
|
||||
// bullet in that entry. Anything that is neither a note, a heading, nor a bullet
|
||||
// is still ignored.
|
||||
export function parseChangelogBody(bodyLines) {
|
||||
const notes = [];
|
||||
const sections = [];
|
||||
let current = { bullets: [], title: null };
|
||||
let quotedLines = null;
|
||||
|
||||
const flushQuote = () => {
|
||||
if (quotedLines === null) {
|
||||
return;
|
||||
}
|
||||
const note = formatQuotedNote(quotedLines);
|
||||
if (note.length > 0) {
|
||||
notes.push(note);
|
||||
}
|
||||
quotedLines = null;
|
||||
};
|
||||
|
||||
for (const line of bodyLines) {
|
||||
const quoteMatch = line.match(blockquotePattern);
|
||||
if (quoteMatch) {
|
||||
if (quotedLines === null) {
|
||||
quotedLines = [];
|
||||
}
|
||||
quotedLines.push(quoteMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A blank line inside a `>` run would already have matched above, so any
|
||||
// non-quote line ends the current note.
|
||||
flushQuote();
|
||||
|
||||
const sectionMatch = line.match(sectionHeadingPattern);
|
||||
if (sectionMatch) {
|
||||
if (current.bullets.length > 0) {
|
||||
sections.push(current);
|
||||
}
|
||||
current = { bullets: [], title: stripChangelogMarkup(sectionMatch[1]) };
|
||||
continue;
|
||||
}
|
||||
|
||||
const bulletMatch = line.match(bulletPattern);
|
||||
if (bulletMatch) {
|
||||
const bullet = stripChangelogMarkup(bulletMatch[1]);
|
||||
if (bullet.length > 0) {
|
||||
current.bullets.push(bullet);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const prose = stripChangelogMarkup(line);
|
||||
if (prose.length > 0) {
|
||||
notes.push(prose);
|
||||
}
|
||||
}
|
||||
|
||||
flushQuote();
|
||||
|
||||
if (current.bullets.length > 0) {
|
||||
sections.push(current);
|
||||
}
|
||||
|
||||
return { notes, sections };
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Generates the F-Droid "What's New" files from CHANGELOG.md.
|
||||
//
|
||||
// Two things make this non-optional to script:
|
||||
//
|
||||
// 1. F-Droid matches `changelogs/<versionCode>.txt` literally, and the F-Droid
|
||||
// profile splits one release into four single-ABI APKs with four distinct
|
||||
// version codes (see packages/app/native-release-version.js). A single
|
||||
// hand-written file matches no published APK, which silently costs the
|
||||
// "What's New" entry and the Latest tab placement that depends on it.
|
||||
// 2. F-Droid caps a changelog at 500 characters. Real Paseo entries run into
|
||||
// the thousands, so they have to be compressed rather than copied.
|
||||
//
|
||||
// This must run before the release tag is created: fdroidserver only reads
|
||||
// metadata from the tag it is building, so the file for version N has to exist in
|
||||
// the commit that N points at. It is wired into the npm `version` lifecycle.
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
// CommonJS on purpose: packages/app/app.config.js and the Expo config plugin both
|
||||
// require it, so the ABI table has exactly one definition across all three callers.
|
||||
import { getFdroidVersionCodes as defaultGetFdroidVersionCodes } from "../packages/app/native-release-version.js";
|
||||
import { parseChangelogBody, parseChangelogEntries } from "./changelog-utils.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
|
||||
// F-Droid's documented cap. fdroidserver truncates past this, so stay under it.
|
||||
const CHANGELOG_CHARACTER_LIMIT = 500;
|
||||
const FULL_NOTES_FOOTER = "Full notes: https://paseo.sh/changelog";
|
||||
const DEFAULT_LOCALE = "en-US";
|
||||
const METADATA_DIR = path.join("fastlane", "metadata", "android");
|
||||
|
||||
function usageAndExit(code = 1) {
|
||||
const usage = `
|
||||
Usage: node scripts/sync-fdroid-changelogs.mjs [options]
|
||||
|
||||
Writes fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt for every
|
||||
ABI the F-Droid build publishes.
|
||||
|
||||
Options:
|
||||
--version <x.y.z> Release version. Defaults to the root package.json version.
|
||||
--locale <locale> Metadata locale directory. Defaults to ${DEFAULT_LOCALE}.
|
||||
--check Exit non-zero if the files on disk are missing or stale.
|
||||
`;
|
||||
process.stderr.write(usage.trimStart());
|
||||
process.stderr.write("\n");
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { check: false, locale: DEFAULT_LOCALE, version: "" };
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--version" || arg === "--locale") {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
usageAndExit();
|
||||
}
|
||||
args[arg === "--version" ? "version" : "locale"] = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--check") {
|
||||
args.check = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
usageAndExit(0);
|
||||
}
|
||||
usageAndExit();
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function truncateText(text, maxLength) {
|
||||
const ellipsis = "...";
|
||||
if (maxLength <= ellipsis.length) {
|
||||
return "";
|
||||
}
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
return `${text.slice(0, maxLength - ellipsis.length).trimEnd()}${ellipsis}`;
|
||||
}
|
||||
|
||||
// Notes claim the budget before any bullet does. Releases use blockquotes for
|
||||
// action-required notices (0.1.109: "you must reinstall manually"), so bullets are
|
||||
// what gets cut. A note too long to fit is truncated rather than dropped — losing
|
||||
// half a warning is recoverable via the footer link, losing all of it is not.
|
||||
function fitNotes(notes, budget) {
|
||||
let noteText = "";
|
||||
|
||||
for (const note of notes) {
|
||||
const separator = noteText.length === 0 ? 0 : 2;
|
||||
const remaining = budget - noteText.length - separator;
|
||||
if (remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const fitted = truncateText(note, remaining);
|
||||
if (fitted.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
noteText += noteText.length === 0 ? fitted : `\n\n${fitted}`;
|
||||
}
|
||||
|
||||
return noteText;
|
||||
}
|
||||
|
||||
// Greedily takes whole section headings and bullets, in order, until the budget runs
|
||||
// out. Returns the kept lines.
|
||||
function fitSections(sections, budget) {
|
||||
const candidates = [];
|
||||
for (const section of sections) {
|
||||
if (candidates.length > 0) {
|
||||
candidates.push("");
|
||||
}
|
||||
if (section.title) {
|
||||
candidates.push(section.title);
|
||||
}
|
||||
for (const bullet of section.bullets) {
|
||||
candidates.push(`- ${bullet}`);
|
||||
}
|
||||
}
|
||||
|
||||
const kept = [];
|
||||
let length = 0;
|
||||
for (const candidate of candidates) {
|
||||
const addedLength = kept.length === 0 ? candidate.length : candidate.length + 1;
|
||||
if (length + addedLength > budget) {
|
||||
break;
|
||||
}
|
||||
kept.push(candidate);
|
||||
length += addedLength;
|
||||
}
|
||||
|
||||
// Drop a trailing blank line or a section heading whose bullets all got cut —
|
||||
// an orphan heading reads like a bug.
|
||||
while (kept.length > 0) {
|
||||
const last = kept[kept.length - 1];
|
||||
if (last === "" || !last.startsWith("- ")) {
|
||||
kept.pop();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return kept;
|
||||
}
|
||||
|
||||
// Fits blockquoted notices, section headings, and bullets into the character budget,
|
||||
// always leaving room for the footer that points at the unabridged notes.
|
||||
export function formatFdroidChangelog(bodyLines, options = {}) {
|
||||
const limit = options.limit ?? CHANGELOG_CHARACTER_LIMIT;
|
||||
const footer = options.footer ?? FULL_NOTES_FOOTER;
|
||||
const { notes, sections } = parseChangelogBody(bodyLines);
|
||||
|
||||
// The rendered file is `body` + "\n\n" + `footer` + "\n", so three newlines and
|
||||
// the footer come out of the limit before any content does.
|
||||
const budget = limit - footer.length - 3;
|
||||
if (budget <= 0) {
|
||||
throw new Error(`Changelog limit ${limit} is too small for the footer "${footer}".`);
|
||||
}
|
||||
|
||||
const noteText = fitNotes(notes, budget);
|
||||
const sectionBudget = budget - noteText.length - (noteText.length > 0 ? 2 : 0);
|
||||
const kept = fitSections(sections, sectionBudget);
|
||||
|
||||
if (kept.length === 0 && noteText.length === 0) {
|
||||
// Nothing fit whole and there is no notice carrying the entry. Salvage the first
|
||||
// bullet so the changelog is never just a bare link.
|
||||
const firstBullet = sections[0]?.bullets[0];
|
||||
const salvaged = firstBullet ? truncateText(`- ${firstBullet}`, sectionBudget) : "";
|
||||
if (salvaged.length > 0) {
|
||||
kept.push(salvaged);
|
||||
}
|
||||
}
|
||||
|
||||
const body = [noteText, kept.join("\n")].filter((part) => part.length > 0).join("\n\n");
|
||||
return `${body ? `${body}\n\n` : ""}${footer}\n`;
|
||||
}
|
||||
|
||||
export function syncFdroidChangelogs(argv = process.argv.slice(2), deps = {}) {
|
||||
const cwd = deps.cwd ?? rootDir;
|
||||
const args = parseArgs(argv);
|
||||
const getFdroidVersionCodes = deps.getFdroidVersionCodes ?? defaultGetFdroidVersionCodes;
|
||||
|
||||
const version =
|
||||
args.version || JSON.parse(readFileSync(path.join(cwd, "package.json"), "utf8")).version;
|
||||
if (typeof version !== "string" || version.length === 0) {
|
||||
throw new Error('Root package.json must contain a valid "version".');
|
||||
}
|
||||
|
||||
if (/-beta\.\d+$/.test(version)) {
|
||||
console.log(`Skipping F-Droid changelogs for beta ${version}.`);
|
||||
return { contents: null, version, versionCodes: [], written: [] };
|
||||
}
|
||||
|
||||
const entries = parseChangelogEntries(readFileSync(path.join(cwd, "CHANGELOG.md"), "utf8"));
|
||||
const entry = entries.find((candidate) => candidate.version === version);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`No CHANGELOG.md entry found for ${version}. Add the release entry before cutting the release.`,
|
||||
);
|
||||
}
|
||||
|
||||
const contents = formatFdroidChangelog(entry.bodyLines);
|
||||
if (contents.length > CHANGELOG_CHARACTER_LIMIT) {
|
||||
throw new Error(
|
||||
`Generated F-Droid changelog for ${version} is ${contents.length} characters, over the ${CHANGELOG_CHARACTER_LIMIT} limit.`,
|
||||
);
|
||||
}
|
||||
|
||||
const changelogDir = path.join(cwd, METADATA_DIR, args.locale, "changelogs");
|
||||
const versionCodes = getFdroidVersionCodes(version);
|
||||
const stale = [];
|
||||
const written = [];
|
||||
|
||||
for (const { abi, versionCode } of versionCodes) {
|
||||
const filePath = path.join(changelogDir, `${versionCode}.txt`);
|
||||
const relativePath = path.relative(cwd, filePath);
|
||||
const current = existsSync(filePath) ? readFileSync(filePath, "utf8") : null;
|
||||
|
||||
if (current === contents) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (args.check) {
|
||||
stale.push(`${relativePath} (${abi})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, contents);
|
||||
written.push(`${relativePath} (${abi})`);
|
||||
}
|
||||
|
||||
if (args.check) {
|
||||
if (stale.length > 0) {
|
||||
throw new Error(
|
||||
`F-Droid changelogs are out of date for ${version}. Run "npm run fdroid:changelogs".\n${stale
|
||||
.map((item) => `- ${item}`)
|
||||
.join("\n")}`,
|
||||
);
|
||||
}
|
||||
console.log(`F-Droid changelogs are up to date for ${version}.`);
|
||||
return { contents, version, versionCodes, written: [] };
|
||||
}
|
||||
|
||||
if (written.length === 0) {
|
||||
console.log(`F-Droid changelogs already current for ${version}.`);
|
||||
} else {
|
||||
console.log(`Wrote F-Droid changelogs for ${version} (${contents.length} chars):`);
|
||||
for (const item of written) {
|
||||
console.log(`- ${item}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { contents, version, versionCodes, written };
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
syncFdroidChangelogs();
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
getFdroidVersionCodes,
|
||||
getNativeReleaseVersion,
|
||||
} from "../packages/app/native-release-version.js";
|
||||
import { formatFdroidChangelog, syncFdroidChangelogs } from "./sync-fdroid-changelogs.mjs";
|
||||
|
||||
const CHANGELOG_CHARACTER_LIMIT = 500;
|
||||
|
||||
function withTempRepo(fn, { changelog, version = "0.2.3" }) {
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-fdroid-changelogs-"));
|
||||
try {
|
||||
writeFileSync(path.join(tempDir, "package.json"), JSON.stringify({ version }));
|
||||
writeFileSync(path.join(tempDir, "CHANGELOG.md"), changelog);
|
||||
fn(tempDir);
|
||||
} finally {
|
||||
rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function readChangelogFile(dir, versionCode, locale = "en-US") {
|
||||
return readFileSync(
|
||||
path.join(dir, "fastlane", "metadata", "android", locale, "changelogs", `${versionCode}.txt`),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
// The whole point of the shared module: these are the numbers the APKs actually
|
||||
// ship with, so the filenames have to track them exactly.
|
||||
test("derives one version code per published ABI", () => {
|
||||
assert.equal(getNativeReleaseVersion("0.2.2").androidVersionCode, 2002);
|
||||
assert.deepEqual(getFdroidVersionCodes("0.2.2"), [
|
||||
{ abi: "armeabi-v7a", versionCode: 20021 },
|
||||
{ abi: "arm64-v8a", versionCode: 20022 },
|
||||
{ abi: "x86", versionCode: 20023 },
|
||||
{ abi: "x86_64", versionCode: 20024 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not write or validate F-Droid changelogs for beta releases", () => {
|
||||
withTempRepo(
|
||||
(dir) => {
|
||||
const result = syncFdroidChangelogs([], { cwd: dir });
|
||||
assert.deepEqual(result, {
|
||||
contents: null,
|
||||
version: "0.2.3-beta.1",
|
||||
versionCodes: [],
|
||||
written: [],
|
||||
});
|
||||
assert.equal(existsSync(path.join(dir, "fastlane")), false);
|
||||
},
|
||||
{ changelog: "", version: "0.2.3-beta.1" },
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects versions that would collide or overflow", () => {
|
||||
assert.throws(() => getNativeReleaseVersion("0.1000.0"), /collision-free/);
|
||||
assert.throws(() => getNativeReleaseVersion("not-a-version"), /unsupported version/);
|
||||
assert.throws(() => getNativeReleaseVersion("210.0.0"), /out of range/);
|
||||
});
|
||||
|
||||
test("writes an identical changelog for every ABI version code", () => {
|
||||
const changelog = "# Changelog\n\n## 0.2.3 - 2026-07-27\n\n### Fixed\n\n- Something broke.\n";
|
||||
withTempRepo(
|
||||
(dir) => {
|
||||
const result = syncFdroidChangelogs([], { cwd: dir });
|
||||
|
||||
assert.equal(result.written.length, 4);
|
||||
for (const { versionCode } of getFdroidVersionCodes("0.2.3")) {
|
||||
assert.equal(readChangelogFile(dir, versionCode), result.contents);
|
||||
}
|
||||
assert.match(result.contents, /- Something broke\./);
|
||||
},
|
||||
{ changelog },
|
||||
);
|
||||
});
|
||||
|
||||
test("writes the four stable 0.5.0 changelog files", () => {
|
||||
const changelog = "## 0.5.0 - 2026-08-21\n\n### Added\n\n- Stable F-Droid metadata.\n";
|
||||
withTempRepo(
|
||||
(dir) => {
|
||||
const result = syncFdroidChangelogs([], { cwd: dir });
|
||||
assert.deepEqual(
|
||||
result.written.map((entry) => entry.replace(/ \(.+\)$/, "")),
|
||||
[
|
||||
"fastlane/metadata/android/en-US/changelogs/50001.txt",
|
||||
"fastlane/metadata/android/en-US/changelogs/50002.txt",
|
||||
"fastlane/metadata/android/en-US/changelogs/50003.txt",
|
||||
"fastlane/metadata/android/en-US/changelogs/50004.txt",
|
||||
],
|
||||
);
|
||||
},
|
||||
{ changelog, version: "0.5.0" },
|
||||
);
|
||||
});
|
||||
|
||||
test("strips PR references and contributor attributions", () => {
|
||||
const changelog = [
|
||||
"## 0.2.3 - 2026-07-27",
|
||||
"",
|
||||
"### Added",
|
||||
"",
|
||||
"- Support for `GitLab` merge requests ([#1913](https://x/1913) by [@nllptrx](https://y))",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
withTempRepo(
|
||||
(dir) => {
|
||||
const { contents } = syncFdroidChangelogs([], { cwd: dir });
|
||||
assert.equal(contents.includes("#1913"), false);
|
||||
assert.equal(contents.includes("nllptrx"), false);
|
||||
assert.equal(contents.includes("`"), false);
|
||||
assert.match(contents, /- Support for GitLab merge requests\n/);
|
||||
},
|
||||
{ changelog },
|
||||
);
|
||||
});
|
||||
|
||||
test("stays inside the 500 character limit and keeps the full-notes link", () => {
|
||||
const bullets = Array.from(
|
||||
{ length: 40 },
|
||||
(_, index) => `- Shipped improvement number ${index}.`,
|
||||
);
|
||||
const changelog = ["## 0.2.3 - 2026-07-27", "", "### Added", "", ...bullets, ""].join("\n");
|
||||
|
||||
withTempRepo(
|
||||
(dir) => {
|
||||
const { contents } = syncFdroidChangelogs([], { cwd: dir });
|
||||
assert.ok(
|
||||
contents.length <= CHANGELOG_CHARACTER_LIMIT,
|
||||
`expected <= ${CHANGELOG_CHARACTER_LIMIT}, got ${contents.length}`,
|
||||
);
|
||||
assert.match(contents, /Full notes: https:\/\/paseo\.sh\/changelog\n$/);
|
||||
// Truncation happens at a bullet boundary, never mid-sentence.
|
||||
assert.equal(contents.includes("Shipped improvement number 0."), true);
|
||||
assert.equal(/- Shipped improvement number \d+\.\.\.$/m.test(contents), false);
|
||||
},
|
||||
{ changelog },
|
||||
);
|
||||
});
|
||||
|
||||
test("never leaves an orphan section heading when bullets get cut", () => {
|
||||
const filler = Array.from({ length: 20 }, (_, index) => `- Improvement ${index} with some text.`);
|
||||
const changelog = [
|
||||
"## 0.2.3 - 2026-07-27",
|
||||
"",
|
||||
"### Improved",
|
||||
"",
|
||||
...filler,
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- A late fix that will not fit.",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
withTempRepo(
|
||||
(dir) => {
|
||||
const { contents } = syncFdroidChangelogs([], { cwd: dir });
|
||||
assert.equal(contents.includes("Fixed"), false);
|
||||
assert.equal(contents.trimEnd().endsWith("Improved"), false);
|
||||
},
|
||||
{ changelog },
|
||||
);
|
||||
});
|
||||
|
||||
test("salvages a truncated first bullet rather than emitting only a link", () => {
|
||||
const contents = formatFdroidChangelog(["### Added", `- ${"x".repeat(600)}`]);
|
||||
assert.ok(contents.length <= CHANGELOG_CHARACTER_LIMIT);
|
||||
assert.match(contents, /^- x+\.\.\.\n\nFull notes:/);
|
||||
});
|
||||
|
||||
// Releases use blockquotes for action-required notices. Dropping one silently is
|
||||
// worse than dropping any bullet, so these pin the behaviour.
|
||||
test("preserves a blockquoted notice ahead of the bullets", () => {
|
||||
const contents = formatFdroidChangelog([
|
||||
"> **Important update notice**",
|
||||
">",
|
||||
"> If you installed Paseo Desktop 0.1.108, you need to [reinstall manually](https://paseo.sh/download).",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Desktop no longer gets stuck connecting.",
|
||||
]);
|
||||
|
||||
assert.match(
|
||||
contents,
|
||||
/^Important update notice\nIf you installed Paseo Desktop 0\.1\.108, you need to reinstall manually\.\n\nFixed\n- Desktop no longer gets stuck connecting\./,
|
||||
);
|
||||
assert.ok(contents.length <= CHANGELOG_CHARACTER_LIMIT);
|
||||
});
|
||||
|
||||
test("keeps the notice whole and cuts bullets when the budget runs out", () => {
|
||||
const notice = `- ${"y".repeat(10)}`;
|
||||
const bullets = Array.from({ length: 30 }, () => `- ${"b".repeat(60)}`);
|
||||
const contents = formatFdroidChangelog([
|
||||
"> Reinstall Paseo manually before updating, the old updater cannot install this build.",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
...bullets,
|
||||
notice,
|
||||
]);
|
||||
|
||||
assert.ok(contents.length <= CHANGELOG_CHARACTER_LIMIT);
|
||||
assert.match(
|
||||
contents,
|
||||
/^Reinstall Paseo manually before updating, the old updater cannot install this build\.\n/,
|
||||
);
|
||||
// The notice survived intact, so some bullets must have been dropped.
|
||||
assert.ok(contents.split("\n").filter((line) => line.startsWith("- b")).length < bullets.length);
|
||||
});
|
||||
|
||||
test("truncates an over-long notice instead of dropping it", () => {
|
||||
const contents = formatFdroidChangelog([`> ${"n".repeat(900)}`, "", "### Fixed", "", "- A fix."]);
|
||||
|
||||
assert.ok(contents.length <= CHANGELOG_CHARACTER_LIMIT);
|
||||
assert.match(contents, /^n+\.\.\.\n\nFull notes:/);
|
||||
assert.equal(contents.includes("A fix."), false);
|
||||
});
|
||||
|
||||
test("captures multiple blockquotes and blockquotes that follow sections", () => {
|
||||
const contents = formatFdroidChangelog([
|
||||
"> First notice.",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- A fix.",
|
||||
"",
|
||||
"> Second notice.",
|
||||
]);
|
||||
|
||||
assert.match(contents, /^First notice\.\n\nSecond notice\.\n\nFixed\n- A fix\./);
|
||||
});
|
||||
|
||||
test("renders the real 0.1.109 notice, which is the entry's whole point", () => {
|
||||
const changelog = [
|
||||
"## 0.1.109 - 2026-07-16",
|
||||
"",
|
||||
"> **Important update notice**",
|
||||
">",
|
||||
"> If you installed Paseo Desktop 0.1.108, you need to [download and reinstall Paseo manually](https://paseo.sh/download) to get this fix. The bug in 0.1.108 prevents its automatic updater from installing 0.1.109. Users on 0.1.107 or earlier can update normally.",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Paseo Desktop no longer gets stuck connecting or loses native window controls after updating ([#2111](https://github.com/getpaseo/paseo/pull/2111) by [@cleiter](https://github.com/cleiter))",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
withTempRepo(
|
||||
(dir) => {
|
||||
const { contents } = syncFdroidChangelogs([], { cwd: dir });
|
||||
assert.ok(contents.length <= CHANGELOG_CHARACTER_LIMIT);
|
||||
assert.match(contents, /^Important update notice\n/);
|
||||
assert.match(contents, /download and reinstall Paseo manually/);
|
||||
assert.match(contents, /Users on 0\.1\.107 or earlier can update normally\./);
|
||||
assert.match(contents, /\nFixed\n- Paseo Desktop no longer gets stuck connecting/);
|
||||
assert.equal(contents.includes("#2111"), false);
|
||||
},
|
||||
{ changelog, version: "0.1.109" },
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves the standalone 0.1.96 Android notice", () => {
|
||||
const changelog = readFileSync(path.resolve("CHANGELOG.md"), "utf8");
|
||||
const entry = changelog.match(/## 0\.1\.96 - 2026-06-13\n([\s\S]*?)(?=\n## |$)/)?.[1];
|
||||
assert.ok(entry);
|
||||
|
||||
const contents = formatFdroidChangelog(entry.split("\n"));
|
||||
assert.match(contents, /This release only fixes an Android issue/);
|
||||
assert.equal(contents.includes("_This release"), false);
|
||||
});
|
||||
|
||||
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(
|
||||
(dir) => {
|
||||
assert.throws(() => syncFdroidChangelogs(["--check"], { cwd: dir }), /out of date/);
|
||||
|
||||
syncFdroidChangelogs([], { cwd: dir });
|
||||
assert.doesNotThrow(() => syncFdroidChangelogs(["--check"], { cwd: dir }));
|
||||
|
||||
const [{ versionCode }] = getFdroidVersionCodes("0.2.3");
|
||||
writeFileSync(
|
||||
path.join(
|
||||
dir,
|
||||
"fastlane",
|
||||
"metadata",
|
||||
"android",
|
||||
"en-US",
|
||||
"changelogs",
|
||||
`${versionCode}.txt`,
|
||||
),
|
||||
"drifted\n",
|
||||
);
|
||||
assert.throws(() => syncFdroidChangelogs(["--check"], { cwd: dir }), /out of date/);
|
||||
},
|
||||
{ changelog },
|
||||
);
|
||||
});
|
||||
|
||||
test("fails loudly when the release has no changelog entry", () => {
|
||||
const changelog = "## 0.2.2 - 2026-07-25\n\n### Fixed\n\n- Older release.\n";
|
||||
withTempRepo(
|
||||
(dir) => {
|
||||
assert.throws(() => syncFdroidChangelogs([], { cwd: dir }), /No CHANGELOG\.md entry/);
|
||||
},
|
||||
{ changelog, version: "0.2.3" },
|
||||
);
|
||||
});
|
||||
|
||||
test("honours a non-default locale directory", () => {
|
||||
const changelog = "## 0.2.3 - 2026-07-27\n\n### Fixed\n\n- Something broke.\n";
|
||||
withTempRepo(
|
||||
(dir) => {
|
||||
mkdirSync(path.join(dir, "fastlane", "metadata", "android", "ja"), { recursive: true });
|
||||
syncFdroidChangelogs(["--locale", "ja"], { cwd: dir });
|
||||
const [{ versionCode }] = getFdroidVersionCodes("0.2.3");
|
||||
assert.match(readChangelogFile(dir, versionCode, "ja"), /- Something broke\./);
|
||||
},
|
||||
{ changelog },
|
||||
);
|
||||
});
|
||||
@@ -2,14 +2,13 @@ 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 {
|
||||
getReleaseInfoFromSourceTag,
|
||||
normalizeReleaseTag,
|
||||
parseReleaseVersion,
|
||||
} from "./release-version-utils.mjs";
|
||||
|
||||
const headingPattern = /^##\s+\[?([^\]\s]+)\]?\s*-\s*([0-9]{4}-[0-9]{2}-[0-9]{2})\s*$/;
|
||||
|
||||
function usageAndExit(code = 1) {
|
||||
const usage = `
|
||||
Usage: node scripts/sync-release-notes-from-changelog.mjs [options]
|
||||
@@ -68,49 +67,14 @@ function parseArgs(argv) {
|
||||
}
|
||||
|
||||
function parseChangelog(changelogText) {
|
||||
const lines = changelogText.split(/\r?\n/);
|
||||
const headings = [];
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
const match = line.match(headingPattern);
|
||||
if (!match) {
|
||||
continue;
|
||||
return parseChangelogEntries(changelogText).map((entry) => {
|
||||
const notesParts = [`## ${entry.version} - ${entry.date}`];
|
||||
if (entry.bodyLines.length > 0) {
|
||||
notesParts.push("", ...entry.bodyLines);
|
||||
}
|
||||
|
||||
headings.push({
|
||||
version: match[1],
|
||||
date: match[2],
|
||||
headingLineIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
if (headings.length === 0) {
|
||||
throw new Error(
|
||||
"No release headings found in CHANGELOG.md. Expected headings like `## 0.1.14 - 2026-02-19`.",
|
||||
);
|
||||
}
|
||||
|
||||
return headings.map((heading, index) => {
|
||||
const nextHeading = headings[index + 1];
|
||||
const bodyStart = heading.headingLineIndex + 1;
|
||||
const bodyEnd = nextHeading ? nextHeading.headingLineIndex : lines.length;
|
||||
|
||||
const bodyLines = lines.slice(bodyStart, bodyEnd);
|
||||
while (bodyLines.length > 0 && bodyLines[0].trim() === "") {
|
||||
bodyLines.shift();
|
||||
}
|
||||
while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1].trim() === "") {
|
||||
bodyLines.pop();
|
||||
}
|
||||
|
||||
const notesParts = [`## ${heading.version} - ${heading.date}`];
|
||||
if (bodyLines.length > 0) {
|
||||
notesParts.push("", ...bodyLines);
|
||||
}
|
||||
|
||||
return Object.assign({}, heading, {
|
||||
tag: `v${heading.version}`,
|
||||
return Object.assign({}, entry, {
|
||||
tag: `v${entry.version}`,
|
||||
notes: `${notesParts.join("\n").trim()}\n`,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user