@mutmutco/cli 3.99.0 → 3.101.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.cjs +316 -20
- package/package.json +1 -1
package/dist/main.cjs
CHANGED
|
@@ -12168,6 +12168,16 @@ async function auditRepoCi(repo, deps) {
|
|
|
12168
12168
|
});
|
|
12169
12169
|
}
|
|
12170
12170
|
}
|
|
12171
|
+
if (repoClass === "deployable") {
|
|
12172
|
+
const exemptReason = meta?.ciExemptReason?.trim();
|
|
12173
|
+
const declaredExempt = statusChecks.size === 0 && !!exemptReason;
|
|
12174
|
+
checks.push({
|
|
12175
|
+
ok: statusChecks.size > 0 || declaredExempt,
|
|
12176
|
+
label: declaredExempt ? "deployable repo declared CI-exempt, not required to gate (#4265)" : "deployable repo requires at least one status check (#4260)",
|
|
12177
|
+
detail: declaredExempt ? `declared-exempt: ${exemptReason}` : statusChecks.size === 0 ? "zero required contexts on an active branch ruleset \u2014 any PR, including an automation seed PR, merges on an empty gate" : void 0,
|
|
12178
|
+
remediation: declaredExempt || statusChecks.size > 0 ? void 0 : `Determine this repo's correct gate context from its own PR-triggered workflow, set it via mmi-cli org project set ${repo} --var requiredChecks=[...], then mmi-cli ci reconcile --repo ${repo} --apply, or, if this repo genuinely has no CI surface, declare it via mmi-cli org project set ${repo} --var ciExemptReason="<why>" (#4265)`
|
|
12179
|
+
});
|
|
12180
|
+
}
|
|
12171
12181
|
if (deployableGated || repoClass === "hub") {
|
|
12172
12182
|
const gate = await readDefaultBranchGate(repo, deps.client, baseBranch, Date.now(), gateWorkflowFiles(prWorkflowPaths));
|
|
12173
12183
|
checks.push({
|
|
@@ -13865,6 +13875,12 @@ async function postSchedulesMode(slug, mode, deps) {
|
|
|
13865
13875
|
async function postSchedulesRun(id, deps) {
|
|
13866
13876
|
return postJson("/schedules/run", { id }, deps, "POST", { noRetry: true });
|
|
13867
13877
|
}
|
|
13878
|
+
async function postSchedulesPark(id, reason, deps) {
|
|
13879
|
+
return postJson("/schedules/park", { id, reason }, deps);
|
|
13880
|
+
}
|
|
13881
|
+
async function postSchedulesUnpark(id, deps) {
|
|
13882
|
+
return postJson("/schedules/unpark", { id }, deps);
|
|
13883
|
+
}
|
|
13868
13884
|
async function tenantControl(payload, deps) {
|
|
13869
13885
|
return postJson("/tenant-control", payload, deps, "POST", { noRetry: true });
|
|
13870
13886
|
}
|
|
@@ -20923,7 +20939,7 @@ function authorizeBodyHasMismatch(body) {
|
|
|
20923
20939
|
}
|
|
20924
20940
|
|
|
20925
20941
|
// src/project-set.ts
|
|
20926
|
-
var UNSET_KEYS = ["oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "dsManifestPath", "fofuEnabled", "consumesDesignSystem", "ci", "requiredChecks", "gate"];
|
|
20942
|
+
var UNSET_KEYS = ["oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "dsManifestPath", "fofuEnabled", "consumesDesignSystem", "ci", "requiredChecks", "ciExemptReason", "gate", "seedCanary"];
|
|
20927
20943
|
var UNSET_KEY_SET = new Set(UNSET_KEYS);
|
|
20928
20944
|
var RUNTIME_SECRET_STAGES = ["dev", "rc", "main"];
|
|
20929
20945
|
var SECRET_CONSUMERS = ["runtime", "build", "lambda", "actions", "agent", "box"];
|
|
@@ -21177,6 +21193,11 @@ function parseRuntimeVaultOnlyVar(raw) {
|
|
|
21177
21193
|
if (raw === "false") return false;
|
|
21178
21194
|
throw new Error("org project set: runtimeVaultOnly must be true or false");
|
|
21179
21195
|
}
|
|
21196
|
+
function parseSeedCanaryVar(raw) {
|
|
21197
|
+
if (raw === "true") return true;
|
|
21198
|
+
if (raw === "false") return false;
|
|
21199
|
+
throw new Error("org project set: seedCanary must be true or false");
|
|
21200
|
+
}
|
|
21180
21201
|
function parseConsumesDesignSystemVar(raw) {
|
|
21181
21202
|
if (raw === "fofu") return raw;
|
|
21182
21203
|
throw new Error('org project set: consumesDesignSystem must be "fofu"');
|
|
@@ -21213,6 +21234,11 @@ function parseRequiredChecksVar(raw) {
|
|
|
21213
21234
|
}
|
|
21214
21235
|
return parsed.map((c) => c.trim());
|
|
21215
21236
|
}
|
|
21237
|
+
function parseCiExemptReasonVar(raw) {
|
|
21238
|
+
const trimmed = raw.trim();
|
|
21239
|
+
if (!trimmed) throw new Error("org project set: ciExemptReason must be a non-empty reason (or use --unset ciExemptReason to clear it)");
|
|
21240
|
+
return trimmed;
|
|
21241
|
+
}
|
|
21216
21242
|
function parseGateVar(raw) {
|
|
21217
21243
|
let parsed;
|
|
21218
21244
|
try {
|
|
@@ -21265,8 +21291,10 @@ var SETTABLE_VAR_KEYS = [
|
|
|
21265
21291
|
"portRange",
|
|
21266
21292
|
"ci",
|
|
21267
21293
|
"requiredChecks",
|
|
21294
|
+
"ciExemptReason",
|
|
21268
21295
|
"gate",
|
|
21269
|
-
"secrets"
|
|
21296
|
+
"secrets",
|
|
21297
|
+
"seedCanary"
|
|
21270
21298
|
];
|
|
21271
21299
|
var SETTABLE_VAR_KEY_SET = new Set(SETTABLE_VAR_KEYS);
|
|
21272
21300
|
var SETTABLE_VAR_HINTS = {
|
|
@@ -21277,6 +21305,7 @@ var SETTABLE_VAR_HINTS = {
|
|
|
21277
21305
|
fofuEnabled: "true|false",
|
|
21278
21306
|
consumesDesignSystem: '"fofu"',
|
|
21279
21307
|
runtimeVaultOnly: "true|false",
|
|
21308
|
+
seedCanary: "true|false",
|
|
21280
21309
|
repos: 'JSON array, e.g. ["mutmutco/mm-foo"]',
|
|
21281
21310
|
oauth: "JSON {subdomains,domains,callbackPath,fofuSubdomain}",
|
|
21282
21311
|
requiredGcpApis: "comma-string",
|
|
@@ -21289,6 +21318,7 @@ var SETTABLE_VAR_HINTS = {
|
|
|
21289
21318
|
portRange: "JSON {start,end} or [start,end]",
|
|
21290
21319
|
ci: "none \u2014 declare intentional no-ci",
|
|
21291
21320
|
requiredChecks: 'JSON array, e.g. ["gate"] or [] for no-ci',
|
|
21321
|
+
ciExemptReason: "non-empty string \u2014 declares this deployable repo has no CI surface (#4265)",
|
|
21292
21322
|
gate: "JSON {runtime,cmd,workdir,cacheDepPath,pyVersion}"
|
|
21293
21323
|
};
|
|
21294
21324
|
function settableVarHelp() {
|
|
@@ -21373,6 +21403,8 @@ function buildProjectSetPatch(input) {
|
|
|
21373
21403
|
patch[key] = parseFofuEnabledVar(raw);
|
|
21374
21404
|
} else if (key === "runtimeVaultOnly") {
|
|
21375
21405
|
patch[key] = parseRuntimeVaultOnlyVar(raw);
|
|
21406
|
+
} else if (key === "seedCanary") {
|
|
21407
|
+
patch[key] = parseSeedCanaryVar(raw);
|
|
21376
21408
|
} else if (key === "consumesDesignSystem") {
|
|
21377
21409
|
patch[key] = parseConsumesDesignSystemVar(raw);
|
|
21378
21410
|
} else if (key === "publishDir") {
|
|
@@ -21384,6 +21416,8 @@ function buildProjectSetPatch(input) {
|
|
|
21384
21416
|
patch[key] = raw;
|
|
21385
21417
|
} else if (key === "requiredChecks") {
|
|
21386
21418
|
patch[key] = parseRequiredChecksVar(raw);
|
|
21419
|
+
} else if (key === "ciExemptReason") {
|
|
21420
|
+
patch[key] = parseCiExemptReasonVar(raw);
|
|
21387
21421
|
} else if (key === "gate") {
|
|
21388
21422
|
patch[key] = parseGateVar(raw);
|
|
21389
21423
|
} else {
|
|
@@ -22721,6 +22755,7 @@ function reconcileGithubActions(liveGithub, registry2, readRepos, activeWorkflow
|
|
|
22721
22755
|
const liveByName = new Map(liveGithub.map((e) => [e.name, e]));
|
|
22722
22756
|
const registryById = new Map(registryGithub.map((r) => [r.id, r]));
|
|
22723
22757
|
const drifts = [];
|
|
22758
|
+
const parked = [];
|
|
22724
22759
|
for (const e of liveGithub) {
|
|
22725
22760
|
if (!registryById.has(e.name)) {
|
|
22726
22761
|
drifts.push({
|
|
@@ -22733,6 +22768,10 @@ function reconcileGithubActions(liveGithub, registry2, readRepos, activeWorkflow
|
|
|
22733
22768
|
}
|
|
22734
22769
|
}
|
|
22735
22770
|
for (const r of registryGithub) {
|
|
22771
|
+
if (r.parkedReason) {
|
|
22772
|
+
parked.push({ id: r.id, executor: r.executor, repo: r.repo, reason: r.parkedReason });
|
|
22773
|
+
continue;
|
|
22774
|
+
}
|
|
22736
22775
|
const live = liveByName.get(r.id);
|
|
22737
22776
|
if (live) {
|
|
22738
22777
|
if (cadenceStale(r.cadence, live.cadence)) {
|
|
@@ -22754,7 +22793,7 @@ function reconcileGithubActions(liveGithub, registry2, readRepos, activeWorkflow
|
|
|
22754
22793
|
name: r.id,
|
|
22755
22794
|
executor: r.executor || "github-actions",
|
|
22756
22795
|
detail: "workflow file present but DISABLED in GitHub Actions \u2014 the dispatcher cannot run it",
|
|
22757
|
-
remedy: `\`gh workflow enable\` it in ${r.repo} to re-arm
|
|
22796
|
+
remedy: `\`gh workflow enable\` it in ${r.repo} to re-arm, or if this is a deliberate hold run \`mmi-cli org schedules park ${r.id} --reason <ref>\` (#4257) so it stops reading as drift \u2014 never prune a lane parked on purpose`
|
|
22758
22797
|
});
|
|
22759
22798
|
}
|
|
22760
22799
|
continue;
|
|
@@ -22769,7 +22808,10 @@ function reconcileGithubActions(liveGithub, registry2, readRepos, activeWorkflow
|
|
|
22769
22808
|
});
|
|
22770
22809
|
}
|
|
22771
22810
|
}
|
|
22772
|
-
return
|
|
22811
|
+
return {
|
|
22812
|
+
drifts: drifts.sort((a, b) => a.class.localeCompare(b.class) || a.name.localeCompare(b.name)),
|
|
22813
|
+
parked: parked.sort((a, b) => a.id.localeCompare(b.id))
|
|
22814
|
+
};
|
|
22773
22815
|
}
|
|
22774
22816
|
function suppressSelfManagedDrifts(drifts, selfManagedRepos) {
|
|
22775
22817
|
return drifts.filter((d) => {
|
|
@@ -22780,6 +22822,9 @@ function suppressSelfManagedDrifts(drifts, selfManagedRepos) {
|
|
|
22780
22822
|
function renderDrift(d) {
|
|
22781
22823
|
return `${d.class}: ${d.name} (${d.executor}) \u2014 ${d.detail}; remedy: ${d.remedy}`;
|
|
22782
22824
|
}
|
|
22825
|
+
function renderParked(p) {
|
|
22826
|
+
return `parked (${p.reason}): ${p.id}`;
|
|
22827
|
+
}
|
|
22783
22828
|
function strayCronDrift(name) {
|
|
22784
22829
|
return {
|
|
22785
22830
|
class: "stray-cron",
|
|
@@ -22815,12 +22860,17 @@ function unlauncheredLlmDrifts(entries) {
|
|
|
22815
22860
|
}
|
|
22816
22861
|
var HARBOUR_ARM_GRACE_MS = 30 * 60 * 1e3;
|
|
22817
22862
|
function registeredButUnarmedDrifts(registry2, liveEntries, opts) {
|
|
22818
|
-
if (!opts.schedulerRead) return [];
|
|
22863
|
+
if (!opts.schedulerRead) return { drifts: [], parked: [] };
|
|
22819
22864
|
const now = opts.now ?? Date.now();
|
|
22820
22865
|
const armed = new Set(liveEntries.map((e) => e.scheduleId).filter((id) => Boolean(id)));
|
|
22821
22866
|
const drifts = [];
|
|
22867
|
+
const parked = [];
|
|
22822
22868
|
for (const row of registry2) {
|
|
22823
22869
|
if (row.executor === "github-actions") continue;
|
|
22870
|
+
if (row.parkedReason) {
|
|
22871
|
+
parked.push({ id: row.id, executor: row.executor, repo: row.repo, reason: row.parkedReason });
|
|
22872
|
+
continue;
|
|
22873
|
+
}
|
|
22824
22874
|
if (armed.has(row.id)) continue;
|
|
22825
22875
|
const stamped = row.updatedAt ? Date.parse(row.updatedAt) : NaN;
|
|
22826
22876
|
if (Number.isFinite(stamped) && now - stamped < HARBOUR_ARM_GRACE_MS) continue;
|
|
@@ -22832,25 +22882,36 @@ function registeredButUnarmedDrifts(registry2, liveEntries, opts) {
|
|
|
22832
22882
|
remedy: "check the fleet-clock reconciler tick; re-run `mmi-cli org schedules register` for the repo and verify with `mmi-cli org schedules`"
|
|
22833
22883
|
});
|
|
22834
22884
|
}
|
|
22835
|
-
return
|
|
22885
|
+
return {
|
|
22886
|
+
drifts: drifts.sort((a, b) => a.name.localeCompare(b.name)),
|
|
22887
|
+
parked: parked.sort((a, b) => a.id.localeCompare(b.id))
|
|
22888
|
+
};
|
|
22836
22889
|
}
|
|
22837
22890
|
function assembleReconciliation(githubEntries2, readRepos, registry2, activeWorkflowNames = /* @__PURE__ */ new Set(), harbour = {}, disabledWorkflowNames = /* @__PURE__ */ new Set()) {
|
|
22838
22891
|
if (registry2 === null) {
|
|
22839
22892
|
return {
|
|
22840
22893
|
reconciliation: [],
|
|
22841
22894
|
driftLines: [],
|
|
22895
|
+
parked: [],
|
|
22896
|
+
parkedLines: [],
|
|
22842
22897
|
incomplete: ["registry: could not read GET /schedules/list \u2014 reconciliation skipped (Hub API unreachable or not authenticated)"]
|
|
22843
22898
|
};
|
|
22844
22899
|
}
|
|
22845
22900
|
const live = githubEntries2.filter((e) => e.executor === "github-actions");
|
|
22846
|
-
const
|
|
22847
|
-
|
|
22848
|
-
|
|
22849
|
-
|
|
22850
|
-
|
|
22851
|
-
|
|
22852
|
-
];
|
|
22853
|
-
return {
|
|
22901
|
+
const githubResult = reconcileGithubActions(live, registry2, readRepos, activeWorkflowNames, disabledWorkflowNames);
|
|
22902
|
+
const harbourResult = registeredButUnarmedDrifts(registry2, harbour.awsEntries ?? [], {
|
|
22903
|
+
schedulerRead: Boolean(harbour.schedulerRead),
|
|
22904
|
+
now: harbour.now
|
|
22905
|
+
});
|
|
22906
|
+
const drifts = [...githubResult.drifts, ...harbourResult.drifts];
|
|
22907
|
+
const parked = [...githubResult.parked, ...harbourResult.parked].sort((a, b) => a.id.localeCompare(b.id));
|
|
22908
|
+
return {
|
|
22909
|
+
reconciliation: drifts,
|
|
22910
|
+
driftLines: drifts.map(renderDrift),
|
|
22911
|
+
parked,
|
|
22912
|
+
parkedLines: parked.map(renderParked),
|
|
22913
|
+
incomplete: []
|
|
22914
|
+
};
|
|
22854
22915
|
}
|
|
22855
22916
|
|
|
22856
22917
|
// src/schedules-commands.ts
|
|
@@ -23014,7 +23075,9 @@ async function fetchNotebook(client = defaultGitHubClient(), readRegistry = defa
|
|
|
23014
23075
|
entries: sortEntries([...gh.entries, ...aws.entries, ...DECLARED_ENTRIES]),
|
|
23015
23076
|
incomplete: [...gh.incomplete, ...aws.incomplete, ...recon.incomplete],
|
|
23016
23077
|
drift: driftLines,
|
|
23017
|
-
reconciliation
|
|
23078
|
+
reconciliation,
|
|
23079
|
+
parked: recon.parked,
|
|
23080
|
+
parkedLines: recon.parkedLines
|
|
23018
23081
|
};
|
|
23019
23082
|
}
|
|
23020
23083
|
async function defaultProjectsRead() {
|
|
@@ -23027,10 +23090,13 @@ function warnIncomplete2(incomplete) {
|
|
|
23027
23090
|
function reportDrift(drift) {
|
|
23028
23091
|
for (const d of drift) console.error(`org schedules: DRIFT \u2014 ${d}`);
|
|
23029
23092
|
}
|
|
23093
|
+
function reportParked(parked) {
|
|
23094
|
+
for (const p of parked) console.log(`org schedules: ${p}`);
|
|
23095
|
+
}
|
|
23030
23096
|
function registerSchedulesCommands(program3) {
|
|
23031
23097
|
const schedules = program3.command("schedules").description("the org schedules notebook \u2014 every armed cron, timer, and scheduled LLM lane, resolved live (jerv side lives in jerv-cli schedules)").option("--json", "machine-readable output (consumed by jerv-cli schedules --all)").option("--doc <path>", "optional snapshot only \u2014 splice inventory between schedules:inventory markers; live `org schedules` / --json remains SSOT (Hub#4120)").action(async (o) => {
|
|
23032
23098
|
try {
|
|
23033
|
-
const { entries, incomplete, drift, reconciliation } = await fetchNotebook();
|
|
23099
|
+
const { entries, incomplete, drift, reconciliation, parked, parkedLines } = await fetchNotebook();
|
|
23034
23100
|
if (o.doc) {
|
|
23035
23101
|
if (incomplete.length) {
|
|
23036
23102
|
warnIncomplete2(incomplete);
|
|
@@ -23046,9 +23112,10 @@ function registerSchedulesCommands(program3) {
|
|
|
23046
23112
|
}
|
|
23047
23113
|
if (o.json) {
|
|
23048
23114
|
const now = /* @__PURE__ */ new Date();
|
|
23049
|
-
console.log(JSON.stringify({ org: entries.map((e) => withTickState(e, now)), incomplete, drift, reconciliation }, null, 2));
|
|
23115
|
+
console.log(JSON.stringify({ org: entries.map((e) => withTickState(e, now)), incomplete, drift, reconciliation, parked }, null, 2));
|
|
23050
23116
|
} else {
|
|
23051
23117
|
console.log(formatSchedulesTable(entries));
|
|
23118
|
+
reportParked(parkedLines);
|
|
23052
23119
|
reportDrift(drift);
|
|
23053
23120
|
warnIncomplete2(incomplete);
|
|
23054
23121
|
}
|
|
@@ -23069,6 +23136,30 @@ function registerSchedulesCommands(program3) {
|
|
|
23069
23136
|
await failGraceful(e.message);
|
|
23070
23137
|
}
|
|
23071
23138
|
});
|
|
23139
|
+
schedules.command("park <schedule-id>").description("record a deliberate hold on a SCHEDULE# row \u2014 the reconciler skips it and `org schedules` renders `parked (<reason>)` instead of DRIFT (#4257)").requiredOption("--reason <ref>", "why the lane is held, e.g. an issue ref (#1774)").action(async (scheduleId, o) => {
|
|
23140
|
+
try {
|
|
23141
|
+
const res = await postSchedulesPark(scheduleId, o.reason, registryClientDeps(await loadConfig()));
|
|
23142
|
+
if (!res.ok) {
|
|
23143
|
+
await failGraceful(`schedules park: HTTP ${res.status} \u2014 ${JSON.stringify(res.body ?? res.error ?? "failed")}`);
|
|
23144
|
+
return;
|
|
23145
|
+
}
|
|
23146
|
+
console.log(JSON.stringify(res.body, null, 2));
|
|
23147
|
+
} catch (e) {
|
|
23148
|
+
await failGraceful(e.message);
|
|
23149
|
+
}
|
|
23150
|
+
});
|
|
23151
|
+
schedules.command("unpark <schedule-id>").description("clear a deliberate hold on a SCHEDULE# row \u2014 reconciliation resumes normally (#4257)").action(async (scheduleId) => {
|
|
23152
|
+
try {
|
|
23153
|
+
const res = await postSchedulesUnpark(scheduleId, registryClientDeps(await loadConfig()));
|
|
23154
|
+
if (!res.ok) {
|
|
23155
|
+
await failGraceful(`schedules unpark: HTTP ${res.status} \u2014 ${JSON.stringify(res.body ?? res.error ?? "failed")}`);
|
|
23156
|
+
return;
|
|
23157
|
+
}
|
|
23158
|
+
console.log(JSON.stringify(res.body, null, 2));
|
|
23159
|
+
} catch (e) {
|
|
23160
|
+
await failGraceful(e.message);
|
|
23161
|
+
}
|
|
23162
|
+
});
|
|
23072
23163
|
schedules.command("mode <mode>").description("fleet schedules doctrine for a repo: 'managed' (default \u2014 Hub registry + drift enforcement) or 'self-managed' (opt out; the repo owns its crons). Project-admin self-serve (#3228).").option("--repo <name>", "bare repo name (defaults to the origin remote basename)").action(async (mode, o) => {
|
|
23073
23164
|
try {
|
|
23074
23165
|
if (mode !== "managed" && mode !== "self-managed") {
|
|
@@ -24017,6 +24108,62 @@ function renderPropagationReport(plan) {
|
|
|
24017
24108
|
return lines.join("\n");
|
|
24018
24109
|
}
|
|
24019
24110
|
|
|
24111
|
+
// src/bootstrap-rollback.ts
|
|
24112
|
+
function resolveRollbackRecord(records, repo, target) {
|
|
24113
|
+
const candidates = records.filter((r) => r.repo === repo && r.target === target && r.mergeSha);
|
|
24114
|
+
if (candidates.length === 0) {
|
|
24115
|
+
return { found: false, reason: `no propagation record resolves for ${repo} + ${target} \u2014 refusing to guess a commit to revert` };
|
|
24116
|
+
}
|
|
24117
|
+
const clean4 = candidates.filter((r) => r.files.length === 1 && r.files[0] === target);
|
|
24118
|
+
if (clean4.length === 0) {
|
|
24119
|
+
return {
|
|
24120
|
+
found: false,
|
|
24121
|
+
reason: `${candidates.length} merged seed-propagate PR(s) found for ${repo} + ${target}, but none is a clean single-file propagation of exactly this target \u2014 refusing to guess a commit to revert`
|
|
24122
|
+
};
|
|
24123
|
+
}
|
|
24124
|
+
const latest = clean4.slice().sort((a, b) => a.mergedAt < b.mergedAt ? 1 : a.mergedAt > b.mergedAt ? -1 : 0)[0];
|
|
24125
|
+
return { found: true, record: latest };
|
|
24126
|
+
}
|
|
24127
|
+
function planRollback(repo, target, slug, records) {
|
|
24128
|
+
const resolution = resolveRollbackRecord(records, repo, target);
|
|
24129
|
+
if (!resolution.found) return { repo, target, resolution };
|
|
24130
|
+
const branch = `seed-rollback-${slug}`;
|
|
24131
|
+
const title = `revert: rollback org-owned ${target} in ${repo} (seed PR #${resolution.record.number})`;
|
|
24132
|
+
const body = renderRollbackPrBody(resolution.record);
|
|
24133
|
+
return { repo, target, resolution, branch, title, body };
|
|
24134
|
+
}
|
|
24135
|
+
function renderRollbackPrBody(record) {
|
|
24136
|
+
return [
|
|
24137
|
+
`Per-repo emergency revert of \`${record.target}\`'s seed-propagate merge (\`mmi-cli bootstrap rollback\`, #4240).`,
|
|
24138
|
+
"",
|
|
24139
|
+
`Reverts ${record.url} (merge ${record.mergeSha}) \u2014 restores this repo's copy of \`${record.target}\` to its content immediately before that merge. Nothing else in this repo changes.`,
|
|
24140
|
+
"",
|
|
24141
|
+
"This is the emergency stop, not the fix: MMI-Hub is still the source of truth during a rollback. Closure is the Hub reverting (or fixing forward) the bad seed commit on `development` \u2014 once it does, this repo matches the Hub again and the drift alarm closes itself. A revert with no Hub-side follow-up re-alarms as drift within a week \u2014 deliberately, so an emergency divergence can never silently become permanent.",
|
|
24142
|
+
"",
|
|
24143
|
+
"Never re-run `bootstrap propagate` with the pre-revert bytes before the Hub itself is fixed \u2014 that reopens exactly what this PR closes."
|
|
24144
|
+
].join("\n");
|
|
24145
|
+
}
|
|
24146
|
+
function renderRollbackReport(plan) {
|
|
24147
|
+
if (!plan.resolution.found) {
|
|
24148
|
+
return `bootstrap rollback \u2014 ${plan.repo} / ${plan.target}: REFUSED \u2014 ${plan.resolution.reason}`;
|
|
24149
|
+
}
|
|
24150
|
+
const r = plan.resolution.record;
|
|
24151
|
+
const opened = plan.prUrl ? ` \u2014 opened ${plan.prUrl}` : "";
|
|
24152
|
+
return `bootstrap rollback \u2014 ${plan.repo} / ${plan.target}: reverting PR#${r.number} (merge ${r.mergeSha}${r.mergedAt ? `, merged ${r.mergedAt}` : ""}) on branch ${plan.branch}${opened}`;
|
|
24153
|
+
}
|
|
24154
|
+
function seedPrRecordFromPropagationRecord(record) {
|
|
24155
|
+
if (!record.mergeSha || record.prNumber == null || !record.prUrl) return null;
|
|
24156
|
+
return {
|
|
24157
|
+
repo: record.repo,
|
|
24158
|
+
target: record.target,
|
|
24159
|
+
number: record.prNumber,
|
|
24160
|
+
url: record.prUrl,
|
|
24161
|
+
mergeSha: record.mergeSha,
|
|
24162
|
+
mergedAt: "",
|
|
24163
|
+
files: [record.target]
|
|
24164
|
+
};
|
|
24165
|
+
}
|
|
24166
|
+
|
|
24020
24167
|
// src/bootstrap-verify.ts
|
|
24021
24168
|
var TRAIN_BRANCHES2 = ["development", "rc", "main"];
|
|
24022
24169
|
var requiredDocs = ["README.md", "architecture.md", "docs/decisions/README.md", "docs/index.md"];
|
|
@@ -25145,9 +25292,28 @@ LIVE apply to ${repo}:
|
|
|
25145
25292
|
}
|
|
25146
25293
|
const bySlugMeta = new Map(projects.flatMap((p) => (p.repos ?? []).map((r) => [(r.includes("/") ? r : `mutmutco/${r}`).toLowerCase(), p])));
|
|
25147
25294
|
const classOf = (repo) => bySlugMeta.get(repo.toLowerCase())?.class ?? "deployable";
|
|
25148
|
-
const
|
|
25149
|
-
const
|
|
25150
|
-
|
|
25295
|
+
const inClassRepos = rosterRepos2.filter((repo) => seed.classes.includes(classOf(repo)));
|
|
25296
|
+
const classPriority = [.../* @__PURE__ */ new Set(["deployable", ...seed.classes])].filter((c) => seed.classes.includes(c));
|
|
25297
|
+
let canaryProject = null;
|
|
25298
|
+
for (const cls of classPriority) {
|
|
25299
|
+
const inClass = projects.filter((p) => p.seedCanary === true && (p.class ?? "deployable") === cls);
|
|
25300
|
+
if (inClass.length === 0) continue;
|
|
25301
|
+
if (inClass.length > 1) {
|
|
25302
|
+
const slugs = inClass.map((p) => (p.repos ?? [])[0]?.split("/").pop() ?? "?").join(", ");
|
|
25303
|
+
return fail(
|
|
25304
|
+
`bootstrap propagate: target '${seed.target}' (classes [${seed.classes.join(", ")}]) matches ${inClass.length} seedCanary:true repos in class '${cls}' (${slugs}) \u2014 exactly one seedCanary:true repo per class is required`
|
|
25305
|
+
);
|
|
25306
|
+
}
|
|
25307
|
+
canaryProject = inClass[0];
|
|
25308
|
+
break;
|
|
25309
|
+
}
|
|
25310
|
+
if (!canaryProject) {
|
|
25311
|
+
return fail(
|
|
25312
|
+
`bootstrap propagate: target '${seed.target}' needs classes [${seed.classes.join(", ")}] but no registry repo with seedCanary:true carries a matching class \u2014 set seedCanary:true on an in-class repo (mmi-cli org project set <slug> --var seedCanary=true)`
|
|
25313
|
+
);
|
|
25314
|
+
}
|
|
25315
|
+
const canarySlug = (canaryProject.repos ?? [])[0]?.split("/").pop()?.toLowerCase() ?? null;
|
|
25316
|
+
const repos = inClassRepos.map((repo) => {
|
|
25151
25317
|
const slug = repo.split("/").pop().toLowerCase();
|
|
25152
25318
|
const waiver = seed.waivers?.[slug];
|
|
25153
25319
|
return { repo, slug, waiver };
|
|
@@ -25271,6 +25437,136 @@ Rollback: \`mmi-cli bootstrap rollback ${rec.repo} --target ${seed.target} --exe
|
|
|
25271
25437
|
else console.log(renderPropagationReport(plan));
|
|
25272
25438
|
if (plan.halted) process.exitCode = 1;
|
|
25273
25439
|
});
|
|
25440
|
+
bootstrap.command("rollback <repo>").description("#4240: open a per-repo revert PR of the recorded seed-propagate merge \u2014 never a fleet-wide overwrite; dry-run unless --execute").addOption(new Option("--class <class>", "deployable | content").default("deployable").choices(["deployable", "content"])).option("--target <path>", "the manifest target to roll back (an ownership:org + source:self seed, e.g. .github/workflows/agent-pr.yml)").option("--record <path>", "read propagation candidates from a persisted `bootstrap propagate --json` report instead of the live seed-propagate PR history").option("--execute", "LIVE revert via gh (master-gated) \u2014 opens/reuses the seed-rollback PR; dry-run prints the plan only").option("--json", "machine-readable output").action(async (repo) => {
|
|
25441
|
+
const o = {
|
|
25442
|
+
class: rawValue("--class", "deployable"),
|
|
25443
|
+
target: rawValue("--target", ""),
|
|
25444
|
+
record: rawValue("--record", ""),
|
|
25445
|
+
execute: rawFlag("--execute"),
|
|
25446
|
+
json: rawFlag("--json")
|
|
25447
|
+
};
|
|
25448
|
+
if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap rollback: --class must be deployable or content");
|
|
25449
|
+
let parsedRepo;
|
|
25450
|
+
try {
|
|
25451
|
+
parsedRepo = parseOwnerRepo(repo);
|
|
25452
|
+
} catch (e) {
|
|
25453
|
+
return fail(`bootstrap rollback: ${e.message}`);
|
|
25454
|
+
}
|
|
25455
|
+
const manifestPath = "skills/bootstrap/seeds/manifest.json";
|
|
25456
|
+
if (!(0, import_node_fs31.existsSync)(manifestPath)) return fail(`bootstrap rollback: ${manifestPath} not found; run from the MMI-Hub repo root \u2014 the manifest names which targets are org-owned and therefore propagated (and rollback-able)`);
|
|
25457
|
+
const manifest = loadBootstrapSeeds((0, import_node_fs31.readFileSync)(manifestPath, "utf8"));
|
|
25458
|
+
const propagatable = manifest.seeds.filter((s) => s.ownership === "org" && s.source === "self");
|
|
25459
|
+
if (!o.target) {
|
|
25460
|
+
return fail(`bootstrap rollback: --target <path> is required \u2014 one of:
|
|
25461
|
+
${propagatable.map((s) => s.target).join("\n ")}`);
|
|
25462
|
+
}
|
|
25463
|
+
const seed = propagatable.find((s) => s.target === o.target);
|
|
25464
|
+
if (!seed) return fail(`bootstrap rollback: --target '${o.target}' names no ownership:org + source:self seed in ${manifestPath}. Propagatable (hence rollback-able) targets:
|
|
25465
|
+
${propagatable.map((s) => s.target).join("\n ")}`);
|
|
25466
|
+
const slug = parsedRepo.slug;
|
|
25467
|
+
const baseBranch = o.class === "content" ? "main" : "development";
|
|
25468
|
+
const branchPrefix = "seed-propagate";
|
|
25469
|
+
const propagateBranch = `${branchPrefix}-${slug}`;
|
|
25470
|
+
const gh = async (args) => execFileP2("gh", args, { timeout: 2e4 });
|
|
25471
|
+
const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
|
|
25472
|
+
let candidates;
|
|
25473
|
+
if (o.record) {
|
|
25474
|
+
if (!(0, import_node_fs31.existsSync)(o.record)) return fail(`bootstrap rollback: --record '${o.record}' not found`);
|
|
25475
|
+
let parsed;
|
|
25476
|
+
try {
|
|
25477
|
+
parsed = JSON.parse((0, import_node_fs31.readFileSync)(o.record, "utf8"));
|
|
25478
|
+
} catch (e) {
|
|
25479
|
+
return fail(`bootstrap rollback: --record '${o.record}' is not valid JSON: ${e.message}`);
|
|
25480
|
+
}
|
|
25481
|
+
const recs = Array.isArray(parsed?.records) ? parsed.records : Array.isArray(parsed) ? parsed : [];
|
|
25482
|
+
candidates = recs.map((r) => {
|
|
25483
|
+
try {
|
|
25484
|
+
return seedPrRecordFromPropagationRecord(r);
|
|
25485
|
+
} catch {
|
|
25486
|
+
return null;
|
|
25487
|
+
}
|
|
25488
|
+
}).filter((r) => r !== null);
|
|
25489
|
+
} else {
|
|
25490
|
+
candidates = [];
|
|
25491
|
+
try {
|
|
25492
|
+
const listed = await gh(["pr", "list", "--repo", repo, "--head", propagateBranch, "--base", baseBranch, "--state", "merged", "--json", "number,url,mergedAt,mergeCommit,files", "--limit", "20"]);
|
|
25493
|
+
const arr = JSON.parse(listed.stdout || "[]");
|
|
25494
|
+
for (const p of arr) {
|
|
25495
|
+
if (!p.mergeCommit?.oid) continue;
|
|
25496
|
+
candidates.push({
|
|
25497
|
+
repo,
|
|
25498
|
+
target: seed.target,
|
|
25499
|
+
number: p.number,
|
|
25500
|
+
url: p.url,
|
|
25501
|
+
mergeSha: p.mergeCommit.oid,
|
|
25502
|
+
mergedAt: p.mergedAt ?? "",
|
|
25503
|
+
files: (p.files ?? []).map((f) => f.path)
|
|
25504
|
+
});
|
|
25505
|
+
}
|
|
25506
|
+
} catch (e) {
|
|
25507
|
+
return fail(`bootstrap rollback: could not read ${repo}'s merged ${propagateBranch} PR history: ${e.message}`);
|
|
25508
|
+
}
|
|
25509
|
+
}
|
|
25510
|
+
const plan = planRollback(repo, seed.target, slug, candidates);
|
|
25511
|
+
if (!plan.resolution.found) {
|
|
25512
|
+
if (o.json) console.log(JSON.stringify(plan, null, 2));
|
|
25513
|
+
else console.log(renderRollbackReport(plan));
|
|
25514
|
+
return fail(`bootstrap rollback: ${plan.resolution.reason}`);
|
|
25515
|
+
}
|
|
25516
|
+
if (o.execute) {
|
|
25517
|
+
const record = plan.resolution.record;
|
|
25518
|
+
const parentResp = await gh(["api", `repos/${repo}/commits/${record.mergeSha}`, "--jq", ".parents[0].sha"]);
|
|
25519
|
+
const parentSha = parentResp.stdout.trim();
|
|
25520
|
+
if (!parentSha) return fail(`bootstrap rollback: could not resolve ${repo}@${record.mergeSha}'s parent commit \u2014 refusing to guess what to restore`);
|
|
25521
|
+
let preSeedContent = null;
|
|
25522
|
+
try {
|
|
25523
|
+
const resp = await gh(["api", `repos/${repo}/contents/${enc(seed.target)}?ref=${parentSha}`]);
|
|
25524
|
+
const parsed = JSON.parse(resp.stdout);
|
|
25525
|
+
preSeedContent = parsed.encoding === "base64" && typeof parsed.content === "string" ? Buffer.from(parsed.content, "base64").toString("utf8") : null;
|
|
25526
|
+
} catch {
|
|
25527
|
+
preSeedContent = null;
|
|
25528
|
+
}
|
|
25529
|
+
if (preSeedContent == null) return fail(`bootstrap rollback: '${seed.target}' did not exist in ${repo} at ${parentSha} (the commit before the seed merge) \u2014 nothing to restore; refusing to guess`);
|
|
25530
|
+
const baseRef = await gh(["api", `repos/${repo}/git/ref/heads/${baseBranch}`, "--jq", ".object.sha"]);
|
|
25531
|
+
const baseSha = baseRef.stdout.trim();
|
|
25532
|
+
let branchExists = true;
|
|
25533
|
+
try {
|
|
25534
|
+
await gh(["api", `repos/${repo}/git/ref/heads/${plan.branch}`]);
|
|
25535
|
+
} catch {
|
|
25536
|
+
branchExists = false;
|
|
25537
|
+
}
|
|
25538
|
+
if (!branchExists) await gh(["api", `repos/${repo}/git/refs`, "-f", `ref=refs/heads/${plan.branch}`, "-f", `sha=${baseSha}`]);
|
|
25539
|
+
let existingSha;
|
|
25540
|
+
try {
|
|
25541
|
+
const cur = await gh(["api", `repos/${repo}/contents/${enc(seed.target)}?ref=${plan.branch}`]);
|
|
25542
|
+
existingSha = JSON.parse(cur.stdout).sha;
|
|
25543
|
+
} catch {
|
|
25544
|
+
existingSha = void 0;
|
|
25545
|
+
}
|
|
25546
|
+
const tmp = (0, import_node_path29.join)((0, import_node_os11.tmpdir)(), `mmi-rollback-${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}.json`);
|
|
25547
|
+
(0, import_node_fs31.writeFileSync)(tmp, JSON.stringify(contentPutBody(seed.target, preSeedContent, plan.branch, existingSha)), "utf8");
|
|
25548
|
+
try {
|
|
25549
|
+
await gh(contentPutInputArgs(repo, seed.target, tmp));
|
|
25550
|
+
} finally {
|
|
25551
|
+
try {
|
|
25552
|
+
(0, import_node_fs31.unlinkSync)(tmp);
|
|
25553
|
+
} catch {
|
|
25554
|
+
}
|
|
25555
|
+
}
|
|
25556
|
+
const openPrs = await gh(["pr", "list", "--repo", repo, "--head", plan.branch, "--base", baseBranch, "--state", "open", "--json", "number,url"]);
|
|
25557
|
+
const prDecision = decideSeedPrAction(JSON.parse(openPrs.stdout || "[]"));
|
|
25558
|
+
let prUrl;
|
|
25559
|
+
if (prDecision.action === "reuse") {
|
|
25560
|
+
prUrl = prDecision.url;
|
|
25561
|
+
} else {
|
|
25562
|
+
const created = await ghCreate(["pr", "create", "--repo", repo, "--base", baseBranch, "--head", plan.branch, "--title", plan.title, "--body", plan.body]);
|
|
25563
|
+
prUrl = created.url;
|
|
25564
|
+
}
|
|
25565
|
+
plan.prUrl = prUrl;
|
|
25566
|
+
}
|
|
25567
|
+
if (o.json) console.log(JSON.stringify(plan, null, 2));
|
|
25568
|
+
else console.log(renderRollbackReport(plan));
|
|
25569
|
+
});
|
|
25274
25570
|
}
|
|
25275
25571
|
|
|
25276
25572
|
// src/stage-commands.ts
|
package/package.json
CHANGED