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