d044218c6a
Each CHANGELOG.md release section now begins with a single plaintext paragraph (max ~500 chars) before any `### Category` heading. That paragraph drives the release blurb in three storefronts and the in-app version-update toast, so we no longer ship a marketing-grade description in one place and a raw bullet list in another. scripts/extract-release-notes.mjs is the single source of truth for extraction. It emits the full section (summary + lists) by default and only the summary paragraph with --summary, with a `Ditto vX.Y.Z` fallback for legacy entries that have no summary. CI changes: - New `release-notes` job (build stage, default node:22 image) produces `artifacts/release-notes.md` and `artifacts/release-notes-summary.txt` once per pipeline. - `release` job pulls release-notes.md as the GitLab Release description (replaces the old inline awk extraction). It now uses `needs:` with `artifacts: false` for build-apk/build-ipa to avoid re-downloading the .apk/.aab/.ipa it doesn't open. - `publish-app-store` copies release-notes-summary.txt to `ios/fastlane/metadata/en-US/release_notes.txt` (replaces its own awk extraction). - `publish-google-play` drops `--skip_upload_changelogs`, writes the summary to `android/fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` and points fastlane supply at `--metadata_path`. This is the first time we upload a What's New text to the Play Store from CI. App-side changes: - `src/lib/changelog.ts` parser captures the leading non-blank paragraph (before any bullet or category heading) into `entry.summary`. - `VersionCheck.tsx` toast uses `entry.summary` when present, falling back to the legacy 60-char first-bullet excerpt for backward compatibility. - `ChangelogPage` renders the summary as a lede paragraph above the bullet list in both LatestRelease and ChangelogEntryCard. Changelog content: - Added summary paragraphs to v2.14.3, v2.14.2, v2.14.1. Skill + AGENTS.md updates: - `release` skill documents the summary paragraph format, the 500-char convention, and the seven-job pipeline. - `ci-cd-publishing` skill gains a 'Release notes pipeline' section mapping each storefront to its source artifact. - AGENTS.md pipeline summary mentions release-notes and the summary flow into both store "What's new" fields.
142 lines
4.3 KiB
JavaScript
Executable File
142 lines
4.3 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Extract release notes from CHANGELOG.md for a given version.
|
|
*
|
|
* The CHANGELOG follows Keep a Changelog format with one extension: each release
|
|
* section MAY begin with a single plaintext paragraph (the "summary") before any
|
|
* `### Added` / `### Changed` / etc. heading. The summary is used as the release
|
|
* blurb on the App Store, Play Store, and the in-app version-update toast. The
|
|
* full section body is used as the GitLab Release description.
|
|
*
|
|
* Format:
|
|
*
|
|
* ## [X.Y.Z] - YYYY-MM-DD
|
|
*
|
|
* A short single-paragraph summary (max 500 characters by convention).
|
|
*
|
|
* ### Added
|
|
* - bullet
|
|
* - bullet
|
|
*
|
|
* ### Changed
|
|
* - bullet
|
|
*
|
|
* Usage:
|
|
* node scripts/extract-release-notes.mjs <version> [--summary] [--changelog <path>]
|
|
*
|
|
* --summary Print only the summary paragraph (no headings, no bullets).
|
|
* Falls back to "Ditto vX.Y.Z" if the section has no summary.
|
|
* --changelog Path to the changelog file. Defaults to CHANGELOG.md.
|
|
*
|
|
* Exits 0 with the extracted text on stdout. Exits non-zero if the version is
|
|
* not found in the changelog.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { argv, exit, stderr, stdout } from 'node:process';
|
|
|
|
function parseArgs(args) {
|
|
let version;
|
|
let summary = false;
|
|
let changelog = 'CHANGELOG.md';
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
if (arg === '--summary') summary = true;
|
|
else if (arg === '--changelog') changelog = args[++i];
|
|
else if (!arg.startsWith('--') && !version) version = arg;
|
|
else {
|
|
stderr.write(`Unknown argument: ${arg}\n`);
|
|
exit(2);
|
|
}
|
|
}
|
|
if (!version) {
|
|
stderr.write('Usage: extract-release-notes.mjs <version> [--summary] [--changelog <path>]\n');
|
|
exit(2);
|
|
}
|
|
// Strip a leading "v" so callers can pass either "v2.14.3" or "2.14.3".
|
|
if (version.startsWith('v')) version = version.slice(1);
|
|
return { version, summary, changelog };
|
|
}
|
|
|
|
/**
|
|
* Extract the lines belonging to a single version section from changelog text,
|
|
* not including the version heading itself.
|
|
*/
|
|
function extractSection(markdown, version) {
|
|
const lines = markdown.split('\n');
|
|
const headingPattern = new RegExp(
|
|
`^## \\[${version.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\]`,
|
|
);
|
|
const nextHeadingPattern = /^## \[/;
|
|
let inSection = false;
|
|
const out = [];
|
|
for (const line of lines) {
|
|
if (!inSection) {
|
|
if (headingPattern.test(line)) {
|
|
inSection = true;
|
|
continue;
|
|
}
|
|
} else {
|
|
if (nextHeadingPattern.test(line)) break;
|
|
out.push(line);
|
|
}
|
|
}
|
|
return inSection ? out : null;
|
|
}
|
|
|
|
/**
|
|
* Pull the leading non-blank paragraph from a section, stopping at the first
|
|
* `###` category heading or `-` bullet. Returns null if no summary paragraph.
|
|
*/
|
|
function extractSummary(sectionLines) {
|
|
const paragraph = [];
|
|
let started = false;
|
|
for (const line of sectionLines) {
|
|
const trimmed = line.trim();
|
|
if (!started) {
|
|
if (!trimmed) continue;
|
|
// If the very first non-blank line is a heading or bullet, there's no summary.
|
|
if (trimmed.startsWith('#') || trimmed.startsWith('- ')) return null;
|
|
started = true;
|
|
paragraph.push(trimmed);
|
|
continue;
|
|
}
|
|
// We're inside the paragraph. A blank line, a heading, or a bullet ends it.
|
|
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('- ')) break;
|
|
paragraph.push(trimmed);
|
|
}
|
|
return paragraph.length ? paragraph.join(' ') : null;
|
|
}
|
|
|
|
/** Trim leading and trailing blank lines from a list of lines. */
|
|
function trimBlankEdges(lines) {
|
|
let start = 0;
|
|
let end = lines.length;
|
|
while (start < end && !lines[start].trim()) start++;
|
|
while (end > start && !lines[end - 1].trim()) end--;
|
|
return lines.slice(start, end);
|
|
}
|
|
|
|
const { version, summary, changelog } = parseArgs(argv.slice(2));
|
|
const markdown = readFileSync(changelog, 'utf8');
|
|
const section = extractSection(markdown, version);
|
|
|
|
if (!section) {
|
|
stderr.write(`Version ${version} not found in ${changelog}\n`);
|
|
exit(1);
|
|
}
|
|
|
|
if (summary) {
|
|
const text = extractSummary(section);
|
|
stdout.write(text ?? `Ditto v${version}`);
|
|
stdout.write('\n');
|
|
} else {
|
|
const body = trimBlankEdges(section).join('\n');
|
|
if (body) {
|
|
stdout.write(body);
|
|
stdout.write('\n');
|
|
} else {
|
|
stdout.write(`Ditto v${version}\n`);
|
|
}
|
|
}
|