Files
eranos/scripts/extract-release-notes.mjs
T
Alex Gleason d044218c6a Use a release-summary paragraph for App Store, Play Store, and the in-app toast
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.
2026-05-11 13:13:33 -07:00

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`);
}
}