#!/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 [--summary] [--changelog ] * * --summary Print only the summary paragraph (no headings, no bullets). * Falls back to "Agora 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 [--summary] [--changelog ]\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 ?? `Agora v${version}`); stdout.write('\n'); } else { const body = trimBlankEdges(section).join('\n'); if (body) { stdout.write(body); stdout.write('\n'); } else { stdout.write(`Agora v${version}\n`); } }