// Matches the first ISO date on any `## ` heading line. Works for both the // bare-date format (`## 2026-05-19`) and Keep-a-Changelog (`## [1.2.3] - 2026-05-19`). const DATED_HEADING = /^##\s+[^\n]*?(\d{4}-\d{2}-\d{2})/m; const ANY_H2 = /^##\s+[^\n]+$/gm; const SEMVER_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; const RELEASED_HEADING = /^##\s+\[(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]/m; const UNRELEASED_HEADING = /^##\s+\[Unreleased\]\s*$/im; export function getLatestChangelogDate(source: string): string | null { const match = source.match(DATED_HEADING); return match ? match[1] : null; } export function sliceTopEntries(source: string, limit: number): string { if (limit <= 0) return source; const indexes: number[] = []; for (const m of source.matchAll(ANY_H2)) { indexes.push(m.index ?? 0); } if (indexes.length <= limit) return source; return source.slice(0, indexes[limit]).trimEnd() + "\n"; } export function getLatestReleasedVersion(source: string): string | null { const match = source.match(RELEASED_HEADING); return match ? match[1] : null; } export function suggestNextVersion(current: string | null): string { if (!current) return "0.1.0"; const core = current.split("-")[0]; const parts = core.split(".").map((p) => Number.parseInt(p, 10)); if (parts.length !== 3 || parts.some(Number.isNaN)) return "0.1.0"; const [major, minor, patch] = parts; return `${major}.${minor}.${patch + 1}`; } /** * The minor bump: `0.9.3` → `0.10.0`. No release yet (or an unreadable * version) is `0.1.0`, as `suggestNextVersion` has it. */ export function suggestNextMinorVersion(current: string | null): string { const parsed = parseVersion(current); if (!parsed) return "0.1.0"; const [major, minor] = parsed.core; return `${major}.${minor + 1}.0`; } const SEMVER_PARTS = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/; // A heading's version, split for precedence: [major, minor, patch] and the // prerelease identifiers (empty for a release). function parseVersion(v: string | null): { core: number[]; pre: string[] } | null { const m = v ? SEMVER_PARTS.exec(v) : null; if (!m) return null; return { core: [m[1], m[2], m[3]].map((p) => Number.parseInt(p, 10)), pre: m[4] ? m[4].split(".") : [], }; } /** * Semver precedence of two heading versions: X.Y.Z numerically, then a release * above any prerelease of the same X.Y.Z, then the prerelease identifiers * (numeric ones numerically and below alphanumeric ones). An unreadable * version sorts first. Negative, zero or positive. */ export function compareVersions(a: string | null, b: string | null): number { const pa = parseVersion(a); const pb = parseVersion(b); if (!pa || !pb) return (pa ? 1 : 0) - (pb ? 1 : 0); for (let i = 0; i < 3; i++) { if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i]; } if (!pa.pre.length || !pb.pre.length) { return (pa.pre.length ? 0 : 1) - (pb.pre.length ? 0 : 1); } for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) { const x = pa.pre[i]; const y = pb.pre[i]; if (x === undefined) return -1; if (y === undefined) return 1; const xNum = /^\d+$/.test(x); const yNum = /^\d+$/.test(y); if (xNum && yNum) { const d = Number(x) - Number(y); if (d !== 0) return d; } else if (xNum !== yNum) { return xNum ? -1 : 1; } else if (x !== y) { return x < y ? -1 : 1; } } return 0; } /** * Why `date` cannot stamp a release heading — or null. It must be YYYY-MM-DD * AND a day on the calendar: `2026-13-45` and `2026-02-30` are refused. */ export function dateISOProblem(date: string): string | null { if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { return `Date "${date}" is not in YYYY-MM-DD form.`; } const [y, m, d] = date.split("-").map((p) => Number.parseInt(p, 10)); const day = new Date(Date.UTC(y, m - 1, d)); if ( day.getUTCFullYear() !== y || day.getUTCMonth() !== m - 1 || day.getUTCDate() !== d ) { return `Date "${date}" is not a calendar date.`; } return null; } const RELEASED_HEADING_DATED = /^##\s+\[(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\](?:\s+-\s+(\d{4}-\d{2}-\d{2}))?/m; /** The newest released heading's version and its date (null when undated). */ export function getLatestRelease( source: string, ): { version: string; date: string | null } | null { const match = source.match(RELEASED_HEADING_DATED); return match ? { version: match[1], date: match[2] ?? null } : null; } export function hasUnreleasedHeading(source: string): boolean { return UNRELEASED_HEADING.test(source); } /** * How many top-level bullets wait under `## [Unreleased]` — one per change, in * this repo's changelogs (a bullet is one long line; nested items are not * counted). */ export function countUnreleasedBullets(source: string): number { return extractUnreleasedSection(source) .split("\n") .filter((line) => /^[-*+]\s/.test(line)).length; } export function extractUnreleasedSection(source: string): string { const start = source.search(UNRELEASED_HEADING); if (start === -1) return ""; const afterHeading = source.indexOf("\n", start); if (afterHeading === -1) return ""; const rest = source.slice(afterHeading + 1); const next = rest.search(/^##\s+/m); const body = next === -1 ? rest : rest.slice(0, next); return body.trim(); } export class CutReleaseError extends Error { constructor(message: string) { super(message); this.name = "CutReleaseError"; } } export function cutRelease( source: string, version: string, dateISO: string, ): string { if (!SEMVER_VERSION.test(version)) { throw new CutReleaseError( `Version "${version}" is not a valid semver (expected X.Y.Z or X.Y.Z-prerelease).`, ); } const dateProblem = dateISOProblem(dateISO); if (dateProblem) throw new CutReleaseError(dateProblem); const match = UNRELEASED_HEADING.exec(source); if (!match) { throw new CutReleaseError( "Could not find a `## [Unreleased]` heading to cut from.", ); } const unreleased = extractUnreleasedSection(source); if (!unreleased) { throw new CutReleaseError("Nothing pending to release under [Unreleased]."); } // A release goes FORWARD: a version at or below the latest heading would // file the notes under a release that already shipped (or before it). const latest = getLatestReleasedVersion(source); if (latest && compareVersions(version, latest) <= 0) { throw new CutReleaseError( `Version ${version} is not newer than the latest release, ${latest}.`, ); } const headingStart = match.index; const headingEnd = headingStart + match[0].length; const before = source.slice(0, headingStart); const after = source.slice(headingEnd); const replacement = `## [${version}] - ${dateISO}`; return `${before}${replacement}${after}`; }