@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.
- package/CHANGELOG.md +12 -0
- package/bin/exceptd.js +26 -25
- package/data/_indexes/_meta.json +2 -2
- package/lib/flag-suggest.js +2 -2
- package/manifest.json +44 -44
- package/orchestrator/index.js +3 -3
- package/package.json +1 -1
- package/sbom.cdx.json +33 -18
- package/scripts/release.js +593 -0
|
@@ -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
|
+
}
|