@botbuddy/cli 1.5.1 → 1.5.3
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/package.json +1 -1
- package/src/botbuddy-release-repair.json +1 -0
- package/src/publish-equal.mjs +154 -6
- package/src/wait-core.mjs +23 -0
- package/src/wait.mjs +4 -0
package/package.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"schema_version":1,"source_version":"1.5.1","source_identity":"1bc59a77cac3a2df9a24764a6ae8c4df3fc9e002701396f6a97dd2dfa84589c6"}
|
package/src/publish-equal.mjs
CHANGED
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Used by .github/workflows/publish-cli-package.yml when the version already
|
|
6
6
|
// exists on npm: an equivalent package is a safe idempotent skip; any drift
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// triggers a fresh release-time version allocation so the change cannot merge
|
|
8
|
+
// green while staying unpublished.
|
|
9
9
|
//
|
|
10
10
|
// The comparison excludes ONLY npm-injected manifest fields (notably gitHead,
|
|
11
11
|
// which npm stamps into the tarball's package.json from the publishing commit).
|
|
12
12
|
// Every other manifest field (bin, files, engines, …) and every shipped file
|
|
13
13
|
// is compared, so a genuine metadata change still counts as drift.
|
|
14
14
|
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
15
16
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
16
17
|
import { join, relative } from "node:path";
|
|
17
18
|
|
|
@@ -22,6 +23,7 @@ import { join, relative } from "node:path";
|
|
|
22
23
|
// included, is compared. Key ORDER is still canonicalised so incidental
|
|
23
24
|
// ordering never reads as drift.
|
|
24
25
|
const NPM_INJECTED_KEYS = [];
|
|
26
|
+
const RELEASE_REPAIR_RECEIPT = "src/botbuddy-release-repair.json";
|
|
25
27
|
|
|
26
28
|
// Deep, key-sorted canonical form so incidental key ordering never reads as a
|
|
27
29
|
// difference.
|
|
@@ -112,9 +114,62 @@ export function isPrerelease(v) {
|
|
|
112
114
|
return parseSemver(v).pre.length > 0;
|
|
113
115
|
}
|
|
114
116
|
|
|
115
|
-
|
|
117
|
+
function compareMain(a, b) {
|
|
118
|
+
for (let i = 0; i < 3; i++) {
|
|
119
|
+
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
|
|
120
|
+
}
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function incrementPatch(main) {
|
|
125
|
+
if (!Number.isSafeInteger(main[2]) || main[2] === Number.MAX_SAFE_INTEGER) {
|
|
126
|
+
throw new Error(`cannot allocate a patch version after ${main.join(".")}`);
|
|
127
|
+
}
|
|
128
|
+
return [main[0], main[1], main[2] + 1];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Allocate a release-only version once a tarball drifted from an immutable npm
|
|
132
|
+
// version. Stable releases advance from the greatest published stable version,
|
|
133
|
+
// preserving the established latest-tag ordering. Prereleases advance past the
|
|
134
|
+
// greatest published core version and retain a prerelease component, so they
|
|
135
|
+
// cannot accidentally become latest while still being greater than `next`.
|
|
136
|
+
// `unique` is a monotonic CI-run identity, not a timestamp, and is only used
|
|
137
|
+
// after a drift is confirmed.
|
|
138
|
+
export function nextUnpublishedVersion(local, publishedVersions, unique = "release") {
|
|
139
|
+
const parsedLocal = parseSemver(local);
|
|
140
|
+
if (!Array.isArray(publishedVersions)) throw new Error("published versions must be an array");
|
|
141
|
+
|
|
142
|
+
let greatestStable = parsedLocal.main;
|
|
143
|
+
let greatestCore = parsedLocal.main;
|
|
144
|
+
for (const version of publishedVersions) {
|
|
145
|
+
const parsed = parseSemver(version);
|
|
146
|
+
if (compareMain(parsed.main, greatestCore) > 0) greatestCore = parsed.main;
|
|
147
|
+
if (parsed.pre.length === 0 && compareMain(parsed.main, greatestStable) > 0) {
|
|
148
|
+
greatestStable = parsed.main;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (parsedLocal.pre.length === 0) return incrementPatch(greatestStable).join(".");
|
|
153
|
+
if (!/^[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*$/.test(unique)) {
|
|
154
|
+
throw new Error(`not a prerelease identifier: ${unique}`);
|
|
155
|
+
}
|
|
156
|
+
return `${incrementPatch(greatestCore).join(".")}-release.${unique}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Kept as the narrow public helper for the stable publish path and for
|
|
160
|
+
// straightforward unit fixtures. The workflow uses nextUnpublishedVersion so
|
|
161
|
+
// a drifted prerelease remains safely tagged as a prerelease too.
|
|
162
|
+
export function nextUnpublishedStableVersion(local, publishedVersions) {
|
|
163
|
+
parseStable(local);
|
|
164
|
+
const next = nextUnpublishedVersion(local, publishedVersions);
|
|
165
|
+
parseStable(next);
|
|
166
|
+
return next;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function normalizeManifest(json, { ignoreVersion = false } = {}) {
|
|
116
170
|
const m = JSON.parse(json);
|
|
117
171
|
for (const k of NPM_INJECTED_KEYS) delete m[k];
|
|
172
|
+
if (ignoreVersion) delete m.version;
|
|
118
173
|
return JSON.stringify(canon(m));
|
|
119
174
|
}
|
|
120
175
|
|
|
@@ -133,7 +188,7 @@ function walkRelative(root) {
|
|
|
133
188
|
}
|
|
134
189
|
|
|
135
190
|
// Compare two extracted `package/` directories. Returns { equal, reason }.
|
|
136
|
-
export function packagesEquivalent(localDir, pubDir) {
|
|
191
|
+
export function packagesEquivalent(localDir, pubDir, { ignoreVersion = false } = {}) {
|
|
137
192
|
const localFiles = walkRelative(localDir);
|
|
138
193
|
const pubFiles = walkRelative(pubDir);
|
|
139
194
|
if (localFiles.join("\n") !== pubFiles.join("\n")) {
|
|
@@ -146,7 +201,7 @@ export function packagesEquivalent(localDir, pubDir) {
|
|
|
146
201
|
const a = readFileSync(join(localDir, rel));
|
|
147
202
|
const b = readFileSync(join(pubDir, rel));
|
|
148
203
|
if (rel === "package.json") {
|
|
149
|
-
if (normalizeManifest(a.toString("utf8")) !== normalizeManifest(b.toString("utf8"))) {
|
|
204
|
+
if (normalizeManifest(a.toString("utf8"), { ignoreVersion }) !== normalizeManifest(b.toString("utf8"), { ignoreVersion })) {
|
|
150
205
|
return { equal: false, reason: "package.json differs beyond npm-injected fields (e.g. bin/files/engines)" };
|
|
151
206
|
}
|
|
152
207
|
} else if (!a.equals(b)) {
|
|
@@ -164,8 +219,47 @@ export function packagesEquivalent(localDir, pubDir) {
|
|
|
164
219
|
return { equal: true };
|
|
165
220
|
}
|
|
166
221
|
|
|
222
|
+
// A release-time repair carries a shipped receipt. It prevents an intentional
|
|
223
|
+
// restore of any historical tarball from being mistaken for an earlier repair
|
|
224
|
+
// of this exact source package. Versions and the receipt itself are excluded
|
|
225
|
+
// so the identity represents only the source package being repaired.
|
|
226
|
+
export function releaseRepairIdentity(packageDir) {
|
|
227
|
+
const hash = createHash("sha256");
|
|
228
|
+
for (const rel of walkRelative(packageDir)) {
|
|
229
|
+
if (rel === RELEASE_REPAIR_RECEIPT) continue;
|
|
230
|
+
const path = join(packageDir, rel);
|
|
231
|
+
const content = rel === "package.json"
|
|
232
|
+
? normalizeManifest(readFileSync(path, "utf8"), { ignoreVersion: true })
|
|
233
|
+
: readFileSync(path);
|
|
234
|
+
hash.update(rel);
|
|
235
|
+
hash.update("\0");
|
|
236
|
+
hash.update(String(statSync(path).mode & 0o777));
|
|
237
|
+
hash.update("\0");
|
|
238
|
+
hash.update(content);
|
|
239
|
+
hash.update("\0");
|
|
240
|
+
}
|
|
241
|
+
return hash.digest("hex");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// True only when `candidateDir` bears the receipt emitted by this workflow for
|
|
245
|
+
// the exact local source. A content-only historical match is deliberately not
|
|
246
|
+
// enough: it must still publish so the restored artifact becomes latest.
|
|
247
|
+
export function matchesReleaseRepair(localDir, candidateDir) {
|
|
248
|
+
try {
|
|
249
|
+
const receipt = JSON.parse(readFileSync(join(candidateDir, RELEASE_REPAIR_RECEIPT), "utf8"));
|
|
250
|
+
const localVersion = JSON.parse(readFileSync(join(localDir, "package.json"), "utf8")).version;
|
|
251
|
+
const sourceIdentity = releaseRepairIdentity(localDir);
|
|
252
|
+
return receipt.schema_version === 1
|
|
253
|
+
&& receipt.source_version === localVersion
|
|
254
|
+
&& receipt.source_identity === sourceIdentity
|
|
255
|
+
&& releaseRepairIdentity(candidateDir) === sourceIdentity;
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
167
261
|
// CLI: `node cli-publish-equal.mjs <localPackageDir> <publishedPackageDir>`.
|
|
168
|
-
// Exit 0 = equivalent (safe skip); exit 3 = drift (must
|
|
262
|
+
// Exit 0 = equivalent (safe skip); exit 3 = drift (must allocate a new version).
|
|
169
263
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
170
264
|
const args = process.argv.slice(2);
|
|
171
265
|
// `--gt <next> <current>`: exit 0 iff stable `next` > stable `current`.
|
|
@@ -192,6 +286,60 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
192
286
|
process.exit(2);
|
|
193
287
|
}
|
|
194
288
|
}
|
|
289
|
+
// `--next-version <local> <unique>` reads the registry's JSON versions list
|
|
290
|
+
// on stdin and prints an absent semver suitable for a drifted package.
|
|
291
|
+
if (args[0] === "--next-version") {
|
|
292
|
+
const [, local, unique] = args;
|
|
293
|
+
try {
|
|
294
|
+
const input = JSON.parse(readFileSync(0, "utf8"));
|
|
295
|
+
const versions = Array.isArray(input) ? input : [input];
|
|
296
|
+
console.log(nextUnpublishedVersion(local, versions, unique));
|
|
297
|
+
process.exit(0);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
console.error(String(err.message ?? err));
|
|
300
|
+
process.exit(2);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (args[0] === "--repair-identity") {
|
|
304
|
+
const [, packageDir] = args;
|
|
305
|
+
if (!packageDir) {
|
|
306
|
+
console.error("usage: publish-equal.mjs --repair-identity <packageDir>");
|
|
307
|
+
process.exit(2);
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
console.log(releaseRepairIdentity(packageDir));
|
|
311
|
+
process.exit(0);
|
|
312
|
+
} catch (err) {
|
|
313
|
+
console.error(String(err.message ?? err));
|
|
314
|
+
process.exit(2);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (args[0] === "--is-release-repair") {
|
|
318
|
+
const [, localDir, candidateDir] = args;
|
|
319
|
+
if (!localDir || !candidateDir) {
|
|
320
|
+
console.error("usage: publish-equal.mjs --is-release-repair <localPackageDir> <candidatePackageDir>");
|
|
321
|
+
process.exit(2);
|
|
322
|
+
}
|
|
323
|
+
process.exit(matchesReleaseRepair(localDir, candidateDir) ? 0 : 3);
|
|
324
|
+
}
|
|
325
|
+
// `--ignore-version <localPackageDir> <publishedPackageDir>` compares the
|
|
326
|
+
// shipped package while disregarding only package.json's version. The
|
|
327
|
+
// workflow uses this after confirmed drift to recognize an earlier
|
|
328
|
+
// release-time repair of the same source tarball on a rerun.
|
|
329
|
+
if (args[0] === "--ignore-version") {
|
|
330
|
+
const [, localDir, pubDir] = args;
|
|
331
|
+
if (!localDir || !pubDir) {
|
|
332
|
+
console.error("usage: publish-equal.mjs --ignore-version <localPackageDir> <publishedPackageDir>");
|
|
333
|
+
process.exit(2);
|
|
334
|
+
}
|
|
335
|
+
const result = packagesEquivalent(localDir, pubDir, { ignoreVersion: true });
|
|
336
|
+
if (result.equal) {
|
|
337
|
+
console.log("identical except package version");
|
|
338
|
+
process.exit(0);
|
|
339
|
+
}
|
|
340
|
+
console.error("drift: " + result.reason);
|
|
341
|
+
process.exit(3);
|
|
342
|
+
}
|
|
195
343
|
const [localDir, pubDir] = args;
|
|
196
344
|
if (!localDir || !pubDir) {
|
|
197
345
|
console.error("usage: publish-equal.mjs <localPackageDir> <publishedPackageDir> | --gt <next> <current>");
|
package/src/wait-core.mjs
CHANGED
|
@@ -217,6 +217,17 @@ const VALIDATORS = {
|
|
|
217
217
|
if (!String(p.sha).trim()) throw new Error("ci sha must be non-empty");
|
|
218
218
|
return { repo, scope: "sha", sha: String(p.sha).trim(), pr: p.pr != null ? String(p.pr).trim() : null };
|
|
219
219
|
},
|
|
220
|
+
"staging-green"(p) {
|
|
221
|
+
const repo = p.repo != null ? String(p.repo).trim() : null;
|
|
222
|
+
if (!repo || !/^[^/\s]+\/[^/\s]+$/.test(repo)) {
|
|
223
|
+
throw new Error("staging-green needs repo=<owner/repo>");
|
|
224
|
+
}
|
|
225
|
+
const extras = Object.keys(p).filter((key) => key !== "repo");
|
|
226
|
+
if (extras.length > 0) {
|
|
227
|
+
throw new Error("staging-green accepts only repo=<owner/repo>");
|
|
228
|
+
}
|
|
229
|
+
return { repo };
|
|
230
|
+
},
|
|
220
231
|
// BOT-1247 — unblocked: wake when a ticket's LAST open blocker clears (its
|
|
221
232
|
// open-blocker count transitions >0 → 0). Level-triggered: a ticket already
|
|
222
233
|
// unblocked (or never blocked) at registration is granted immediately with
|
|
@@ -643,6 +654,18 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
|
|
|
643
654
|
return signal.subject_key === `${params.repo}#${params.pr}` ||
|
|
644
655
|
(signal.payload?.pr_number != null && String(signal.payload.pr_number) === params.pr);
|
|
645
656
|
}
|
|
657
|
+
case "staging-green": {
|
|
658
|
+
if (signal.signal_type !== "staging_gate" || signal.subject_key !== params.repo) return false;
|
|
659
|
+
const payload = signal.payload ?? {};
|
|
660
|
+
// A staging_gate signal is also used for manual overrides and red/freeze
|
|
661
|
+
// transitions. This public condition is deliberately narrower: only the
|
|
662
|
+
// webhook's evidenced, automatic recovery may wake a delivery session.
|
|
663
|
+
return (payload.reason === "green_recovery" || payload.reason === "already_green") &&
|
|
664
|
+
payload.source === "automatic_workflow_recovery" &&
|
|
665
|
+
payload.verified === true &&
|
|
666
|
+
payload.gate_open === false &&
|
|
667
|
+
payload.operational_status === "healthy";
|
|
668
|
+
}
|
|
646
669
|
case "unblocked": {
|
|
647
670
|
// Wakes on the ticket_unblocked spine signal for this ticket (the server emits both
|
|
648
671
|
// the genuine >0→0 transition and the level-triggered `already_unblocked` echo).
|
package/src/wait.mjs
CHANGED
|
@@ -78,6 +78,9 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
|
|
|
78
78
|
a PR's CI reaching a terminal conclusion (owner-scoped);
|
|
79
79
|
GitHub Actions or an external check_suite provider.
|
|
80
80
|
A repo with no CI feed is rejected (no_signal_source).
|
|
81
|
+
staging-green:repo=<owner/repo> verified automatic recovery of an enabled staging gate.
|
|
82
|
+
Wakes only after a complete non-vacuous workflow success;
|
|
83
|
+
manual resolutions, overrides, skipped, and neutral runs do not match.
|
|
81
84
|
event:type=<signal_type>[,subject=<key>]
|
|
82
85
|
raw signal-spine match (escape hatch)
|
|
83
86
|
|
|
@@ -211,6 +214,7 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
211
214
|
"wait_tenant_unresolved",
|
|
212
215
|
"wait_cross_tenant",
|
|
213
216
|
"wait_tenant_mismatch",
|
|
217
|
+
"staging_repository_unconfigured",
|
|
214
218
|
// BOT-1259: a `linear` condition set spanning multiple workspaces can't bind one
|
|
215
219
|
// tenant; the server rejects it (register one wait per workspace) — a hard stop, not
|
|
216
220
|
// a transient error, so don't fall back to an untracked live-only wait.
|