prepare-release.mjs 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. #!/usr/bin/env node
  2. /**
  3. * Promote `## [Unreleased]` content into `## [<version>]` in CHANGELOG.md
  4. * so the release.yml workflow's `extract-release-notes.mjs <version>` call
  5. * picks up everything that landed since the last release.
  6. *
  7. * **Why this exists:** the release workflow used to do a literal
  8. * `extract-release-notes.mjs <version>` lookup with an `[Unreleased]`
  9. * fallback. The fallback only triggers if the `[<version>]` block
  10. * doesn't exist at all — and in practice maintainers sometimes had a
  11. * sparse `[<version>]` block pre-populated (e.g. one early fix
  12. * documented before the rest of the work landed). The workflow then
  13. * extracted that sparse block, ignoring the much larger `[Unreleased]`
  14. * section above it — so the published release notes were missing most
  15. * of what shipped. See v0.9.5 for the canonical post-mortem.
  16. *
  17. * **What it does**, idempotently:
  18. *
  19. * Case A — `[<version>]` does not exist yet:
  20. * Rename the `[Unreleased]` header to `[<version>] - <YYYY-MM-DD>`
  21. * and add a fresh empty `## [Unreleased]` block above it. This is
  22. * the common case.
  23. *
  24. * Case B — `[<version>]` exists AND `[Unreleased]` has content:
  25. * Merge `[Unreleased]`'s sub-sections (### Added / ### Fixed /
  26. * ### Changed / ### Removed / ### Deprecated / ### Security) into
  27. * the corresponding sub-sections of `[<version>]`. Unmatched
  28. * sub-sections are appended to `[<version>]`. The `[Unreleased]`
  29. * block is then emptied.
  30. *
  31. * Case C — `[Unreleased]` has no content:
  32. * No-op. Exit 0. Re-runs of the workflow are safe.
  33. *
  34. * **Where the date comes from:** for Case A, `<YYYY-MM-DD>` is the
  35. * UTC date at run time. Matches the existing CHANGELOG convention.
  36. *
  37. * **Usage:**
  38. *
  39. * node scripts/prepare-release.mjs # reads version from package.json
  40. * node scripts/prepare-release.mjs 1.2.3 # explicit version
  41. *
  42. * **Output:**
  43. *
  44. * Writes CHANGELOG.md in place. Prints a summary line to stdout
  45. * like `prepare-release: 0.9.5 — promoted 6 Unreleased entries`.
  46. * Exits non-zero on parse failures.
  47. */
  48. import { readFileSync, writeFileSync } from 'node:fs';
  49. import { resolve } from 'node:path';
  50. const CHANGELOG_PATH = resolve(process.cwd(), 'CHANGELOG.md');
  51. function readPackageVersion() {
  52. const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'));
  53. if (!pkg.version) throw new Error('package.json has no "version" field');
  54. return pkg.version;
  55. }
  56. function todayUtcIsoDate() {
  57. // YYYY-MM-DD in UTC. Matches the CHANGELOG's existing convention
  58. // (the existing dated entries don't disclose a timezone, but UTC is
  59. // stable across runners and is what the workflow's runner produces
  60. // by default anyway).
  61. return new Date().toISOString().slice(0, 10);
  62. }
  63. /**
  64. * Split the CHANGELOG into a header preface + an ordered list of
  65. * version blocks `{ header, body[] }`, preserving line content
  66. * verbatim so we can re-join without surprises.
  67. */
  68. function parseChangelog(text) {
  69. const lines = text.split('\n');
  70. const versionHeaderRe = /^## \[([^\]]+)\](?:\s+-\s+(.+))?\s*$/;
  71. const preface = [];
  72. const blocks = []; // { header: string, name: string, body: string[] }
  73. let cur = null;
  74. for (const line of lines) {
  75. const m = line.match(versionHeaderRe);
  76. if (m) {
  77. if (cur) blocks.push(cur);
  78. cur = { header: line, name: m[1], date: m[2] ?? null, body: [] };
  79. } else if (cur) {
  80. cur.body.push(line);
  81. } else {
  82. preface.push(line);
  83. }
  84. }
  85. if (cur) blocks.push(cur);
  86. return { preface, blocks };
  87. }
  88. function joinChangelog({ preface, blocks }) {
  89. const parts = [preface.join('\n')];
  90. for (const b of blocks) {
  91. // Reconstruct: header + body. The block body INCLUDES the blank
  92. // line after the header (it was captured verbatim).
  93. parts.push([b.header, ...b.body].join('\n'));
  94. }
  95. return parts.join('\n');
  96. }
  97. /**
  98. * Split a block body into ordered sub-sections keyed by their
  99. * `### Heading`. Lines before the first `### Heading` go in
  100. * `leading`. Preserves the original (line-array) body inside each
  101. * sub-section so we can splice cleanly when merging.
  102. */
  103. function splitSubsections(body) {
  104. const subsectionRe = /^### (\w+)\s*$/;
  105. const leading = [];
  106. const subs = []; // { heading: 'Added' | 'Fixed' | …, headerLine: string, body: string[] }
  107. let cur = null;
  108. for (const line of body) {
  109. const m = line.match(subsectionRe);
  110. if (m) {
  111. if (cur) subs.push(cur);
  112. cur = { heading: m[1], headerLine: line, body: [] };
  113. } else if (cur) {
  114. cur.body.push(line);
  115. } else {
  116. leading.push(line);
  117. }
  118. }
  119. if (cur) subs.push(cur);
  120. return { leading, subs };
  121. }
  122. function rebuildBody({ leading, subs }) {
  123. const parts = [];
  124. if (leading.length) parts.push(leading.join('\n'));
  125. for (const s of subs) {
  126. parts.push([s.headerLine, ...s.body].join('\n'));
  127. }
  128. return parts.join('\n').split('\n');
  129. }
  130. /**
  131. * Return true when the block has any meaningful entries (a bullet line
  132. * starting with `-`, `*`, or a digit) — vs. being empty / just
  133. * whitespace / just sub-section headers with nothing under them.
  134. */
  135. function blockHasContent(body) {
  136. for (const line of body) {
  137. if (/^\s*([-*]|\d+\.)\s+/.test(line)) return true;
  138. }
  139. return false;
  140. }
  141. /**
  142. * Trim trailing blank lines from an array of lines, then return.
  143. * Keeps the output tidy when merging.
  144. */
  145. function trimTrailingBlank(arr) {
  146. let i = arr.length;
  147. while (i > 0 && /^\s*$/.test(arr[i - 1])) i--;
  148. return arr.slice(0, i);
  149. }
  150. function main() {
  151. const versionArg = process.argv[2];
  152. const version = versionArg || readPackageVersion();
  153. const text = readFileSync(CHANGELOG_PATH, 'utf8');
  154. const parsed = parseChangelog(text);
  155. const unrelIdx = parsed.blocks.findIndex((b) => b.name === 'Unreleased');
  156. const verIdx = parsed.blocks.findIndex((b) => b.name === version);
  157. if (unrelIdx === -1) {
  158. console.log(`prepare-release: no [Unreleased] block — nothing to do`);
  159. return;
  160. }
  161. const unrel = parsed.blocks[unrelIdx];
  162. if (!blockHasContent(unrel.body)) {
  163. console.log(`prepare-release: [Unreleased] is empty — nothing to do`);
  164. return;
  165. }
  166. if (verIdx === -1) {
  167. // Case A — promote Unreleased → [version].
  168. const today = todayUtcIsoDate();
  169. const promoted = {
  170. header: `## [${version}] - ${today}`,
  171. name: version,
  172. date: today,
  173. body: trimTrailingBlank(unrel.body).concat(['']), // single trailing blank
  174. };
  175. const emptied = {
  176. header: `## [Unreleased]`,
  177. name: 'Unreleased',
  178. date: null,
  179. body: ['', ''], // two blank lines for the next round of entries
  180. };
  181. parsed.blocks.splice(unrelIdx, 1, emptied, promoted);
  182. const next = joinChangelog(parsed);
  183. writeFileSync(CHANGELOG_PATH, appendLinkRef(next, version));
  184. console.log(`prepare-release: ${version} — renamed [Unreleased] to [${version}] - ${today}`);
  185. return;
  186. }
  187. // Case B — merge Unreleased sub-sections into the existing
  188. // [version] sub-sections. New sub-section headings encountered in
  189. // Unreleased that don't exist in [version] get appended.
  190. const ver = parsed.blocks[verIdx];
  191. const unrelSubs = splitSubsections(unrel.body);
  192. const verSubs = splitSubsections(ver.body);
  193. let merged = 0;
  194. for (const us of unrelSubs.subs) {
  195. const target = verSubs.subs.find((s) => s.heading === us.heading);
  196. const usBody = trimTrailingBlank(us.body);
  197. if (usBody.length === 0) continue;
  198. if (target) {
  199. // Append Unreleased's entries to the end of the version's matching
  200. // sub-section, keeping their original ordering. Insert a separating
  201. // blank line if the existing sub-section doesn't already end in one.
  202. const existing = trimTrailingBlank(target.body);
  203. const sep = existing.length && !/^\s*$/.test(existing[existing.length - 1]) ? [''] : [];
  204. target.body = existing.concat(sep, usBody, ['']);
  205. } else {
  206. // Append the whole sub-section to the end.
  207. verSubs.subs.push({
  208. heading: us.heading,
  209. headerLine: us.headerLine,
  210. body: usBody.concat(['']),
  211. });
  212. }
  213. merged += usBody.filter((l) => /^\s*([-*]|\d+\.)\s+/.test(l)).length;
  214. }
  215. ver.body = rebuildBody(verSubs);
  216. // Empty out Unreleased.
  217. unrel.body = ['', ''];
  218. const merged_text = joinChangelog(parsed);
  219. writeFileSync(CHANGELOG_PATH, appendLinkRef(merged_text, version));
  220. console.log(`prepare-release: ${version} — merged ${merged} Unreleased entries into existing [${version}] block`);
  221. }
  222. /**
  223. * Append a `[X.Y.Z]: https://github.com/colbymchenry/codegraph/releases/tag/vX.Y.Z`
  224. * link reference at the end of the file IF one doesn't already exist. The
  225. * link ref is what makes `## [X.Y.Z]` heading text auto-link to its tag in
  226. * GitHub's renderer; without it the heading still renders, just unlinked.
  227. *
  228. * Idempotent. The existing CHANGELOG mixes link refs scattered through the
  229. * file and a sorted block at the bottom — we just append at the very end,
  230. * which CommonMark accepts regardless.
  231. */
  232. function appendLinkRef(text, version) {
  233. const refLine = `[${version}]: https://github.com/colbymchenry/codegraph/releases/tag/v${version}`;
  234. // Already there? Look for a line that EQUALS this (anywhere in the file)
  235. // to keep idempotency robust against the scattered-vs-block layout.
  236. const lines = text.split('\n');
  237. if (lines.some((l) => l.trim() === refLine)) return text;
  238. // Append, separated by a blank line from the prior content. Preserve a
  239. // single trailing newline at EOF.
  240. const trailingNewline = text.endsWith('\n') ? '' : '\n';
  241. return text + trailingNewline + refLine + '\n';
  242. }
  243. try {
  244. main();
  245. } catch (err) {
  246. console.error(`prepare-release: ${err?.message ?? err}`);
  247. process.exit(1);
  248. }