@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.
- package/LICENSE +21 -0
- package/README.md +553 -0
- package/bin/launcher.js +186 -0
- package/bin/nish +23 -0
- package/bin/packaging.js +220 -0
- package/docs/AI.md +1076 -0
- package/docs/INSTALL.md +468 -0
- package/llms.txt +49 -0
- package/package.json +87 -0
- package/runtime/nish.d.ts +290 -0
- package/runtime/nish.h +340 -0
- package/runtime/nish.mjs +143 -0
- package/runtime/runtime.c +1184 -0
- package/runtime/runtime_os.c +351 -0
- package/runtime/runtime_parallel.c +156 -0
- package/runtime/runtime_wasm.c +99 -0
- package/runtime/shim.mjs +672 -0
- package/scripts/bootstrap.sh +357 -0
- package/scripts/build.sh +279 -0
- package/scripts/changelog-gen.mjs +528 -0
- package/scripts/changelog-section.sh +28 -0
- package/scripts/ci-profile.mjs +187 -0
- package/scripts/codes-registry.js +73 -0
- package/scripts/gen-diagnostic-codes.mjs +199 -0
- package/scripts/gen-pow5-tables.py +45 -0
- package/scripts/nish-compiler.sh +17 -0
- package/scripts/platform-package.mjs +91 -0
- package/scripts/postinstall.mjs +133 -0
- package/scripts/size-report.sh +72 -0
- package/scripts/smoke.sh +94 -0
- package/scripts/verify-binaries.sh +213 -0
- package/std/README.md +185 -0
- package/std/json.ts +402 -0
- package/std/pair.ts +28 -0
- package/std/testing.ts +347 -0
- package/std/text.ts +193 -0
|
@@ -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"
|