mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +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
@@ -52,7 +52,7 @@ jobs:
|
||||
filters: .github/ci-paths.yml
|
||||
|
||||
- name: Validate CI contracts
|
||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs
|
||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs
|
||||
|
||||
format:
|
||||
name: format
|
||||
|
||||
@@ -56,7 +56,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and routing preferences |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
|
||||
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||
|
||||
+34
-1
@@ -15,7 +15,9 @@ EAS profiles: `development`, `production`, and `production-apk` in `packages/app
|
||||
|
||||
## Version codes
|
||||
|
||||
`packages/app/app.config.js` derives Android `versionCode` from the package version with:
|
||||
`packages/app/native-release-version.js` is the single definition of native and F-Droid version-code math. Do not re-derive these numbers anywhere else — a drifted copy produces changelog files that match no published APK, and nothing fails loudly.
|
||||
|
||||
The base version code comes from the package version:
|
||||
|
||||
```text
|
||||
major * 1_000_000 + minor * 1_000 + patch
|
||||
@@ -145,6 +147,37 @@ Keep the excluded npm packages installed. Normal builds use them, while the F-Dr
|
||||
|
||||
The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.
|
||||
|
||||
### F-Droid store metadata
|
||||
|
||||
F-Droid reads the store listing from `fastlane/metadata/android/<locale>/` **at the repo root**. This location provides the best compatibility with the F-Droid release process.
|
||||
|
||||
```text
|
||||
fastlane/metadata/android/
|
||||
├── en-US/ (F-Droid fallback locale, mandatory)
|
||||
│ ├── title.txt (<=50 chars)
|
||||
│ ├── short_description.txt (<=80 chars)
|
||||
│ ├── full_description.txt (<=4000 chars, limited HTML)
|
||||
│ ├── images/
|
||||
│ │ ├── icon.png (512x512)
|
||||
│ │ ├── featureGraphic.png (1024x500)
|
||||
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
|
||||
│ └── changelogs/ (generated — see below)
|
||||
├── ja/
|
||||
└── zh-CN/
|
||||
```
|
||||
|
||||
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
|
||||
|
||||
F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result.
|
||||
|
||||
One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.
|
||||
|
||||
Stable sync fails loudly if `CHANGELOG.md` has no entry for the version being cut. That is intentional — the release checklist requires the entry to be committed first, so an abort here means the checklist was skipped.
|
||||
|
||||
Because the generator runs off the version in `package.json`, it must run **before** the tag is created: fdroidserver only reads metadata from the tag it builds, so the file for version N has to exist in the commit N points at.
|
||||
|
||||
Beta releases are an explicit no-op: they do not create or rewrite F-Droid changelog files. Stable releases and promotions generate the four ABI entries from their final changelog.
|
||||
|
||||
### React version lockstep
|
||||
|
||||
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
|
||||
|
||||
@@ -436,6 +436,7 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
|
||||
## Notes
|
||||
|
||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||
- The npm `version` lifecycle regenerates F-Droid changelog files from `CHANGELOG.md` for stable releases only (`npm run fdroid:changelogs`) and stages them, so the release tag carries them. Betas are a no-op. A stable run **aborts the release** if `CHANGELOG.md` has no entry for the version being cut — commit the changelog entry first. See [docs/android.md](android.md) for why these files are generated per ABI.
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
Paseo is a client for AI coding agents that run on your own machine. Your dev environment, in your pocket.
|
||||
|
||||
Paseo runs a local server called the daemon on your computer. It manages your coding agents and streams their output in real time. This app connects to that daemon, so your code never leaves your machine and there is no cloud service in between.
|
||||
|
||||
<b>Supported agents</b>
|
||||
|
||||
<ul>
|
||||
<li>Claude Code</li>
|
||||
<li>Codex</li>
|
||||
<li>GitHub Copilot</li>
|
||||
<li>OpenCode</li>
|
||||
<li>Pi</li>
|
||||
</ul>
|
||||
|
||||
You bring your own provider credentials and use your existing subscriptions. Paseo adds no markup and no inference costs of its own.
|
||||
|
||||
<b>What you can do</b>
|
||||
|
||||
<ul>
|
||||
<li>Start agents, watch them work, and answer their questions from your phone</li>
|
||||
<li>Run several agents in parallel across projects and git worktrees</li>
|
||||
<li>Review diffs, browse commit history, and edit files</li>
|
||||
<li>Work with pull requests from GitHub, GitLab, Gitea, Forgejo, and Codeberg</li>
|
||||
<li>Open a terminal in any workspace</li>
|
||||
<li>Dictate prompts or talk through a problem in voice mode</li>
|
||||
<li>Schedule agents to run on a recurring basis</li>
|
||||
</ul>
|
||||
|
||||
<b>Privacy</b>
|
||||
|
||||
No telemetry, no tracking, no account required. The app talks only to daemons you point it at. Remote access goes through an end-to-end encrypted relay that cannot read your traffic, or over your own network or VPN if you prefer.
|
||||
|
||||
<b>You need a daemon to connect to</b>
|
||||
|
||||
This app is not useful on its own. Install the Paseo daemon on the computer where your code and agent CLIs live, then pair this app with it from Settings. The desktop app bundles the daemon, or you can run it from npm, Docker, or Nix.
|
||||
|
||||
Setup instructions: https://paseo.sh
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1 @@
|
||||
../../../../../../packages/website/public/phone-1.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../packages/website/public/phone-2.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../packages/website/public/phone-3.png
|
||||
@@ -0,0 +1 @@
|
||||
Monitor and control your local AI coding agents from anywhere
|
||||
@@ -0,0 +1 @@
|
||||
Paseo
|
||||
@@ -0,0 +1,37 @@
|
||||
Paseo は、自分のマシン上で動作する AI コーディングエージェントのクライアントです。あなたの開発環境を、ポケットの中に。
|
||||
|
||||
Paseo はデーモンと呼ばれるローカルサーバーをあなたのコンピューター上で実行します。デーモンがコーディングエージェントを管理し、その出力をリアルタイムに配信します。このアプリはそのデーモンに接続するため、コードがマシンの外に出ることはなく、間にクラウドサービスも入りません。
|
||||
|
||||
<b>対応エージェント</b>
|
||||
|
||||
<ul>
|
||||
<li>Claude Code</li>
|
||||
<li>Codex</li>
|
||||
<li>GitHub Copilot</li>
|
||||
<li>OpenCode</li>
|
||||
<li>Pi</li>
|
||||
</ul>
|
||||
|
||||
プロバイダーの認証情報はご自身のものを使用し、既存のサブスクリプションをそのまま活用できます。Paseo が独自の手数料や推論コストを追加することはありません。
|
||||
|
||||
<b>できること</b>
|
||||
|
||||
<ul>
|
||||
<li>スマートフォンからエージェントを起動し、作業を確認し、質問に回答する</li>
|
||||
<li>プロジェクトや git worktree をまたいで複数のエージェントを並列実行する</li>
|
||||
<li>差分のレビュー、コミット履歴の閲覧、ファイルの編集</li>
|
||||
<li>GitHub、GitLab、Gitea、Forgejo、Codeberg のプルリクエストを扱う</li>
|
||||
<li>任意のワークスペースでターミナルを開く</li>
|
||||
<li>音声でプロンプトを入力したり、音声モードで問題を相談する</li>
|
||||
<li>エージェントを定期実行するようスケジュールする</li>
|
||||
</ul>
|
||||
|
||||
<b>プライバシー</b>
|
||||
|
||||
テレメトリー、トラッキング、アカウント登録は一切ありません。アプリは、あなたが指定したデーモンにのみ通信します。リモートアクセスは通信内容を読み取れないエンドツーエンド暗号化リレーを経由するか、ご自身のネットワークや VPN を利用できます。
|
||||
|
||||
<b>接続先のデーモンが必要です</b>
|
||||
|
||||
このアプリは単体では機能しません。コードとエージェント CLI があるコンピューターに Paseo デーモンをインストールし、設定画面からこのアプリをペアリングしてください。デスクトップアプリにはデーモンが同梱されています。npm、Docker、Nix から実行することもできます。
|
||||
|
||||
セットアップ手順: https://paseo.sh
|
||||
@@ -0,0 +1 @@
|
||||
ローカルのAIコーディングエージェントをどこからでも操作
|
||||
@@ -0,0 +1,37 @@
|
||||
Paseo 是运行在你自己机器上的 AI 编程 agents 的客户端。把你的开发环境装进口袋。
|
||||
|
||||
Paseo 会在你的电脑上运行一个名为 daemon 的本地服务,由它管理编程 agents 并实时推送输出。本应用连接到该 daemon,因此你的代码不会离开你的机器,中间也没有任何云服务。
|
||||
|
||||
<b>支持的 agents</b>
|
||||
|
||||
<ul>
|
||||
<li>Claude Code</li>
|
||||
<li>Codex</li>
|
||||
<li>GitHub Copilot</li>
|
||||
<li>OpenCode</li>
|
||||
<li>Pi</li>
|
||||
</ul>
|
||||
|
||||
你使用自己的提供商凭据和现有订阅。Paseo 不加价,也不产生自己的推理费用。
|
||||
|
||||
<b>你可以做什么</b>
|
||||
|
||||
<ul>
|
||||
<li>在手机上启动 agents、查看进展并回答它们的提问</li>
|
||||
<li>跨项目和 git worktree 并行运行多个 agents</li>
|
||||
<li>审查差异、浏览提交历史、编辑文件</li>
|
||||
<li>处理来自 GitHub、GitLab、Gitea、Forgejo 和 Codeberg 的合并请求</li>
|
||||
<li>在任意工作区打开终端</li>
|
||||
<li>用语音输入提示,或在语音模式下讨论问题</li>
|
||||
<li>将 agents 设置为定期运行</li>
|
||||
</ul>
|
||||
|
||||
<b>隐私</b>
|
||||
|
||||
没有遥测、没有追踪、无需账号。应用只与你指定的 daemon 通信。远程访问通过无法读取你流量的端到端加密中继,或者你也可以使用自己的网络或 VPN。
|
||||
|
||||
<b>你需要一个可连接的 daemon</b>
|
||||
|
||||
本应用无法独立使用。请在存放代码和 agent CLI 的电脑上安装 Paseo daemon,然后在设置中将本应用与其配对。桌面应用已内置 daemon,你也可以通过 npm、Docker 或 Nix 运行。
|
||||
|
||||
安装说明:https://paseo.sh
|
||||
@@ -0,0 +1 @@
|
||||
随时随地监控和控制本地的 AI 编程 agents
|
||||
+3
-1
@@ -84,6 +84,8 @@
|
||||
"android:release": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
|
||||
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
||||
"fdroid:changelogs": "node scripts/sync-fdroid-changelogs.mjs",
|
||||
"fdroid:changelogs:check": "node scripts/sync-fdroid-changelogs.mjs --check",
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
|
||||
@@ -91,7 +93,7 @@
|
||||
"build:desktop": "npm run build:app-deps:clean && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
|
||||
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||
"cli": "./scripts/dev-home.sh npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
"version": "npm run version:sync-internal && npm run fdroid:changelogs && npm run release:prepare && git add -A",
|
||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||
"release:prepare": "npm install --workspaces --include-workspace-root",
|
||||
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
const versionPattern = /^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/;
|
||||
const stableIosBuildSlot = 999;
|
||||
const FDROID_ABI_VERSION_CODE_SUFFIXES = {
|
||||
"armeabi-v7a": 1,
|
||||
"arm64-v8a": 2,
|
||||
x86: 3,
|
||||
x86_64: 4,
|
||||
};
|
||||
|
||||
function getNativeReleaseVersion(version) {
|
||||
const match = versionPattern.exec(version);
|
||||
@@ -21,7 +27,11 @@ function getNativeReleaseVersion(version) {
|
||||
}
|
||||
|
||||
const versionCode = major * 1_000_000 + minor * 1_000 + patch;
|
||||
if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) {
|
||||
if (
|
||||
!Number.isSafeInteger(versionCode) ||
|
||||
versionCode <= 0 ||
|
||||
versionCode * 10 + 9 > 2_100_000_000
|
||||
) {
|
||||
throw new Error(`Derived Android versionCode is out of range: ${versionCode}`);
|
||||
}
|
||||
|
||||
@@ -38,4 +48,16 @@ function getNativeReleaseVersion(version) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getNativeReleaseVersion };
|
||||
function getFdroidVersionCodes(version) {
|
||||
const { androidVersionCode } = getNativeReleaseVersion(version);
|
||||
return Object.entries(FDROID_ABI_VERSION_CODE_SUFFIXES).map(([abi, suffix]) => ({
|
||||
abi,
|
||||
versionCode: androidVersionCode * 10 + suffix,
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FDROID_ABI_VERSION_CODE_SUFFIXES,
|
||||
getFdroidVersionCodes,
|
||||
getNativeReleaseVersion,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { getNativeReleaseVersion } = require("./native-release-version");
|
||||
const {
|
||||
FDROID_ABI_VERSION_CODE_SUFFIXES,
|
||||
getFdroidVersionCodes,
|
||||
getNativeReleaseVersion,
|
||||
} = require("./native-release-version");
|
||||
|
||||
describe("native release version", () => {
|
||||
it("reserves the final iOS build slot for a stable release", () => {
|
||||
@@ -24,4 +28,19 @@ describe("native release version", () => {
|
||||
"iOS beta number must be between 1 and 998",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives one F-Droid version code per published ABI", () => {
|
||||
expect(FDROID_ABI_VERSION_CODE_SUFFIXES).toEqual({
|
||||
"armeabi-v7a": 1,
|
||||
"arm64-v8a": 2,
|
||||
x86: 3,
|
||||
x86_64: 4,
|
||||
});
|
||||
expect(getFdroidVersionCodes("0.5.0")).toEqual([
|
||||
{ abi: "armeabi-v7a", versionCode: 50001 },
|
||||
{ abi: "arm64-v8a", versionCode: 50002 },
|
||||
{ abi: "x86", versionCode: 50003 },
|
||||
{ abi: "x86_64", versionCode: 50004 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { withAppBuildGradle, withDangerousMod, withSettingsGradle } = require("expo/config-plugins");
|
||||
const { FDROID_ABI_VERSION_CODE_SUFFIXES } = require("../native-release-version");
|
||||
|
||||
const EXCLUDED_ANDROID_MODULES = [
|
||||
"expo-camera",
|
||||
@@ -11,12 +12,15 @@ const EXCLUDED_ANDROID_MODULES = [
|
||||
"expo-dev-menu-interface",
|
||||
];
|
||||
|
||||
// Generated from the shared suffix table so the Groovy literal can never drift
|
||||
// from the version codes the F-Droid changelog filenames are derived from.
|
||||
const FDROID_ABI_VERSION_CODE_ENTRIES = Object.entries(FDROID_ABI_VERSION_CODE_SUFFIXES)
|
||||
.map(([abi, suffix]) => ` "${abi}": ${suffix},`)
|
||||
.join("\n");
|
||||
|
||||
const FDROID_ABI_VERSION_CODE_BLOCK = `// Paseo F-Droid single-ABI version codes
|
||||
def paseoAbiVersionCodes = [
|
||||
"armeabi-v7a": 1,
|
||||
"arm64-v8a": 2,
|
||||
"x86": 3,
|
||||
"x86_64": 4,
|
||||
${FDROID_ABI_VERSION_CODE_ENTRIES}
|
||||
]
|
||||
def paseoArchitectures = (findProperty("reactNativeArchitectures") ?: "")
|
||||
.toString()
|
||||
|
||||
@@ -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