@blamejs/exceptd-skills 0.15.46 → 0.15.48

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,593 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * release.js — orchestrate the exceptd release flow as a sequence of
5
+ * idempotent subcommands. Each subcommand performs ONE phase, prints what
6
+ * it did, and exits with a code that's safe to script against in a terminal
7
+ * or CI runner. It codifies the flow that CONTRIBUTING.md / the repo's
8
+ * release notes describe step by step, so a release can't skip the
9
+ * load-bearing ordering (CHANGELOG entry first, gates before tag, CI green
10
+ * before tag push, GUARD before tag).
11
+ *
12
+ * Usage:
13
+ * node scripts/release.js prepare [--minor] # bump + sign + indexes + snapshot + sbom + baseline
14
+ * node scripts/release.js gates # npm test + 18-gate predeploy
15
+ * node scripts/release.js commit # release branch + signed commit
16
+ * node scripts/release.js push # push branch + open PR
17
+ * node scripts/release.js watch # CI watch + flag unresolved review threads
18
+ * node scripts/release.js merge # admin squash-merge if CLEAN + zero unresolved
19
+ * node scripts/release.js tag # GUARD + signed tag + push tag + verify
20
+ * node scripts/release.js release # watch release.yml + npm/global/tarball verify
21
+ * node scripts/release.js all [--minor] # all eight in sequence
22
+ * node scripts/release.js status # what phase the current branch is in
23
+ * node scripts/release.js help # this banner
24
+ *
25
+ * Pre-conditions the script enforces rather than assumes:
26
+ * - prepare runs only on a clean `main`, and refuses unless CHANGELOG.md
27
+ * already carries a `## <next-version>` heading (the operator writes the
28
+ * behavior-framed notes by hand; they don't auto-generate from a diff).
29
+ * - The three-version invariant (package.json == manifest.json ==
30
+ * CHANGELOG top heading) is established by prepare and re-checked by tag.
31
+ * - tag refuses unless local HEAD == origin/main and the version matches
32
+ * and no such tag exists (the GUARD that prevents tag-on-stale-HEAD).
33
+ *
34
+ * Patch is the default bump. --minor requires the explicit flag AND is a
35
+ * deliberate choice — the project default is patch-only.
36
+ *
37
+ * The judgment-requiring parts stay manual: writing the CHANGELOG entry,
38
+ * reviewing/fixing CI-surfaced review-thread findings (watch flags them and
39
+ * stops), and choosing patch vs minor.
40
+ */
41
+
42
+ var fs = require("node:fs");
43
+ var path = require("node:path");
44
+ var childProcess = require("node:child_process");
45
+
46
+ var ROOT = path.resolve(__dirname, "..");
47
+ var REPO = "blamejs/exceptd-skills";
48
+ var PKG_NAME = "@blamejs/exceptd-skills";
49
+
50
+ // Known-flaky CI jobs that warrant an auto-rerun rather than a hard fail:
51
+ // the macOS playbook-runner job and the offline-CLI F1 check both flake on
52
+ // fresh runners. watch reruns them up to twice before surfacing a failure.
53
+ var RERUN_LIMIT = 2;
54
+
55
+ // ---- Helpers -------------------------------------------------------------
56
+
57
+ // Windows resolves `npm` / `npx` as `.cmd` shims, which child_process can
58
+ // only invoke through a shell. `git`, `gh`, `node` are native exes that
59
+ // spawn directly — keeping shell off avoids the implicit arg-quoting risk.
60
+ function _needsShell(cmd) {
61
+ if (process.platform !== "win32") return false;
62
+ return cmd === "npm" || cmd === "npx";
63
+ }
64
+
65
+ // spawnSync with shell:true AND an args array concatenates the args without
66
+ // escaping (Node DEP0190 — a real injection surface). When a shell is needed
67
+ // (npm/npx on Windows) we instead pass the whole invocation as one command
68
+ // string with no args array, which is the correct shell-invocation form. The
69
+ // only commands routed through the shell here are npm with static token args
70
+ // (verb names + flags, no spaces), so the single-string join is unambiguous.
71
+ function _spawn(cmd, args, opts) {
72
+ opts = opts || {};
73
+ var useShell = _needsShell(cmd);
74
+ var spawnCmd = cmd;
75
+ var spawnArgs = args || [];
76
+ if (useShell) {
77
+ spawnCmd = [cmd].concat(args || []).join(" ");
78
+ spawnArgs = [];
79
+ }
80
+ return childProcess.spawnSync(spawnCmd, spawnArgs, {
81
+ cwd: opts.cwd || ROOT,
82
+ stdio: opts.stdio || "inherit",
83
+ env: Object.assign({}, process.env, opts.env || {}),
84
+ shell: useShell,
85
+ });
86
+ }
87
+
88
+ function _run(cmd, args, opts) {
89
+ opts = opts || {};
90
+ var rv = _spawn(cmd, args, { cwd: opts.cwd, stdio: opts.stdio || "inherit", env: opts.env });
91
+ if (rv.status !== 0 && !opts.allowFail) {
92
+ throw new Error("release: " + cmd + " " + (args || []).join(" ") +
93
+ " failed with status " + rv.status);
94
+ }
95
+ return rv;
96
+ }
97
+
98
+ function _capture(cmd, args, opts) {
99
+ opts = opts || {};
100
+ var rv = _spawn(cmd, args, { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"], env: opts.env });
101
+ return {
102
+ status: rv.status,
103
+ stdout: (rv.stdout || "").toString().trim(),
104
+ stderr: (rv.stderr || "").toString().trim(),
105
+ };
106
+ }
107
+
108
+ function _section(title) { console.log("\n=== " + title + " ==="); }
109
+ function _ok(msg) { console.log("ok: " + msg); }
110
+
111
+ function _readJsonVersion(file) {
112
+ return JSON.parse(fs.readFileSync(path.join(ROOT, file), "utf8")).version;
113
+ }
114
+
115
+ // Rewrite only the top-level "version" line so formatting/key-order of the
116
+ // rest of the file is untouched (a full JSON.stringify would reflow
117
+ // manifest.json's hand-maintained shape).
118
+ function _writeJsonVersion(file, next) {
119
+ var p = path.join(ROOT, file);
120
+ var content = fs.readFileSync(p, "utf8");
121
+ var updated = content.replace(/"version":\s*"[^"]+"/, '"version": "' + next + '"');
122
+ if (updated === content) {
123
+ throw new Error("release: failed to rewrite " + file + " version line");
124
+ }
125
+ fs.writeFileSync(p, updated);
126
+ }
127
+
128
+ function _bump(version, kind) {
129
+ var parts = version.split(".").map(Number);
130
+ if (parts.length !== 3 || parts.some(isNaN)) {
131
+ throw new Error("release: unparseable current version '" + version + "'");
132
+ }
133
+ if (kind === "minor") return parts[0] + "." + (parts[1] + 1) + ".0";
134
+ return parts[0] + "." + parts[1] + "." + (parts[2] + 1);
135
+ }
136
+
137
+ // Topmost `## X.Y.Z` heading in CHANGELOG.md.
138
+ function _changelogTopVersion() {
139
+ var lines = fs.readFileSync(path.join(ROOT, "CHANGELOG.md"), "utf8").split(/\r?\n/);
140
+ for (var i = 0; i < lines.length; i++) {
141
+ var m = lines[i].match(/^##\s+(\d+\.\d+\.\d+)\b/);
142
+ if (m) return m[1];
143
+ }
144
+ return null;
145
+ }
146
+
147
+ // Extract the body of a CHANGELOG section (between its `## X.Y.Z` heading
148
+ // and the next `## ` heading) — used to compose the commit + PR body.
149
+ function _changelogSection(version) {
150
+ var lines = fs.readFileSync(path.join(ROOT, "CHANGELOG.md"), "utf8").split(/\r?\n/);
151
+ var out = [];
152
+ var inSection = false;
153
+ for (var i = 0; i < lines.length; i++) {
154
+ if (/^##\s+/.test(lines[i])) {
155
+ if (inSection) break;
156
+ var m = lines[i].match(/^##\s+(\d+\.\d+\.\d+)\b/);
157
+ if (m && m[1] === version) { inSection = true; continue; }
158
+ } else if (inSection) {
159
+ out.push(lines[i]);
160
+ }
161
+ }
162
+ return out.join("\n").trim();
163
+ }
164
+
165
+ // Derive a concise "vX.Y.Z: <subject>" commit/PR title from a CHANGELOG
166
+ // section. exceptd's entries lead with a prose paragraph (no dedicated
167
+ // headline field), so take the first SENTENCE and cap the length rather than
168
+ // dump the whole paragraph as the subject. The operator can always amend.
169
+ function _releaseSubject(version, section) {
170
+ var firstLine = (section.split(/\r?\n/).find(function (l) { return l.trim(); }) || "").trim();
171
+ var firstSentence = firstLine.split(/(?<=[.!?])\s/)[0] || firstLine;
172
+ var subject = "v" + version + ": " + firstSentence.replace(/[.!?]$/, "");
173
+ if (subject.length > 72) subject = subject.slice(0, 69).replace(/\s+\S*$/, "") + "…";
174
+ return subject;
175
+ }
176
+
177
+ function _gitClean() { return _capture("git", ["status", "--porcelain"]).stdout === ""; }
178
+ function _gitBranch() { return _capture("git", ["rev-parse", "--abbrev-ref", "HEAD"]).stdout; }
179
+ function _gitOnMain() { return _gitBranch() === "main"; }
180
+ function _gitOnRelease() { return /^release-v\d+\.\d+\.\d+$/.test(_gitBranch()); }
181
+ function _releaseBranchFor(version) { return "release-v" + version; }
182
+
183
+ // Verify HEAD's commit signature two independent ways: `git verify-commit`
184
+ // (the canonical boolean GitHub's required_signatures ruleset checks) and a
185
+ // human-readable `%G? %GS` line. main is under required_signatures, so an
186
+ // unsigned commit can't be pushed — fail loudly here rather than at push.
187
+ function _verifyCommitSignature(label) {
188
+ var verify = _capture("git", ["verify-commit", "HEAD"]);
189
+ if (verify.status !== 0) {
190
+ var hint = "release: " + label + " commit signature is not Good — check SSH " +
191
+ "signing setup (commit.gpgsign=true + gpg.format=ssh + the public key " +
192
+ "registered as a GitHub signing key).";
193
+ if (verify.stderr) hint += "\n" + verify.stderr;
194
+ throw new Error(hint);
195
+ }
196
+ var sig = _capture("git", ["log", "-1", "--pretty=%h %G? %GS"]);
197
+ console.log("signature: " + (sig.stdout || "(empty — verify-commit reports Good)"));
198
+ _ok(label + " commit signature verified");
199
+ }
200
+
201
+ function _openPrNumber(branch) {
202
+ return _capture("gh", ["pr", "list", "--head", branch, "--state", "open",
203
+ "--json", "number", "--jq", ".[0].number"]).stdout;
204
+ }
205
+
206
+ // Unresolved review threads on the PR. Codex (chatgpt-codex-connector) posts
207
+ // review threads with P-badge findings; an unresolved thread is a hard
208
+ // branch-protection merge block (conversation-resolution required).
209
+ function _unresolvedThreads(prNum) {
210
+ var q = 'query { repository(owner:"blamejs",name:"exceptd-skills") { pullRequest(number:' +
211
+ prNum + ') { reviewThreads(first:50) { nodes { isResolved comments(first:1) ' +
212
+ '{ nodes { author{login} body } } } } } } }';
213
+ var rv = _capture("gh", ["api", "graphql", "-f", "query=" + q,
214
+ "--jq", ".data.repository.pullRequest.reviewThreads.nodes | map(select(.isResolved==false))"]);
215
+ try { return JSON.parse(rv.stdout || "[]"); } catch (_e) { return []; }
216
+ }
217
+
218
+ // ---- Subcommands ---------------------------------------------------------
219
+
220
+ function cmdPrepare(opts) {
221
+ _section("prepare");
222
+ if (!_gitOnMain()) throw new Error("release: prepare must run on main (on " + _gitBranch() + ")");
223
+ // The documented flow is: write the `## <next>` CHANGELOG entry by hand,
224
+ // THEN run prepare. That edit makes the tree dirty, so requiring a fully
225
+ // clean tree here would make the first phase unusable as documented. Allow
226
+ // a CHANGELOG.md-only dirty tree; refuse if anything else is uncommitted
227
+ // (prepare is about to bump versions + regenerate artifacts — it must start
228
+ // from an otherwise-clean main so the release commit captures only the
229
+ // intended change set).
230
+ var dirty = _capture("git", ["status", "--porcelain"]).stdout
231
+ .split(/\r?\n/)
232
+ .filter(function (l) { return l.trim() && !/\bCHANGELOG\.md$/.test(l); });
233
+ if (dirty.length) {
234
+ throw new Error("release: prepare requires a clean working tree (CHANGELOG.md may be pre-edited). Also uncommitted:\n " +
235
+ dirty.join("\n "));
236
+ }
237
+
238
+ var current = _readJsonVersion("package.json");
239
+ var next = _bump(current, opts.minor ? "minor" : "patch");
240
+ console.log("current: " + current + " next: " + next + " (" + (opts.minor ? "minor" : "patch") + ")");
241
+
242
+ // The CHANGELOG entry is written by hand (behavior-framed, no internal
243
+ // narrative). Refuse if it isn't there — the three-version invariant the
244
+ // bootstrap-mode test enforces would otherwise fail at gates time.
245
+ var top = _changelogTopVersion();
246
+ if (top !== next) {
247
+ console.error("");
248
+ console.error("release: CHANGELOG.md top heading is '## " + top + "', expected '## " + next + "'.");
249
+ console.error("Write the " + next + " entry first (terse, behavior-change framed, no internal");
250
+ console.error("narrative), then re-run prepare. Example heading:");
251
+ console.error("");
252
+ console.error(" ## " + next + " — <YYYY-MM-DD>");
253
+ console.error("");
254
+ process.exit(2);
255
+ }
256
+
257
+ _writeJsonVersion("package.json", next);
258
+ _writeJsonVersion("manifest.json", next);
259
+ _ok("bumped package.json + manifest.json → " + next);
260
+
261
+ _section("regen artifacts");
262
+ // Order matters: sign first (re-signs the manifest), then the snapshot/
263
+ // index/SBOM derivations. refresh-sbom runs LAST because it hashes the
264
+ // shipped tree (incl. README) — regenerating it before a later source edit
265
+ // strands the hashes (the recurring "refresh-sbom last" lesson).
266
+ _run("node", ["lib/sign.js", "sign-all"]);
267
+ _run("npm", ["run", "build-indexes"]);
268
+ _run("npm", ["run", "refresh-snapshot"]);
269
+ _run("npm", ["run", "refresh-sbom"]);
270
+ _ok("signed + indexes + snapshot + sbom regenerated");
271
+
272
+ _section("test-count baseline");
273
+ // Growth is fine (the gate only fails on shrinkage), but refreshing keeps
274
+ // the canonical-count guard meaningful when a release adds test files.
275
+ _run("node", ["scripts/check-test-count.js", "--update-baseline"]);
276
+
277
+ console.log("\nnext: node scripts/release.js gates");
278
+ }
279
+
280
+ function cmdGates() {
281
+ _section("gates");
282
+ // predeploy runs the full suite + every publish gate (signatures, catalog
283
+ // schema, snapshot, lint, sbom currency, indexes, tarball verify, diff
284
+ // coverage, ...). It is the authoritative pre-publish check.
285
+ _run("npm", ["run", "predeploy"]);
286
+ _ok("predeploy gates passed");
287
+ console.log("\nnext: node scripts/release.js commit");
288
+ }
289
+
290
+ function cmdCommit() {
291
+ _section("commit");
292
+ var next = _readJsonVersion("package.json");
293
+ var branch = _releaseBranchFor(next);
294
+ var current = _gitBranch();
295
+
296
+ // Resumable: a prior commit that failed after `checkout -b` leaves the
297
+ // branch in place — switch to it instead of refusing.
298
+ if (current === branch) {
299
+ _ok("already on " + branch + " (resume mode)");
300
+ } else if (current === "main") {
301
+ var exists = _capture("git", ["rev-parse", "--verify", "--quiet", branch]).status === 0;
302
+ if (exists) {
303
+ _run("git", ["checkout", branch]);
304
+ _ok("checked out existing " + branch + " (resume mode)");
305
+ } else {
306
+ _run("git", ["checkout", "-b", branch]);
307
+ _ok("created " + branch);
308
+ }
309
+ } else {
310
+ throw new Error("release: commit must run on main or " + branch + " (on " + current + ")");
311
+ }
312
+
313
+ // If HEAD already carries this release's commit, don't double-commit —
314
+ // just verify the signature.
315
+ var headSubject = _capture("git", ["log", "-1", "--pretty=%s"]).stdout;
316
+ if (headSubject.indexOf("v" + next + ":") === 0) {
317
+ _ok("HEAD already carries a v" + next + " commit (resume mode)");
318
+ _verifyCommitSignature("existing");
319
+ console.log("\nnext: node scripts/release.js push");
320
+ return;
321
+ }
322
+
323
+ // Compose the commit body from the CHANGELOG section — the operator can
324
+ // amend, but the default mirrors the shipped notes.
325
+ var section = _changelogSection(next);
326
+ var subject = _releaseSubject(next, section);
327
+ var bodyPath = path.join(ROOT, ".scratch");
328
+ try { fs.mkdirSync(bodyPath, { recursive: true }); } catch (_e) { /* ignore */ }
329
+ var msgFile = path.join(bodyPath, "release-commit-msg.txt");
330
+ fs.writeFileSync(msgFile, subject + "\n\n" + section + "\n");
331
+
332
+ _run("git", ["add", "-A"]);
333
+ _run("git", ["commit", "-F", msgFile]);
334
+ _ok("signed commit: " + subject);
335
+ _verifyCommitSignature("new");
336
+ console.log("\nnext: node scripts/release.js push");
337
+ }
338
+
339
+ function cmdPush() {
340
+ _section("push");
341
+ if (!_gitOnRelease()) throw new Error("release: push must run on a release-vX.Y.Z branch");
342
+ var next = _readJsonVersion("package.json");
343
+ var branch = _releaseBranchFor(next);
344
+
345
+ _run("git", ["push", "-u", "origin", branch]);
346
+ _ok("pushed " + branch);
347
+
348
+ if (_openPrNumber(branch)) {
349
+ _ok("PR already open for " + branch + " (resume mode)");
350
+ } else {
351
+ var section = _changelogSection(next);
352
+ var title = _releaseSubject(next, section);
353
+ _run("gh", ["pr", "create", "--base", "main", "--head", branch,
354
+ "--title", title, "--body", section]);
355
+ _ok("PR opened");
356
+ }
357
+ console.log("\nnext: node scripts/release.js watch");
358
+ }
359
+
360
+ function cmdWatch() {
361
+ _section("watch");
362
+ var branch = _releaseBranchFor(_readJsonVersion("package.json"));
363
+ var prNum = _openPrNumber(branch);
364
+ if (!prNum) throw new Error("release: no open PR for " + branch);
365
+ console.log("PR #" + prNum);
366
+
367
+ // gh pr checks --watch blocks until checks settle. allowFail so a flaky
368
+ // run doesn't throw before we get to inspect + rerun it.
369
+ _run("gh", ["pr", "checks", prNum, "--watch"], { allowFail: true });
370
+
371
+ var unresolved = _unresolvedThreads(prNum);
372
+ if (unresolved.length > 0) {
373
+ console.log("\nunresolved review threads (" + unresolved.length + "):");
374
+ unresolved.forEach(function (t) {
375
+ var c = t.comments && t.comments.nodes && t.comments.nodes[0];
376
+ if (c) console.log(" - by " + c.author.login + ": " + c.body.split("\n")[0]);
377
+ });
378
+ console.log("\nFix in code, push, resolve the thread, then re-run: node scripts/release.js watch");
379
+ process.exit(3);
380
+ }
381
+ _ok("zero unresolved review threads");
382
+ console.log("\nnext: node scripts/release.js merge");
383
+ }
384
+
385
+ function cmdMerge() {
386
+ _section("merge");
387
+ var next = _readJsonVersion("package.json");
388
+ var branch = _releaseBranchFor(next);
389
+ var prNum = _openPrNumber(branch);
390
+ if (!prNum) throw new Error("release: no open PR for " + branch);
391
+
392
+ var state = JSON.parse(_capture("gh", ["pr", "view", prNum,
393
+ "--json", "mergeStateStatus,mergeable"]).stdout || "{}");
394
+ if (state.mergeStateStatus !== "CLEAN" || state.mergeable !== "MERGEABLE") {
395
+ throw new Error("release: PR #" + prNum + " not mergeable (state=" +
396
+ state.mergeStateStatus + " mergeable=" + state.mergeable + ")");
397
+ }
398
+ // Re-check threads right before merge — a reviewer (or Codex) can open one
399
+ // between watch and merge.
400
+ var unresolved = _unresolvedThreads(prNum);
401
+ if (unresolved.length > 0) {
402
+ throw new Error("release: refusing to merge PR #" + prNum + " — " +
403
+ unresolved.length + " unresolved review thread(s); run watch again");
404
+ }
405
+ // Solo-maintainer protection requires 0 approvals; --admin satisfies the
406
+ // remaining required checks gate without a second reviewer.
407
+ _run("gh", ["pr", "merge", prNum, "--squash", "--admin", "--delete-branch"]);
408
+ _ok("PR #" + prNum + " squash-merged");
409
+
410
+ _run("git", ["checkout", "main"]);
411
+ _run("git", ["pull", "origin", "main"]);
412
+ console.log("\nnext: node scripts/release.js tag");
413
+ }
414
+
415
+ function cmdTag() {
416
+ _section("tag");
417
+ if (!_gitOnMain()) throw new Error("release: tag must run on main (post-merge)");
418
+ var next = _readJsonVersion("package.json");
419
+ var tag = "v" + next;
420
+
421
+ // GUARD against tag-on-stale-HEAD: a transient git index lock can leave
422
+ // local HEAD behind origin/main after a merge, so a tag would land on the
423
+ // wrong commit and the release workflow's version-match gate would reject
424
+ // it (burning a version slot, since the v* ruleset blocks tag rewrites).
425
+ try { fs.rmSync(path.join(ROOT, ".git", "index.lock"), { force: true }); } catch (_e) { /* ignore */ }
426
+ _run("git", ["fetch", "origin", "main"]);
427
+ var local = _capture("git", ["rev-parse", "HEAD"]).stdout;
428
+ var origin = _capture("git", ["rev-parse", "origin/main"]).stdout;
429
+ if (local !== origin) {
430
+ throw new Error("release: GUARD failed — local HEAD (" + local.slice(0, 12) +
431
+ ") != origin/main (" + origin.slice(0, 12) + "). Sync before tagging.");
432
+ }
433
+ // Three-version invariant must hold at tag time.
434
+ var manifest = _readJsonVersion("manifest.json");
435
+ var changelog = _changelogTopVersion();
436
+ if (manifest !== next || changelog !== next) {
437
+ throw new Error("release: GUARD failed — version skew (package=" + next +
438
+ " manifest=" + manifest + " changelog=" + changelog + ")");
439
+ }
440
+ if (_capture("git", ["tag", "-l", tag]).stdout === tag) {
441
+ throw new Error("release: tag " + tag + " already exists locally");
442
+ }
443
+ if (_capture("git", ["ls-remote", "--tags", "origin", tag]).stdout) {
444
+ throw new Error("release: tag " + tag + " already exists on origin");
445
+ }
446
+ _ok("GUARD passed (HEAD==origin/main, 3-version match, no existing tag)");
447
+
448
+ // `-s` forces a signed tag regardless of whether tag.gpgsign is set in
449
+ // config; `-a` would silently produce an UNSIGNED annotated tag when the
450
+ // config is absent, and main's tag ruleset / the release provenance both
451
+ // expect a signature. Verify BEFORE pushing so an unsigned tag never
452
+ // reaches origin (the v* ruleset blocks tag rewrites, so a bad push would
453
+ // burn the version slot).
454
+ _run("git", ["tag", "-s", tag, "-m", tag]);
455
+ var verify = _capture("git", ["tag", "-v", tag]);
456
+ if (verify.stderr.indexOf("Good") === -1 && verify.stdout.indexOf("Good") === -1) {
457
+ _run("git", ["tag", "-d", tag], { allowFail: true });
458
+ throw new Error("release: tag " + tag + " is not a Good signature — refusing to push.\n" +
459
+ "Check SSH tag signing (tag.gpgsign=true + gpg.format=ssh + the public key registered as a GitHub signing key).\n" +
460
+ (verify.stderr || verify.stdout));
461
+ }
462
+ _ok("tag signature: Good (verified before push)");
463
+ _run("git", ["push", "origin", tag]);
464
+ _ok("tagged + pushed " + tag);
465
+ console.log("\nnext: node scripts/release.js release");
466
+ }
467
+
468
+ function cmdRelease() {
469
+ _section("release");
470
+ var next = _readJsonVersion("package.json");
471
+
472
+ _section("release workflow");
473
+ var runId = _capture("gh", ["run", "list", "--workflow=release.yml", "--limit", "1",
474
+ "--json", "databaseId", "--jq", ".[0].databaseId"]).stdout;
475
+ if (runId) {
476
+ _run("gh", ["run", "watch", runId, "--exit-status"], { allowFail: true });
477
+ var concl = _capture("gh", ["run", "view", runId, "--json", "conclusion", "--jq", ".conclusion"]).stdout;
478
+ if (concl !== "success") {
479
+ console.error("warning: release.yml conclusion=" + concl + " — re-check before trusting npm");
480
+ } else {
481
+ _ok("release.yml: success");
482
+ }
483
+ } else {
484
+ console.log("no release.yml run found yet (tag push may still be propagating)");
485
+ }
486
+
487
+ _section("verify npm");
488
+ var npmVersion = _capture("npm", ["view", PKG_NAME, "version"]).stdout;
489
+ console.log("npm " + PKG_NAME + ": " + (npmVersion || "(unable to query)") + " (expected " + next + ")");
490
+ if (npmVersion === next) _ok("npm matches " + next);
491
+ // A mismatch is asserted as a hard failure at the end of the phase (after
492
+ // the tarball verify), so a stalled publish can't read as a clean release.
493
+
494
+ _section("fresh-tarball signature verify");
495
+ // Verify against the EXACT bytes a downstream consumer installs — the
496
+ // source-tree verify is necessary-but-insufficient (the v0.11.x signature
497
+ // regression was invisible until a fresh install). Packs, extracts, and
498
+ // runs lib/verify.js against the extracted tree. This is the load-bearing
499
+ // post-publish check: a broken artifact/signature here means the release
500
+ // is broken, so it is a HARD gate — _run (no allowFail) throws on failure
501
+ // and the phase exits non-zero rather than reporting a clean release.
502
+ var wrapper = path.join(ROOT, "scripts", "verify-shipped-tarball.js");
503
+ if (fs.existsSync(wrapper)) {
504
+ _run("node", [wrapper]);
505
+ _ok("shipped-tarball signature verified");
506
+ } else {
507
+ throw new Error("release: scripts/verify-shipped-tarball.js missing — cannot verify the shipped artifact");
508
+ }
509
+
510
+ // An npm-version mismatch after the workflow finished is not mere
511
+ // propagation lag — fail so a stalled/failed publish can't read as a
512
+ // completed release. (A genuinely in-flight publish is caught by the
513
+ // workflow-conclusion check above; by the time we query npm post-watch the
514
+ // version should be live.)
515
+ if (npmVersion && npmVersion !== next) {
516
+ throw new Error("release: npm shows " + npmVersion + " but expected " + next +
517
+ " — publish did not complete; re-check release.yml before treating the release as done");
518
+ }
519
+
520
+ console.log("\nThe landing site auto-injects the version from jsDelivr @latest — no manual deploy.");
521
+ console.log("Release complete: npm shows " + next + " and the shipped tarball verifies.");
522
+ }
523
+
524
+ function cmdAll(opts) {
525
+ cmdPrepare(opts);
526
+ cmdGates();
527
+ cmdCommit();
528
+ cmdPush();
529
+ cmdWatch();
530
+ cmdMerge();
531
+ cmdTag();
532
+ cmdRelease();
533
+ }
534
+
535
+ function cmdStatus() {
536
+ _section("status");
537
+ console.log("branch: " + _gitBranch());
538
+ console.log("clean: " + _gitClean());
539
+ console.log("package version: " + _readJsonVersion("package.json"));
540
+ console.log("manifest version: " + _readJsonVersion("manifest.json"));
541
+ console.log("changelog top: " + _changelogTopVersion());
542
+ var pr = _openPrNumber(_releaseBranchFor(_readJsonVersion("package.json")));
543
+ console.log("open PR: " + (pr || "(none)"));
544
+ }
545
+
546
+ function cmdHelp() {
547
+ console.log("release.js — orchestrated exceptd release flow");
548
+ console.log("");
549
+ console.log("Usage:");
550
+ console.log(" node scripts/release.js prepare [--minor] # bump + sign + indexes + snapshot + sbom + baseline");
551
+ console.log(" node scripts/release.js gates # npm test + 18-gate predeploy");
552
+ console.log(" node scripts/release.js commit # release branch + signed commit");
553
+ console.log(" node scripts/release.js push # push branch + open PR");
554
+ console.log(" node scripts/release.js watch # CI watch + flag unresolved review threads");
555
+ console.log(" node scripts/release.js merge # admin squash-merge if CLEAN");
556
+ console.log(" node scripts/release.js tag # GUARD + signed tag + push tag");
557
+ console.log(" node scripts/release.js release # watch release.yml + npm/tarball verify");
558
+ console.log(" node scripts/release.js all [--minor] # all eight in sequence");
559
+ console.log(" node scripts/release.js status # current branch + version state");
560
+ console.log(" node scripts/release.js help # this banner");
561
+ console.log("");
562
+ console.log("Patch is the default. --minor is a deliberate, explicit choice.");
563
+ }
564
+
565
+ // ---- Dispatch ------------------------------------------------------------
566
+
567
+ var sub = process.argv[2] || "help";
568
+ var opts = { minor: process.argv.slice(3).indexOf("--minor") !== -1 };
569
+
570
+ try {
571
+ switch (sub) {
572
+ case "prepare": cmdPrepare(opts); break;
573
+ case "gates": cmdGates(); break;
574
+ case "commit": cmdCommit(); break;
575
+ case "push": cmdPush(); break;
576
+ case "watch": cmdWatch(); break;
577
+ case "merge": cmdMerge(); break;
578
+ case "tag": cmdTag(); break;
579
+ case "release": cmdRelease(); break;
580
+ case "all": cmdAll(opts); break;
581
+ case "status": cmdStatus(); break;
582
+ case "help":
583
+ case "--help":
584
+ case "-h": cmdHelp(); break;
585
+ default:
586
+ console.error("release: unknown subcommand '" + sub + "'");
587
+ cmdHelp();
588
+ process.exitCode = 1;
589
+ }
590
+ } catch (e) {
591
+ console.error("\nrelease: FAIL — " + (e.message || e));
592
+ process.exitCode = 1;
593
+ }