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:
Anton Lazarev
2026-08-25 23:37:54 +02:00
committed by Mohamed Boudra
co-authored by Mohamed Boudra
parent 4a5716360b
commit f6288629ff
23 changed files with 975 additions and 54 deletions
+163
View File
@@ -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 };
}
+268
View File
@@ -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();
}
+327
View File
@@ -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 },
);
});
+7 -43
View File
@@ -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`,
});
});