From f6288629ffb87e5049f63d778567438f308cfc31 Mon Sep 17 00:00:00 2001 From: Anton Lazarev <22821309+antonok-edm@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:43:20 -0700 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 2 +- CLAUDE.md | 2 +- docs/android.md | 35 +- docs/release.md | 1 + .../android/en-US/full_description.txt | 37 ++ .../metadata/android/en-US/images/icon.png | Bin 0 -> 19787 bytes .../en-US/images/phoneScreenshots/1.png | 1 + .../en-US/images/phoneScreenshots/2.png | 1 + .../en-US/images/phoneScreenshots/3.png | 1 + .../android/en-US/short_description.txt | 1 + fastlane/metadata/android/en-US/title.txt | 1 + .../metadata/android/ja/full_description.txt | 37 ++ .../metadata/android/ja/short_description.txt | 1 + .../android/zh-CN/full_description.txt | 37 ++ .../android/zh-CN/short_description.txt | 1 + package.json | 4 +- packages/app/native-release-version.js | 26 +- packages/app/native-release-version.test.ts | 21 +- .../app/plugins/with-fdroid-autolinking.js | 12 +- scripts/changelog-utils.mjs | 163 +++++++++ scripts/sync-fdroid-changelogs.mjs | 268 ++++++++++++++ scripts/sync-fdroid-changelogs.test.mjs | 327 ++++++++++++++++++ scripts/sync-release-notes-from-changelog.mjs | 50 +-- 23 files changed, 975 insertions(+), 54 deletions(-) create mode 100644 fastlane/metadata/android/en-US/full_description.txt create mode 100644 fastlane/metadata/android/en-US/images/icon.png create mode 120000 fastlane/metadata/android/en-US/images/phoneScreenshots/1.png create mode 120000 fastlane/metadata/android/en-US/images/phoneScreenshots/2.png create mode 120000 fastlane/metadata/android/en-US/images/phoneScreenshots/3.png create mode 100644 fastlane/metadata/android/en-US/short_description.txt create mode 100644 fastlane/metadata/android/en-US/title.txt create mode 100644 fastlane/metadata/android/ja/full_description.txt create mode 100644 fastlane/metadata/android/ja/short_description.txt create mode 100644 fastlane/metadata/android/zh-CN/full_description.txt create mode 100644 fastlane/metadata/android/zh-CN/short_description.txt create mode 100644 scripts/changelog-utils.mjs create mode 100644 scripts/sync-fdroid-changelogs.mjs create mode 100644 scripts/sync-fdroid-changelogs.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 833eafa67a..69689e9c13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index ddb63d268d..ad2a3ff758 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/docs/android.md b/docs/android.md index d65caf9871..b25e775cf0 100644 --- a/docs/android.md +++ b/docs/android.md @@ -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//` **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. diff --git a/docs/release.md b/docs/release.md index eef4fdbcf4..2d57bd29ce 100644 --- a/docs/release.md +++ b/docs/release.md @@ -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 diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000000..7e113dc504 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -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. + +Supported agents + + + +You bring your own provider credentials and use your existing subscriptions. Paseo adds no markup and no inference costs of its own. + +What you can do + + + +Privacy + +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. + +You need a daemon to connect to + +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 diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3c9c7832b7047ba20f510506bbf10d294695d78c GIT binary patch literal 19787 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 m6UPA#af&3)S@m$wUH%^wOCtM6_Gp{{0000対応エージェント + +
    +
  • Claude Code
  • +
  • Codex
  • +
  • GitHub Copilot
  • +
  • OpenCode
  • +
  • Pi
  • +
+ +プロバイダーの認証情報はご自身のものを使用し、既存のサブスクリプションをそのまま活用できます。Paseo が独自の手数料や推論コストを追加することはありません。 + +できること + +
    +
  • スマートフォンからエージェントを起動し、作業を確認し、質問に回答する
  • +
  • プロジェクトや git worktree をまたいで複数のエージェントを並列実行する
  • +
  • 差分のレビュー、コミット履歴の閲覧、ファイルの編集
  • +
  • GitHub、GitLab、Gitea、Forgejo、Codeberg のプルリクエストを扱う
  • +
  • 任意のワークスペースでターミナルを開く
  • +
  • 音声でプロンプトを入力したり、音声モードで問題を相談する
  • +
  • エージェントを定期実行するようスケジュールする
  • +
+ +プライバシー + +テレメトリー、トラッキング、アカウント登録は一切ありません。アプリは、あなたが指定したデーモンにのみ通信します。リモートアクセスは通信内容を読み取れないエンドツーエンド暗号化リレーを経由するか、ご自身のネットワークや VPN を利用できます。 + +接続先のデーモンが必要です + +このアプリは単体では機能しません。コードとエージェント CLI があるコンピューターに Paseo デーモンをインストールし、設定画面からこのアプリをペアリングしてください。デスクトップアプリにはデーモンが同梱されています。npm、Docker、Nix から実行することもできます。 + +セットアップ手順: https://paseo.sh diff --git a/fastlane/metadata/android/ja/short_description.txt b/fastlane/metadata/android/ja/short_description.txt new file mode 100644 index 0000000000..fc7c5692c8 --- /dev/null +++ b/fastlane/metadata/android/ja/short_description.txt @@ -0,0 +1 @@ +ローカルのAIコーディングエージェントをどこからでも操作 diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt new file mode 100644 index 0000000000..94636c1c78 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/full_description.txt @@ -0,0 +1,37 @@ +Paseo 是运行在你自己机器上的 AI 编程 agents 的客户端。把你的开发环境装进口袋。 + +Paseo 会在你的电脑上运行一个名为 daemon 的本地服务,由它管理编程 agents 并实时推送输出。本应用连接到该 daemon,因此你的代码不会离开你的机器,中间也没有任何云服务。 + +支持的 agents + +
    +
  • Claude Code
  • +
  • Codex
  • +
  • GitHub Copilot
  • +
  • OpenCode
  • +
  • Pi
  • +
+ +你使用自己的提供商凭据和现有订阅。Paseo 不加价,也不产生自己的推理费用。 + +你可以做什么 + +
    +
  • 在手机上启动 agents、查看进展并回答它们的提问
  • +
  • 跨项目和 git worktree 并行运行多个 agents
  • +
  • 审查差异、浏览提交历史、编辑文件
  • +
  • 处理来自 GitHub、GitLab、Gitea、Forgejo 和 Codeberg 的合并请求
  • +
  • 在任意工作区打开终端
  • +
  • 用语音输入提示,或在语音模式下讨论问题
  • +
  • 将 agents 设置为定期运行
  • +
+ +隐私 + +没有遥测、没有追踪、无需账号。应用只与你指定的 daemon 通信。远程访问通过无法读取你流量的端到端加密中继,或者你也可以使用自己的网络或 VPN。 + +你需要一个可连接的 daemon + +本应用无法独立使用。请在存放代码和 agent CLI 的电脑上安装 Paseo daemon,然后在设置中将本应用与其配对。桌面应用已内置 daemon,你也可以通过 npm、Docker 或 Nix 运行。 + +安装说明:https://paseo.sh diff --git a/fastlane/metadata/android/zh-CN/short_description.txt b/fastlane/metadata/android/zh-CN/short_description.txt new file mode 100644 index 0000000000..1ade10a028 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/short_description.txt @@ -0,0 +1 @@ +随时随地监控和控制本地的 AI 编程 agents diff --git a/package.json b/package.json index 596c8b7901..f66610dc22 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/app/native-release-version.js b/packages/app/native-release-version.js index 323136d4bf..45a3991e1b 100644 --- a/packages/app/native-release-version.js +++ b/packages/app/native-release-version.js @@ -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, +}; diff --git a/packages/app/native-release-version.test.ts b/packages/app/native-release-version.test.ts index ca4f8d506f..0e6ca4a794 100644 --- a/packages/app/native-release-version.test.ts +++ b/packages/app/native-release-version.test.ts @@ -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 }, + ]); + }); }); diff --git a/packages/app/plugins/with-fdroid-autolinking.js b/packages/app/plugins/with-fdroid-autolinking.js index 0a924a6261..fcdf8795df 100644 --- a/packages/app/plugins/with-fdroid-autolinking.js +++ b/packages/app/plugins/with-fdroid-autolinking.js @@ -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() diff --git a/scripts/changelog-utils.mjs b/scripts/changelog-utils.mjs new file mode 100644 index 0000000000..a3ae0017a7 --- /dev/null +++ b/scripts/changelog-utils.mjs @@ -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 }; +} diff --git a/scripts/sync-fdroid-changelogs.mjs b/scripts/sync-fdroid-changelogs.mjs new file mode 100644 index 0000000000..11690f39ef --- /dev/null +++ b/scripts/sync-fdroid-changelogs.mjs @@ -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/.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//changelogs/.txt for every +ABI the F-Droid build publishes. + +Options: + --version Release version. Defaults to the root package.json version. + --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(); +} diff --git a/scripts/sync-fdroid-changelogs.test.mjs b/scripts/sync-fdroid-changelogs.test.mjs new file mode 100644 index 0000000000..26eb4631b7 --- /dev/null +++ b/scripts/sync-fdroid-changelogs.test.mjs @@ -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 }, + ); +}); diff --git a/scripts/sync-release-notes-from-changelog.mjs b/scripts/sync-release-notes-from-changelog.mjs index 70cee16154..8d566f51a1 100644 --- a/scripts/sync-release-notes-from-changelog.mjs +++ b/scripts/sync-release-notes-from-changelog.mjs @@ -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`, }); });