@amritk/nish 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,528 @@
1
+ #!/usr/bin/env node
2
+ // Build a release's changelog from the commits it contains.
3
+ //
4
+ // node scripts/changelog-gen.mjs --version 0.2.0 [--from <ref>] [--to <ref>]
5
+ // [--write] [--stdout md|json]
6
+ // [--include-unconventional]
7
+ //
8
+ // Writes `changelog/<version>.json` — the structured record — and renders
9
+ // Markdown from it for CHANGELOG.md and the GitHub release notes. JSON is the
10
+ // source and Markdown is a view of it, so the website and the repository
11
+ // cannot drift: there is one account of a release, rendered twice.
12
+ //
13
+ // The two views are not the same shape. The JSON keeps everything a commit
14
+ // said -- the prose, the `Measured:` numbers, the refs and the tests -- because
15
+ // the website renders an entry as a page. The Markdown is an index: one line
16
+ // per change, its title and a link to the pull request it landed in, grouped
17
+ // under the type's heading. A reader scanning CHANGELOG.md or a release body
18
+ // wants to know what changed and where to read the rest, and the pull request
19
+ // is where the rest already lives; repeating each body inline turned one
20
+ // release into pages of prose that nobody scrolled.
21
+ //
22
+ // Why the commits rather than a file maintained by hand: CHANGELOG.md is
23
+ // written when a release is cut, and the thing that knows what went into a
24
+ // release is the range of commits it contains. The subject line carries the
25
+ // classification and the body carries the explanation, so both survive into
26
+ // the record instead of the body being lost the way a subject-only generator
27
+ // loses it.
28
+ //
29
+ // The commit convention (see CLAUDE.md):
30
+ //
31
+ // type(scope): imperative subject
32
+ //
33
+ // The body, in prose. Markdown. This is the entry's account on the
34
+ // website, so write it for a reader and not only for the reviewer.
35
+ //
36
+ // Measured: 1.58x on an element loop
37
+ // Refs: docs/IR_COOKBOOK.md#arrays
38
+ // Tests: tests/cases/arr_alias_domains
39
+ // Release-Note: overrides the body for public notes, when the body is
40
+ // about the review rather than about the change
41
+ // Release-As: 1.0.0 -- the version the next release takes, when it is not
42
+ // the one the types imply (see nextVersion)
43
+ //
44
+ // `type!` or a `BREAKING CHANGE:` trailer marks a breaking change.
45
+ //
46
+ // Only conventional subjects become entries. The release notes are the account
47
+ // of what a release changed, and a commit that did not say what it changed is
48
+ // not that account: `pr-title.yml` makes every squash-merge subject
49
+ // conventional, so what the filter removes is the history behind a merge
50
+ // commit -- the work-in-progress commits whose landed subject already has an
51
+ // entry -- and the commits that predate the convention. Nothing is dropped
52
+ // silently: every skipped subject is listed on stderr, and the
53
+ // `--include-unconventional` flag files them under "Uncategorised" the way this
54
+ // tool behaved before. Use it for the first release after the convention
55
+ // lands, or write the history up by hand in `changelog/<version>.intro.md`,
56
+ // which renders above the sections.
57
+ import fs from "node:fs";
58
+ import path from "node:path";
59
+ import { execFileSync } from "node:child_process";
60
+
61
+ const root = path.resolve(import.meta.dirname, "..");
62
+
63
+ /**
64
+ * The repository the entries link into, from `package.json#repository`. Read
65
+ * from the manifest rather than hard-coded so a fork renders its own links,
66
+ * and optional: without it an entry names `#38` as text instead of claiming a
67
+ * URL it cannot know.
68
+ */
69
+ const REPO_URL = (() => {
70
+ try {
71
+ const { repository } = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
72
+ const url = typeof repository === "string" ? repository : repository?.url;
73
+ const m = /github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/.exec(url ?? "");
74
+ return m ? `https://github.com/${m[1]}` : undefined;
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ })();
79
+
80
+ /** Conventional-commit types, in the order a release renders them. */
81
+ const TYPES = [
82
+ ["feat", "Added"],
83
+ ["fix", "Fixed"],
84
+ ["perf", "Performance"],
85
+ ["refactor", "Changed"],
86
+ ["docs", "Documentation"],
87
+ ["test", "Tests"],
88
+ ["build", "Build"],
89
+ ["ci", "CI"],
90
+ ["chore", "Internal"],
91
+ // Only reachable under --include-unconventional; ordinarily a subject that
92
+ // does not classify is skipped rather than filed here.
93
+ ["other", "Uncategorised"],
94
+ ];
95
+
96
+ /** The types a subject may name. `other` is this tool's bucket, not a type. */
97
+ const CONVENTIONAL_TYPES = TYPES.filter(([k]) => k !== "other").map(([k]) => k);
98
+
99
+ /**
100
+ * Trailers that are bookkeeping rather than content. They are stripped from
101
+ * every body: a release note is for the reader, and who co-authored a commit
102
+ * or which session produced it is not part of what changed.
103
+ */
104
+ const DROPPED_TRAILERS = /^(Co-Authored-By|Claude-Session|Signed-off-by|Reviewed-by):/i;
105
+
106
+ /** Trailers this tool reads. Everything else is left in the body. */
107
+ const KNOWN_TRAILERS = /^(Measured|Refs|Tests|Release-Note|Release-As|BREAKING[ -]CHANGE):\s*(.*)$/i;
108
+
109
+ /**
110
+ * GitHub ends a squashed body with a rule when the branch had more than one
111
+ * commit. It renders as an `<hr>` in the middle of the notes and says nothing,
112
+ * so it goes the way the bookkeeping trailers do. It sits above the
113
+ * co-authorship trailers rather than at the end, so it comes off the prose.
114
+ */
115
+ const SQUASH_RULE = /\n[ \t]*\n[ \t]*-{3,}[ \t]*$/;
116
+
117
+ function git(args, quiet = false) {
118
+ return execFileSync("git", args, {
119
+ cwd: root,
120
+ encoding: "utf8",
121
+ maxBuffer: 64 * 1024 * 1024,
122
+ // `git describe` with no tags writes "fatal: No names found" to stderr and
123
+ // exits non-zero. That is an answer here, not a failure, so it is caught
124
+ // below -- but inheriting stderr would print a fatal error during a run
125
+ // that succeeded, which is how a green log gets read as a broken one.
126
+ stdio: quiet ? ["ignore", "pipe", "ignore"] : undefined,
127
+ });
128
+ }
129
+
130
+ /** The previous release tag, or undefined when this is the first release. */
131
+ function lastTag() {
132
+ try {
133
+ return git(["describe", "--tags", "--abbrev=0", "--match", "v*"], true).trim() || undefined;
134
+ } catch {
135
+ return undefined; // no tags yet: the first release covers the whole history
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Split a commit body into prose and trailers. Only a trailing run of
141
+ * trailer-shaped lines counts, so a colon inside the prose is safe.
142
+ */
143
+ function splitTrailers(body) {
144
+ const lines = body.replace(/\r\n/g, "\n").split("\n");
145
+ const trailers = [];
146
+ let end = lines.length;
147
+ while (end > 0) {
148
+ const line = lines[end - 1];
149
+ if (line.trim() === "") {
150
+ end -= 1;
151
+ continue;
152
+ }
153
+ if (DROPPED_TRAILERS.test(line) || KNOWN_TRAILERS.test(line)) {
154
+ trailers.unshift(line);
155
+ end -= 1;
156
+ continue;
157
+ }
158
+ break;
159
+ }
160
+ return { prose: lines.slice(0, end).join("\n").trim(), trailers };
161
+ }
162
+
163
+ function parseTrailers(lines) {
164
+ const out = { metrics: [], refs: [], tests: [], releaseNote: undefined, releaseAs: [], breaking: undefined };
165
+ for (const line of lines) {
166
+ if (DROPPED_TRAILERS.test(line)) continue;
167
+ const m = line.match(KNOWN_TRAILERS);
168
+ if (!m) continue;
169
+ const key = m[1].toLowerCase().replace(/[ -]/g, "");
170
+ const value = m[2].trim();
171
+ if (key === "measured") out.metrics.push(value);
172
+ else if (key === "refs") out.refs.push(...value.split(",").map((s) => s.trim()).filter(Boolean));
173
+ else if (key === "tests") out.tests.push(...value.split(",").map((s) => s.trim()).filter(Boolean));
174
+ else if (key === "releasenote") out.releaseNote = value;
175
+ else if (key === "releaseas") out.releaseAs.push(value);
176
+ else if (key === "breakingchange") out.breaking = value;
177
+ }
178
+ return out;
179
+ }
180
+
181
+ /** `type(scope)!: subject`, or undefined when the subject is not conventional. */
182
+ function parseSubject(subject) {
183
+ const m = subject.match(/^([a-z]+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/);
184
+ if (!m) return undefined;
185
+ return { type: m[1], scope: m[2], bang: Boolean(m[3]), title: m[4].trim() };
186
+ }
187
+
188
+ /** A stable, human-readable anchor. The website links entries by this. */
189
+ function slug(title, taken) {
190
+ const base =
191
+ title
192
+ .toLowerCase()
193
+ .replace(/`[^`]*`/g, (s) => s.slice(1, -1))
194
+ .replace(/[^a-z0-9]+/g, "-")
195
+ .replace(/^-|-$/g, "")
196
+ .split("-")
197
+ .slice(0, 8)
198
+ .join("-") || "entry";
199
+ let id = base;
200
+ for (let n = 2; taken.has(id); n += 1) id = `${base}-${n}`;
201
+ taken.add(id);
202
+ return id;
203
+ }
204
+
205
+ /**
206
+ * The entries a release contains, the subjects that did not become one, and
207
+ * the `Release-As:` trailers the range carries.
208
+ *
209
+ * A subject that does not classify is skipped: `pr-title.yml` makes every
210
+ * squash-merge subject conventional, so what is left over is the branch
211
+ * history behind a merge commit -- already represented by the subject that
212
+ * landed -- or a commit from before the convention. Both are noise in the
213
+ * notes rather than content, and both used to be most of the file. The skipped
214
+ * subjects are returned so the caller can report them; `includeUnconventional`
215
+ * restores the old behaviour and files them under "Uncategorised".
216
+ *
217
+ * A trailer is read from every commit in the range, skipped or not: a version
218
+ * someone asked for is not lost because the commit that asked was the
219
+ * work-in-progress half of a merge.
220
+ */
221
+ function collect(from, to, includeUnconventional = false) {
222
+ const range = from ? `${from}..${to}` : to;
223
+ // \x00 between fields and \x1e between records: a commit body contains
224
+ // newlines and may contain anything else, so the separators must be bytes
225
+ // that cannot appear in one.
226
+ const raw = git(["log", "--no-merges", "--reverse", `--format=%H%x00%an%x00%aI%x00%s%x00%b%x1e`, range]);
227
+ const taken = new Set();
228
+ const entries = [];
229
+ const skipped = [];
230
+ const releaseAs = [];
231
+
232
+ for (const record of raw.split("\x1e")) {
233
+ const text = record.replace(/^\n/, "");
234
+ if (!text.trim()) continue;
235
+ const [sha, author, date, subject, body = ""] = text.split("\x00");
236
+ const { prose: rawProse, trailers } = splitTrailers(body);
237
+ const t = parseTrailers(trailers);
238
+ for (const version of t.releaseAs) releaseAs.push({ version, commit: `${sha.slice(0, 7)} ${subject}` });
239
+
240
+ const parsed = parseSubject(subject);
241
+ const classified = parsed !== undefined && CONVENTIONAL_TYPES.includes(parsed.type);
242
+ if (!classified) {
243
+ skipped.push(`${sha.slice(0, 7)} ${subject}`);
244
+ if (!includeUnconventional) continue;
245
+ }
246
+
247
+ const prose = rawProse.replace(SQUASH_RULE, "");
248
+ const title = parsed ? parsed.title : subject;
249
+ const pr = /\(#(\d+)\)\s*$/.exec(subject)?.[1];
250
+
251
+ entries.push({
252
+ id: slug(title, taken),
253
+ type: classified ? parsed.type : "other",
254
+ scope: parsed?.scope,
255
+ breaking: Boolean(parsed?.bang || t.breaking),
256
+ breakingNote: t.breaking,
257
+ title: title.replace(/\s*\(#\d+\)\s*$/, ""),
258
+ body: t.releaseNote ?? prose,
259
+ metrics: t.metrics,
260
+ refs: t.refs,
261
+ tests: t.tests,
262
+ commits: [sha.slice(0, 7)],
263
+ pr: pr ? Number(pr) : undefined,
264
+ author,
265
+ date: date.slice(0, 10),
266
+ });
267
+ }
268
+ return { entries, skipped, releaseAs };
269
+ }
270
+
271
+ function renderMarkdown(release) {
272
+ const out = [];
273
+ if (release.intro) out.push(release.intro.trim(), "");
274
+
275
+ const group = (predicate) => release.entries.filter(predicate).map(renderEntry);
276
+
277
+ const breaking = group((e) => e.breaking);
278
+ if (breaking.length > 0) out.push("### Breaking changes", "", ...breaking, "");
279
+
280
+ for (const [type, heading] of TYPES) {
281
+ const lines = group((e) => e.type === type && !e.breaking);
282
+ if (lines.length === 0) continue;
283
+ out.push(`### ${heading}`, "", ...lines, "");
284
+ }
285
+ return `${out.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
286
+ }
287
+
288
+ /**
289
+ * Where to read the rest of an entry: the pull request it landed in, or the
290
+ * commit when it landed without one. Every line carries one, so the index
291
+ * always leads somewhere.
292
+ */
293
+ function entryLink(e) {
294
+ if (e.pr) return REPO_URL ? `[#${e.pr}](${REPO_URL}/pull/${e.pr})` : `#${e.pr}`;
295
+ const sha = e.commits[0];
296
+ if (!sha) return undefined;
297
+ return REPO_URL ? `[\`${sha}\`](${REPO_URL}/commit/${sha})` : `\`${sha}\``;
298
+ }
299
+
300
+ /**
301
+ * One line: the scope, the title, and the link. A conventional subject is
302
+ * lower case by convention and these read as sentences, so the scope and the
303
+ * title are joined and the first letter is raised -- unless the title starts
304
+ * with `code`, which keeps its backtick and its case.
305
+ */
306
+ function renderEntry(e) {
307
+ const title = e.title.startsWith("`") ? e.title : e.title.charAt(0).toUpperCase() + e.title.slice(1);
308
+ const link = entryLink(e);
309
+ return `- ${e.scope ? `${e.scope}: ` : ""}${title}${link ? ` (${link})` : ""}`;
310
+ }
311
+
312
+ /**
313
+ * The version the commits imply, from their types, as `[major, minor, patch]`.
314
+ *
315
+ * Before 1.0 a breaking change moves the minor rather than the major, because
316
+ * 0.x is the "anything may change" range and burning 1.0 on the first breaking
317
+ * change would be a lie about stability. 1.0 is therefore never implied: it is
318
+ * asked for, with a `Release-As:` trailer (see nextVersion). From 1.0 on the
319
+ * same branch is ordinary semver -- a break moves the major, a `feat` the
320
+ * minor, anything else the patch -- so nothing changes here when 1.0 is cut.
321
+ */
322
+ function impliedVersion(current, entries, previousTag) {
323
+ // The first release is 0.1.0 whatever the commits say. Every commit before
324
+ // the convention existed is typed `other`, so a type-driven bump would read
325
+ // the entire history as a patch and ship 0.0.1 — a number that would claim
326
+ // the compiler is a bug-fix on nothing. 0.1.0 is also what the seed policy
327
+ // already names as the base case (docs/wp12-release.md, "The bootstrap seed").
328
+ if (!previousTag) return [0, 1, 0];
329
+ const [major, minor, patch] = current.split(".").map(Number);
330
+ if (entries.some((e) => e.breaking)) return major === 0 ? [0, minor + 1, 0] : [major + 1, 0, 0];
331
+ if (entries.some((e) => e.type === "feat")) return [major, minor + 1, 0];
332
+ return [major, minor, patch + 1];
333
+ }
334
+
335
+ /** `X.Y.Z` as three numbers, or undefined when it is not one. No `v`, no pre-release, no leading zeros. */
336
+ function parseVersion(text) {
337
+ const m = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(text);
338
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
339
+ }
340
+
341
+ /** Negative, zero or positive as `a` is below, at or above `b`. */
342
+ function compareVersions(a, b) {
343
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
344
+ }
345
+
346
+ /**
347
+ * The next version: the one the commits imply, unless a `Release-As: X.Y.Z`
348
+ * trailer in the range asks for another. The highest trailer wins, and it may
349
+ * only raise the version -- the implied one is a floor, so a trailer can cut
350
+ * 1.0.0 over a range that implies 0.10.0, but it can never downgrade, and
351
+ * never ship a patch where a `feat` asks for the minor.
352
+ *
353
+ * Returns `{ version }`, or `{ error }` naming the trailer and its commit when
354
+ * one is malformed, below the floor, or disagrees with another trailer on the
355
+ * same commit -- one commit asking for two versions has not said which it means. Neither is skipped: a trailer is a
356
+ * human's decision about a number the release will carry for ever, and the
357
+ * release PR proposing some other number without saying so would be worse than
358
+ * the train stopping.
359
+ */
360
+ function nextVersion(current, entries, previousTag, releaseAs = []) {
361
+ const implied = impliedVersion(current, entries, previousTag);
362
+ let chosen;
363
+ const byCommit = new Map();
364
+ for (const request of releaseAs) {
365
+ const parsed = parseVersion(request.version);
366
+ if (!parsed) {
367
+ return {
368
+ error: `malformed trailer \`Release-As: ${request.version}\` on ${request.commit}; the value is a version, X.Y.Z`,
369
+ };
370
+ }
371
+ const earlier = byCommit.get(request.commit);
372
+ if (earlier !== undefined && earlier !== request.version) {
373
+ return {
374
+ error: `two trailers on ${request.commit} disagree: \`Release-As: ${earlier}\` and \`Release-As: ${request.version}\`; a commit asks for one version`,
375
+ };
376
+ }
377
+ byCommit.set(request.commit, request.version);
378
+ if (!chosen || compareVersions(parsed, chosen.parsed) > 0) chosen = { ...request, parsed };
379
+ }
380
+ if (!chosen) return { version: implied.join(".") };
381
+ if (compareVersions(chosen.parsed, implied) < 0) {
382
+ return {
383
+ error:
384
+ `trailer \`Release-As: ${chosen.version}\` on ${chosen.commit} is below ${implied.join(".")}, ` +
385
+ `the version the commits since ${previousTag ?? "the start of the history"} imply; ` +
386
+ "a trailer may raise the next version, never lower it",
387
+ };
388
+ }
389
+ return { version: chosen.version };
390
+ }
391
+
392
+ // ---- CLI ---------------------------------------------------------------------------------------
393
+
394
+ const argv = process.argv.slice(2);
395
+ function flag(name, fallback) {
396
+ const i = argv.indexOf(`--${name}`);
397
+ return i === -1 ? fallback : argv[i + 1];
398
+ }
399
+
400
+ // Validating one subject line. This lives here rather than in the workflow so
401
+ // that what CI enforces and what the generator parses are the same rule: a
402
+ // title the check accepts and the generator files under "Uncategorised" would
403
+ // be worse than no check at all.
404
+ const checkSubject = flag("check-subject", undefined);
405
+ if (checkSubject !== undefined) {
406
+ const parsed = parseSubject(checkSubject);
407
+ if (!parsed) {
408
+ console.error(`not a conventional commit subject:\n\n ${checkSubject}\n`);
409
+ console.error(`Expected \`type(scope): subject\`, where type is one of: ${CONVENTIONAL_TYPES.join(", ")}.`);
410
+ console.error(`A \`!\` after the type or scope marks a breaking change.\n`);
411
+ console.error(`Examples:\n feat(checker): accept non-generic type aliases`);
412
+ console.error(` perf(codegen)!: hoist the array header out of element loops`);
413
+ console.error(`\nThe subject becomes the heading in the release notes, so write it for a reader.`);
414
+ process.exit(1);
415
+ }
416
+ if (!CONVENTIONAL_TYPES.includes(parsed.type)) {
417
+ console.error(`unknown type \`${parsed.type}\` in:\n\n ${checkSubject}\n`);
418
+ console.error(`Use one of: ${CONVENTIONAL_TYPES.join(", ")}.`);
419
+ process.exit(1);
420
+ }
421
+ if (/[.]$/.test(parsed.title)) {
422
+ console.error(`subject ends with a full stop:\n\n ${checkSubject}\n`);
423
+ console.error("It is a heading, not a sentence.");
424
+ process.exit(1);
425
+ }
426
+ console.log(`ok: ${parsed.type}${parsed.scope ? `(${parsed.scope})` : ""}${parsed.bang ? "!" : ""} — ${parsed.title}`);
427
+ process.exit(0);
428
+ }
429
+
430
+ // Rendering an already-written release: the release job must publish exactly
431
+ // the file the release PR was reviewed with, not a fresh walk of the log that
432
+ // could differ by a commit.
433
+ const fromJson = flag("from-json", undefined);
434
+ if (fromJson) {
435
+ const stored = JSON.parse(fs.readFileSync(fromJson, "utf8"));
436
+ process.stdout.write(renderMarkdown(stored));
437
+ process.exit(0);
438
+ }
439
+
440
+ const pkgVersion = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version;
441
+ const version = flag("version", pkgVersion);
442
+ const from = flag("from", lastTag());
443
+ const to = flag("to", "HEAD");
444
+ const stdoutKind = flag("stdout", "md");
445
+ const write = argv.includes("--write");
446
+ const includeUnconventional = argv.includes("--include-unconventional");
447
+
448
+ const { entries, skipped, releaseAs } = collect(from, to, includeUnconventional);
449
+
450
+ // `--next` answers the version and nothing else, for the release PR to name
451
+ // itself and to bump package.json with. Everything release-pr.yml writes reads
452
+ // this one line, so a `Release-As:` trailer reaches all of it here.
453
+ if (argv.includes("--next")) {
454
+ const next = nextVersion(pkgVersion, entries, from, releaseAs);
455
+ if (next.error) {
456
+ console.error(`changelog-gen: ${next.error}`);
457
+ process.exit(1);
458
+ }
459
+ process.stdout.write(`${next.version}\n`);
460
+ process.exit(0);
461
+ }
462
+
463
+ // Skipped is not silent: the subjects are named, because a change that belongs
464
+ // in the notes and was written without a type is a defect to fix in the
465
+ // commit, not something for a reader of the notes to discover missing.
466
+ if (skipped.length > 0 && !includeUnconventional) {
467
+ const shown = skipped.slice(0, 20);
468
+ console.error(
469
+ `changelog-gen: skipped ${skipped.length} commit${skipped.length === 1 ? "" : "s"} whose subject is not a conventional commit:`
470
+ );
471
+ for (const line of shown) console.error(` ${line}`);
472
+ if (skipped.length > shown.length) console.error(` ... and ${skipped.length - shown.length} more`);
473
+ console.error("Pass --include-unconventional to file them under \"Uncategorised\" instead.");
474
+ }
475
+
476
+ if (entries.length === 0) {
477
+ const range = from ? `${from}..${to}` : to;
478
+ console.error(
479
+ skipped.length > 0
480
+ ? `changelog-gen: no commit in ${range} carries a conventional subject (${skipped.length} skipped, listed above), so the release has no entries; fix the subjects or pass --include-unconventional`
481
+ : `changelog-gen: no commits in ${range}; a release with no changes is a mistake, not an empty section`
482
+ );
483
+ process.exit(1);
484
+ }
485
+
486
+ const introPath = path.join(root, "changelog", `${version}.intro.md`);
487
+ const release = {
488
+ version,
489
+ date: new Date().toISOString().slice(0, 10),
490
+ tag: `v${version}`,
491
+ range: { from: from ?? null, to: git(["rev-parse", "--short", to]).trim() },
492
+ intro: fs.existsSync(introPath) ? fs.readFileSync(introPath, "utf8").trim() : undefined,
493
+ entries,
494
+ };
495
+
496
+ const jsonDir = path.join(root, "changelog");
497
+ const jsonPath = path.join(jsonDir, `${version}.json`);
498
+ const markdown = renderMarkdown(release);
499
+
500
+ if (write) {
501
+ fs.mkdirSync(jsonDir, { recursive: true });
502
+ fs.writeFileSync(jsonPath, `${JSON.stringify(release, null, 2)}\n`);
503
+
504
+ // Splice the rendered section into CHANGELOG.md under `## [Unreleased]`,
505
+ // which is where the file's own header says a release section goes.
506
+ const changelogPath = path.join(root, "CHANGELOG.md");
507
+ const current = fs.readFileSync(changelogPath, "utf8");
508
+ const marker = "## [Unreleased]";
509
+ if (!current.includes(marker)) {
510
+ console.error(`changelog-gen: CHANGELOG.md has no \`${marker}\` heading to write under`);
511
+ process.exit(1);
512
+ }
513
+ const section = `${marker}\n\n## [${version}] - ${release.date}\n\n${markdown}`;
514
+ let next = current.replace(marker, section);
515
+ const link = `[${version}]: https://github.com/amritk/nish/releases/tag/v${version}`;
516
+ if (!next.includes(link)) next = `${next.trimEnd()}\n${link}\n`;
517
+ fs.writeFileSync(changelogPath, next);
518
+
519
+ console.error(`changelog-gen: wrote changelog/${version}.json and the CHANGELOG.md section`);
520
+ }
521
+
522
+ if (includeUnconventional && skipped.length > 0) {
523
+ console.error(
524
+ `changelog-gen: ${skipped.length} of ${entries.length} commits are not conventional and landed under "Uncategorised"`
525
+ );
526
+ }
527
+
528
+ process.stdout.write(stdoutKind === "json" ? `${JSON.stringify(release, null, 2)}\n` : markdown);
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bash
2
+ # Print the CHANGELOG.md section for one version, for use as GitHub release notes.
3
+ #
4
+ # scripts/changelog-section.sh <version> e.g. 0.2.0 (a leading `v` is ignored)
5
+ #
6
+ # Prints the body between `## [<version>]` and the next `## ` heading. If the
7
+ # version has no section yet, prints the `## [Unreleased]` body instead and
8
+ # says so on stderr, so a release never ships with empty notes.
9
+ set -euo pipefail
10
+ cd "$(dirname "$0")/.."
11
+
12
+ version=${1:?usage: scripts/changelog-section.sh <version>}
13
+ version=${version#v}
14
+
15
+ section() { # section <heading-text>
16
+ awk -v h="## [$1]" '
17
+ index($0, h) == 1 { on = 1; next }
18
+ on && /^## / { exit }
19
+ on { print }
20
+ ' CHANGELOG.md
21
+ }
22
+
23
+ body=$(section "$version")
24
+ if [ -z "$(printf '%s' "$body" | tr -d '[:space:]')" ]; then
25
+ echo "warning: CHANGELOG.md has no [$version] section; using [Unreleased]" >&2
26
+ body=$(section Unreleased)
27
+ fi
28
+ printf '%s\n' "$body"