@amritk/nish 0.12.0 → 0.14.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/README.md +27 -23
- package/bin/launcher.js +45 -41
- package/bin/packaging.js +39 -33
- package/docs/AI.md +157 -35
- package/docs/INSTALL.md +13 -13
- package/llms.txt +1 -1
- package/package.json +9 -6
- package/runtime/nish.d.ts +85 -1
- package/runtime/nish.h +62 -11
- package/runtime/nish.mjs +38 -3
- package/runtime/runtime-host.c +178 -0
- package/runtime/{runtime_os.c → runtime-os.c} +16 -1
- package/runtime/{runtime_parallel.c → runtime-parallel.c} +112 -4
- package/runtime/{runtime_wasm.c → runtime-wasm.c} +6 -1
- package/runtime/runtime.c +5 -5
- package/runtime/shim.mjs +128 -6
- package/scripts/bootstrap.sh +29 -29
- package/scripts/build.sh +19 -13
- package/scripts/changelog-gen.mjs +260 -192
- package/scripts/ci-profile.mjs +80 -68
- package/scripts/codes-registry.js +25 -13
- package/scripts/gen-diagnostic-codes.mjs +124 -85
- package/scripts/nish-compiler.sh +2 -0
- package/scripts/platform-package.mjs +26 -26
- package/scripts/postinstall.mjs +46 -36
- package/scripts/size-report.sh +3 -3
- package/scripts/smoke.sh +1 -1
- package/std/README.md +42 -2
- package/std/collections.ts +194 -188
- package/std/crypto/base64url.ts +145 -0
- package/std/crypto/ct.ts +64 -0
- package/std/crypto/hkdf.ts +118 -0
- package/std/crypto/hmac.ts +155 -0
- package/std/crypto/sha256.ts +444 -0
- package/std/crypto/sha512.ts +510 -0
- package/std/crypto/x25519.ts +494 -0
- package/std/json.ts +136 -136
- package/std/map.ts +9 -7
- package/std/pair.ts +2 -2
- package/std/testing.ts +67 -67
- package/std/text.ts +54 -54
- package/std/threads.ts +126 -38
|
@@ -54,11 +54,11 @@
|
|
|
54
54
|
// tool behaved before. Use it for the first release after the convention
|
|
55
55
|
// lands, or write the history up by hand in `changelog/<version>.intro.md`,
|
|
56
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"
|
|
57
|
+
import fs from "node:fs"
|
|
58
|
+
import path from "node:path"
|
|
59
|
+
import { execFileSync } from "node:child_process"
|
|
60
60
|
|
|
61
|
-
const root = path.resolve(import.meta.dirname, "..")
|
|
61
|
+
const root = path.resolve(import.meta.dirname, "..")
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
64
|
* The repository the entries link into, from `package.json#repository`. Read
|
|
@@ -68,14 +68,14 @@ const root = path.resolve(import.meta.dirname, "..");
|
|
|
68
68
|
*/
|
|
69
69
|
const REPO_URL = (() => {
|
|
70
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
|
|
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
75
|
} catch {
|
|
76
|
-
return undefined
|
|
76
|
+
return undefined
|
|
77
77
|
}
|
|
78
|
-
})()
|
|
78
|
+
})()
|
|
79
79
|
|
|
80
80
|
/** Conventional-commit types, in the order a release renders them. */
|
|
81
81
|
const TYPES = [
|
|
@@ -91,20 +91,20 @@ const TYPES = [
|
|
|
91
91
|
// Only reachable under --include-unconventional; ordinarily a subject that
|
|
92
92
|
// does not classify is skipped rather than filed here.
|
|
93
93
|
["other", "Uncategorised"],
|
|
94
|
-
]
|
|
94
|
+
]
|
|
95
95
|
|
|
96
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)
|
|
97
|
+
const CONVENTIONAL_TYPES = TYPES.filter(([k]) => k !== "other").map(([k]) => k)
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
100
|
* Trailers that are bookkeeping rather than content. They are stripped from
|
|
101
101
|
* every body: a release note is for the reader, and who co-authored a commit
|
|
102
102
|
* or which session produced it is not part of what changed.
|
|
103
103
|
*/
|
|
104
|
-
const DROPPED_TRAILERS = /^(Co-Authored-By|Claude-Session|Signed-off-by|Reviewed-by):/i
|
|
104
|
+
const DROPPED_TRAILERS = /^(Co-Authored-By|Claude-Session|Signed-off-by|Reviewed-by):/i
|
|
105
105
|
|
|
106
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
|
|
107
|
+
const KNOWN_TRAILERS = /^(Measured|Refs|Tests|Release-Note|Release-As|BREAKING[ -]CHANGE):\s*(.*)$/i
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
110
|
* GitHub ends a squashed body with a rule when the branch had more than one
|
|
@@ -112,10 +112,10 @@ const KNOWN_TRAILERS = /^(Measured|Refs|Tests|Release-Note|Release-As|BREAKING[
|
|
|
112
112
|
* so it goes the way the bookkeeping trailers do. It sits above the
|
|
113
113
|
* co-authorship trailers rather than at the end, so it comes off the prose.
|
|
114
114
|
*/
|
|
115
|
-
const SQUASH_RULE = /\n[ \t]*\n[ \t]*-{3,}[ \t]
|
|
115
|
+
const SQUASH_RULE = /\n[ \t]*\n[ \t]*-{3,}[ \t]*$/
|
|
116
116
|
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
const git = (args, quiet = false) =>
|
|
118
|
+
execFileSync("git", args, {
|
|
119
119
|
cwd: root,
|
|
120
120
|
encoding: "utf8",
|
|
121
121
|
maxBuffer: 64 * 1024 * 1024,
|
|
@@ -124,15 +124,14 @@ function git(args, quiet = false) {
|
|
|
124
124
|
// below -- but inheriting stderr would print a fatal error during a run
|
|
125
125
|
// that succeeded, which is how a green log gets read as a broken one.
|
|
126
126
|
stdio: quiet ? ["ignore", "pipe", "ignore"] : undefined,
|
|
127
|
-
})
|
|
128
|
-
}
|
|
127
|
+
})
|
|
129
128
|
|
|
130
129
|
/** The previous release tag, or undefined when this is the first release. */
|
|
131
|
-
|
|
130
|
+
const lastTag = () => {
|
|
132
131
|
try {
|
|
133
|
-
return git(["describe", "--tags", "--abbrev=0", "--match", "v*"], true).trim() || undefined
|
|
132
|
+
return git(["describe", "--tags", "--abbrev=0", "--match", "v*"], true).trim() || undefined
|
|
134
133
|
} catch {
|
|
135
|
-
return undefined
|
|
134
|
+
return undefined // no tags yet: the first release covers the whole history
|
|
136
135
|
}
|
|
137
136
|
}
|
|
138
137
|
|
|
@@ -140,53 +139,83 @@ function lastTag() {
|
|
|
140
139
|
* Split a commit body into prose and trailers. Only a trailing run of
|
|
141
140
|
* trailer-shaped lines counts, so a colon inside the prose is safe.
|
|
142
141
|
*/
|
|
143
|
-
|
|
144
|
-
const lines = body.replace(/\r\n/g, "\n").split("\n")
|
|
145
|
-
const trailers = []
|
|
146
|
-
let end = lines.length
|
|
142
|
+
const splitTrailers = (body) => {
|
|
143
|
+
const lines = body.replace(/\r\n/g, "\n").split("\n")
|
|
144
|
+
const trailers = []
|
|
145
|
+
let end = lines.length
|
|
147
146
|
while (end > 0) {
|
|
148
|
-
const line = lines[end - 1]
|
|
147
|
+
const line = lines[end - 1]
|
|
149
148
|
if (line.trim() === "") {
|
|
150
|
-
end -= 1
|
|
151
|
-
continue
|
|
149
|
+
end -= 1
|
|
150
|
+
continue
|
|
152
151
|
}
|
|
153
152
|
if (DROPPED_TRAILERS.test(line) || KNOWN_TRAILERS.test(line)) {
|
|
154
|
-
trailers.unshift(line)
|
|
155
|
-
end -= 1
|
|
156
|
-
continue
|
|
153
|
+
trailers.unshift(line)
|
|
154
|
+
end -= 1
|
|
155
|
+
continue
|
|
157
156
|
}
|
|
158
|
-
break
|
|
157
|
+
break
|
|
159
158
|
}
|
|
160
|
-
return { prose: lines.slice(0, end).join("\n").trim(), trailers }
|
|
159
|
+
return { prose: lines.slice(0, end).join("\n").trim(), trailers }
|
|
161
160
|
}
|
|
162
161
|
|
|
163
|
-
|
|
164
|
-
const out = {
|
|
162
|
+
const parseTrailers = (lines) => {
|
|
163
|
+
const out = {
|
|
164
|
+
metrics: [],
|
|
165
|
+
refs: [],
|
|
166
|
+
tests: [],
|
|
167
|
+
releaseNote: undefined,
|
|
168
|
+
releaseAs: [],
|
|
169
|
+
breaking: undefined,
|
|
170
|
+
}
|
|
165
171
|
for (const line of lines) {
|
|
166
|
-
if (DROPPED_TRAILERS.test(line))
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
172
|
+
if (DROPPED_TRAILERS.test(line)) {
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
const m = line.match(KNOWN_TRAILERS)
|
|
176
|
+
if (!m) {
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
const key = m[1].toLowerCase().replace(/[ -]/g, "")
|
|
180
|
+
const value = m[2].trim()
|
|
181
|
+
if (key === "measured") {
|
|
182
|
+
out.metrics.push(value)
|
|
183
|
+
} else if (key === "refs") {
|
|
184
|
+
out.refs.push(
|
|
185
|
+
...value
|
|
186
|
+
.split(",")
|
|
187
|
+
.map((s) => s.trim())
|
|
188
|
+
.filter(Boolean)
|
|
189
|
+
)
|
|
190
|
+
} else if (key === "tests") {
|
|
191
|
+
out.tests.push(
|
|
192
|
+
...value
|
|
193
|
+
.split(",")
|
|
194
|
+
.map((s) => s.trim())
|
|
195
|
+
.filter(Boolean)
|
|
196
|
+
)
|
|
197
|
+
} else if (key === "releasenote") {
|
|
198
|
+
out.releaseNote = value
|
|
199
|
+
} else if (key === "releaseas") {
|
|
200
|
+
out.releaseAs.push(value)
|
|
201
|
+
} else if (key === "breakingchange") {
|
|
202
|
+
out.breaking = value
|
|
203
|
+
}
|
|
177
204
|
}
|
|
178
|
-
return out
|
|
205
|
+
return out
|
|
179
206
|
}
|
|
180
207
|
|
|
181
208
|
/** `type(scope)!: subject`, or undefined when the subject is not conventional. */
|
|
182
|
-
|
|
183
|
-
const m = subject.match(/^([a-z]+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/)
|
|
184
|
-
if (!m)
|
|
185
|
-
|
|
209
|
+
const parseSubject = (subject) => {
|
|
210
|
+
const m = subject.match(/^([a-z]+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/)
|
|
211
|
+
if (!m) {
|
|
212
|
+
return undefined
|
|
213
|
+
}
|
|
214
|
+
return { type: m[1], scope: m[2], bang: Boolean(m[3]), title: m[4].trim() }
|
|
186
215
|
}
|
|
187
216
|
|
|
188
217
|
/** A stable, human-readable anchor. The website links entries by this. */
|
|
189
|
-
|
|
218
|
+
const slug = (title, taken) => {
|
|
190
219
|
const base =
|
|
191
220
|
title
|
|
192
221
|
.toLowerCase()
|
|
@@ -195,11 +224,13 @@ function slug(title, taken) {
|
|
|
195
224
|
.replace(/^-|-$/g, "")
|
|
196
225
|
.split("-")
|
|
197
226
|
.slice(0, 8)
|
|
198
|
-
.join("-") || "entry"
|
|
199
|
-
let id = base
|
|
200
|
-
for (let n = 2; taken.has(id); n += 1)
|
|
201
|
-
|
|
202
|
-
|
|
227
|
+
.join("-") || "entry"
|
|
228
|
+
let id = base
|
|
229
|
+
for (let n = 2; taken.has(id); n += 1) {
|
|
230
|
+
id = `${base}-${n}`
|
|
231
|
+
}
|
|
232
|
+
taken.add(id)
|
|
233
|
+
return id
|
|
203
234
|
}
|
|
204
235
|
|
|
205
236
|
/**
|
|
@@ -218,35 +249,41 @@ function slug(title, taken) {
|
|
|
218
249
|
* someone asked for is not lost because the commit that asked was the
|
|
219
250
|
* work-in-progress half of a merge.
|
|
220
251
|
*/
|
|
221
|
-
|
|
222
|
-
const range =
|
|
252
|
+
const collect = (fromRef, toRef, withUnconventional = false) => {
|
|
253
|
+
const range = fromRef ? `${fromRef}..${toRef}` : toRef
|
|
223
254
|
// \x00 between fields and \x1e between records: a commit body contains
|
|
224
255
|
// newlines and may contain anything else, so the separators must be bytes
|
|
225
256
|
// that cannot appear in one.
|
|
226
|
-
const raw = git(["log", "--no-merges", "--reverse",
|
|
227
|
-
const taken = new Set()
|
|
228
|
-
const entries = []
|
|
229
|
-
const skipped = []
|
|
230
|
-
const releaseAs = []
|
|
257
|
+
const raw = git(["log", "--no-merges", "--reverse", "--format=%H%x00%an%x00%aI%x00%s%x00%b%x1e", range])
|
|
258
|
+
const taken = new Set()
|
|
259
|
+
const entries = []
|
|
260
|
+
const skipped = []
|
|
261
|
+
const releaseAs = []
|
|
231
262
|
|
|
232
263
|
for (const record of raw.split("\x1e")) {
|
|
233
|
-
const text = record.replace(/^\n/, "")
|
|
234
|
-
if (!text.trim())
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
264
|
+
const text = record.replace(/^\n/, "")
|
|
265
|
+
if (!text.trim()) {
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
const [sha, author, date, subject, body = ""] = text.split("\x00")
|
|
269
|
+
const { prose: rawProse, trailers } = splitTrailers(body)
|
|
270
|
+
const t = parseTrailers(trailers)
|
|
271
|
+
for (const version of t.releaseAs) {
|
|
272
|
+
releaseAs.push({ version, commit: `${sha.slice(0, 7)} ${subject}` })
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const parsed = parseSubject(subject)
|
|
276
|
+
const classified = parsed !== undefined && CONVENTIONAL_TYPES.includes(parsed.type)
|
|
242
277
|
if (!classified) {
|
|
243
|
-
skipped.push(`${sha.slice(0, 7)} ${subject}`)
|
|
244
|
-
if (!
|
|
278
|
+
skipped.push(`${sha.slice(0, 7)} ${subject}`)
|
|
279
|
+
if (!withUnconventional) {
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
245
282
|
}
|
|
246
283
|
|
|
247
|
-
const prose = rawProse.replace(SQUASH_RULE, "")
|
|
248
|
-
const title = parsed ? parsed.title : subject
|
|
249
|
-
const pr = /\(#(\d+)\)\s*$/.exec(subject)?.[1]
|
|
284
|
+
const prose = rawProse.replace(SQUASH_RULE, "")
|
|
285
|
+
const title = parsed ? parsed.title : subject
|
|
286
|
+
const pr = /\(#(\d+)\)\s*$/.exec(subject)?.[1]
|
|
250
287
|
|
|
251
288
|
entries.push({
|
|
252
289
|
id: slug(title, taken),
|
|
@@ -263,26 +300,35 @@ function collect(from, to, includeUnconventional = false) {
|
|
|
263
300
|
pr: pr ? Number(pr) : undefined,
|
|
264
301
|
author,
|
|
265
302
|
date: date.slice(0, 10),
|
|
266
|
-
})
|
|
303
|
+
})
|
|
267
304
|
}
|
|
268
|
-
return { entries, skipped, releaseAs }
|
|
305
|
+
return { entries, skipped, releaseAs }
|
|
269
306
|
}
|
|
270
307
|
|
|
271
|
-
|
|
272
|
-
const out = []
|
|
273
|
-
if (
|
|
308
|
+
const renderMarkdown = (releaseData) => {
|
|
309
|
+
const out = []
|
|
310
|
+
if (releaseData.intro) {
|
|
311
|
+
out.push(releaseData.intro.trim(), "")
|
|
312
|
+
}
|
|
274
313
|
|
|
275
|
-
const group = (predicate) =>
|
|
314
|
+
const group = (predicate) => releaseData.entries.filter(predicate).map(renderEntry)
|
|
276
315
|
|
|
277
|
-
const breaking = group((e) => e.breaking)
|
|
278
|
-
if (breaking.length > 0)
|
|
316
|
+
const breaking = group((e) => e.breaking)
|
|
317
|
+
if (breaking.length > 0) {
|
|
318
|
+
out.push("### Breaking changes", "", ...breaking, "")
|
|
319
|
+
}
|
|
279
320
|
|
|
280
321
|
for (const [type, heading] of TYPES) {
|
|
281
|
-
const lines = group((e) => e.type === type && !e.breaking)
|
|
282
|
-
if (lines.length === 0)
|
|
283
|
-
|
|
322
|
+
const lines = group((e) => e.type === type && !e.breaking)
|
|
323
|
+
if (lines.length === 0) {
|
|
324
|
+
continue
|
|
325
|
+
}
|
|
326
|
+
out.push(`### ${heading}`, "", ...lines, "")
|
|
284
327
|
}
|
|
285
|
-
return `${out
|
|
328
|
+
return `${out
|
|
329
|
+
.join("\n")
|
|
330
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
331
|
+
.trim()}\n`
|
|
286
332
|
}
|
|
287
333
|
|
|
288
334
|
/**
|
|
@@ -290,11 +336,15 @@ function renderMarkdown(release) {
|
|
|
290
336
|
* commit when it landed without one. Every line carries one, so the index
|
|
291
337
|
* always leads somewhere.
|
|
292
338
|
*/
|
|
293
|
-
|
|
294
|
-
if (e.pr)
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
339
|
+
const entryLink = (e) => {
|
|
340
|
+
if (e.pr) {
|
|
341
|
+
return REPO_URL ? `[#${e.pr}](${REPO_URL}/pull/${e.pr})` : `#${e.pr}`
|
|
342
|
+
}
|
|
343
|
+
const sha = e.commits[0]
|
|
344
|
+
if (!sha) {
|
|
345
|
+
return undefined
|
|
346
|
+
}
|
|
347
|
+
return REPO_URL ? `[\`${sha}\`](${REPO_URL}/commit/${sha})` : `\`${sha}\``
|
|
298
348
|
}
|
|
299
349
|
|
|
300
350
|
/**
|
|
@@ -303,10 +353,10 @@ function entryLink(e) {
|
|
|
303
353
|
* title are joined and the first letter is raised -- unless the title starts
|
|
304
354
|
* with `code`, which keeps its backtick and its case.
|
|
305
355
|
*/
|
|
306
|
-
|
|
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})` : ""}
|
|
356
|
+
const renderEntry = (e) => {
|
|
357
|
+
const title = e.title.startsWith("`") ? e.title : e.title.charAt(0).toUpperCase() + e.title.slice(1)
|
|
358
|
+
const link = entryLink(e)
|
|
359
|
+
return `- ${e.scope ? `${e.scope}: ` : ""}${title}${link ? ` (${link})` : ""}`
|
|
310
360
|
}
|
|
311
361
|
|
|
312
362
|
/**
|
|
@@ -319,29 +369,33 @@ function renderEntry(e) {
|
|
|
319
369
|
* same branch is ordinary semver -- a break moves the major, a `feat` the
|
|
320
370
|
* minor, anything else the patch -- so nothing changes here when 1.0 is cut.
|
|
321
371
|
*/
|
|
322
|
-
|
|
372
|
+
const impliedVersion = (current, commits, previousTag) => {
|
|
323
373
|
// The first release is 0.1.0 whatever the commits say. Every commit before
|
|
324
374
|
// the convention existed is typed `other`, so a type-driven bump would read
|
|
325
375
|
// the entire history as a patch and ship 0.0.1 — a number that would claim
|
|
326
376
|
// the compiler is a bug-fix on nothing. 0.1.0 is also what the seed policy
|
|
327
377
|
// already names as the base case (docs/wp12-release.md, "The bootstrap seed").
|
|
328
|
-
if (!previousTag)
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
378
|
+
if (!previousTag) {
|
|
379
|
+
return [0, 1, 0]
|
|
380
|
+
}
|
|
381
|
+
const [major, minor, patch] = current.split(".").map(Number)
|
|
382
|
+
if (commits.some((e) => e.breaking)) {
|
|
383
|
+
return major === 0 ? [0, minor + 1, 0] : [major + 1, 0, 0]
|
|
384
|
+
}
|
|
385
|
+
if (commits.some((e) => e.type === "feat")) {
|
|
386
|
+
return [major, minor + 1, 0]
|
|
387
|
+
}
|
|
388
|
+
return [major, minor, patch + 1]
|
|
333
389
|
}
|
|
334
390
|
|
|
335
391
|
/** `X.Y.Z` as three numbers, or undefined when it is not one. No `v`, no pre-release, no leading zeros. */
|
|
336
|
-
|
|
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
|
|
392
|
+
const parseVersion = (text) => {
|
|
393
|
+
const m = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(text)
|
|
394
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined
|
|
339
395
|
}
|
|
340
396
|
|
|
341
397
|
/** Negative, zero or positive as `a` is below, at or above `b`. */
|
|
342
|
-
|
|
343
|
-
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
344
|
-
}
|
|
398
|
+
const compareVersions = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]
|
|
345
399
|
|
|
346
400
|
/**
|
|
347
401
|
* The next version: the one the commits imply, unless a `Release-As: X.Y.Z`
|
|
@@ -357,133 +411,145 @@ function compareVersions(a, b) {
|
|
|
357
411
|
* release PR proposing some other number without saying so would be worse than
|
|
358
412
|
* the train stopping.
|
|
359
413
|
*/
|
|
360
|
-
|
|
361
|
-
const implied = impliedVersion(current,
|
|
362
|
-
let chosen
|
|
363
|
-
const byCommit = new Map()
|
|
364
|
-
for (const request of
|
|
365
|
-
const parsed = parseVersion(request.version)
|
|
414
|
+
const nextVersion = (current, commits, previousTag, releaseAsTrailers = []) => {
|
|
415
|
+
const implied = impliedVersion(current, commits, previousTag)
|
|
416
|
+
let chosen
|
|
417
|
+
const byCommit = new Map()
|
|
418
|
+
for (const request of releaseAsTrailers) {
|
|
419
|
+
const parsed = parseVersion(request.version)
|
|
366
420
|
if (!parsed) {
|
|
367
421
|
return {
|
|
368
422
|
error: `malformed trailer \`Release-As: ${request.version}\` on ${request.commit}; the value is a version, X.Y.Z`,
|
|
369
|
-
}
|
|
423
|
+
}
|
|
370
424
|
}
|
|
371
|
-
const earlier = byCommit.get(request.commit)
|
|
425
|
+
const earlier = byCommit.get(request.commit)
|
|
372
426
|
if (earlier !== undefined && earlier !== request.version) {
|
|
373
427
|
return {
|
|
374
428
|
error: `two trailers on ${request.commit} disagree: \`Release-As: ${earlier}\` and \`Release-As: ${request.version}\`; a commit asks for one version`,
|
|
375
|
-
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
byCommit.set(request.commit, request.version)
|
|
432
|
+
if (!chosen || compareVersions(parsed, chosen.parsed) > 0) {
|
|
433
|
+
chosen = { ...request, parsed }
|
|
376
434
|
}
|
|
377
|
-
byCommit.set(request.commit, request.version);
|
|
378
|
-
if (!chosen || compareVersions(parsed, chosen.parsed) > 0) chosen = { ...request, parsed };
|
|
379
435
|
}
|
|
380
|
-
if (!chosen)
|
|
436
|
+
if (!chosen) {
|
|
437
|
+
return { version: implied.join(".") }
|
|
438
|
+
}
|
|
381
439
|
if (compareVersions(chosen.parsed, implied) < 0) {
|
|
382
440
|
return {
|
|
383
441
|
error:
|
|
384
442
|
`trailer \`Release-As: ${chosen.version}\` on ${chosen.commit} is below ${implied.join(".")}, ` +
|
|
385
443
|
`the version the commits since ${previousTag ?? "the start of the history"} imply; ` +
|
|
386
444
|
"a trailer may raise the next version, never lower it",
|
|
387
|
-
}
|
|
445
|
+
}
|
|
388
446
|
}
|
|
389
|
-
return { version: chosen.version }
|
|
447
|
+
return { version: chosen.version }
|
|
390
448
|
}
|
|
391
449
|
|
|
392
450
|
// ---- CLI ---------------------------------------------------------------------------------------
|
|
393
451
|
|
|
394
|
-
const argv = process.argv.slice(2)
|
|
395
|
-
|
|
396
|
-
const i = argv.indexOf(`--${name}`)
|
|
397
|
-
return i === -1 ? fallback : argv[i + 1]
|
|
452
|
+
const argv = process.argv.slice(2)
|
|
453
|
+
const flag = (name, fallback) => {
|
|
454
|
+
const i = argv.indexOf(`--${name}`)
|
|
455
|
+
return i === -1 ? fallback : argv[i + 1]
|
|
398
456
|
}
|
|
399
457
|
|
|
400
458
|
// Validating one subject line. This lives here rather than in the workflow so
|
|
401
459
|
// that what CI enforces and what the generator parses are the same rule: a
|
|
402
460
|
// title the check accepts and the generator files under "Uncategorised" would
|
|
403
461
|
// be worse than no check at all.
|
|
404
|
-
const checkSubject = flag("check-subject", undefined)
|
|
462
|
+
const checkSubject = flag("check-subject", undefined)
|
|
405
463
|
if (checkSubject !== undefined) {
|
|
406
|
-
const parsed = parseSubject(checkSubject)
|
|
464
|
+
const parsed = parseSubject(checkSubject)
|
|
407
465
|
if (!parsed) {
|
|
408
|
-
console.error(`not a conventional commit subject:\n\n ${checkSubject}\n`)
|
|
409
|
-
console.error(
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
console.error(
|
|
413
|
-
console.error(
|
|
414
|
-
|
|
466
|
+
console.error(`not a conventional commit subject:\n\n ${checkSubject}\n`)
|
|
467
|
+
console.error(
|
|
468
|
+
`Expected \`type(scope): subject\`, where type is one of: ${CONVENTIONAL_TYPES.join(", ")}.`
|
|
469
|
+
)
|
|
470
|
+
console.error("A `!` after the type or scope marks a breaking change.\n")
|
|
471
|
+
console.error("Examples:\n feat(checker): accept non-generic type aliases")
|
|
472
|
+
console.error(" perf(codegen)!: hoist the array header out of element loops")
|
|
473
|
+
console.error("\nThe subject becomes the heading in the release notes, so write it for a reader.")
|
|
474
|
+
process.exit(1)
|
|
415
475
|
}
|
|
416
476
|
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)
|
|
477
|
+
console.error(`unknown type \`${parsed.type}\` in:\n\n ${checkSubject}\n`)
|
|
478
|
+
console.error(`Use one of: ${CONVENTIONAL_TYPES.join(", ")}.`)
|
|
479
|
+
process.exit(1)
|
|
420
480
|
}
|
|
421
481
|
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)
|
|
482
|
+
console.error(`subject ends with a full stop:\n\n ${checkSubject}\n`)
|
|
483
|
+
console.error("It is a heading, not a sentence.")
|
|
484
|
+
process.exit(1)
|
|
425
485
|
}
|
|
426
|
-
console.log(
|
|
427
|
-
|
|
486
|
+
console.log(
|
|
487
|
+
`ok: ${parsed.type}${parsed.scope ? `(${parsed.scope})` : ""}${parsed.bang ? "!" : ""} — ${parsed.title}`
|
|
488
|
+
)
|
|
489
|
+
process.exit(0)
|
|
428
490
|
}
|
|
429
491
|
|
|
430
492
|
// Rendering an already-written release: the release job must publish exactly
|
|
431
493
|
// the file the release PR was reviewed with, not a fresh walk of the log that
|
|
432
494
|
// could differ by a commit.
|
|
433
|
-
const fromJson = flag("from-json", undefined)
|
|
495
|
+
const fromJson = flag("from-json", undefined)
|
|
434
496
|
if (fromJson) {
|
|
435
|
-
const stored = JSON.parse(fs.readFileSync(fromJson, "utf8"))
|
|
436
|
-
process.stdout.write(renderMarkdown(stored))
|
|
437
|
-
process.exit(0)
|
|
497
|
+
const stored = JSON.parse(fs.readFileSync(fromJson, "utf8"))
|
|
498
|
+
process.stdout.write(renderMarkdown(stored))
|
|
499
|
+
process.exit(0)
|
|
438
500
|
}
|
|
439
501
|
|
|
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")
|
|
502
|
+
const pkgVersion = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version
|
|
503
|
+
const version = flag("version", pkgVersion)
|
|
504
|
+
const from = flag("from", lastTag())
|
|
505
|
+
const to = flag("to", "HEAD")
|
|
506
|
+
const stdoutKind = flag("stdout", "md")
|
|
507
|
+
const write = argv.includes("--write")
|
|
508
|
+
const includeUnconventional = argv.includes("--include-unconventional")
|
|
447
509
|
|
|
448
|
-
const { entries, skipped, releaseAs } = collect(from, to, includeUnconventional)
|
|
510
|
+
const { entries, skipped, releaseAs } = collect(from, to, includeUnconventional)
|
|
449
511
|
|
|
450
512
|
// `--next` answers the version and nothing else, for the release PR to name
|
|
451
513
|
// itself and to bump package.json with. Everything release-pr.yml writes reads
|
|
452
514
|
// this one line, so a `Release-As:` trailer reaches all of it here.
|
|
453
515
|
if (argv.includes("--next")) {
|
|
454
|
-
const next = nextVersion(pkgVersion, entries, from, releaseAs)
|
|
516
|
+
const next = nextVersion(pkgVersion, entries, from, releaseAs)
|
|
455
517
|
if (next.error) {
|
|
456
|
-
console.error(`changelog-gen: ${next.error}`)
|
|
457
|
-
process.exit(1)
|
|
518
|
+
console.error(`changelog-gen: ${next.error}`)
|
|
519
|
+
process.exit(1)
|
|
458
520
|
}
|
|
459
|
-
process.stdout.write(`${next.version}\n`)
|
|
460
|
-
process.exit(0)
|
|
521
|
+
process.stdout.write(`${next.version}\n`)
|
|
522
|
+
process.exit(0)
|
|
461
523
|
}
|
|
462
524
|
|
|
463
525
|
// Skipped is not silent: the subjects are named, because a change that belongs
|
|
464
526
|
// in the notes and was written without a type is a defect to fix in the
|
|
465
527
|
// commit, not something for a reader of the notes to discover missing.
|
|
466
528
|
if (skipped.length > 0 && !includeUnconventional) {
|
|
467
|
-
const shown = skipped.slice(0, 20)
|
|
529
|
+
const shown = skipped.slice(0, 20)
|
|
468
530
|
console.error(
|
|
469
531
|
`changelog-gen: skipped ${skipped.length} commit${skipped.length === 1 ? "" : "s"} whose subject is not a conventional commit:`
|
|
470
|
-
)
|
|
471
|
-
for (const line of shown)
|
|
472
|
-
|
|
473
|
-
|
|
532
|
+
)
|
|
533
|
+
for (const line of shown) {
|
|
534
|
+
console.error(` ${line}`)
|
|
535
|
+
}
|
|
536
|
+
if (skipped.length > shown.length) {
|
|
537
|
+
console.error(` ... and ${skipped.length - shown.length} more`)
|
|
538
|
+
}
|
|
539
|
+
console.error('Pass --include-unconventional to file them under "Uncategorised" instead.')
|
|
474
540
|
}
|
|
475
541
|
|
|
476
542
|
if (entries.length === 0) {
|
|
477
|
-
const range = from ? `${from}..${to}` : to
|
|
543
|
+
const range = from ? `${from}..${to}` : to
|
|
478
544
|
console.error(
|
|
479
545
|
skipped.length > 0
|
|
480
546
|
? `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
547
|
: `changelog-gen: no commits in ${range}; a release with no changes is a mistake, not an empty section`
|
|
482
|
-
)
|
|
483
|
-
process.exit(1)
|
|
548
|
+
)
|
|
549
|
+
process.exit(1)
|
|
484
550
|
}
|
|
485
551
|
|
|
486
|
-
const introPath = path.join(root, "changelog", `${version}.intro.md`)
|
|
552
|
+
const introPath = path.join(root, "changelog", `${version}.intro.md`)
|
|
487
553
|
const release = {
|
|
488
554
|
version,
|
|
489
555
|
date: new Date().toISOString().slice(0, 10),
|
|
@@ -491,38 +557,40 @@ const release = {
|
|
|
491
557
|
range: { from: from ?? null, to: git(["rev-parse", "--short", to]).trim() },
|
|
492
558
|
intro: fs.existsSync(introPath) ? fs.readFileSync(introPath, "utf8").trim() : undefined,
|
|
493
559
|
entries,
|
|
494
|
-
}
|
|
560
|
+
}
|
|
495
561
|
|
|
496
|
-
const jsonDir = path.join(root, "changelog")
|
|
497
|
-
const jsonPath = path.join(jsonDir, `${version}.json`)
|
|
498
|
-
const markdown = renderMarkdown(release)
|
|
562
|
+
const jsonDir = path.join(root, "changelog")
|
|
563
|
+
const jsonPath = path.join(jsonDir, `${version}.json`)
|
|
564
|
+
const markdown = renderMarkdown(release)
|
|
499
565
|
|
|
500
566
|
if (write) {
|
|
501
|
-
fs.mkdirSync(jsonDir, { recursive: true })
|
|
502
|
-
fs.writeFileSync(jsonPath, `${JSON.stringify(release, null, 2)}\n`)
|
|
567
|
+
fs.mkdirSync(jsonDir, { recursive: true })
|
|
568
|
+
fs.writeFileSync(jsonPath, `${JSON.stringify(release, null, 2)}\n`)
|
|
503
569
|
|
|
504
570
|
// Splice the rendered section into CHANGELOG.md under `## [Unreleased]`,
|
|
505
571
|
// 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]"
|
|
572
|
+
const changelogPath = path.join(root, "CHANGELOG.md")
|
|
573
|
+
const current = fs.readFileSync(changelogPath, "utf8")
|
|
574
|
+
const marker = "## [Unreleased]"
|
|
509
575
|
if (!current.includes(marker)) {
|
|
510
|
-
console.error(`changelog-gen: CHANGELOG.md has no \`${marker}\` heading to write under`)
|
|
511
|
-
process.exit(1)
|
|
576
|
+
console.error(`changelog-gen: CHANGELOG.md has no \`${marker}\` heading to write under`)
|
|
577
|
+
process.exit(1)
|
|
578
|
+
}
|
|
579
|
+
const section = `${marker}\n\n## [${version}] - ${release.date}\n\n${markdown}`
|
|
580
|
+
let next = current.replace(marker, section)
|
|
581
|
+
const link = `[${version}]: https://github.com/amritk/nish/releases/tag/v${version}`
|
|
582
|
+
if (!next.includes(link)) {
|
|
583
|
+
next = `${next.trimEnd()}\n${link}\n`
|
|
512
584
|
}
|
|
513
|
-
|
|
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);
|
|
585
|
+
fs.writeFileSync(changelogPath, next)
|
|
518
586
|
|
|
519
|
-
console.error(`changelog-gen: wrote changelog/${version}.json and the CHANGELOG.md section`)
|
|
587
|
+
console.error(`changelog-gen: wrote changelog/${version}.json and the CHANGELOG.md section`)
|
|
520
588
|
}
|
|
521
589
|
|
|
522
590
|
if (includeUnconventional && skipped.length > 0) {
|
|
523
591
|
console.error(
|
|
524
592
|
`changelog-gen: ${skipped.length} of ${entries.length} commits are not conventional and landed under "Uncategorised"`
|
|
525
|
-
)
|
|
593
|
+
)
|
|
526
594
|
}
|
|
527
595
|
|
|
528
|
-
process.stdout.write(stdoutKind === "json" ? `${JSON.stringify(release, null, 2)}\n` : markdown)
|
|
596
|
+
process.stdout.write(stdoutKind === "json" ? `${JSON.stringify(release, null, 2)}\n` : markdown)
|