@mutmutco/cli 4.5.82 → 4.5.84
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/dist/main.cjs +618 -97
- package/package.json +1 -1
package/dist/main.cjs
CHANGED
|
@@ -3967,7 +3967,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
|
|
|
3967
3967
|
const negativeNumberArg = (arg) => {
|
|
3968
3968
|
if (!/^-(\d+|\d*\.\d+)(e[+-]?\d+)?$/.test(arg)) return false;
|
|
3969
3969
|
return !this._getCommandAndAncestors().some(
|
|
3970
|
-
(cmd) => cmd.options.map((opt) => opt.short).some((
|
|
3970
|
+
(cmd) => cmd.options.map((opt) => opt.short).some((short3) => /^-\d$/.test(short3))
|
|
3971
3971
|
);
|
|
3972
3972
|
};
|
|
3973
3973
|
let activeVariadicOption = null;
|
|
@@ -6390,6 +6390,16 @@ var RUNNER_INFRA_PRESTART_CONCLUSIONS = /* @__PURE__ */ new Set([
|
|
|
6390
6390
|
"cancelled",
|
|
6391
6391
|
"startup_failure"
|
|
6392
6392
|
]);
|
|
6393
|
+
var TEST_FAILURE_LINE = /^(?:✖|not ok\b|FAIL\b|×)|\bAssertionError\b/;
|
|
6394
|
+
function firstTestFailureLine(log) {
|
|
6395
|
+
for (const raw of log.split(/\r?\n/)) {
|
|
6396
|
+
const line = raw.replace(/^\uFEFF?\d{4}-\d\d-\d\dT[\d:.]+Z\s*/, "").replace(/\x1b\[[0-9;]*m/g, "").trim();
|
|
6397
|
+
if (TEST_FAILURE_LINE.test(line)) {
|
|
6398
|
+
return line.replace(/\b(?:gh[pousr]_|github_pat_|AKIA|sk-)[\w-]+/g, "[redacted]").slice(0, 200);
|
|
6399
|
+
}
|
|
6400
|
+
}
|
|
6401
|
+
return void 0;
|
|
6402
|
+
}
|
|
6393
6403
|
var BARE_CHECKS_FAILURE_MESSAGE = "GitHub reported a checks failure but exposed no per-check data.";
|
|
6394
6404
|
function actionRunIdFromDetailsUrl(detailsUrl) {
|
|
6395
6405
|
const match = detailsUrl?.match(/\/actions\/runs\/(\d+)(?:\/|$)/);
|
|
@@ -6430,7 +6440,7 @@ function classifyFailedChecks(failing) {
|
|
|
6430
6440
|
for (const check of failing) {
|
|
6431
6441
|
const errors = check.annotations.filter(isErrorAnnotation);
|
|
6432
6442
|
if (errors.length > 0) {
|
|
6433
|
-
const allInfra = errors.every((a) => isRunnerInfraAnnotation(a));
|
|
6443
|
+
const allInfra = errors.every((a) => isRunnerInfraAnnotation(a)) && !check.testFailureLine;
|
|
6434
6444
|
(allInfra ? infraFailures : otherFailures).push(check.name);
|
|
6435
6445
|
continue;
|
|
6436
6446
|
}
|
|
@@ -6449,12 +6459,14 @@ function classifyFailedChecks(failing) {
|
|
|
6449
6459
|
reason: `${infraFailures.length} check(s) failed on RUNNER INFRASTRUCTURE, not your diff: ${infraFailures.join(", ")}. No test failed \u2014 the shared runner hit a wall-clock budget kill, ran out of disk, was missing a toolchain, could not acquire an mmi-live lane, or Actions returned Service Unavailable before checkout (the check annotation / conclusion names which). Re-run once the runner drains/heals (gh run rerun --failed); do not debug the diff.`
|
|
6450
6460
|
};
|
|
6451
6461
|
}
|
|
6462
|
+
const failedTests = failing.filter((c) => c.testFailureLine);
|
|
6463
|
+
const testNote = failedTests.length ? ` Test failures in the job log (first: ${failedTests[0].testFailureLine})` + (failedTests.some((c) => c.annotations.some((a) => isErrorAnnotation(a) && isRunnerInfraAnnotation(a))) ? ", followed by a runner wall-clock budget kill" : "") + "; fix the failing test(s)." : "";
|
|
6452
6464
|
return {
|
|
6453
6465
|
cause: "checks-failure",
|
|
6454
6466
|
infraFailures,
|
|
6455
6467
|
otherFailures,
|
|
6456
6468
|
failedChecks: [],
|
|
6457
|
-
reason: infraFailures.length ? `checks failed: ${otherFailures.join(", ")}; separately, ${infraFailures.join(", ")} failed on runner infrastructure, not a test.` : `checks failed: ${otherFailures.join(", ") || "unknown"}.`
|
|
6469
|
+
reason: testNote && !infraFailures.length ? `checks failed: ${otherFailures.join(", ")}.${testNote}` : infraFailures.length ? `checks failed: ${otherFailures.join(", ")}; separately, ${infraFailures.join(", ")} failed on runner infrastructure, not a test.` : `checks failed: ${otherFailures.join(", ") || "unknown"}.`
|
|
6458
6470
|
};
|
|
6459
6471
|
}
|
|
6460
6472
|
async function diagnoseFailedRestChecks(prNumber, repo, gh2 = defaultGhApi) {
|
|
@@ -6489,11 +6501,18 @@ async function diagnoseFailedRestChecks(prNumber, repo, gh2 = defaultGhApi) {
|
|
|
6489
6501
|
if (!failing.length) return { state: "absent", reason: "no failing check-run on the current head to diagnose" };
|
|
6490
6502
|
const failedChecks = failing.map((run) => failedCheckReceipt(run, repo));
|
|
6491
6503
|
try {
|
|
6492
|
-
const annotated = await Promise.all(failing.map(async (run) =>
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6504
|
+
const annotated = await Promise.all(failing.map(async (run) => {
|
|
6505
|
+
const annotations = JSON.parse(await gh2([`repos/${repo}/check-runs/${run.id}/annotations`]));
|
|
6506
|
+
const errors = annotations.filter(isErrorAnnotation);
|
|
6507
|
+
let testFailureLine;
|
|
6508
|
+
if (errors.length && errors.every(isRunnerInfraAnnotation)) {
|
|
6509
|
+
try {
|
|
6510
|
+
testFailureLine = firstTestFailureLine(await gh2([`repos/${repo}/actions/jobs/${run.id}/logs`]));
|
|
6511
|
+
} catch {
|
|
6512
|
+
}
|
|
6513
|
+
}
|
|
6514
|
+
return { name: run.name ?? `check-run ${run.id}`, conclusion: run.conclusion ?? null, annotations, testFailureLine };
|
|
6515
|
+
}));
|
|
6497
6516
|
return { state: "ok", diagnosis: { ...classifyFailedChecks(annotated), failedChecks } };
|
|
6498
6517
|
} catch (e) {
|
|
6499
6518
|
return {
|
|
@@ -17249,10 +17268,10 @@ var rollout_plan_default = {
|
|
|
17249
17268
|
note: "The v4.0.0 stamp happens at cut time (D6e #4463); until then the candidate is the origin/development head artifacts (built cli/dist + npm pack), identity proven by dist content hash (D6a)."
|
|
17250
17269
|
},
|
|
17251
17270
|
baseline: {
|
|
17252
|
-
version: "4.5.
|
|
17253
|
-
tag: "v4.5.
|
|
17254
|
-
commit: "
|
|
17255
|
-
npm: "@mutmutco/cli@4.5.
|
|
17271
|
+
version: "4.5.84",
|
|
17272
|
+
tag: "v4.5.84",
|
|
17273
|
+
commit: "86b566fd9857",
|
|
17274
|
+
npm: "@mutmutco/cli@4.5.84"
|
|
17256
17275
|
},
|
|
17257
17276
|
exitCriterion: "fleet-n-of-n",
|
|
17258
17277
|
hubOnlyShortcut: "forbidden",
|
|
@@ -17269,14 +17288,14 @@ var rollout_plan_default = {
|
|
|
17269
17288
|
repo: "mutmutco/mmi-hub",
|
|
17270
17289
|
role: "canary",
|
|
17271
17290
|
schedule: "train",
|
|
17272
|
-
v3Target: "v4.5.
|
|
17291
|
+
v3Target: "v4.5.84"
|
|
17273
17292
|
}
|
|
17274
17293
|
],
|
|
17275
17294
|
rollbackTrigger: "Any red inside the post-contract soak window: `devops train gate` FAIL attributable to the v4 doors, Hub endpoint health probe failure, a pre-v4 client admitted instead of receiving actionable HTTP 426, or npm consumer install/doctor failure on the v4-only dist.",
|
|
17276
17295
|
rollback: {
|
|
17277
17296
|
independent: true,
|
|
17278
|
-
mechanism: "npm dist-tag latest -> 4.5.
|
|
17279
|
-
v3Target: "v4.5.
|
|
17297
|
+
mechanism: "npm dist-tag latest -> 4.5.84 and redeploy the Hub Lambda from tag v4.5.84 (86b566fd9857); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
|
|
17298
|
+
v3Target: "v4.5.84 (@mutmutco/cli@4.5.84, tag commit 86b566fd9857 \u2014 last known-good release carrying the repo-index v4-only contract)"
|
|
17280
17299
|
}
|
|
17281
17300
|
},
|
|
17282
17301
|
{
|
|
@@ -20268,6 +20287,10 @@ var SUITE_CUT_TOKEN = /* @__PURE__ */ Symbol("suite-cut");
|
|
|
20268
20287
|
function suiteCutContext(repo, lead) {
|
|
20269
20288
|
return { repo, lead, token: SUITE_CUT_TOKEN };
|
|
20270
20289
|
}
|
|
20290
|
+
var SUITE_LEAD_TOKEN = /* @__PURE__ */ Symbol("suite-lead");
|
|
20291
|
+
function suiteLeadContext(lead) {
|
|
20292
|
+
return { repo: lead, lead, token: SUITE_LEAD_TOKEN };
|
|
20293
|
+
}
|
|
20271
20294
|
function validSuiteCutContext(repo, context) {
|
|
20272
20295
|
return !!context && context.token === SUITE_CUT_TOKEN && context.repo.toLowerCase() === repo.toLowerCase();
|
|
20273
20296
|
}
|
|
@@ -20281,7 +20304,12 @@ function isBundleOnlySuiteMember(repo, suite) {
|
|
|
20281
20304
|
}
|
|
20282
20305
|
function suiteMemberRefusal(repo, meta, lane, solo = false, cut) {
|
|
20283
20306
|
const suite = meta?.suite;
|
|
20284
|
-
if (!suite
|
|
20307
|
+
if (!suite) return null;
|
|
20308
|
+
if (!isBundleOnlySuiteMember(repo, suite)) {
|
|
20309
|
+
if (solo || cut?.token === SUITE_LEAD_TOKEN && cut.repo.toLowerCase() === repo.toLowerCase()) return null;
|
|
20310
|
+
const route = lane === "hotfix" ? "hotfix the affected member from its own checkout, then run mmi-cli devops suite release --owner-go" : `run mmi-cli devops suite ${lane} --owner-go`;
|
|
20311
|
+
return `${repo} is the ${suite} suite lead; a plain ${lane} would cut it without its members: ${route}. A lead-only ${lane} needs --suite-solo --owner-go.`;
|
|
20312
|
+
}
|
|
20285
20313
|
if (authorisedSuiteCut(repo, meta, cut)) return null;
|
|
20286
20314
|
if (lane !== "rcand" && solo) return null;
|
|
20287
20315
|
return `${repo} is part of the ${suite} suite; run the suite cut/release from ${suiteLeadRepo(repo, suite)} (mmi-cli devops suite rcand|release). A solo backend release needs --suite-solo --owner-go.`;
|
|
@@ -26878,6 +26906,7 @@ async function runReleaseResume(deps, options = {}) {
|
|
|
26878
26906
|
async function runReleaseResumeLocked(deps, options) {
|
|
26879
26907
|
const ctx = await buildTrainApplyContext(deps);
|
|
26880
26908
|
await requireCleanTree(deps);
|
|
26909
|
+
const entryBranch = await currentBranch(deps).catch(() => "");
|
|
26881
26910
|
await runGitRemoteRead(deps, ["fetch", "origin", "--tags"]);
|
|
26882
26911
|
const meta = requireProjectMetaForTrain(await loadProjectMeta(deps, ctx), ctx.repo);
|
|
26883
26912
|
const suiteRefusal = suiteMemberRefusal(ctx.repo, meta, "release", options.suiteSolo === true, deps.suiteCut);
|
|
@@ -26885,7 +26914,7 @@ async function runReleaseResumeLocked(deps, options) {
|
|
|
26885
26914
|
const branchHints = await loadReleaseTrackBranchHints(deps);
|
|
26886
26915
|
const directTrack = isHubControlRepo(ctx.repo) || resolveReleaseTrack(meta, branchHints) === "direct";
|
|
26887
26916
|
const deployModel = await preflight(deps, ctx, "main", meta);
|
|
26888
|
-
const startBranch = directTrack ? "development" : "rc";
|
|
26917
|
+
const startBranch = TRAIN_BRANCHES.includes(entryBranch) ? entryBranch : directTrack ? "development" : "rc";
|
|
26889
26918
|
return withCheckoutRestoredAfterReleaseResume(
|
|
26890
26919
|
deps,
|
|
26891
26920
|
startBranch,
|
|
@@ -35575,6 +35604,7 @@ init_github_client();
|
|
|
35575
35604
|
init_cli_shared();
|
|
35576
35605
|
|
|
35577
35606
|
// src/suite-status.ts
|
|
35607
|
+
var import_node_crypto13 = require("node:crypto");
|
|
35578
35608
|
var SUITE_REPO = "mutmutco/MM-Work";
|
|
35579
35609
|
var REPOS = {
|
|
35580
35610
|
"mmi-scout": "mutmutco/MMI-Scout",
|
|
@@ -35584,6 +35614,54 @@ var REPOS = {
|
|
|
35584
35614
|
katip: "mutmutco/MMI-Katip"
|
|
35585
35615
|
};
|
|
35586
35616
|
var STAGES3 = ["dev", "rc", "main"];
|
|
35617
|
+
var MAPPING_MARKER = /<!-- mmi-suite-members (\{[^\n]*\}) -->/;
|
|
35618
|
+
function suiteMappingBlock(tag, mapping) {
|
|
35619
|
+
const rows = Object.entries(mapping).map(([id, v]) => `- ${id}: ${v}`).join("\n");
|
|
35620
|
+
return `<!-- mmi-suite-members ${JSON.stringify(mapping)} -->
|
|
35621
|
+
## Suite ${tag} members
|
|
35622
|
+
${rows}
|
|
35623
|
+
`;
|
|
35624
|
+
}
|
|
35625
|
+
function parseSuiteMapping(body) {
|
|
35626
|
+
const raw = MAPPING_MARKER.exec(body ?? "")?.[1];
|
|
35627
|
+
if (!raw) return void 0;
|
|
35628
|
+
try {
|
|
35629
|
+
const v = JSON.parse(raw);
|
|
35630
|
+
return Object.values(v).every((x) => typeof x === "string") ? v : void 0;
|
|
35631
|
+
} catch {
|
|
35632
|
+
return void 0;
|
|
35633
|
+
}
|
|
35634
|
+
}
|
|
35635
|
+
var sha256 = (b) => (0, import_node_crypto13.createHash)("sha256").update(b).digest("hex");
|
|
35636
|
+
async function proveBundle(url, tag, fetchUrl) {
|
|
35637
|
+
const get = async (u) => {
|
|
35638
|
+
try {
|
|
35639
|
+
return await fetchUrl(u);
|
|
35640
|
+
} catch (e) {
|
|
35641
|
+
return `unreachable (${e.message})`;
|
|
35642
|
+
}
|
|
35643
|
+
};
|
|
35644
|
+
const bundle = await get(url);
|
|
35645
|
+
if (typeof bundle === "string") return { state: "unprovable", detail: `bundle ${bundle}` };
|
|
35646
|
+
if (bundle.status !== 200) return { state: "unprovable", detail: `bundle HTTP ${bundle.status}` };
|
|
35647
|
+
const served = sha256(bundle.body);
|
|
35648
|
+
const recordUrl = new URL(url);
|
|
35649
|
+
recordUrl.search = "";
|
|
35650
|
+
recordUrl.pathname = recordUrl.pathname.replace(/[^/]*$/, "module.json");
|
|
35651
|
+
const rec = await get(recordUrl.toString());
|
|
35652
|
+
if (typeof rec === "string") return { state: "unprovable", detail: `build record ${rec}; served ${served.slice(0, 12)}` };
|
|
35653
|
+
if (rec.status !== 200) return { state: "unprovable", detail: `build record not served (HTTP ${rec.status}); served ${served.slice(0, 12)}` };
|
|
35654
|
+
let record;
|
|
35655
|
+
try {
|
|
35656
|
+
record = JSON.parse(Buffer.from(rec.body).toString("utf8"));
|
|
35657
|
+
} catch {
|
|
35658
|
+
return { state: "unprovable", detail: `build record unreadable; served ${served.slice(0, 12)}` };
|
|
35659
|
+
}
|
|
35660
|
+
if (typeof record.tag !== "string" || typeof record.sha256 !== "string") return { state: "unprovable", detail: `build record malformed; served ${served.slice(0, 12)}` };
|
|
35661
|
+
if (record.tag !== tag) return { state: "mismatch", detail: `host serves ${record.tag}, pinned ${tag}` };
|
|
35662
|
+
if (record.sha256.toLowerCase() !== served) return { state: "mismatch", detail: `served ${served.slice(0, 12)}, pinned ${record.sha256.slice(0, 12)}` };
|
|
35663
|
+
return { state: "proven", detail: `sha ${served.slice(0, 12)}` };
|
|
35664
|
+
}
|
|
35587
35665
|
function suiteMembers(raw) {
|
|
35588
35666
|
if (!Array.isArray(raw)) throw new Error("MM-Work seed/modules.json is unreadable");
|
|
35589
35667
|
const members = [{ repo: SUITE_REPO, id: "mm-work", bundles: {} }];
|
|
@@ -35619,6 +35697,7 @@ async function stageStatus(member, stage, deps) {
|
|
|
35619
35697
|
let served = "unknown";
|
|
35620
35698
|
let source = "unknown";
|
|
35621
35699
|
let commit;
|
|
35700
|
+
let note;
|
|
35622
35701
|
let deployStatus;
|
|
35623
35702
|
const pin = stage !== "dev" ? bundleTag(member.bundles[stage]) : void 0;
|
|
35624
35703
|
if (pin) {
|
|
@@ -35640,47 +35719,157 @@ async function stageStatus(member, stage, deps) {
|
|
|
35640
35719
|
else {
|
|
35641
35720
|
try {
|
|
35642
35721
|
commit = await deps.tagCommit(member.repo, served);
|
|
35722
|
+
if (!commit && stage === "main") note = `pin names no release (tag ${served} absent)`;
|
|
35643
35723
|
} catch {
|
|
35644
35724
|
}
|
|
35645
35725
|
}
|
|
35646
35726
|
}
|
|
35647
|
-
|
|
35727
|
+
let lag = commit && head !== "unknown" ? commit.toLowerCase() !== head.toLowerCase() : null;
|
|
35728
|
+
let proof;
|
|
35729
|
+
if (pin && stage !== "dev" && deps.fetchUrl) {
|
|
35730
|
+
proof = await proveBundle(member.bundles[stage], pin, deps.fetchUrl);
|
|
35731
|
+
if (proof.state === "mismatch") lag = true;
|
|
35732
|
+
}
|
|
35648
35733
|
return {
|
|
35649
35734
|
stage,
|
|
35650
35735
|
head,
|
|
35651
35736
|
served,
|
|
35652
35737
|
source,
|
|
35653
|
-
lag: source === "registry-pin" && lag === false ? null : lag,
|
|
35738
|
+
lag: source === "registry-pin" && lag === false && proof?.state !== "proven" ? null : lag,
|
|
35739
|
+
...proof ? { proof } : {},
|
|
35740
|
+
...note ? { note } : {},
|
|
35654
35741
|
...deployStatus ? { deployStatus } : {}
|
|
35655
35742
|
};
|
|
35656
35743
|
}
|
|
35657
35744
|
async function readSuiteStatus(deps) {
|
|
35658
35745
|
const members = suiteMembers(await deps.registry());
|
|
35659
|
-
|
|
35660
|
-
|
|
35661
|
-
|
|
35662
|
-
|
|
35663
|
-
|
|
35664
|
-
|
|
35665
|
-
|
|
35666
|
-
|
|
35746
|
+
const rows = await Promise.all(members.map(async (member) => ({
|
|
35747
|
+
repo: member.repo,
|
|
35748
|
+
id: member.id,
|
|
35749
|
+
stages: await Promise.all(STAGES3.map((stage) => stageStatus(member, stage, deps)))
|
|
35750
|
+
})));
|
|
35751
|
+
if (!deps.leadTags || !deps.registryAt) return { suite: "mm-work", members: rows };
|
|
35752
|
+
const tags = await deps.leadTags().catch(() => []);
|
|
35753
|
+
const latest = (rc) => tags.filter((t) => /^v\d+\.\d+\.\d+(?:-rc\.\d+)?$/.test(t) && t.includes("-rc.") === rc).sort((a, b) => compareVersions(b, a))[0];
|
|
35754
|
+
const leadTag = { rc: latest(true), main: latest(false) };
|
|
35755
|
+
const lead = rows[0].stages;
|
|
35756
|
+
for (const stage of ["rc", "main"]) {
|
|
35757
|
+
const tag = leadTag[stage];
|
|
35758
|
+
if (!tag) continue;
|
|
35759
|
+
let pins = [];
|
|
35760
|
+
try {
|
|
35761
|
+
pins = suiteMembers(await deps.registryAt(tag));
|
|
35762
|
+
} catch {
|
|
35763
|
+
}
|
|
35764
|
+
for (const row of rows.slice(1)) {
|
|
35765
|
+
const st = row.stages.find((s) => s.stage === stage);
|
|
35766
|
+
st.suitePin = { suite: tag, pin: bundleTag(pins.find((m) => m.id === row.id)?.bundles[stage]) };
|
|
35767
|
+
}
|
|
35768
|
+
}
|
|
35769
|
+
const recorded = leadTag.main && deps.leadReleaseBody ? parseSuiteMapping(await deps.leadReleaseBody(leadTag.main).catch(() => null)) : void 0;
|
|
35770
|
+
return { suite: "mm-work", lead: { dev: lead[0].head, rc: leadTag.rc ?? "n/a", main: leadTag.main ?? "unknown", ...recorded ? { members: recorded } : {} }, members: rows };
|
|
35771
|
+
}
|
|
35772
|
+
var short = (v) => /^[a-f0-9]{40}$/i.test(v) ? v.slice(0, 12) : v;
|
|
35773
|
+
var proofText = (p) => p ? p.state === "proven" ? "proven" : p.state === "mismatch" ? `mismatch (${p.detail})` : `unprovable (${p.detail})` : "";
|
|
35774
|
+
function formatSuiteMarkdown(report) {
|
|
35775
|
+
const cell = (st) => !st || st.source === "n/a" ? "n/a" : short(st.served);
|
|
35776
|
+
const by = (st, name) => st?.suitePin ? `${name} ${st.suitePin.suite}${st.suitePin.pin && st.suitePin.pin !== st.served ? ` (pins ${st.suitePin.pin})` : st.suitePin.pin ? "" : " (no pin)"}` : "";
|
|
35777
|
+
const lines2 = ["| Member | dev | rc | main | Pinned by suite |", "| --- | --- | --- | --- | --- |"];
|
|
35778
|
+
if (report.lead) lines2.push(`| MM-Work (suite) | ${short(report.lead.dev)} | ${report.lead.rc} | ${report.lead.main} | - |`);
|
|
35779
|
+
for (const m of report.members.slice(report.lead ? 1 : 0)) {
|
|
35780
|
+
const [dev, rc, main] = ["dev", "rc", "main"].map((s) => m.stages.find((x) => x.stage === s));
|
|
35781
|
+
lines2.push(`| ${m.repo.split("/")[1]} | ${cell(dev)} | ${cell(rc)} | ${cell(main)} | ${[by(rc, "rc"), by(main, "main")].filter(Boolean).join(", ") || "-"} |`);
|
|
35782
|
+
}
|
|
35783
|
+
return lines2.join("\n");
|
|
35667
35784
|
}
|
|
35668
35785
|
function formatSuiteStatus(report) {
|
|
35669
35786
|
const lines2 = [
|
|
35670
35787
|
"suite mm-work \u2014 registry pins are declarations, tenant versions require a healthy probe",
|
|
35788
|
+
...report.lead ? [`suite version: dev ${short(report.lead.dev)} rc ${report.lead.rc} main ${report.lead.main}`] : [],
|
|
35789
|
+
...report.lead?.members ? [`suite ${report.lead.main} = members ${Object.entries(report.lead.members).map(([id, v]) => `${id} ${v}`).join(", ")} (recorded by suite hotfix, #7738)`] : [],
|
|
35671
35790
|
"member stage head served source lag"
|
|
35672
35791
|
];
|
|
35673
35792
|
for (const member of report.members) for (const row of member.stages) {
|
|
35674
35793
|
const served = /^[a-f0-9]{40}$/i.test(row.served) ? row.served.slice(0, 12) : row.served;
|
|
35675
|
-
lines2.push(`${member.repo.split("/")[1].padEnd(18)} ${row.stage.padEnd(6)} ${row.head.slice(0, 12).padEnd(13)} ${served.padEnd(18)} ${row.source.padEnd(15)} ${row.source === "n/a" ? "n/a" : row.lag === null ? "?" : row.lag ? "LAG" : "ok"}`);
|
|
35794
|
+
lines2.push(`${member.repo.split("/")[1].padEnd(18)} ${row.stage.padEnd(6)} ${row.head.slice(0, 12).padEnd(13)} ${served.padEnd(18)} ${row.source.padEnd(15)} ${row.source === "n/a" ? "n/a" : row.lag === null ? row.note ?? "?" : row.lag ? "LAG" : "ok"}${row.proof ? ` ${proofText(row.proof)}` : ""}${row.suitePin ? ` by suite ${row.suitePin.suite}` : ""}`);
|
|
35676
35795
|
}
|
|
35677
35796
|
return lines2.join("\n");
|
|
35678
35797
|
}
|
|
35798
|
+
async function setSuiteHold(deps, member, hold) {
|
|
35799
|
+
const wanted = member.toLowerCase();
|
|
35800
|
+
const found = suiteMembers(await deps.registry()).find((m) => m.repo.toLowerCase() === wanted || m.repo.split("/")[1].toLowerCase() === wanted || m.id === wanted);
|
|
35801
|
+
if (!found) throw new Error(`${member} is not an MM-Work suite member; nothing was written`);
|
|
35802
|
+
if (found.repo === SUITE_REPO) throw new Error(`${SUITE_REPO} is the suite lead and cannot be held; nothing was written`);
|
|
35803
|
+
const slug = found.repo.split("/")[1].toLowerCase();
|
|
35804
|
+
const before = await deps.read(slug);
|
|
35805
|
+
if (!before.ok || !before.project) throw new Error(`${found.repo}: registry META unreadable${before.ok ? "" : ` (${before.error})`}; nothing was written`);
|
|
35806
|
+
const track = before.project.releaseTrack;
|
|
35807
|
+
const res = await deps.write(slug, { suiteHold: hold ? parseSuiteHoldVar(JSON.stringify(hold)) : null, repo: found.repo, ...track ? { releaseTrack: track } : {} });
|
|
35808
|
+
if (!res.ok) throw new Error(`${found.repo}: registry write failed (HTTP ${res.status}${res.error ? `, ${res.error}` : ""})`);
|
|
35809
|
+
const after = await deps.read(slug);
|
|
35810
|
+
if (!after.ok || !after.project) throw new Error(`${found.repo}: write sent but read-back failed; verify with org project get`);
|
|
35811
|
+
const got = after.project.suiteHold ?? null;
|
|
35812
|
+
if (JSON.stringify(got) !== JSON.stringify(hold)) throw new Error(`${found.repo}: read-back suiteHold ${JSON.stringify(got)} does not match what was written`);
|
|
35813
|
+
if (track && after.project.releaseTrack !== track) throw new Error(`${found.repo}: releaseTrack changed from ${track} to ${after.project.releaseTrack ?? "unset"} by the write; restore it with org project set --release-track ${track}`);
|
|
35814
|
+
return hold ? `${found.repo}: HELD (${hold.reason})` : `${found.repo}: hold cleared`;
|
|
35815
|
+
}
|
|
35816
|
+
|
|
35817
|
+
// src/suite-dev.ts
|
|
35818
|
+
var WALK = 20;
|
|
35819
|
+
var short2 = (s) => s && /^[a-f0-9]{40}$/i.test(s) ? s.slice(0, 12) : s ?? "-";
|
|
35820
|
+
var same = (a, b) => !!a && !!b && a.toLowerCase() === b.toLowerCase();
|
|
35821
|
+
async function newestGreen(deps, repo) {
|
|
35822
|
+
const checks = await deps.requiredChecks(repo);
|
|
35823
|
+
for (const sha of await deps.commits(repo, WALK)) {
|
|
35824
|
+
const runs = await deps.checkRuns(repo, sha);
|
|
35825
|
+
if (checks.every((name) => {
|
|
35826
|
+
const latest = runs.filter((r) => r.name === name).sort((a, b) => b.id - a.id)[0];
|
|
35827
|
+
return latest?.status === "completed" && latest.conclusion === "success";
|
|
35828
|
+
})) return sha;
|
|
35829
|
+
}
|
|
35830
|
+
return void 0;
|
|
35831
|
+
}
|
|
35832
|
+
async function readSuiteDev(deps) {
|
|
35833
|
+
const report = await deps.status();
|
|
35834
|
+
return Promise.all(report.members.map(async (m) => {
|
|
35835
|
+
const st = m.stages.find((s) => s.stage === "dev");
|
|
35836
|
+
const row = { repo: m.repo, head: st.head, served: st.served };
|
|
35837
|
+
if (st.source === "n/a") return { ...row, state: "converged", note: "no dev stage" };
|
|
35838
|
+
if (st.lag === false) return { ...row, state: "converged", note: "dev serves the development head" };
|
|
35839
|
+
let green;
|
|
35840
|
+
try {
|
|
35841
|
+
green = await newestGreen(deps, m.repo);
|
|
35842
|
+
} catch (e) {
|
|
35843
|
+
return { ...row, state: "unknown", note: `checks unreadable (${e.message})` };
|
|
35844
|
+
}
|
|
35845
|
+
const last = st.deployStatus?.lastRun;
|
|
35846
|
+
if (st.served === "unknown") return { ...row, green, state: "unknown", note: `dev version unproven (health probe failed or no deploy record)${last?.url ? `; last deploy ${last.conclusion ?? last.status}: ${last.url}` : ""}` };
|
|
35847
|
+
if (same(st.served, green)) return { ...row, green, state: "converged", note: `dev serves the newest green SHA; head ${short2(st.head)} is not green yet` };
|
|
35848
|
+
if (!green) return { ...row, state: "blocked", note: `no green SHA in the last ${WALK} development commits` };
|
|
35849
|
+
if (last?.conclusion === "failure") return { ...row, green, state: "blocked", note: `last dev deploy failed: ${last.url ?? "no run url"}` };
|
|
35850
|
+
return { ...row, green, state: "lagging", note: `newest green ${short2(green)} not deployed yet; the dev auto-deploy sweep (every 15 min) picks it up` };
|
|
35851
|
+
}));
|
|
35852
|
+
}
|
|
35853
|
+
function formatSuiteDev(rows) {
|
|
35854
|
+
return [
|
|
35855
|
+
"member head served green state note",
|
|
35856
|
+
...rows.map((r) => `${r.repo.split("/")[1].padEnd(18)} ${short2(r.head).padEnd(13)} ${short2(r.served).padEnd(13)} ${short2(r.green).padEnd(13)} ${r.state.padEnd(10)} ${r.note}`)
|
|
35857
|
+
].join("\n");
|
|
35858
|
+
}
|
|
35859
|
+
async function waitSuiteDev(deps, opts) {
|
|
35860
|
+
const sleep2 = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
35861
|
+
for (let waited = 0; ; waited += opts.intervalMs) {
|
|
35862
|
+
const rows = await readSuiteDev(deps);
|
|
35863
|
+
opts.onTick?.(rows);
|
|
35864
|
+
if (!rows.some((r) => r.state === "lagging") || waited + opts.intervalMs > opts.timeoutMs) return rows;
|
|
35865
|
+
await sleep2(opts.intervalMs);
|
|
35866
|
+
}
|
|
35867
|
+
}
|
|
35679
35868
|
|
|
35680
35869
|
// src/suite-train.ts
|
|
35681
35870
|
var import_node_fs29 = require("node:fs");
|
|
35682
35871
|
var import_node_path29 = require("node:path");
|
|
35683
|
-
var
|
|
35872
|
+
var import_node_crypto14 = require("node:crypto");
|
|
35684
35873
|
|
|
35685
35874
|
// src/kit-version.ts
|
|
35686
35875
|
var parse = (v) => /^(\d+)\.(\d+)\.(\d+)(-.+)?$/.exec(typeof v === "string" ? v.trim() : "");
|
|
@@ -35771,7 +35960,7 @@ function readSuiteCut(path2) {
|
|
|
35771
35960
|
}
|
|
35772
35961
|
function writeSuiteCut(path2, record) {
|
|
35773
35962
|
(0, import_node_fs29.mkdirSync)((0, import_node_path29.dirname)(path2), { recursive: true });
|
|
35774
|
-
const temp = `${path2}.${(0,
|
|
35963
|
+
const temp = `${path2}.${(0, import_node_crypto14.randomBytes)(8).toString("hex")}.tmp`;
|
|
35775
35964
|
try {
|
|
35776
35965
|
(0, import_node_fs29.writeFileSync)(temp, JSON.stringify(record, null, 2), { flag: "wx" });
|
|
35777
35966
|
(0, import_node_fs29.renameSync)(temp, path2);
|
|
@@ -35816,8 +36005,41 @@ function suiteAbandon(recordPath, ownerGo = false, now = /* @__PURE__ */ new Dat
|
|
|
35816
36005
|
(0, import_node_fs29.renameSync)(recordPath, archive);
|
|
35817
36006
|
return { archive, orphanTags: record.members.filter((m) => m.tag).map((m) => `${m.repo}@${m.tag}`) };
|
|
35818
36007
|
}
|
|
36008
|
+
async function suiteRestoreMainPins(deps) {
|
|
36009
|
+
const record = readSuiteCut(deps.recordPath);
|
|
36010
|
+
if (!record?.pinPr || record.leadReleased || record.members.some((m) => m.released)) return void 0;
|
|
36011
|
+
const pins = [];
|
|
36012
|
+
for (const m of record.members) {
|
|
36013
|
+
if (m.carried || !m.tag || !m.priorMain) continue;
|
|
36014
|
+
const planned = suiteReleaseTag(m.tag);
|
|
36015
|
+
if (!await deps.tagSha(m.repo, planned)) pins.push({ module: m.id, planned, prior: m.priorMain });
|
|
36016
|
+
}
|
|
36017
|
+
return pins.length ? deps.restoreMain({ cut: record.leadDevelopment, pins }) : void 0;
|
|
36018
|
+
}
|
|
36019
|
+
async function dryRunStatus(deps, m) {
|
|
36020
|
+
const [dev, rc] = await Promise.all([deps.head(m.repo, "development"), deps.head(m.repo, "rc")]);
|
|
36021
|
+
const previousRc = rc ?? await deps.head(m.repo, "main");
|
|
36022
|
+
if (!dev || !previousRc) return "branch heads unreadable";
|
|
36023
|
+
const verdict = await deps.contains(m.repo, dev, previousRc) ? "carried (no change on development since its last rc tag)" : "new candidate";
|
|
36024
|
+
let pending = "";
|
|
36025
|
+
try {
|
|
36026
|
+
const why = await pendingBackend(deps, m, dev);
|
|
36027
|
+
if (why) pending = `; pending backend release (${why})`;
|
|
36028
|
+
} catch (e) {
|
|
36029
|
+
pending = `; live backend unreadable (${e.message})`;
|
|
36030
|
+
}
|
|
36031
|
+
return verdict + pending;
|
|
36032
|
+
}
|
|
36033
|
+
var NOT_BACKEND = /^(module|docs|\.github)\/|\.md$/;
|
|
36034
|
+
async function pendingBackend(deps, m, dev) {
|
|
36035
|
+
const { version } = await deps.backend(m);
|
|
36036
|
+
const live = await deps.tagSha(m.repo, version);
|
|
36037
|
+
if (!live || await deps.contains(m.repo, dev, live)) return "";
|
|
36038
|
+
const files = (await deps.changedPaths(m.repo, live, dev)).filter((f) => !NOT_BACKEND.test(f));
|
|
36039
|
+
return files.length ? `${files.length} backend file(s) changed since live ${version}, e.g. ${files[0]}` : "";
|
|
36040
|
+
}
|
|
35819
36041
|
async function suiteRcand(deps, opts) {
|
|
35820
|
-
if (!opts.ownerGo) throw new Error("suite rcand requires --owner-go (rc uses live data); nothing was written");
|
|
36042
|
+
if (!opts.ownerGo && !opts.dryRun) throw new Error("suite rcand requires --owner-go (rc uses live data); nothing was written");
|
|
35821
36043
|
const members = suiteMembers(await deps.registry());
|
|
35822
36044
|
const modules = members.filter((m) => m.repo !== SUITE_REPO);
|
|
35823
36045
|
const { active, held } = await holdSplit(deps, modules);
|
|
@@ -35829,7 +36051,10 @@ async function suiteRcand(deps, opts) {
|
|
|
35829
36051
|
];
|
|
35830
36052
|
const lead = await deps.head(SUITE_REPO, "development");
|
|
35831
36053
|
plan.push(await kitCheck(deps, lead, active.map((m) => ({ id: m.id, url: m.bundles.dev })), "dev bundles"));
|
|
35832
|
-
if (opts.dryRun)
|
|
36054
|
+
if (opts.dryRun) {
|
|
36055
|
+
plan.push(...await Promise.all(modules.map(async (m) => `${m.repo}: ${heldById.has(m.id) ? "HELD" : await dryRunStatus(deps, m)}`)));
|
|
36056
|
+
return { plan };
|
|
36057
|
+
}
|
|
35833
36058
|
let record = readSuiteCut(deps.recordPath);
|
|
35834
36059
|
if (record?.leadReleased && record.announcement === "pending") {
|
|
35835
36060
|
const reason = await deps.announceSkipReason(record);
|
|
@@ -35852,6 +36077,12 @@ async function suiteRcand(deps, opts) {
|
|
|
35852
36077
|
record = void 0;
|
|
35853
36078
|
}
|
|
35854
36079
|
if (!record) {
|
|
36080
|
+
const pending = (await Promise.all(active.map(async (m) => {
|
|
36081
|
+
const dev = await deps.head(m.repo, "development");
|
|
36082
|
+
const why = dev ? await pendingBackend(deps, m, dev) : "";
|
|
36083
|
+
return why ? `${m.repo} (${why})` : "";
|
|
36084
|
+
}))).filter(Boolean);
|
|
36085
|
+
if (pending.length) throw new Error(`member backends not live yet: ${pending.join("; ")}. Release each from its own checkout first (mmi-cli devops release --suite-solo --owner-go), or hold it (suite hold set), then re-run suite rcand; nothing was cut`);
|
|
35855
36086
|
const [leadDevelopment, leadPreviousRc, snapshots] = await Promise.all([
|
|
35856
36087
|
deps.head(SUITE_REPO, "development"),
|
|
35857
36088
|
deps.head(SUITE_REPO, "rc"),
|
|
@@ -35952,6 +36183,11 @@ Each module must ship a live release that bind-mounts <deployPath>/data read-onl
|
|
|
35952
36183
|
const pins = suitePins(record, bundlesOf(modules));
|
|
35953
36184
|
await kitCheck(deps, record.leadDevelopment, pins.map((p) => ({ id: p.module, url: p.rc })), "rc bundles");
|
|
35954
36185
|
await deps.verifyLeadCut(record.leadDevelopment);
|
|
36186
|
+
for (const member of record.members) {
|
|
36187
|
+
const prior = modules.find((m) => m.id === member.id)?.bundles.main;
|
|
36188
|
+
if (!member.carried && !member.priorMain && prior && pinVersion(prior) !== suiteReleaseTag(member.tag)) member.priorMain = prior;
|
|
36189
|
+
}
|
|
36190
|
+
writeSuiteCut(deps.recordPath, record);
|
|
35955
36191
|
const pr = await deps.pin({ cut: record.leadDevelopment, pins, heldIds: record.heldMembers?.map((h) => h.id) });
|
|
35956
36192
|
await deps.verifyLeadCut(record.leadDevelopment);
|
|
35957
36193
|
if (record.pinPr && record.pinPr !== pr.pr) throw new Error(`pin PR changed from ${record.pinPr} to ${pr.pr}; stop`);
|
|
@@ -36055,6 +36291,112 @@ Each member's live release must serve <data>/main/module.js at its main pin path
|
|
|
36055
36291
|
}
|
|
36056
36292
|
return record;
|
|
36057
36293
|
}
|
|
36294
|
+
var hotfixRecordPath = (deps) => (0, import_node_path29.join)((0, import_node_path29.dirname)(deps.recordPath), "hotfix.json");
|
|
36295
|
+
function readSuiteHotfix(path2) {
|
|
36296
|
+
let raw;
|
|
36297
|
+
try {
|
|
36298
|
+
raw = (0, import_node_fs29.readFileSync)(path2, "utf8");
|
|
36299
|
+
} catch (e) {
|
|
36300
|
+
if (e.code === "ENOENT") return void 0;
|
|
36301
|
+
throw e;
|
|
36302
|
+
}
|
|
36303
|
+
let r;
|
|
36304
|
+
try {
|
|
36305
|
+
r = JSON.parse(raw);
|
|
36306
|
+
} catch {
|
|
36307
|
+
throw new Error(`suite hotfix record unreadable at ${path2}; repair it, never restart`);
|
|
36308
|
+
}
|
|
36309
|
+
if (r.schema !== "mmi-suite-hotfix/v1" || !r.repo || !r.member || !r.from || r.tag !== void 0 && !RELEASE_TAG.test(r.tag) || r.released && !r.tag || r.pinMerged && !r.released || r.leadTag !== void 0 && (!RELEASE_TAG.test(r.leadTag) || !r.pinMerged) || r.leadReleased && !r.leadTag || r.mapped && !r.leadReleased)
|
|
36310
|
+
throw new Error(`suite hotfix record has unknown or invalid state at ${path2}; repair it, never restart`);
|
|
36311
|
+
return r;
|
|
36312
|
+
}
|
|
36313
|
+
function writeSuiteHotfix(path2, record) {
|
|
36314
|
+
writeSuiteCut(path2, record);
|
|
36315
|
+
}
|
|
36316
|
+
async function suiteHotfix(deps, opts) {
|
|
36317
|
+
if (!opts.ownerGo && !opts.dryRun) throw new Error("suite hotfix requires --owner-go (it ships a member and MM-Work to production); nothing was written");
|
|
36318
|
+
const wanted = opts.member.toLowerCase();
|
|
36319
|
+
const found = suiteMembers(await deps.registry()).find((m) => m.repo.toLowerCase() === wanted || m.repo.split("/")[1].toLowerCase() === wanted || m.id === wanted);
|
|
36320
|
+
if (!found) throw new Error(`${opts.member} is not an MM-Work suite member; nothing was written`);
|
|
36321
|
+
if (found.repo === SUITE_REPO) throw new Error(`${SUITE_REPO} is the suite lead; hotfix a member, this command cuts the lead; nothing was written`);
|
|
36322
|
+
const held = await deps.hold(found);
|
|
36323
|
+
if (held) throw new Error(`${found.repo} is HELD (${held.reason}); clear it with suite hold clear before a suite hotfix; nothing was written`);
|
|
36324
|
+
const plan = [
|
|
36325
|
+
`${found.repo}: hotfix start --from ${opts.from} + release (suite-solo, in-process)`,
|
|
36326
|
+
`${SUITE_REPO}: main pin PR for ${found.id}, wait for merge`,
|
|
36327
|
+
`${SUITE_REPO}: hotfix (patch) carrying only the merged pin PR onto main`,
|
|
36328
|
+
`${SUITE_REPO}: record suite version -> member versions on its release`
|
|
36329
|
+
];
|
|
36330
|
+
if (opts.dryRun) return { plan };
|
|
36331
|
+
const cut = readSuiteCut(deps.recordPath);
|
|
36332
|
+
if (cut && !cut.leadReleased) throw new Error(`an unfinished suite cut (${cut.leadRcTag ?? "no lead tag"}) is in flight; finish it with suite release or retire it with suite abandon first`);
|
|
36333
|
+
const path2 = hotfixRecordPath(deps);
|
|
36334
|
+
let record = readSuiteHotfix(path2);
|
|
36335
|
+
if (record?.mapped) {
|
|
36336
|
+
(0, import_node_fs29.renameSync)(path2, `${path2}.${record.leadTag}.json`);
|
|
36337
|
+
record = void 0;
|
|
36338
|
+
}
|
|
36339
|
+
if (record && record.repo !== found.repo) throw new Error(`an unfinished suite hotfix for ${record.repo} is recorded; finish it (rerun with that member) first`);
|
|
36340
|
+
const rec = record ?? { schema: "mmi-suite-hotfix/v1", member: found.id, repo: found.repo, from: opts.from, ...opts.ack?.length ? { ack: opts.ack } : {} };
|
|
36341
|
+
const save = () => writeSuiteHotfix(path2, rec);
|
|
36342
|
+
save();
|
|
36343
|
+
const resume = `rerun suite hotfix ${found.id} --from ${rec.from} --owner-go to resume`;
|
|
36344
|
+
if (!rec.tag) {
|
|
36345
|
+
const started = await deps.hotfixStart(found.repo, { from: rec.from, ack: rec.ack });
|
|
36346
|
+
if (!RELEASE_TAG.test(started.tag)) throw new Error(`${found.repo}: hotfix start returned no vX.Y.Z tag`);
|
|
36347
|
+
rec.tag = started.tag;
|
|
36348
|
+
save();
|
|
36349
|
+
}
|
|
36350
|
+
if (!rec.released) {
|
|
36351
|
+
let out;
|
|
36352
|
+
try {
|
|
36353
|
+
out = await deps.hotfixRelease(found.repo, rec.tag);
|
|
36354
|
+
} catch (e) {
|
|
36355
|
+
throw new Error(`${e.message}; the hotfix ${rec.tag} is recorded: ${resume}`);
|
|
36356
|
+
}
|
|
36357
|
+
if (out.tag !== rec.tag) throw new Error(`${found.repo}: released ${out.tag}, expected ${rec.tag}; stop`);
|
|
36358
|
+
rec.released = true;
|
|
36359
|
+
save();
|
|
36360
|
+
}
|
|
36361
|
+
if (!rec.pinMerged) {
|
|
36362
|
+
const pin = await deps.soloPin(found.repo, rec.tag);
|
|
36363
|
+
if (pin.status === "superseded") throw new Error(`${found.id} pin at ${rec.tag} is superseded by ${pin.pr}; a newer ${found.id} release owns the pin; stop`);
|
|
36364
|
+
rec.pinPr = pin.pr;
|
|
36365
|
+
save();
|
|
36366
|
+
if (pin.status !== "merged") throw new Error(`pin PR ${pin.pr} is pending; merge it, then ${resume} (the hotfix is not repeated)`);
|
|
36367
|
+
rec.pinMerged = true;
|
|
36368
|
+
save();
|
|
36369
|
+
}
|
|
36370
|
+
if (!rec.leadTag) {
|
|
36371
|
+
const prNumber = /\/pull\/(\d+)$/.exec(rec.pinPr ?? "")?.[1];
|
|
36372
|
+
if (!prNumber) throw new Error(`pin PR ${rec.pinPr ?? "(none)"} has no PR number; MM-Work not hotfixed`);
|
|
36373
|
+
const started = await deps.hotfixStart(SUITE_REPO, { from: prNumber });
|
|
36374
|
+
if (!RELEASE_TAG.test(started.tag)) throw new Error(`${SUITE_REPO}: hotfix start returned no vX.Y.Z tag`);
|
|
36375
|
+
rec.leadTag = started.tag;
|
|
36376
|
+
save();
|
|
36377
|
+
}
|
|
36378
|
+
const leadTag = rec.leadTag;
|
|
36379
|
+
if (!rec.leadReleased) {
|
|
36380
|
+
let out;
|
|
36381
|
+
try {
|
|
36382
|
+
out = await deps.hotfixRelease(SUITE_REPO, leadTag);
|
|
36383
|
+
} catch (e) {
|
|
36384
|
+
throw new Error(`${e.message}; the MM-Work hotfix ${leadTag} is recorded: ${resume}`);
|
|
36385
|
+
}
|
|
36386
|
+
if (out.tag !== leadTag) throw new Error(`${SUITE_REPO}: released ${out.tag}, expected ${leadTag}; stop`);
|
|
36387
|
+
rec.leadReleased = true;
|
|
36388
|
+
save();
|
|
36389
|
+
}
|
|
36390
|
+
if (!rec.mapped) {
|
|
36391
|
+
const mapping = await deps.suiteMapping(leadTag);
|
|
36392
|
+
if (mapping[found.id] !== rec.tag) throw new Error(`MM-Work ${leadTag} pins ${found.id} at ${mapping[found.id] ?? "nothing"}, not the hotfix ${rec.tag}; mapping not recorded`);
|
|
36393
|
+
await deps.recordMapping(leadTag, mapping);
|
|
36394
|
+
rec.mapping = mapping;
|
|
36395
|
+
rec.mapped = true;
|
|
36396
|
+
save();
|
|
36397
|
+
}
|
|
36398
|
+
return { plan, record: rec };
|
|
36399
|
+
}
|
|
36058
36400
|
|
|
36059
36401
|
// src/suite-rc-data.ts
|
|
36060
36402
|
var RC_DATA_VAULT_REPO = "mutmutco/MM-Work";
|
|
@@ -36573,8 +36915,8 @@ async function preflightHotfixBatchPicks(deps, sources, pickTolerated) {
|
|
|
36573
36915
|
if (sources.length === 0) return { failures: [], skipped: [] };
|
|
36574
36916
|
const { mkdtemp: mkdtemp2, rm: rm2 } = await import("node:fs/promises");
|
|
36575
36917
|
const { tmpdir: tmpdir11 } = await import("node:os");
|
|
36576
|
-
const { join:
|
|
36577
|
-
const dir = await mkdtemp2(
|
|
36918
|
+
const { join: join46 } = await import("node:path");
|
|
36919
|
+
const dir = await mkdtemp2(join46(tmpdir11(), "mmi-hotfix-preflight-"));
|
|
36578
36920
|
const wt = { run: (file, args) => deps.run(file, file === "git" ? ["-C", dir, ...args] : args) };
|
|
36579
36921
|
const failures = [];
|
|
36580
36922
|
const skipped = [];
|
|
@@ -39747,6 +40089,59 @@ async function openSuiteCutPinPr(deps, input) {
|
|
|
39747
40089
|
if (scratch) await (0, import_promises6.rm)(scratch, { recursive: true, force: true });
|
|
39748
40090
|
}
|
|
39749
40091
|
}
|
|
40092
|
+
async function openSuiteRestorePinPr(deps, input) {
|
|
40093
|
+
if (!/^[a-f0-9]{40}$/.test(input.cut)) throw new Error("suite restore PR needs an anchored cut");
|
|
40094
|
+
const branch = `suite-pin/restore-${input.cut.slice(0, 16)}`;
|
|
40095
|
+
let scratch = "";
|
|
40096
|
+
try {
|
|
40097
|
+
const prs = JSON.parse(await deps.run("gh", ["pr", "list", "--repo", SUITE_REPO, "--head", branch, "--state", "all", "--json", "url,state"]));
|
|
40098
|
+
if (prs.some((p) => p.state === "CLOSED")) throw new Error(`${branch} was closed without landing`);
|
|
40099
|
+
if (prs.length) return { status: prs.some((p) => p.state === "MERGED") ? "merged" : "pending", pr: prs[0].url };
|
|
40100
|
+
const get = JSON.parse(await deps.run("gh", ["api", `repos/${SUITE_REPO}/contents/seed/modules.json?ref=development`]));
|
|
40101
|
+
if (!get.content) throw new Error("MM-Work seed/modules.json is unreadable");
|
|
40102
|
+
const rows = JSON.parse(Buffer.from(get.content, "base64").toString("utf8"));
|
|
40103
|
+
const todo = input.pins.filter((p) => {
|
|
40104
|
+
try {
|
|
40105
|
+
return new URL(rows.find((r) => r.id === p.module)?.bundles?.main ?? "").searchParams.get("v") === p.planned;
|
|
40106
|
+
} catch {
|
|
40107
|
+
return false;
|
|
40108
|
+
}
|
|
40109
|
+
});
|
|
40110
|
+
if (!todo.length) return void 0;
|
|
40111
|
+
await (0, import_promises6.mkdir)((0, import_node_path34.join)(deps.root, "tmp"), { recursive: true });
|
|
40112
|
+
scratch = await (0, import_promises6.mkdtemp)((0, import_node_path34.join)(deps.root, "tmp", "suite-pin-"));
|
|
40113
|
+
const run = (file, args) => deps.run(file, args, scratch);
|
|
40114
|
+
await run("git", ["clone", "--quiet", "--branch", "development", "--single-branch", `https://github.com/${SUITE_REPO}.git`, "."]);
|
|
40115
|
+
await run("git", ["switch", "-c", branch]);
|
|
40116
|
+
const path2 = (0, import_node_path34.join)(scratch, "main-pins.json");
|
|
40117
|
+
await (0, import_promises6.writeFile)(path2, JSON.stringify(todo.map((p) => ({ module: p.module, url: p.prior }))));
|
|
40118
|
+
await run("node", ["scripts/pin-modules.mjs", "--stage", "main", "--pins", path2]);
|
|
40119
|
+
await run("git", ["add", "seed/modules.json"]);
|
|
40120
|
+
await run("git", ["commit", "-m", `Restore main pins of abandoned suite cut ${input.cut.slice(0, 12)} (#7758)`]);
|
|
40121
|
+
await run("git", ["push", "origin", branch]);
|
|
40122
|
+
const pr = (await run("gh", [
|
|
40123
|
+
"pr",
|
|
40124
|
+
"create",
|
|
40125
|
+
"--repo",
|
|
40126
|
+
SUITE_REPO,
|
|
40127
|
+
"--base",
|
|
40128
|
+
"development",
|
|
40129
|
+
"--head",
|
|
40130
|
+
branch,
|
|
40131
|
+
"--title",
|
|
40132
|
+
"Restore main pins of abandoned suite cut",
|
|
40133
|
+
"--body",
|
|
40134
|
+
`Abandoned cut ${input.cut.slice(0, 12)} pinned main to releases that never shipped (${todo.map((p) => `${p.module} ${p.planned}`).join(", ")}); restore the pre-cut pins (#7758).`
|
|
40135
|
+
])).trim();
|
|
40136
|
+
if (!pr.includes("/pull/")) throw new Error(`gh pr create returned no PR URL: ${pr}`);
|
|
40137
|
+
await run("gh", ["pr", "merge", pr, "--repo", SUITE_REPO, "--auto", "--squash"]);
|
|
40138
|
+
return { status: "pending", pr };
|
|
40139
|
+
} catch (e) {
|
|
40140
|
+
throw new Error(`suite restore pin PR FAILED: ${e.message}; rerun suite abandon --owner-go (the record is kept until the restore PR exists)`);
|
|
40141
|
+
} finally {
|
|
40142
|
+
if (scratch) await (0, import_promises6.rm)(scratch, { recursive: true, force: true });
|
|
40143
|
+
}
|
|
40144
|
+
}
|
|
39750
40145
|
async function openSuitePinPr(deps, input) {
|
|
39751
40146
|
const { repo, tag, stage } = input;
|
|
39752
40147
|
const remedy = `open/merge the ${SUITE_REPO} development pin PR for ${repo} ${tag}, then rerun the same train command; do not recut the tag \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#suite-pin-pr`;
|
|
@@ -39958,7 +40353,7 @@ init_github_quota();
|
|
|
39958
40353
|
|
|
39959
40354
|
// src/issue-commands.ts
|
|
39960
40355
|
var import_node_fs34 = require("node:fs");
|
|
39961
|
-
var
|
|
40356
|
+
var import_node_crypto15 = require("node:crypto");
|
|
39962
40357
|
init_cli_shared();
|
|
39963
40358
|
init_clean_exit();
|
|
39964
40359
|
init_error_codes();
|
|
@@ -40431,7 +40826,7 @@ function rowIdempotencyKey(batchKey, spec) {
|
|
|
40431
40826
|
const identity = `${spec.type}
|
|
40432
40827
|
${spec.title.trim()}
|
|
40433
40828
|
${spec.body ?? ""}`;
|
|
40434
|
-
const hash = (0,
|
|
40829
|
+
const hash = (0, import_node_crypto15.createHash)("sha256").update(identity).digest("hex").slice(0, 16);
|
|
40435
40830
|
return `${batchKey}:${hash}`;
|
|
40436
40831
|
}
|
|
40437
40832
|
var BATCH_SPEC_KEYS = /* @__PURE__ */ new Set(["type", "title", "body", "priority", "labels", "label", "parent", "repo", "surface"]);
|
|
@@ -45819,7 +46214,7 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
|
|
|
45819
46214
|
async function pinSoloMember(repo, tag) {
|
|
45820
46215
|
const read = await fetchProjectBySlugChecked(slugOf(repo), registryClientDeps(await loadConfig()));
|
|
45821
46216
|
if (!read.ok || !read.project) throw new Error(`suite pin PR FAILED: registry META unreadable for ${repo} (${read.ok ? "missing" : read.error}); rerun the same train command after restoring registry access \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#suite-pin-pr`);
|
|
45822
|
-
if (!
|
|
46217
|
+
if (!isBundleOnlySuiteMember(repo, read.project.suite)) return void 0;
|
|
45823
46218
|
const root = (await execFileP("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
|
|
45824
46219
|
return openSuitePinPr({ root, run: async (file, args, cwd) => (await execFileP(file, args, { cwd, timeout: GIT_TIMEOUT_MS })).stdout }, { repo, tag, stage: "main" });
|
|
45825
46220
|
}
|
|
@@ -45854,8 +46249,8 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
|
|
|
45854
46249
|
} catch (e) {
|
|
45855
46250
|
return fail(`${commandName}: ${e.message}`);
|
|
45856
46251
|
}
|
|
45857
|
-
if (o.suiteSolo !== o.ownerGo
|
|
45858
|
-
return fail(`${commandName}:
|
|
46252
|
+
if (o.suiteSolo !== o.ownerGo)
|
|
46253
|
+
return fail(`${commandName}: --suite-solo and --owner-go must be passed together (a solo member release/hotfix, or a lead-only cut; a member rcand is always refused)`);
|
|
45859
46254
|
if (o.ack && commandName !== "release") {
|
|
45860
46255
|
return fail(`${commandName}: --ack applies only to release \u2014 it overrides the rc -> main hotfix-coverage guard, which rcand does not run. Run: mmi-cli devops release --ack <shas>`);
|
|
45861
46256
|
}
|
|
@@ -46357,16 +46752,18 @@ function suiteTrainIo(root, summaryFile, memberSummaryFiles = {}) {
|
|
|
46357
46752
|
throw new Error(`${repo}: suite checkout origin mismatch at ${target}; stop`);
|
|
46358
46753
|
return target;
|
|
46359
46754
|
};
|
|
46360
|
-
const inTrain = async (repo, action) => {
|
|
46755
|
+
const inTrain = async (repo, action, solo = false) => {
|
|
46361
46756
|
const target = await checkout(repo);
|
|
46362
46757
|
const before = process.cwd();
|
|
46363
46758
|
process.chdir(target);
|
|
46364
46759
|
try {
|
|
46365
46760
|
const deps = trainApplyDeps();
|
|
46366
|
-
if (
|
|
46761
|
+
if (solo) {
|
|
46762
|
+
} else if (repo !== SUITE_REPO) {
|
|
46367
46763
|
deps.suiteCut = suiteCutContext(repo, SUITE_REPO);
|
|
46368
46764
|
deps.announce = async () => ({ status: "skipped", note: "suite announcement belongs to MM-Work" });
|
|
46369
46765
|
} else {
|
|
46766
|
+
deps.suiteCut = suiteLeadContext(SUITE_REPO);
|
|
46370
46767
|
deps.announce = async () => ({ status: "skipped", note: "suite announcement after all members" });
|
|
46371
46768
|
}
|
|
46372
46769
|
return await action(deps);
|
|
@@ -46575,12 +46972,18 @@ function suiteTrainIo(root, summaryFile, memberSummaryFiles = {}) {
|
|
|
46575
46972
|
return false;
|
|
46576
46973
|
}
|
|
46577
46974
|
},
|
|
46975
|
+
changedPaths: async (repo, base2, head) => {
|
|
46976
|
+
const target = await checkout(repo);
|
|
46977
|
+
await exec("git", ["fetch", "--quiet", "origin", "--tags"], target, WATCH_TIMEOUT);
|
|
46978
|
+
return (await exec("git", ["diff", "--name-only", base2, head], target)).split("\n").map((f) => f.trim()).filter(Boolean);
|
|
46979
|
+
},
|
|
46578
46980
|
rcand: runRcand,
|
|
46579
46981
|
publish,
|
|
46580
46982
|
preflight: preflight2,
|
|
46581
46983
|
publishMain,
|
|
46582
46984
|
preflightMain,
|
|
46583
46985
|
pin: (input) => openSuiteCutPinPr({ root, run: (file, args, cwd) => exec(file, args, cwd, GIT_TIMEOUT_MS) }, input),
|
|
46986
|
+
restoreMain: (input) => openSuiteRestorePinPr({ root, run: (file, args, cwd) => exec(file, args, cwd, GIT_TIMEOUT_MS) }, input),
|
|
46584
46987
|
verifyLeadCut: async (sha) => {
|
|
46585
46988
|
const current = await gh(["api", `repos/${SUITE_REPO}/git/ref/heads/development`, "--jq", ".object.sha"]);
|
|
46586
46989
|
if (current.toLowerCase() === sha.toLowerCase()) return;
|
|
@@ -46612,6 +47015,39 @@ function suiteTrainIo(root, summaryFile, memberSummaryFiles = {}) {
|
|
|
46612
47015
|
return void 0;
|
|
46613
47016
|
}
|
|
46614
47017
|
},
|
|
47018
|
+
// #7738: a member hotfixes as suite-solo; the lead hotfixes under its suite-lead capability (inTrain sets it).
|
|
47019
|
+
hotfixStart: (repo, o) => inTrain(repo, async (deps) => {
|
|
47020
|
+
const r = await runHotfixStart(deps, { from: o.from, ack: o.ack, suiteSolo: repo !== SUITE_REPO });
|
|
47021
|
+
return { tag: r.tag };
|
|
47022
|
+
}, repo !== SUITE_REPO),
|
|
47023
|
+
hotfixRelease: (repo, tag) => inTrain(repo, async (deps) => ({ tag: (await runHotfixRelease(deps, tag, { suiteSolo: repo !== SUITE_REPO })).tag }), repo !== SUITE_REPO),
|
|
47024
|
+
// #7738: the #7714 solo pin leg, polled until the auto-merge lands (30 s x 40 = 20 min), then the caller decides.
|
|
47025
|
+
soloPin: async (repo, tag) => {
|
|
47026
|
+
for (let i = 0; ; i++) {
|
|
47027
|
+
const pin = await openSuitePinPr({ root, run: (file, args, cwd) => exec(file, args, cwd, GIT_TIMEOUT_MS) }, { repo, tag, stage: "main" });
|
|
47028
|
+
if (pin.status !== "pending" || i >= 40) return pin;
|
|
47029
|
+
await new Promise((resolve7) => setTimeout(resolve7, 3e4));
|
|
47030
|
+
}
|
|
47031
|
+
},
|
|
47032
|
+
suiteMapping: async (leadTag) => {
|
|
47033
|
+
const rows = suiteMembers(await registry2(leadTag)).filter((m) => m.repo !== SUITE_REPO);
|
|
47034
|
+
return Object.fromEntries(rows.map((m) => {
|
|
47035
|
+
const v = bundleTag(m.bundles.main);
|
|
47036
|
+
if (!v) throw new Error(`${m.id}: MM-Work ${leadTag} has no versioned main pin; mapping not recorded`);
|
|
47037
|
+
return [m.id, v];
|
|
47038
|
+
}));
|
|
47039
|
+
},
|
|
47040
|
+
recordMapping: async (leadTag, mapping) => {
|
|
47041
|
+
const body = await gh(["release", "view", leadTag, "--repo", SUITE_REPO, "--json", "body", "--jq", ".body"]);
|
|
47042
|
+
if (parseSuiteMapping(body)) return;
|
|
47043
|
+
const dir = repoRuntimeStatePath(root, "suite");
|
|
47044
|
+
(0, import_node_fs43.mkdirSync)(dir, { recursive: true });
|
|
47045
|
+
const file = (0, import_node_path43.join)(dir, `notes-${leadTag}.md`);
|
|
47046
|
+
(0, import_node_fs43.writeFileSync)(file, `${body}
|
|
47047
|
+
|
|
47048
|
+
${suiteMappingBlock(leadTag, mapping)}`);
|
|
47049
|
+
await gh(["release", "edit", leadTag, "--repo", SUITE_REPO, "--notes-file", file]);
|
|
47050
|
+
},
|
|
46615
47051
|
release,
|
|
46616
47052
|
shippedTag,
|
|
46617
47053
|
sameContent: sameContent2,
|
|
@@ -46668,9 +47104,9 @@ function registerSuiteCommands(program3) {
|
|
|
46668
47104
|
if (repo?.toLowerCase() !== SUITE_REPO.toLowerCase()) throw new Error(`suite commands run only from ${SUITE_REPO} (got ${repo ?? "unknown"})`);
|
|
46669
47105
|
return (await execFileP("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
|
|
46670
47106
|
};
|
|
46671
|
-
suite.command("rcand").description("cut all module candidates, publish bundle-only rc, merge one two-stage pin PR, then cut MM-Work").option("--owner-go", "explicit approval: rc tests on live data").option("--dry-run", "
|
|
47107
|
+
suite.command("rcand").description("cut all module candidates, publish bundle-only rc, merge one two-stage pin PR, then cut MM-Work").option("--owner-go", "explicit approval: rc tests on live data (not needed for --dry-run)").option("--dry-run", "read-only: per member HELD / carried / new candidate, flagging a pending backend release; no --owner-go needed. Shows the roster and sequence without writing").option("--json", "structured receipt").action(async (o) => {
|
|
46672
47108
|
try {
|
|
46673
|
-
if (!o.ownerGo) throw new Error("suite rcand requires --owner-go (rc uses live data); nothing was written");
|
|
47109
|
+
if (!o.ownerGo && !o.dryRun) throw new Error("suite rcand requires --owner-go (rc uses live data); nothing was written");
|
|
46674
47110
|
const root = await leadRoot();
|
|
46675
47111
|
const unlock = o.dryRun ? async () => void 0 : await acquireTrainWriterLock(root, "suite rcand");
|
|
46676
47112
|
let result;
|
|
@@ -46689,19 +47125,39 @@ function registerSuiteCommands(program3) {
|
|
|
46689
47125
|
return fail(`suite rcand: ${e.message}`);
|
|
46690
47126
|
}
|
|
46691
47127
|
});
|
|
47128
|
+
const holdCmd = suite.command("hold").description("freeze or unfreeze a member live release in the suite train (registry META suiteHold, #7735)");
|
|
47129
|
+
const holdWrite = async (member, hold) => {
|
|
47130
|
+
try {
|
|
47131
|
+
const root = await leadRoot();
|
|
47132
|
+
const reg = registryClientDeps(await loadConfig());
|
|
47133
|
+
console.log(await setSuiteHold({
|
|
47134
|
+
registry: () => suiteTrainIo(root).registry(),
|
|
47135
|
+
read: (slug) => fetchProjectBySlugChecked(slug, reg),
|
|
47136
|
+
write: (slug, patch) => upsertProject(slug, patch, reg)
|
|
47137
|
+
}, member, hold));
|
|
47138
|
+
} catch (e) {
|
|
47139
|
+
return fail(`suite hold: ${e.message}`);
|
|
47140
|
+
}
|
|
47141
|
+
};
|
|
47142
|
+
holdCmd.command("set <member-repo>").description("cut the member out of the suite train until cleared").requiredOption("--reason <text>", "why the live release is frozen").option("--since <date>", "when the freeze began (e.g. 2026-09-28)").action((member, o) => holdWrite(member, { reason: o.reason, ...o.since ? { since: o.since } : {} }));
|
|
47143
|
+
holdCmd.command("clear <member-repo>").description("put the member back in the suite train").action((member) => holdWrite(member, null));
|
|
46692
47144
|
suite.command("abandon").description("retire an unfinished cut: archive the record; member rc tags stay (#7396). A cut whose pin PR landed but released nothing needs --owner-go (#7450)").option("--owner-go", "explicit owner approval to retire a cut whose pin PR already landed").option("--json", "structured receipt").action(async (o) => {
|
|
46693
47145
|
try {
|
|
46694
47146
|
const root = await leadRoot();
|
|
46695
47147
|
const unlock = await acquireTrainWriterLock(root, "suite abandon");
|
|
46696
47148
|
let result;
|
|
47149
|
+
let restored;
|
|
46697
47150
|
try {
|
|
46698
|
-
|
|
47151
|
+
const io = suiteTrainIo(root);
|
|
47152
|
+
restored = o.ownerGo === true ? await suiteRestoreMainPins(io) : void 0;
|
|
47153
|
+
result = suiteAbandon(io.recordPath, o.ownerGo === true);
|
|
46699
47154
|
} finally {
|
|
46700
47155
|
await unlock();
|
|
46701
47156
|
}
|
|
46702
47157
|
console.log(o.json ? JSON.stringify(result, null, 2) : [
|
|
46703
47158
|
`suite abandon: archived the cut record to ${result.archive}`,
|
|
46704
47159
|
...result.orphanTags.length ? [" member rc tags left in place (immutable; the next cut reuses one still unreleased on its rc head, #7450):", ...result.orphanTags.map((t) => ` - ${t}`)] : [" no member rc tag was minted"],
|
|
47160
|
+
...restored ? [` main pins the cut planned for unreleased tags are restored by ${restored.pr} (${restored.status}, #7758)`] : [],
|
|
46705
47161
|
" the next suite rcand --owner-go re-snapshots every member"
|
|
46706
47162
|
].join("\n"));
|
|
46707
47163
|
} catch (e) {
|
|
@@ -46733,6 +47189,26 @@ function registerSuiteCommands(program3) {
|
|
|
46733
47189
|
return fail(`suite release: ${e.message}`);
|
|
46734
47190
|
}
|
|
46735
47191
|
});
|
|
47192
|
+
suite.command("hotfix <member>").description("member hotfix, main pin PR, MM-Work patch release, and the recorded suite version -> member versions mapping (#7738)").requiredOption("--from <pr#|sha[,pr#|sha...]>", "merged development PR(s) or SHA(s) the member hotfix carries").option("--ack <shas>", "dev shas verified as not needed on main (see hotfix start --ack)").option("--owner-go", "explicit owner approval: ships the member and MM-Work to production (not needed for --dry-run)").option("--dry-run", "read-only: print the ordered plan; writes nothing").option("--json", "structured receipt").action(async (member, o) => {
|
|
47193
|
+
try {
|
|
47194
|
+
if (!o.ownerGo && !o.dryRun) throw new Error("suite hotfix requires --owner-go (it ships to production); nothing was written. --dry-run prints the plan");
|
|
47195
|
+
const root = await leadRoot();
|
|
47196
|
+
const unlock = o.dryRun ? async () => void 0 : await acquireTrainWriterLock(root, "suite hotfix");
|
|
47197
|
+
let result;
|
|
47198
|
+
try {
|
|
47199
|
+
result = await suiteHotfix(suiteTrainIo(root), { member, from: o.from, ack: o.ack?.split(",").map((s) => s.trim()).filter(Boolean), ownerGo: o.ownerGo, dryRun: o.dryRun });
|
|
47200
|
+
} finally {
|
|
47201
|
+
await unlock();
|
|
47202
|
+
}
|
|
47203
|
+
console.log(o.json ? JSON.stringify(result, null, 2) : [
|
|
47204
|
+
`suite hotfix ${o.dryRun ? "dry-run" : "receipt"}:`,
|
|
47205
|
+
...result.plan.map((line) => ` - ${line}`),
|
|
47206
|
+
...result.record?.mapping ? [` - MM-Work ${result.record.leadTag} = members ${Object.entries(result.record.mapping).map(([id, v]) => `${id} ${v}`).join(", ")}`] : []
|
|
47207
|
+
].join("\n"));
|
|
47208
|
+
} catch (e) {
|
|
47209
|
+
return fail(`suite hotfix: ${e.message}`);
|
|
47210
|
+
}
|
|
47211
|
+
});
|
|
46736
47212
|
suite.command("rc-data").description("switch the rc Preview envs between the live and rc data sets as one bound switch (#7588)").option("--to <direction>", "switch source: live or rc").option("--apply", "write the switch (default is a dry-run plan)").option("--owner-go", "explicit approval: touches the live data key").option("--json", "structured receipt").action(async (o) => {
|
|
46737
47213
|
try {
|
|
46738
47214
|
if (o.to !== "live" && o.to !== "rc") throw new Error(`--to must be live or rc (got ${o.to ?? "nothing"}); nothing was written`);
|
|
@@ -46758,56 +47234,101 @@ function registerSuiteCommands(program3) {
|
|
|
46758
47234
|
return fail(`suite rc-data: ${e.message}`);
|
|
46759
47235
|
}
|
|
46760
47236
|
});
|
|
46761
|
-
suite.command("status").description("compare served versions with development, rc and main branch heads across MM-Work modules").option("--suite <name>", "suite name (mm-work)", "mm-work").option("--json", "structured output").action(async (o) => {
|
|
47237
|
+
suite.command("status").description("compare served versions with development, rc and main branch heads across MM-Work modules").option("--suite <name>", "suite name (mm-work)", "mm-work").option("--json", "structured output").option("--markdown", "print a markdown docs table of member versions per stage and the suite tag that pins each").action(async (o) => {
|
|
46762
47238
|
if (o.suite !== "mm-work") return fail("suite status: only mm-work is supported");
|
|
46763
47239
|
const github = defaultGitHubClient();
|
|
46764
47240
|
const cfg = await loadConfig();
|
|
46765
47241
|
const registry2 = registryClientDeps(cfg);
|
|
46766
47242
|
try {
|
|
46767
|
-
const report = await readSuiteStatus(
|
|
46768
|
-
|
|
46769
|
-
const file = await github.rest("GET", `repos/${SUITE_REPO}/contents/seed/modules.json?ref=development`);
|
|
46770
|
-
if (!file?.content) throw new Error("MM-Work module registry is unavailable");
|
|
46771
|
-
return JSON.parse(decodeGitHubContents(file.content));
|
|
46772
|
-
},
|
|
46773
|
-
head: async (repo, branch) => {
|
|
46774
|
-
try {
|
|
46775
|
-
const ref = await github.rest("GET", `repos/${repo}/git/ref/heads/${branch}`);
|
|
46776
|
-
return ref?.object?.sha ?? "";
|
|
46777
|
-
} catch (e) {
|
|
46778
|
-
if (e instanceof GitHubApiError && e.status === 404) return null;
|
|
46779
|
-
throw e;
|
|
46780
|
-
}
|
|
46781
|
-
},
|
|
46782
|
-
tagCommit: async (repo, tag) => {
|
|
46783
|
-
const commit = await github.rest("GET", `repos/${repo}/commits/${encodeURIComponent(tag)}`);
|
|
46784
|
-
return commit?.sha;
|
|
46785
|
-
},
|
|
46786
|
-
deploy: async (repo, stage) => {
|
|
46787
|
-
const slug = repo.split("/")[1].toLowerCase();
|
|
46788
|
-
const facts = await fetchDeployFactsBySlug(slug, registry2);
|
|
46789
|
-
if (!facts) return void 0;
|
|
46790
|
-
const url = publicUrlFromDeployFact(facts.stages[stage]);
|
|
46791
|
-
const [health, lastRun] = await Promise.all([
|
|
46792
|
-
url ? probePublicHealth(url) : Promise.resolve(void 0),
|
|
46793
|
-
fetchLastTenantDeployRun(slug, stage)
|
|
46794
|
-
]);
|
|
46795
|
-
return buildDeployStatusReport({
|
|
46796
|
-
repo,
|
|
46797
|
-
slug,
|
|
46798
|
-
stage,
|
|
46799
|
-
deployFacts: facts,
|
|
46800
|
-
lastRun: lastRun.run,
|
|
46801
|
-
lastRunAbsence: lastRun.absence,
|
|
46802
|
-
health
|
|
46803
|
-
});
|
|
46804
|
-
}
|
|
46805
|
-
});
|
|
46806
|
-
console.log(o.json ? JSON.stringify(report, null, 2) : formatSuiteStatus(report));
|
|
47243
|
+
const report = await readSuiteStatus(suiteStatusDeps(github, registry2));
|
|
47244
|
+
console.log(o.json ? JSON.stringify(report, null, 2) : o.markdown ? formatSuiteMarkdown(report) : formatSuiteStatus(report));
|
|
46807
47245
|
} catch (e) {
|
|
46808
47246
|
return fail(`suite status: ${e.message}`);
|
|
46809
47247
|
}
|
|
46810
47248
|
});
|
|
47249
|
+
suite.command("dev").description("per member: development head, the version dev serves and the newest green SHA; --wait until none is lagging (#7739)").option("--wait", "poll every 60s until no member is lagging (blocked or unknown members end the wait)").option("--timeout <minutes>", "how long --wait polls", "30").option("--json", "structured output").action(async (o) => {
|
|
47250
|
+
const github = defaultGitHubClient();
|
|
47251
|
+
const registry2 = registryClientDeps(await loadConfig());
|
|
47252
|
+
const status = suiteStatusDeps(github, registry2);
|
|
47253
|
+
const deps = {
|
|
47254
|
+
status: () => readSuiteStatus(status),
|
|
47255
|
+
commits: async (repo, limit) => (await github.rest("GET", `repos/${repo}/commits?sha=development&per_page=${limit}`) ?? []).map((c) => c.sha),
|
|
47256
|
+
checkRuns: async (repo, sha) => (await github.rest("GET", `repos/${repo}/commits/${sha}/check-runs?per_page=100`))?.check_runs ?? [],
|
|
47257
|
+
requiredChecks: async (repo) => {
|
|
47258
|
+
const meta = await fetchProjectBySlugChecked(repo.split("/")[1].toLowerCase(), registry2);
|
|
47259
|
+
if (!meta.ok) throw new Error(`${repo} META unreadable: ${meta.error}`);
|
|
47260
|
+
return meta.project?.requiredChecks ?? ["gate"];
|
|
47261
|
+
}
|
|
47262
|
+
};
|
|
47263
|
+
try {
|
|
47264
|
+
const minutes = Number(o.timeout);
|
|
47265
|
+
if (!Number.isFinite(minutes) || minutes <= 0) return fail("suite dev: --timeout must be a positive number of minutes");
|
|
47266
|
+
const rows = o.wait ? await waitSuiteDev(deps, { timeoutMs: minutes * 6e4, intervalMs: 6e4, onTick: (r) => {
|
|
47267
|
+
if (!o.json) console.error(`suite dev: ${r.filter((x) => x.state === "lagging").length} lagging`);
|
|
47268
|
+
} }) : await readSuiteDev(deps);
|
|
47269
|
+
console.log(o.json ? JSON.stringify(rows, null, 2) : formatSuiteDev(rows));
|
|
47270
|
+
if (rows.some((r) => r.state !== "converged")) process.exitCode = 1;
|
|
47271
|
+
} catch (e) {
|
|
47272
|
+
return fail(`suite dev: ${e.message}`);
|
|
47273
|
+
}
|
|
47274
|
+
});
|
|
47275
|
+
}
|
|
47276
|
+
function suiteStatusDeps(github, registry2) {
|
|
47277
|
+
return {
|
|
47278
|
+
registry: async () => {
|
|
47279
|
+
const file = await github.rest("GET", `repos/${SUITE_REPO}/contents/seed/modules.json?ref=development`);
|
|
47280
|
+
if (!file?.content) throw new Error("MM-Work module registry is unavailable");
|
|
47281
|
+
return JSON.parse(decodeGitHubContents(file.content));
|
|
47282
|
+
},
|
|
47283
|
+
head: async (repo, branch) => {
|
|
47284
|
+
try {
|
|
47285
|
+
const ref = await github.rest("GET", `repos/${repo}/git/ref/heads/${branch}`);
|
|
47286
|
+
return ref?.object?.sha ?? "";
|
|
47287
|
+
} catch (e) {
|
|
47288
|
+
if (e instanceof GitHubApiError && e.status === 404) return null;
|
|
47289
|
+
throw e;
|
|
47290
|
+
}
|
|
47291
|
+
},
|
|
47292
|
+
tagCommit: async (repo, tag) => {
|
|
47293
|
+
try {
|
|
47294
|
+
const commit = await github.rest("GET", `repos/${repo}/commits/${encodeURIComponent(tag)}`);
|
|
47295
|
+
return commit?.sha;
|
|
47296
|
+
} catch (e) {
|
|
47297
|
+
if (e instanceof GitHubApiError && (e.status === 404 || e.status === 422)) return void 0;
|
|
47298
|
+
throw e;
|
|
47299
|
+
}
|
|
47300
|
+
},
|
|
47301
|
+
fetchUrl: async (url) => {
|
|
47302
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(1e4), redirect: "follow" });
|
|
47303
|
+
return { status: res.status, body: new Uint8Array(await res.arrayBuffer()) };
|
|
47304
|
+
},
|
|
47305
|
+
leadTags: async () => (await github.rest("GET", `repos/${SUITE_REPO}/tags?per_page=100`) ?? []).map((t) => t.name),
|
|
47306
|
+
leadReleaseBody: async (tag) => (await github.rest("GET", `repos/${SUITE_REPO}/releases/tags/${encodeURIComponent(tag)}`))?.body ?? null,
|
|
47307
|
+
registryAt: async (ref) => {
|
|
47308
|
+
const file = await github.rest("GET", `repos/${SUITE_REPO}/contents/seed/modules.json?ref=${encodeURIComponent(ref)}`);
|
|
47309
|
+
if (!file?.content) throw new Error(`MM-Work seed/modules.json at ${ref} is unavailable`);
|
|
47310
|
+
return JSON.parse(decodeGitHubContents(file.content));
|
|
47311
|
+
},
|
|
47312
|
+
deploy: async (repo, stage) => {
|
|
47313
|
+
const slug = repo.split("/")[1].toLowerCase();
|
|
47314
|
+
const facts = await fetchDeployFactsBySlug(slug, registry2);
|
|
47315
|
+
if (!facts) return void 0;
|
|
47316
|
+
const url = publicUrlFromDeployFact(facts.stages[stage]);
|
|
47317
|
+
const [health, lastRun] = await Promise.all([
|
|
47318
|
+
url ? probePublicHealth(url) : Promise.resolve(void 0),
|
|
47319
|
+
fetchLastTenantDeployRun(slug, stage)
|
|
47320
|
+
]);
|
|
47321
|
+
return buildDeployStatusReport({
|
|
47322
|
+
repo,
|
|
47323
|
+
slug,
|
|
47324
|
+
stage,
|
|
47325
|
+
deployFacts: facts,
|
|
47326
|
+
lastRun: lastRun.run,
|
|
47327
|
+
lastRunAbsence: lastRun.absence,
|
|
47328
|
+
health
|
|
47329
|
+
});
|
|
47330
|
+
}
|
|
47331
|
+
};
|
|
46811
47332
|
}
|
|
46812
47333
|
|
|
46813
47334
|
// src/discovery-commands.ts
|
|
@@ -50806,14 +51327,14 @@ vercel preview (${preview.branch}): ${d.state}${d.url ? ` \u2014 ${d.url}` : ""}
|
|
|
50806
51327
|
}
|
|
50807
51328
|
|
|
50808
51329
|
// src/tenant-artifact.ts
|
|
50809
|
-
var
|
|
51330
|
+
var import_node_crypto16 = require("node:crypto");
|
|
50810
51331
|
var import_node_fs48 = require("node:fs");
|
|
50811
51332
|
var import_promises11 = require("node:fs/promises");
|
|
50812
51333
|
var import_node_path46 = require("node:path");
|
|
50813
51334
|
var ARTIFACT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
50814
51335
|
var MAX_BYTES = 5 * 1024 * 1024 * 1024;
|
|
50815
51336
|
async function sha256File(path2) {
|
|
50816
|
-
const hash = (0,
|
|
51337
|
+
const hash = (0, import_node_crypto16.createHash)("sha256");
|
|
50817
51338
|
for await (const chunk of (0, import_node_fs48.createReadStream)(path2)) hash.update(chunk);
|
|
50818
51339
|
return hash.digest("hex");
|
|
50819
51340
|
}
|
|
@@ -50823,8 +51344,8 @@ async function putTenantArtifact(repo, stage, inputPath, deps) {
|
|
|
50823
51344
|
const info = await (0, import_promises11.stat)(path2);
|
|
50824
51345
|
if (!info.isFile()) throw new Error("tenant artifact put: input path must be a file");
|
|
50825
51346
|
if (!Number.isSafeInteger(info.size) || info.size < 1 || info.size > MAX_BYTES) throw new Error(`tenant artifact put: file must be 1..${MAX_BYTES} bytes`);
|
|
50826
|
-
const
|
|
50827
|
-
const prepared = await tenantArtifactUpload({ repo, stage, size: info.size, sha256:
|
|
51347
|
+
const sha2563 = await sha256File(path2);
|
|
51348
|
+
const prepared = await tenantArtifactUpload({ repo, stage, size: info.size, sha256: sha2563 }, deps);
|
|
50828
51349
|
if (!prepared.ok) {
|
|
50829
51350
|
const detail = prepared.body?.error ?? prepared.error ?? `HTTP ${prepared.status}`;
|
|
50830
51351
|
throw new Error(`tenant artifact put: ${detail}`);
|
|
@@ -50852,8 +51373,8 @@ async function putTenantArtifact(repo, stage, inputPath, deps) {
|
|
|
50852
51373
|
throw error;
|
|
50853
51374
|
}
|
|
50854
51375
|
if (!uploaded.ok) throw new Error(`tenant artifact put: object upload failed (HTTP ${uploaded.status})`);
|
|
50855
|
-
if (body.size !== info.size || body.sha256 !==
|
|
50856
|
-
return { artifactId: body.artifactId, repo, stage, size: info.size, sha256:
|
|
51376
|
+
if (body.size !== info.size || body.sha256 !== sha2563 || typeof body.expiresAt !== "string" || !Number.isFinite(Date.parse(body.expiresAt))) throw new Error("tenant artifact put: Hub returned inconsistent artifact metadata");
|
|
51377
|
+
return { artifactId: body.artifactId, repo, stage, size: info.size, sha256: sha2563, expiresAt: body.expiresAt };
|
|
50857
51378
|
}
|
|
50858
51379
|
|
|
50859
51380
|
// src/tenant-sweep.ts
|
|
@@ -51043,13 +51564,13 @@ async function executeWaveLand(plan, deps) {
|
|
|
51043
51564
|
|
|
51044
51565
|
// src/dist-drift.ts
|
|
51045
51566
|
var import_node_child_process18 = require("node:child_process");
|
|
51046
|
-
var
|
|
51567
|
+
var import_node_crypto18 = require("node:crypto");
|
|
51047
51568
|
var import_node_fs50 = require("node:fs");
|
|
51048
51569
|
var import_node_os22 = require("node:os");
|
|
51049
51570
|
var import_node_path48 = require("node:path");
|
|
51050
51571
|
|
|
51051
51572
|
// ../scripts/distribution-digest.mjs
|
|
51052
|
-
var
|
|
51573
|
+
var import_node_crypto17 = require("node:crypto");
|
|
51053
51574
|
var import_node_fs49 = require("node:fs");
|
|
51054
51575
|
var import_node_path47 = require("node:path");
|
|
51055
51576
|
var slash = (value) => value.replaceAll("\\", "/");
|
|
@@ -51062,7 +51583,7 @@ function repoPath(root, declaredPath, label) {
|
|
|
51062
51583
|
return target;
|
|
51063
51584
|
}
|
|
51064
51585
|
function digestFiles(files) {
|
|
51065
|
-
const hash = (0,
|
|
51586
|
+
const hash = (0, import_node_crypto17.createHash)("sha256");
|
|
51066
51587
|
for (const file of [...files].sort((a, b) => a.relative.localeCompare(b.relative))) {
|
|
51067
51588
|
const content = file.stat.isSymbolicLink() ? Buffer.from((0, import_node_fs49.readlinkSync)(file.absolute), "utf8") : (0, import_node_fs49.readFileSync)(file.absolute);
|
|
51068
51589
|
hash.update(file.relative, "utf8");
|
|
@@ -51092,10 +51613,10 @@ var DIST_ARTIFACTS = [
|
|
|
51092
51613
|
];
|
|
51093
51614
|
var BOM_DIST_TREE_ID = "mmi-cli-dist";
|
|
51094
51615
|
var ABSENT = "absent";
|
|
51095
|
-
var
|
|
51616
|
+
var sha2562 = (bytes) => `sha256:${(0, import_node_crypto18.createHash)("sha256").update(bytes).digest("hex")}`;
|
|
51096
51617
|
function artifactDrift(path2, committedBytes, rebuiltBytes) {
|
|
51097
|
-
const committed = committedBytes ?
|
|
51098
|
-
const rebuiltExpected = rebuiltBytes ?
|
|
51618
|
+
const committed = committedBytes ? sha2562(committedBytes) : ABSENT;
|
|
51619
|
+
const rebuiltExpected = rebuiltBytes ? sha2562(rebuiltBytes) : ABSENT;
|
|
51099
51620
|
return { path: path2, status: committed === rebuiltExpected ? "current" : "stale", committed, rebuiltExpected };
|
|
51100
51621
|
}
|
|
51101
51622
|
function distTreeIdentity(labels, rebuilt, committed, digest) {
|
package/package.json
CHANGED