@mutmutco/cli 4.5.34 → 4.5.36

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.
Files changed (2) hide show
  1. package/dist/main.cjs +328 -109
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -7295,11 +7295,11 @@ function decorateAwsModel(entries, registry2, verdicts) {
7295
7295
  });
7296
7296
  }
7297
7297
  var DECLARED_ENTRIES = [
7298
- { name: "mmi-backup (mm-central, mm-work, mmi-oguz; zuber unverified)", cadence: "17 2 * * *", executor: "box cron.d", llm: "no", resolved: "declared", source: "/etc/cron.d/mmi-backup \u2192 /opt/mmi-control/nightly-backup.sh; every Docker volume is tarred and every running Postgres container is pg_dumpall'd beside it (#7147; source scripts/nightly-backup.sh); zuber (46.225.58.31) absent from box inventory and its SSH host key changed on 2026-09-24 \u2014 unverified, not dropped (verify: mmi-cli devops runtime box get <box> --ssh)" },
7298
+ { name: "mmi-backup (mm-central, mm-work, mmi-oguz)", cadence: "17 2 * * *", executor: "box cron.d", llm: "no", resolved: "declared", source: "/etc/cron.d/mmi-backup \u2192 /opt/mmi-control/nightly-backup.sh; every Docker volume is tarred and every running Postgres container is pg_dumpall'd beside it (#7147; source scripts/nightly-backup.sh); zuber (46.225.58.31) is gone: deleted 2026-09-18 by the owner-approved ZuberShade decommission (MMC-ZuberShade#1838, snapshot kept); Hetzner has since reassigned that IP to another machine (#7351)" },
7299
7299
  // #7148: a backup nobody restored is a hope. Monthly, restore the latest artifact of every
7300
7300
  // archived stage (including a stage whose containers are down) into scratch Postgres and compare
7301
7301
  // row counts, and read-verify every storage archive against its listing; per-stage PASS/FAIL.
7302
- { name: "mmi-backup-restore-check (mm-central, mm-work, mmi-oguz; zuber unverified)", cadence: "0 3 1 * *", executor: "box cron.d", llm: "no", resolved: "declared", source: "/etc/cron.d/mmi-backup-restore-check \u2192 /opt/mmi-control/nightly-backup-restore-check.sh; monthly restore proof for every archived stage (#7148; source scripts/nightly-backup-restore-check.sh); zuber (46.225.58.31) absent from box inventory and its SSH host key changed on 2026-09-24 \u2014 unverified, not dropped (verify: mmi-cli devops runtime box get <box> --ssh)" },
7302
+ { name: "mmi-backup-restore-check (mm-central, mm-work, mmi-oguz)", cadence: "0 3 1 * *", executor: "box cron.d", llm: "no", resolved: "declared", source: "/etc/cron.d/mmi-backup-restore-check \u2192 /opt/mmi-control/nightly-backup-restore-check.sh; monthly restore proof for every archived stage (#7148; source scripts/nightly-backup-restore-check.sh); zuber (46.225.58.31) is gone: deleted 2026-09-18 by the owner-approved ZuberShade decommission (MMC-ZuberShade#1838, snapshot kept); Hetzner has since reassigned that IP to another machine (#7351)" },
7303
7303
  // #3370/#6270: the one runner hygiene layer (runner-hygiene.yml was folded in and deleted). A workflow ran ON the runner and needed checkout +
7304
7304
  // setup-node before it can prune, so at 100% it dies at checkout and never prunes — the cleanup is
7305
7305
  // unavailable exactly when it is needed. This one is root cron on the box: no checkout, no node, and
@@ -15715,9 +15715,9 @@ function trainFollowUpExitCode(status) {
15715
15715
  }
15716
15716
  function trainFollowUpNote(status) {
15717
15717
  if (status === "complete") return void 0;
15718
- return `follow-up ${status}: the exit code is never the verdict, read workflowRuns \u2014 see docs/Guides/train-troubleshooting.md#follow-up-pending`;
15718
+ return `follow-up ${status}: the exit code is never the verdict, read workflowRuns \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#follow-up-pending`;
15719
15719
  }
15720
- var TRAIN_DEPLOY_FAILED_NOTE = "promotion stands; retry the deploy, do not re-tag \u2014 see docs/Guides/train-troubleshooting.md#deploy-failed";
15720
+ var TRAIN_DEPLOY_FAILED_NOTE = "promotion stands; retry the deploy, do not re-tag \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#deploy-failed";
15721
15721
  function missingRunUrlNote(runId) {
15722
15722
  return runId != null ? `no run URL emitted \u2014 GitHub Actions returned run ${runId} without its URL` : "no run URL emitted \u2014 run absent or unreadable";
15723
15723
  }
@@ -16840,10 +16840,10 @@ var rollout_plan_default = {
16840
16840
  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)."
16841
16841
  },
16842
16842
  baseline: {
16843
- version: "4.5.34",
16844
- tag: "v4.5.34",
16845
- commit: "fe244fb48abc",
16846
- npm: "@mutmutco/cli@4.5.34"
16843
+ version: "4.5.36",
16844
+ tag: "v4.5.36",
16845
+ commit: "dfc9e43e72eb",
16846
+ npm: "@mutmutco/cli@4.5.36"
16847
16847
  },
16848
16848
  exitCriterion: "fleet-n-of-n",
16849
16849
  hubOnlyShortcut: "forbidden",
@@ -16860,14 +16860,14 @@ var rollout_plan_default = {
16860
16860
  repo: "mutmutco/mmi-hub",
16861
16861
  role: "canary",
16862
16862
  schedule: "train",
16863
- v3Target: "v4.5.34"
16863
+ v3Target: "v4.5.36"
16864
16864
  }
16865
16865
  ],
16866
16866
  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.",
16867
16867
  rollback: {
16868
16868
  independent: true,
16869
- mechanism: "npm dist-tag latest -> 4.5.34 and redeploy the Hub Lambda from tag v4.5.34 (fe244fb48abc); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
16870
- v3Target: "v4.5.34 (@mutmutco/cli@4.5.34, tag commit fe244fb48abc \u2014 last known-good release carrying the repo-index v4-only contract)"
16869
+ mechanism: "npm dist-tag latest -> 4.5.36 and redeploy the Hub Lambda from tag v4.5.36 (dfc9e43e72eb); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
16870
+ v3Target: "v4.5.36 (@mutmutco/cli@4.5.36, tag commit dfc9e43e72eb \u2014 last known-good release carrying the repo-index v4-only contract)"
16871
16871
  }
16872
16872
  },
16873
16873
  {
@@ -19826,7 +19826,7 @@ function installerDeclarationError(value, division) {
19826
19826
 
19827
19827
  // src/train-suite.ts
19828
19828
  var SUITE_GUARD_CODE = "suite-member-solo";
19829
- var SUITE_GUARD_ANCHOR = `docs/Guides/train-troubleshooting.md#${SUITE_GUARD_CODE}`;
19829
+ var SUITE_GUARD_ANCHOR = `https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#${SUITE_GUARD_CODE}`;
19830
19830
  function suiteLeadRepo(repo, suite) {
19831
19831
  const owner = repo.split("/")[0];
19832
19832
  return `${owner}/${suite === "mm-work" ? "MM-Work" : suite}`;
@@ -20521,7 +20521,7 @@ async function appendJervGatewayReleaseDeploy(deps, repo, tag, tagSha, dispatch)
20521
20521
  ...dispatch,
20522
20522
  // #6095: the helper's receipt, health or rollback failed after a proven publish — the release resumes, never re-tags.
20523
20523
  // #6985: keep the FULL failing output in the note so the ledger can persist its tail (the first-line 240-char clip hid the real cause).
20524
- note: `${dispatch.note}; Jerv Gateway deploy FAILED (${full}) \u2014 see docs/Guides/train-troubleshooting.md#jerv-gateway-deploy`,
20524
+ note: `${dispatch.note}; Jerv Gateway deploy FAILED (${full}) \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#jerv-gateway-deploy`,
20525
20525
  workflowRuns: [...dispatch.workflowRuns ?? [], { workflow: "jerv-gateway", runUrlNote: JERV_GATEWAY_RUN_URL_NOTE, conclusion: "failure" }],
20526
20526
  deployStatus: "failure"
20527
20527
  };
@@ -20742,7 +20742,7 @@ async function dispatchDeploy(deps, ctx, stage, ref, model, watch, autoRunSince,
20742
20742
  const deployStatus = watch ? await watchTenantRun(deps, runId) : "pending";
20743
20743
  let note = `dispatched tenant-deploy.yml (slug=${ctx.slug}, ref=${ref}, stage=${stage})`;
20744
20744
  if (deployStatus === "failure") {
20745
- note = `${note}; run FAILED \u2014 if its log shows exit 78 \`compose is fileless but DEPLOY#${stage}.noEnvFile is not true\`, set-deploy + reconcile before the redeploy \u2014 see docs/Guides/train-troubleshooting.md#deploy-exit-78-fileless`;
20745
+ note = `${note}; run FAILED \u2014 if its log shows exit 78 \`compose is fileless but DEPLOY#${stage}.noEnvFile is not true\`, set-deploy + reconcile before the redeploy \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#deploy-exit-78-fileless`;
20746
20746
  }
20747
20747
  return { note, runId, runUrl, deployStatus };
20748
20748
  }
@@ -21057,7 +21057,7 @@ function isCanaryAccessBlockText(text) {
21057
21057
  }
21058
21058
  function actionsCanaryUndispatchedRefusal(detail) {
21059
21059
  return new Error(
21060
- `release refused: the hosted-job canary (#5604) was not dispatched, so a hosted job start is UNPROVEN \u2014 this is NOT a billing verdict. ${detail} The Hub dispatches ${CANARY_WORKFLOW} with its App token for a train-authorized caller (#6319): refresh the Hub session (\`gh auth login\`) and retry, and confirm \`mmi-cli oracle org access role <owner/repo>\` reports train authority. Nothing was tagged or pushed \u2014 see docs/Guides/train-troubleshooting.md#actions-job-start-canary`
21060
+ `release refused: the hosted-job canary (#5604) was not dispatched, so a hosted job start is UNPROVEN \u2014 this is NOT a billing verdict. ${detail} The Hub dispatches ${CANARY_WORKFLOW} with its App token for a train-authorized caller (#6319): refresh the Hub session (\`gh auth login\`) and retry, and confirm \`mmi-cli oracle org access role <owner/repo>\` reports train authority. Nothing was tagged or pushed \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#actions-job-start-canary`
21061
21061
  );
21062
21062
  }
21063
21063
  function actionsBillingRefusal(detail) {
@@ -21221,11 +21221,11 @@ async function assertActionsJobsCanStart(deps, targetRepo2) {
21221
21221
  return;
21222
21222
  }
21223
21223
  if (last === "billing-blocked" || last === "never-started") {
21224
- throw actionsBillingRefusal(`${url}: hosted canary ${last} \u2014 see docs/Guides/train-troubleshooting.md#actions-job-start-canary`);
21224
+ throw actionsBillingRefusal(`${url}: hosted canary ${last} \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#actions-job-start-canary`);
21225
21225
  }
21226
21226
  }
21227
21227
  throw actionsBillingRefusal(
21228
- `${url}: hosted canary stayed ${last} after ${CANARY_POLLS} polls \u2014 cannot prove a hosted job can start \u2014 see docs/Guides/train-troubleshooting.md#actions-job-start-canary`
21228
+ `${url}: hosted canary stayed ${last} after ${CANARY_POLLS} polls \u2014 cannot prove a hosted job can start \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#actions-job-start-canary`
21229
21229
  );
21230
21230
  }
21231
21231
 
@@ -23641,7 +23641,7 @@ async function supersedeContentCausedPublishLeg(deps, cwd) {
23641
23641
  }
23642
23642
  let gatewayDeploy;
23643
23643
  const deferredGateway = isJervHubRepo(prior.repo) && deferredGatewayDeployLegShape(phases.deploy);
23644
- const unresolved = ["promotion", "ordinaryCi", "githubRelease", "deploy", "alignment"].filter((phase) => !(phase === "deploy" && deferredGateway)).filter((phase) => phases[phase].state === "pending" || phases[phase].state === "failed");
23644
+ const unresolved = ["promotion", "ordinaryCi", "githubRelease", "deploy", "alignment"].filter((phase) => !(phase === "deploy" && deferredGateway)).filter((phase) => !(phase === "alignment" && phases.alignment.state === "pending")).filter((phase) => phases[phase].state === "pending" || phases[phase].state === "failed");
23645
23645
  if (unresolved.length > 0) {
23646
23646
  throw new Error(
23647
23647
  `release --supersede-publish: ${prior.tag} has unresolved legs besides publish (${unresolved.map((p) => `${p}=${phases[p].state}`).join(", ")}) \u2014 the supersession closes only the publish leg of an otherwise-complete release; resolve those first, nothing was written (#6638)`
@@ -23689,7 +23689,7 @@ async function supersedeContentCausedPublishLeg(deps, cwd) {
23689
23689
  repo: prior.repo,
23690
23690
  tag: prior.tag,
23691
23691
  tagSha: prior.tagSha,
23692
- note: `closed the ${prior.tag} publish leg as superseded (skipped) \u2014 the ledger is phases-green; rerun the train doctor to confirm, then run the next patch train (hotfix or release) to carry the cause fix`,
23692
+ note: `closed the ${prior.tag} publish leg as superseded (skipped) \u2014 the ledger is phases-green; rerun the train doctor to confirm, then run the next patch train (hotfix or release) to carry the cause fix${phases.alignment.state === "pending" ? "; alignment still pending (fold-port PR open \u2014 closes live at the next pre-flight, #6061)" : ""}`,
23693
23693
  ...gatewayDeploy ? { gatewayDeploy } : {}
23694
23694
  };
23695
23695
  });
@@ -24793,7 +24793,7 @@ async function preflightMergeToMain(deps, deployModel, remoteRef, blockingPrefix
24793
24793
  const predicted = await predictMergeConflicts(deps, ours, remoteRef);
24794
24794
  const predictedBlocking = predicted.filter((f) => !pathIsTolerated(f, tolerated));
24795
24795
  if (predictedBlocking.length > 0) {
24796
- throw new Error(`${blockingPrefix}: ${predictedBlocking.join(", ")} \u2014 no merge was started. ${realignMessage} \u2014 see docs/Guides/train-troubleshooting.md#merge-conflict`);
24796
+ throw new Error(`${blockingPrefix}: ${predictedBlocking.join(", ")} \u2014 no merge was started. ${realignMessage} \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#merge-conflict`);
24797
24797
  }
24798
24798
  return { foldPaths, tolerated, predicted };
24799
24799
  }
@@ -25022,7 +25022,7 @@ async function recoverFailedFold(deps, cause, startBranch, preFoldMainSha, resum
25022
25022
  const causeMessage = anchor ? `${rawCause} \u2014 see ${anchor}` : rawCause;
25023
25023
  const probe = await probeFoldFailureState(deps);
25024
25024
  if (probe.mergeInProgress) {
25025
- const conflictCause = `${rawCause} \u2014 see docs/Guides/train-troubleshooting.md#merge-conflict`;
25025
+ const conflictCause = `${rawCause} \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#merge-conflict`;
25026
25026
  try {
25027
25027
  await deps.run("git", ["merge", "--abort"]);
25028
25028
  } catch (e) {
@@ -26593,7 +26593,7 @@ ${spent.note}`), rcandRecovery(tag2));
26593
26593
  if (!coverage.ok) {
26594
26594
  const list = coverage.uncovered.map((c) => `${c.sha.slice(0, 8)} ${c.subject}`).join("; ");
26595
26595
  throw new Error(
26596
- `hotfix-coverage: ${coverage.uncovered.length} main-only commit(s) not proven in origin/rc \u2014 the candidate would revert a prod hotfix: ${list}. Re-cut /rcand from development, or have the authorized human verify the content is in the candidate and rerun release with --ack <sha>[,<sha>\u2026] \u2014 see docs/Guides/train-troubleshooting.md#hotfix-coverage`
26596
+ `hotfix-coverage: ${coverage.uncovered.length} main-only commit(s) not proven in origin/rc \u2014 the candidate would revert a prod hotfix: ${list}. Re-cut /rcand from development, or have the authorized human verify the content is in the candidate and rerun release with --ack <sha>[,<sha>\u2026] \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#hotfix-coverage`
26597
26597
  // #6095: the guard names its lesson
26598
26598
  );
26599
26599
  }
@@ -26673,7 +26673,7 @@ async function resolveTrainBumpIntent(deps, env = process.env, latestRelease) {
26673
26673
  const explicitIntent = (env.MMI_BUMP_INTENT ?? "").trim();
26674
26674
  if (explicitIntent) {
26675
26675
  if (!TRAIN_BUMP_INTENTS.includes(explicitIntent)) {
26676
- throw new Error("MMI_BUMP_INTENT must be one of major|minor|patch \u2014 see docs/Guides/train-troubleshooting.md#bump-intent-invalid");
26676
+ throw new Error("MMI_BUMP_INTENT must be one of major|minor|patch \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#bump-intent-invalid");
26677
26677
  }
26678
26678
  return explicitIntent;
26679
26679
  }
@@ -26718,12 +26718,12 @@ async function resolveHotfixVersion(deps, env = process.env) {
26718
26718
  }
26719
26719
  const nonPatch = declared.find((d) => d.intent !== "patch");
26720
26720
  if (nonPatch) {
26721
- throw new Error(`hotfix is PATCH-only \u2014 ${nonPatch.name}=${nonPatch.value} declares a ${nonPatch.intent} bump over ${baseTag}; unset it (or run the release train for a ${nonPatch.intent} release) \u2014 see docs/Guides/train-troubleshooting.md#bump-intent-invalid`);
26721
+ throw new Error(`hotfix is PATCH-only \u2014 ${nonPatch.name}=${nonPatch.value} declares a ${nonPatch.intent} bump over ${baseTag}; unset it (or run the release train for a ${nonPatch.intent} release) \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#bump-intent-invalid`);
26722
26722
  }
26723
26723
  const explicit = explicitVersion.replace(/^v/, "");
26724
26724
  const nextPatch = `${latest.major}.${latest.minor}.${latest.patch + 1}`;
26725
26725
  if (explicitIntent && explicit && explicit !== nextPatch) {
26726
- throw new Error(`hotfix version declarations conflict \u2014 MMI_BUMP_INTENT=patch targets ${nextPatch} from ${baseTag}, but MMI_RELEASE_VERSION=${explicitVersion} targets ${explicit}; set one declaration or make both target ${nextPatch} \u2014 see docs/Guides/train-troubleshooting.md#bump-intent-invalid`);
26726
+ throw new Error(`hotfix version declarations conflict \u2014 MMI_BUMP_INTENT=patch targets ${nextPatch} from ${baseTag}, but MMI_RELEASE_VERSION=${explicitVersion} targets ${explicit}; set one declaration or make both target ${nextPatch} \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#bump-intent-invalid`);
26727
26727
  }
26728
26728
  const version = explicit || nextPatch;
26729
26729
  return { tag: `v${version}`, version, baseTag };
@@ -29694,7 +29694,7 @@ init_hub_auth();
29694
29694
  init_github_client();
29695
29695
 
29696
29696
  // src/project-set.ts
29697
- var UNSET_KEYS = ["projectAdmins", "installer", "oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "tenantTasks", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "releaseChannel", "releaseLanguage", "dsManifestPath", "consumesDesignSystem", "ci", "requiredChecks", "requiredCheckBranches", "ciExemptReason", "gate", "seedCanary"];
29697
+ var UNSET_KEYS = ["projectAdmins", "installer", "oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "tenantTasks", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "releaseChannel", "releaseLanguage", "dsManifestPath", "consumesDesignSystem", "ci", "requiredChecks", "requiredCheckBranches", "ciExemptReason", "gate", "seedCanary", "notifyOnDeploy"];
29698
29698
  var UNSET_KEY_SET = new Set(UNSET_KEYS);
29699
29699
  var RUNTIME_SECRET_STAGES = ["dev", "rc", "main"];
29700
29700
  var SECRET_CONSUMERS = ["runtime", "build", "lambda", "actions", "agent", "box"];
@@ -30033,6 +30033,21 @@ function parseRequiredCheckBranchesVar(raw) {
30033
30033
  }
30034
30034
  return REQUIRED_CHECK_BRANCHES.filter((branch) => parsed.includes(branch));
30035
30035
  }
30036
+ function parseNotifyOnDeployVar(raw) {
30037
+ let parsed;
30038
+ try {
30039
+ parsed = JSON.parse(raw);
30040
+ } catch {
30041
+ throw new Error('org project set: notifyOnDeploy must be a JSON array, e.g. ["dev"]');
30042
+ }
30043
+ if (!Array.isArray(parsed) || parsed.some((s) => s !== "dev" && s !== "rc" && s !== "main")) {
30044
+ throw new Error('org project set: notifyOnDeploy must be a JSON array of stages: dev, rc, main (e.g. ["dev"])');
30045
+ }
30046
+ if (new Set(parsed).size !== parsed.length) {
30047
+ throw new Error("org project set: notifyOnDeploy must not contain duplicate stages");
30048
+ }
30049
+ return parsed;
30050
+ }
30036
30051
  function parseCiExemptReasonVar(raw) {
30037
30052
  const trimmed = raw.trim();
30038
30053
  if (!trimmed) throw new Error("org project set: ciExemptReason must be a non-empty reason (or use --unset ciExemptReason to clear it)");
@@ -30173,7 +30188,8 @@ var SETTABLE_VAR_KEYS = [
30173
30188
  "gate",
30174
30189
  "secrets",
30175
30190
  "seedCanary",
30176
- "installer"
30191
+ "installer",
30192
+ "notifyOnDeploy"
30177
30193
  ];
30178
30194
  var SETTABLE_VAR_KEY_SET = new Set(SETTABLE_VAR_KEYS);
30179
30195
  var SETTABLE_VAR_HINTS = {
@@ -30202,6 +30218,7 @@ var SETTABLE_VAR_HINTS = {
30202
30218
  ci: "none \u2014 declare intentional no-ci",
30203
30219
  requiredChecks: 'JSON array, e.g. ["gate"] or [] for no-ci',
30204
30220
  requiredCheckBranches: "non-empty JSON branch-name array; exact product-ruleset scope (#5210)",
30221
+ notifyOnDeploy: 'JSON stage array, e.g. ["dev"] \u2014 successful tenant-deploy sends <stage>-deployed to the project repo (#7358)',
30205
30222
  ciExemptReason: "non-empty string \u2014 declares this deployable repo has no CI surface (#4265)",
30206
30223
  gate: "JSON {runtime,cmd,affectedCmd,workdir,cacheDepPath,pyVersion}"
30207
30224
  };
@@ -30345,6 +30362,8 @@ function buildProjectSetPatch(input) {
30345
30362
  patch[key] = parseRequiredChecksVar(raw);
30346
30363
  } else if (key === "requiredCheckBranches") {
30347
30364
  patch[key] = parseRequiredCheckBranchesVar(raw);
30365
+ } else if (key === "notifyOnDeploy") {
30366
+ patch[key] = parseNotifyOnDeployVar(raw);
30348
30367
  } else if (key === "ciExemptReason") {
30349
30368
  patch[key] = parseCiExemptReasonVar(raw);
30350
30369
  } else if (key === "gate") {
@@ -30389,6 +30408,8 @@ var DEPLOY_SHELL_SAFE_RE = /^[A-Za-z0-9./:_?&=%-]*$/;
30389
30408
  var DEPLOY_ROLE_ARN_RE = /^arn:aws:iam::\d{12}:role\/[A-Za-z0-9+=,.@_/-]{1,128}$/;
30390
30409
  var DEPLOY_BUCKET_RE = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/;
30391
30410
  var DEPLOY_DISTRIBUTION_ID_RE = /^[A-Z0-9]{5,32}$/;
30411
+ var COMPOSE_PROFILE_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
30412
+ var MAX_COMPOSE_PROFILES = 8;
30392
30413
  function buildSetDeployPatch(_slug, input) {
30393
30414
  const clean4 = (v) => typeof v === "string" && v.trim() !== "" ? v.trim() : void 0;
30394
30415
  const stage = clean4(input.stage);
@@ -30432,6 +30453,23 @@ function buildSetDeployPatch(_slug, input) {
30432
30453
  if (input.noEnvFile !== void 0 && typeof input.noEnvFile !== "boolean") {
30433
30454
  throw new Error("org project set-deploy: --no-env-file must be true or false");
30434
30455
  }
30456
+ if (input.clearComposeProfiles && (input.composeProfiles?.length ?? 0) > 0) {
30457
+ throw new Error("org project set-deploy: --clear-compose-profiles cannot be combined with --compose-profiles (they contradict)");
30458
+ }
30459
+ let composeProfiles;
30460
+ if (input.clearComposeProfiles) {
30461
+ composeProfiles = [];
30462
+ } else if (input.composeProfiles !== void 0) {
30463
+ const names = input.composeProfiles.map((p) => typeof p === "string" ? p.trim() : "");
30464
+ if (names.length > MAX_COMPOSE_PROFILES) throw new Error(`org project set-deploy: --compose-profiles carries at most ${MAX_COMPOSE_PROFILES} profiles`);
30465
+ const seen = /* @__PURE__ */ new Set();
30466
+ for (const name of names) {
30467
+ if (!COMPOSE_PROFILE_RE.test(name)) throw new Error(`org project set-deploy: --compose-profiles must be comma-separated profile names ([a-z0-9_-], up to 63 chars), got ${JSON.stringify(name)}`);
30468
+ if (seen.has(name)) throw new Error(`org project set-deploy: --compose-profiles carries a duplicate profile ${JSON.stringify(name)}`);
30469
+ seen.add(name);
30470
+ }
30471
+ composeProfiles = [...seen];
30472
+ }
30435
30473
  return {
30436
30474
  stage,
30437
30475
  ...substrate !== void 0 ? { substrate } : {},
@@ -30448,7 +30486,10 @@ function buildSetDeployPatch(_slug, input) {
30448
30486
  // #2986: an explicit `[]` is what the route reads as "clear them"; omitting the key is what it reads as
30449
30487
  // "keep them". Those are different instructions and they now have different flags.
30450
30488
  ...input.clearAliases ? { aliases: [] } : uniqueAliases.length ? { aliases: uniqueAliases } : {},
30451
- ...input.noEnvFile !== void 0 ? { noEnvFile: input.noEnvFile } : {}
30489
+ ...input.noEnvFile !== void 0 ? { noEnvFile: input.noEnvFile } : {},
30490
+ // #7353: an explicit `[]` is what the route reads as "clear them"; omitting the key is what it reads as
30491
+ // "keep them" — same patch contract as aliases (#2986/#2984).
30492
+ ...composeProfiles !== void 0 ? { composeProfiles } : {}
30452
30493
  };
30453
30494
  }
30454
30495
  var DEPLOY_STAGE_BRANCH = { dev: "development", rc: "rc", main: "main" };
@@ -34313,12 +34354,13 @@ var import_node_crypto11 = require("node:crypto");
34313
34354
  var SCHEMA = "mmi-suite-cut/v1";
34314
34355
  var SHA2 = /^[a-f0-9]{40}$/i;
34315
34356
  var RC_TAG = /^v\d+\.\d+\.\d+-rc\.\d+$/;
34357
+ var RELEASE_TAG = /^v\d+\.\d+\.\d+$/;
34316
34358
  function suitePins(record) {
34317
- return record.members.map((m) => ({
34359
+ return record.members.map((m) => m.carried ? { module: m.id, rc: m.rcPin, main: m.mainPin } : {
34318
34360
  module: m.id,
34319
34361
  rc: `https://${m.id === "katip" ? m.rcHost ?? m.host : m.host}/${m.id === "katip" ? "" : "rc/"}module.js?v=${m.tag}`,
34320
34362
  main: `https://${m.host}/module.js?v=${suiteReleaseTag(m.tag)}`
34321
- }));
34363
+ });
34322
34364
  }
34323
34365
  function suiteReleaseTag(rc) {
34324
34366
  if (!RC_TAG.test(rc)) throw new Error(`invalid rc tag ${rc}`);
@@ -34338,7 +34380,7 @@ function readSuiteCut(path2) {
34338
34380
  } catch {
34339
34381
  throw new Error(`suite cut record unreadable at ${path2}; repair it, never recut`);
34340
34382
  }
34341
- if (record.schema !== SCHEMA || record.lead !== SUITE_REPO || !SHA2.test(record.leadDevelopment) || !SHA2.test(record.leadPreviousRc) || !Array.isArray(record.members) || record.members.length === 0 || record.pinPr !== void 0 && (typeof record.pinPr !== "string" || !record.pinPr.startsWith("https://github.com/mutmutco/MM-Work/pull/")) || record.pinMerged !== void 0 && (record.pinMerged !== true && record.pinMerged !== false || !record.pinPr) || record.leadRcTag !== void 0 && (!RC_TAG.test(record.leadRcTag) || !SHA2.test(record.leadRcSha ?? "") || !record.pinMerged) || record.leadReleased !== void 0 && (record.leadReleased !== true || !record.leadRcTag) || record.announcement !== void 0 && (!["pending", "sent"].includes(record.announcement) || !record.leadReleased) || record.members.some((m) => !m.id || !m.repo || !m.backend || !m.host || !SHA2.test(m.development) || !SHA2.test(m.previousRc) || m.rcWasAbsent !== void 0 && m.rcWasAbsent !== true || m.tag !== void 0 && (!RC_TAG.test(m.tag) || !SHA2.test(m.sha ?? "")) || m.published !== void 0 && (m.published !== true || !m.tag) || m.released !== void 0 && (m.released !== true || !m.published) || m.backendAfterRelease !== void 0 && (!m.released || typeof m.backendAfterRelease !== "string")))
34383
+ if (record.schema !== SCHEMA || record.lead !== SUITE_REPO || !SHA2.test(record.leadDevelopment) || !SHA2.test(record.leadPreviousRc) || !Array.isArray(record.members) || record.members.length === 0 || record.pinPr !== void 0 && (typeof record.pinPr !== "string" || !record.pinPr.startsWith("https://github.com/mutmutco/MM-Work/pull/")) || record.pinMerged !== void 0 && (record.pinMerged !== true && record.pinMerged !== false || !record.pinPr) || record.leadRcTag !== void 0 && (!RC_TAG.test(record.leadRcTag) || !SHA2.test(record.leadRcSha ?? "") || !record.pinMerged) || record.leadReleased !== void 0 && (record.leadReleased !== true || !record.leadRcTag) || record.announcement !== void 0 && (!["pending", "sent"].includes(record.announcement) || !record.leadReleased) || record.members.some((m) => !m.id || !m.repo || !m.backend || !m.host || !SHA2.test(m.development) || !SHA2.test(m.previousRc) || m.rcWasAbsent !== void 0 && m.rcWasAbsent !== true || m.tag !== void 0 && (!RC_TAG.test(m.tag) || !SHA2.test(m.sha ?? "")) || m.published !== void 0 && (m.published !== true || !m.tag) || m.released !== void 0 && (m.released !== true || !m.published) || m.backendAfterRelease !== void 0 && (!m.released || typeof m.backendAfterRelease !== "string") || m.carried !== void 0 && (m.carried !== true || m.tag !== void 0 || m.published !== void 0 || !isPin(m.rcPin) || !isPin(m.mainPin)) || m.carried === void 0 && (m.rcPin !== void 0 || m.mainPin !== void 0)))
34342
34384
  throw new Error(`suite cut record has unknown or invalid state at ${path2}; repair it, never recut`);
34343
34385
  return record;
34344
34386
  }
@@ -34352,6 +34394,27 @@ function writeSuiteCut(path2, record) {
34352
34394
  throw new Error(`suite cut record write failed at ${path2}: ${e.message}; stop, verify the file before retrying`);
34353
34395
  }
34354
34396
  }
34397
+ function isPin(url) {
34398
+ return typeof url === "string" && url.startsWith("https://");
34399
+ }
34400
+ function pinVersion(url) {
34401
+ try {
34402
+ return url ? new URL(url).searchParams.get("v") ?? "" : "";
34403
+ } catch {
34404
+ return "";
34405
+ }
34406
+ }
34407
+ async function carriedPins(deps, member, bundles) {
34408
+ const main = bundles.main;
34409
+ if (!main || !RELEASE_TAG.test(pinVersion(main))) throw new Error(`${member.repo}: nothing new since rc, but its MM-Work main pin ${main ?? "(none)"} has no ?v=vX.Y.Z release; pin it before the suite cut`);
34410
+ for (const url of [bundles.rc, main]) {
34411
+ const tag = pinVersion(url);
34412
+ if (!RC_TAG.test(tag) && !RELEASE_TAG.test(tag)) continue;
34413
+ const sha = await deps.tagSha(member.repo, tag);
34414
+ if (sha && await deps.contains(member.repo, member.development, sha)) return { rcPin: url, mainPin: main };
34415
+ }
34416
+ throw new Error(`${member.repo}: nothing new since rc, but no pinned MM-Work bundle carries development ${member.development.slice(0, 12)}; pin it before the suite cut`);
34417
+ }
34355
34418
  async function suiteRcand(deps, opts) {
34356
34419
  if (!opts.ownerGo) throw new Error("suite rcand requires --owner-go (rc uses live data); nothing was written");
34357
34420
  const members = suiteMembers(await deps.registry());
@@ -34364,8 +34427,6 @@ async function suiteRcand(deps, opts) {
34364
34427
  if (opts.dryRun) return { plan };
34365
34428
  let record = readSuiteCut(deps.recordPath);
34366
34429
  if (record?.announcement === "sent" && record.leadReleased) {
34367
- const heads = await Promise.all(modules.map(async (m) => [await deps.head(m.repo, "development"), await deps.head(m.repo, "rc")]));
34368
- if (heads.some(([dev, rc]) => dev === rc)) throw new Error("a member has no new development commits since rc; no new suite cut started");
34369
34430
  const archive = `${deps.recordPath}.${record.leadRcTag}.json`;
34370
34431
  if ((0, import_node_fs29.existsSync)(archive)) throw new Error(`suite cut archive already exists at ${archive}; inspect before a new cut`);
34371
34432
  (0, import_node_fs29.renameSync)(deps.recordPath, archive);
@@ -34390,9 +34451,38 @@ async function suiteRcand(deps, opts) {
34390
34451
  if (!record) throw new Error("suite cut record unavailable after initialization");
34391
34452
  if (record.members.length !== modules.length || modules.some((m) => !record.members.some((r) => r.repo === m.repo && r.id === m.id)))
34392
34453
  throw new Error("suite roster changed since cut began; refuse to omit or add a member");
34454
+ const quiet = [];
34455
+ for (const member of record.members) {
34456
+ if (!member.tag && !member.carried && await deps.contains(member.repo, member.development, member.previousRc)) quiet.push(member);
34457
+ }
34458
+ if (record.members.every((m) => m.carried || quiet.includes(m))) {
34459
+ (0, import_node_fs29.rmSync)(deps.recordPath, { force: true });
34460
+ throw new Error("nothing to promote: no MM-Work module has new development commits since its rc; no suite cut started");
34461
+ }
34462
+ for (const member of quiet) {
34463
+ Object.assign(member, { carried: true }, await carriedPins(deps, member, modules.find((m) => m.repo === member.repo).bundles));
34464
+ writeSuiteCut(deps.recordPath, record);
34465
+ }
34466
+ const refusals = [];
34467
+ for (const member of record.members) {
34468
+ if (member.carried || member.published || member.id === "katip") continue;
34469
+ try {
34470
+ await deps.preflight(member.repo);
34471
+ } catch (e) {
34472
+ refusals.push(`${member.repo}: ${e.message}`);
34473
+ }
34474
+ }
34475
+ if (refusals.length) {
34476
+ const untouched = record.members.every((m) => !m.tag) && !record.pinPr;
34477
+ if (untouched) (0, import_node_fs29.rmSync)(deps.recordPath, { force: true });
34478
+ throw new Error(`module rc serving is not ready; ${untouched ? "no suite cut started" : "no new tag was cut"}:
34479
+ ${refusals.join("\n")}
34480
+ Each module must ship a live release that bind-mounts <deployPath>/data read-only and serves <data>/rc/module.js at /rc/module.js (docs/Guides/suite-trains.md).`);
34481
+ }
34393
34482
  for (const member of record.members) {
34394
34483
  if ((await deps.head(member.repo, "development"))?.toLowerCase() !== member.development.toLowerCase())
34395
34484
  throw new Error(`${member.repo}: development moved after the cut snapshot; refuse to add untested code`);
34485
+ if (member.carried) continue;
34396
34486
  if (!member.tag) {
34397
34487
  const cut = await deps.rcand(member.repo, member.previousRc, member.rcWasAbsent);
34398
34488
  if (!RC_TAG.test(cut.tag) || !SHA2.test(cut.sha) || cut.sha.toLowerCase() === member.previousRc.toLowerCase()) throw new Error(`${member.repo}: rcand did not return a new immutable rc tag/SHA`);
@@ -34443,6 +34533,11 @@ async function suiteRelease(deps, ownerGo) {
34443
34533
  if (members.length !== record.members.length || members.some((m) => !record.members.some((r) => r.repo === m.repo && r.id === m.id))) throw new Error("suite roster changed after the cut; release refused");
34444
34534
  await deps.verifyPins(suitePins(record));
34445
34535
  for (const member of record.members) {
34536
+ if (member.carried) {
34537
+ const live2 = await deps.backend({ repo: member.repo, id: member.id, bundles: {} });
34538
+ if (live2.version !== member.backend) throw new Error(`${member.repo}: live backend changed after the cut (${member.backend} \u2192 ${live2.version}); release refused`);
34539
+ continue;
34540
+ }
34446
34541
  const [sha, live, rcHead] = await Promise.all([deps.tagSha(member.repo, member.tag), deps.backend({ repo: member.repo, id: member.id, bundles: {} }), deps.head(member.repo, "rc")]);
34447
34542
  if (!sha || sha.toLowerCase() !== member.sha?.toLowerCase() || !member.released && rcHead?.toLowerCase() !== member.sha?.toLowerCase()) throw new Error(`${member.repo}: rc tag/branch ${member.tag} changed after the cut; release refused`);
34448
34543
  const katipReleaseSha = member.id === "katip" ? await deps.tagSha(member.repo, suiteReleaseTag(member.tag)) : void 0;
@@ -34452,7 +34547,7 @@ async function suiteRelease(deps, ownerGo) {
34452
34547
  }
34453
34548
  if ((await deps.tagSha(SUITE_REPO, record.leadRcTag))?.toLowerCase() !== record.leadRcSha?.toLowerCase()) throw new Error("MM-Work rc tag changed after the cut; release refused");
34454
34549
  for (const member of record.members) {
34455
- if (member.released) continue;
34550
+ if (member.released || member.carried) continue;
34456
34551
  await deps.release(member.repo, suiteReleaseTag(member.tag), member.sha);
34457
34552
  if (member.id === "katip") {
34458
34553
  const expected = await deps.tagSha(member.repo, suiteReleaseTag(member.tag));
@@ -34593,7 +34688,7 @@ function announceSummaryFidelity(lines2, rangeLog) {
34593
34688
  if (shipped.size === 0) return null;
34594
34689
  const invented = [...new Set(lines2.flatMap((l) => l.match(/#\d+/g) ?? []))].filter((ref) => !shipped.has(ref));
34595
34690
  if (invented.length === 0) return null;
34596
- return `--announce-summary-file cites ${invented.join(", ")}, which ship in no commit of this release range \u2014 a summary line must describe work that actually shipped. Rebuild the summary from \`git log origin/main..HEAD\` (read commit BODIES too: a bundled PR closes issues its subject never names), then rerun the same release command \u2014 see docs/Guides/train-troubleshooting.md#announce-summary-missing`;
34691
+ return `--announce-summary-file cites ${invented.join(", ")}, which ship in no commit of this release range \u2014 a summary line must describe work that actually shipped. Rebuild the summary from \`git log origin/main..HEAD\` (read commit BODIES too: a bundled PR closes issues its subject never names), then rerun the same release command \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#announce-summary-missing`;
34597
34692
  }
34598
34693
  async function resolveAnnounceTarget(deps, repo) {
34599
34694
  if (repo.toLowerCase() === ANNOUNCE_REPO.toLowerCase()) return { kind: "alerts" };
@@ -34671,6 +34766,12 @@ function normalizeHotfixVersion(input) {
34671
34766
  return { tag: `v${m[1]}`, version: m[1] };
34672
34767
  }
34673
34768
  var deriveHotfixVersion = resolveHotfixVersion;
34769
+ function bumpHotfixPatchVersion(version) {
34770
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
34771
+ if (!m) throw new Error(`cannot bump non-X.Y.Z hotfix version "${version}"`);
34772
+ const next = `${m[1]}.${m[2]}.${Number(m[3]) + 1}`;
34773
+ return { tag: `v${next}`, version: next };
34774
+ }
34674
34775
  function hotfixBranch(tag) {
34675
34776
  return `hotfix/${tag}`;
34676
34777
  }
@@ -34724,7 +34825,7 @@ function formatHotfixBatchPreflightRefusal(failures, fromSpecs) {
34724
34825
  const batchFrom = fromSpecs.join(",");
34725
34826
  return `hotfix start batch preflight refused: ${failures.length} pick(s) would hard-stop on origin/main:
34726
34827
  ${lines2.join("\n")}
34727
- Land ONE development-based cite-only synthesis PR for the whole batch (#4643 supersedes the old cut-from-origin/main wording, which produced main-lineage PRs into development \u2014 the shape portFoldToDevelopment forbids, #4365/#4371): cut the branch FROM origin/development, regenerate the listed conflict content so it cherry-picks cleanly onto current origin/main, and land that development-parented PR with the cite-only marker (the proven #5096 flow), then rerun \`mmi-cli devops hotfix start --from ${batchFrom}\` with the port merge SHA(s) \u2014 never hand-resolve onto main; never a main-parented branch into development; never N serial ports (#3167) \u2014 see docs/Guides/train-troubleshooting.md#hotfix-pick-refused`;
34828
+ Land ONE development-based cite-only synthesis PR for the whole batch (#4643 supersedes the old cut-from-origin/main wording, which produced main-lineage PRs into development \u2014 the shape portFoldToDevelopment forbids, #4365/#4371): cut the branch FROM origin/development, regenerate the listed conflict content so it cherry-picks cleanly onto current origin/main, and land that development-parented PR with the cite-only marker (the proven #5096 flow), then rerun \`mmi-cli devops hotfix start --from ${batchFrom}\` with the port merge SHA(s) \u2014 never hand-resolve onto main; never a main-parented branch into development; never N serial ports (#3167) \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#hotfix-pick-refused`;
34728
34829
  }
34729
34830
  var CHERRY_PICKED_FROM = /\(cherry picked from commit ([0-9a-f]{7,40})\)/g;
34730
34831
  async function findHotfixPickGaps(deps, sources, tolerated) {
@@ -34753,32 +34854,44 @@ ${lines2.join("\n")}
34753
34854
  A hotfix branch is cut from origin/main and carries ONLY the fixes --from names, in order (#4411) \u2014 every other merged development fix stays unshipped while the channel reports healthy (#6994). Add each omitted fix to the pick list and rerun \`mmi-cli devops hotfix start --from ${[...fromSpecs, ...gaps.map((g) => g.sha.slice(0, 7))].join(",")}\`, or, when a listed fix is provably not needed on main (a development-only change, already superseded), rerun with \`--ack ${gaps.map((g) => g.sha.slice(0, 7)).join(",")}\` naming exactly the sha(s) a human excluded \u2014 the ack is recorded in the PR body, never silent.`;
34754
34855
  }
34755
34856
  async function preflightHotfixBatchPicks(deps, sources, pickTolerated) {
34756
- if (sources.length === 0) return [];
34757
- const startBranch = clean3(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
34758
- const preflightBranch = `__mmi_hotfix_preflight_${Date.now()}`;
34857
+ if (sources.length === 0) return { failures: [], skipped: [] };
34858
+ const { mkdtemp: mkdtemp2, rm: rm2 } = await import("node:fs/promises");
34859
+ const { tmpdir: tmpdir10 } = await import("node:os");
34860
+ const { join: join44 } = await import("node:path");
34861
+ const dir = await mkdtemp2(join44(tmpdir10(), "mmi-hotfix-preflight-"));
34862
+ const wt = { run: (file, args) => deps.run(file, file === "git" ? ["-C", dir, ...args] : args) };
34759
34863
  const failures = [];
34864
+ const skipped = [];
34760
34865
  try {
34761
- await deps.run("git", ["checkout", "-B", preflightBranch, "origin/main"]);
34762
- for (const source of sources) {
34763
- try {
34764
- await cherryPickWithToleratedPaths(deps, source.sha, pickTolerated);
34765
- } catch (e) {
34766
- const message2 = e.message ?? String(e);
34767
- const blocking = parseCherryPickBlockingPaths(message2);
34768
- failures.push({
34769
- label: source.label,
34770
- sha: source.sha,
34771
- blocking: blocking.length > 0 ? blocking : [message2.split("\n")[0] ?? message2]
34772
- });
34866
+ await deps.run("git", ["worktree", "add", "--detach", dir, "origin/main"]);
34867
+ try {
34868
+ for (const source of sources) {
34869
+ const before = await wt.run("git", ["status", "--porcelain"]);
34870
+ try {
34871
+ await cherryPickWithToleratedPaths(wt, source.sha, pickTolerated);
34872
+ } catch (e) {
34873
+ const message2 = e.message ?? String(e);
34874
+ const after = await wt.run("git", ["status", "--porcelain"]).catch(() => null);
34875
+ if (after !== null && after === before && /empty|nothing to commit/i.test(message2)) {
34876
+ await wt.run("git", ["cherry-pick", "--quit"]).catch(() => void 0);
34877
+ skipped.push({ sha: source.sha, label: source.label });
34878
+ continue;
34879
+ }
34880
+ const blocking = parseCherryPickBlockingPaths(message2);
34881
+ failures.push({
34882
+ label: source.label,
34883
+ sha: source.sha,
34884
+ blocking: blocking.length > 0 ? blocking : [message2.split("\n")[0] ?? message2]
34885
+ });
34886
+ }
34773
34887
  }
34888
+ } finally {
34889
+ await deps.run("git", ["worktree", "remove", "--force", dir]).catch(() => void 0);
34774
34890
  }
34775
34891
  } finally {
34776
- if (startBranch && startBranch !== "HEAD") {
34777
- await deps.run("git", ["checkout", startBranch]).catch(() => void 0);
34778
- }
34779
- await deps.run("git", ["branch", "-D", preflightBranch]).catch(() => void 0);
34892
+ await rm2(dir, { recursive: true, force: true }).catch(() => void 0);
34780
34893
  }
34781
- return failures;
34894
+ return { failures, skipped };
34782
34895
  }
34783
34896
  async function restoreAfterFailedPort(deps, opts) {
34784
34897
  const { startBranch, branch, created } = opts;
@@ -34902,9 +35015,19 @@ async function runHotfixStartLocked(deps, options, doctor) {
34902
35015
  }
34903
35016
  await deps.run("git", ["fetch", "origin", "--tags"]);
34904
35017
  const { deployModel } = await hotfixPreflight(deps, ctx, "start");
34905
- const { tag, version } = await resolveHotfixVersion(deps);
35018
+ const bumpNotes = [];
35019
+ let resolved = await resolveHotfixVersion(deps);
35020
+ for (; ; ) {
35021
+ const priorPr = await findHotfixPr(deps, ctx, resolved.tag);
35022
+ if ((priorPr?.state ?? "").toUpperCase() !== "MERGED") break;
35023
+ const priorReleased = await hotfixReleaseExists(deps, ctx, resolved.tag);
35024
+ const next = bumpHotfixPatchVersion(resolved.version);
35025
+ bumpNotes.push(`hotfix ${resolved.tag} is already merged on origin/main (PR #${priorPr?.number}, ${priorReleased ? "released" : "unreleased"}) \u2014 origin/main already carries its fold, so this cycle mints ${next.tag} instead of reusing an already-bumped version (#7338)`);
35026
+ resolved = next;
35027
+ }
35028
+ const { tag, version } = resolved;
34906
35029
  const branch = hotfixBranch(tag);
34907
- const notes = [];
35030
+ const notes = [...bumpNotes];
34908
35031
  const existingPr = await findHotfixPr(deps, ctx, tag);
34909
35032
  const existingState = (existingPr?.state ?? "").toUpperCase();
34910
35033
  const releaseExists = existingState === "MERGED" ? await hotfixReleaseExists(deps, ctx, tag) : false;
@@ -34936,8 +35059,8 @@ async function runHotfixStartLocked(deps, options, doctor) {
34936
35059
  if (specs.length === 0) throw new Error("hotfix start: --from named no PR or SHA");
34937
35060
  const sources = [];
34938
35061
  for (const spec of specs) {
34939
- const resolved = await resolveHotfixSource(deps, ctx, spec);
34940
- if (!sources.some((s) => s.sha === resolved.sha)) sources.push(resolved);
35062
+ const resolved2 = await resolveHotfixSource(deps, ctx, spec);
35063
+ if (!sources.some((s) => s.sha === resolved2.sha)) sources.push(resolved2);
34941
35064
  }
34942
35065
  const label = sources.map((s) => s.label).join(", ");
34943
35066
  const { foldPaths, pickTolerated } = await resolveHotfixPickTolerated(deps, deployModel);
@@ -34953,21 +35076,33 @@ async function runHotfixStartLocked(deps, options, doctor) {
34953
35076
  if (pickGaps.length > 0) {
34954
35077
  notes.push(`pick-completeness ack: excluded ${pickGaps.map((g) => `${g.sha.slice(0, 7)} ${g.subject}`).join("; ")} by --ack (#6994)`);
34955
35078
  }
35079
+ let activeSources = sources;
35080
+ let activeLabel = label;
34956
35081
  const remoteBranch = clean3(await deps.run("git", ["ls-remote", "origin", `refs/heads/${branch}`]));
34957
35082
  if (remoteBranch) {
34958
35083
  await deps.run("git", ["checkout", branch]);
34959
35084
  await deps.run("git", ["pull", "--ff-only", "origin", branch]);
34960
35085
  notes.push(`branch ${branch} already on origin \u2014 reused (cherry-pick/bump assumed present; PR step resumes)`);
34961
35086
  } else {
34962
- const preflightFailures = await preflightHotfixBatchPicks(deps, sources, pickTolerated);
34963
- if (preflightFailures.length > 0) {
34964
- throw new Error(formatHotfixBatchPreflightRefusal(preflightFailures, specs));
35087
+ const preflight2 = await preflightHotfixBatchPicks(deps, sources, pickTolerated);
35088
+ if (preflight2.failures.length > 0) {
35089
+ throw new Error(formatHotfixBatchPreflightRefusal(preflight2.failures, specs));
35090
+ }
35091
+ if (preflight2.skipped.length > 0) {
35092
+ const skippedShas = new Set(preflight2.skipped.map((s) => s.sha));
35093
+ const remaining = sources.filter((s) => !skippedShas.has(s.sha));
35094
+ notes.push(`auto-skipped ${preflight2.skipped.length} pick(s) already present on origin/main (empty cherry-pick \u2014 content already shipped there, #7338): ${preflight2.skipped.map((s) => s.label).join(", ")}`);
35095
+ if (remaining.length === 0) {
35096
+ throw new Error(`hotfix start: every named pick is already present on origin/main (${preflight2.skipped.map((s) => s.label).join(", ")}) \u2014 nothing to carry, so no ${branch} branch was cut`);
35097
+ }
35098
+ activeSources = remaining;
35099
+ activeLabel = remaining.map((s) => s.label).join(", ");
34965
35100
  }
34966
35101
  const startBranch = clean3(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
34967
35102
  const preexistingLocal = clean3(await deps.run("git", ["branch", "--list", branch]));
34968
35103
  await deps.run("git", ["checkout", "-B", branch, "origin/main"]);
34969
35104
  try {
34970
- for (const source of sources) {
35105
+ for (const source of activeSources) {
34971
35106
  const autoResolved = await cherryPickWithToleratedPaths(deps, source.sha, pickTolerated);
34972
35107
  if (autoResolved.length > 0) {
34973
35108
  notes.push(`auto-resolved regenerable cherry-pick conflict(s) for ${source.label}: ${autoResolved.join(", ")} (regenerated in bump step)`);
@@ -34976,12 +35111,12 @@ async function runHotfixStartLocked(deps, options, doctor) {
34976
35111
  } catch (e) {
34977
35112
  const recovery = await restoreAfterFailedPort(deps, { startBranch, branch, created: !preexistingLocal });
34978
35113
  throw new Error(
34979
- `cherry-pick of ${label} onto ${branch} conflicted \u2014 aborted; nothing was pushed. Land the conflict resolution on development through a normal PR, then rerun \`mmi-cli devops hotfix start --from <that PR's merge sha>\` \u2014 never hand-resolve the port onto main. ${recovery} (${e.message ?? e})`
35114
+ `cherry-pick of ${activeLabel} onto ${branch} conflicted \u2014 aborted; nothing was pushed. Land the conflict resolution on development through a normal PR, then rerun \`mmi-cli devops hotfix start --from <that PR's merge sha>\` \u2014 never hand-resolve the port onto main. ${recovery} (${e.message ?? e})`
34980
35115
  );
34981
35116
  }
34982
- notes.push(`cherry-picked ${label} onto ${branch} (from origin/main, -x trailer recorded)`);
35117
+ notes.push(`cherry-picked ${activeLabel} onto ${branch} (from origin/main, -x trailer recorded)`);
35118
+ const durableSource = clean3(await deps.run("git", ["merge-base", "HEAD", "origin/main"]));
34983
35119
  if (deployModel === "hub-serverless") {
34984
- const durableSource = clean3(await deps.run("git", ["merge-base", "HEAD", "origin/main"]));
34985
35120
  await deps.run("node", ["scripts/release-distribution.mjs", "prepare", version, "--source-commit", durableSource]);
34986
35121
  const changedFiles = (await deps.run("node", ["scripts/release-distribution.mjs", "changed-files"])).split("\n").map((s) => s.trim()).filter(Boolean);
34987
35122
  await deps.run("git", ["add", "-f", "--", ...changedFiles]);
@@ -34993,11 +35128,12 @@ async function runHotfixStartLocked(deps, options, doctor) {
34993
35128
  notes.push("distribution prepare produced no changes \u2014 nothing extra committed");
34994
35129
  }
34995
35130
  } else if (foldPaths.length > 0) {
34996
- const foldNote = await foldReleaseVersion(deps, deployModel, tag, foldPaths);
35131
+ const foldNote = await withFoldSourceCommit(durableSource, () => foldReleaseVersion(deps, deployModel, tag, foldPaths));
34997
35132
  notes.push(foldNote);
34998
35133
  } else {
34999
35134
  notes.push(`version fold skipped (deployModel=${deployModel} carries no version manifest \u2014 the tag is the version)`);
35000
35135
  }
35136
+ await assertHotfixFoldBomStamp(deps, tag);
35001
35137
  await deps.run("git", ["push", "-u", "origin", branch]);
35002
35138
  }
35003
35139
  const bumpNote = deployModel === "hub-serverless" ? " with the locked distribution bump" : foldPaths.length > 0 ? " with the package version bump" : "";
@@ -35015,16 +35151,16 @@ async function runHotfixStartLocked(deps, options, doctor) {
35015
35151
  // Every picked sha goes in the marker (#4411) — `hotfix release` reads it when --carries is omitted,
35016
35152
  // so a multi-fix cycle proves ALL of its targets present before tagging, not just the first.
35017
35153
  "--body",
35018
- `Hotfix ${tag}: cherry-pick of ${label} onto origin/main${bumpNote}.
35154
+ `Hotfix ${tag}: cherry-pick of ${activeLabel} onto origin/main${bumpNote}.
35019
35155
 
35020
- <!-- mmi-hotfix-carries: ${sources.map((s) => s.sha).join(",")} -->${pickGaps.length > 0 ? `
35156
+ <!-- mmi-hotfix-carries: ${activeSources.map((s) => s.sha).join(",")} -->${pickGaps.length > 0 ? `
35021
35157
 
35022
35158
  <!-- mmi-hotfix-acked-excluded: ${pickGaps.map((g) => g.sha).join(",")} (#6994 pick-completeness) -->` : ""}
35023
35159
 
35024
35160
  Merge this PR (human-initiated), then run \`mmi-cli devops hotfix release ${tag}\`.`
35025
35161
  ]));
35026
35162
  notes.push(`opened hotfix PR ${prUrl} \u2014 merge it (human-initiated), then: mmi-cli devops hotfix release ${tag}`);
35027
- return { ...ctx, command: "hotfix-start", tag, version, branch, source: label, prUrl, reused: Boolean(remoteBranch), notes };
35163
+ return { ...ctx, command: "hotfix-start", tag, version, branch, source: activeLabel, prUrl, reused: Boolean(remoteBranch), notes };
35028
35164
  }
35029
35165
  function hotfixWorkflowRunFromDispatch(workflow, dispatch) {
35030
35166
  return {
@@ -35061,6 +35197,13 @@ async function watchReleaseRun(deps, ctx, workflow, sha) {
35061
35197
  const run = rows.find((r) => r.headSha === sha && typeof r.databaseId === "number");
35062
35198
  if (!run) continue;
35063
35199
  if (run.status === "completed") return { workflow, runId: run.databaseId, url: run.url, conclusion: run.conclusion ?? "unknown" };
35200
+ try {
35201
+ const viewed = JSON.parse(await deps.run("gh", ["run", "view", String(run.databaseId), "--repo", ctx.repo, "--json", "status,conclusion"]));
35202
+ if (viewed.status === "completed") {
35203
+ return { workflow, runId: run.databaseId, url: run.url, conclusion: typeof viewed.conclusion === "string" ? viewed.conclusion : "unknown" };
35204
+ }
35205
+ } catch {
35206
+ }
35064
35207
  try {
35065
35208
  await deps.run("gh", ghRunWatchArgs(run.databaseId, ctx.repo));
35066
35209
  return { workflow, runId: run.databaseId, url: run.url, conclusion: "success" };
@@ -35088,9 +35231,9 @@ async function portFoldToDevelopment(deps, ctx, deployModel, tag) {
35088
35231
  const branch = devFoldBranch(tag);
35089
35232
  const existing = await findFoldPr(deps, ctx, tag);
35090
35233
  if (existing?.state === "OPEN") {
35091
- return (await enqueueAlignmentAutoMerge(deps, ctx, existing.number, existing.url, `development fold PR #${existing.number} for ${tag} is OPEN \u2014 reused${existing.url ? ` (${existing.url})` : ""} \u2014 see docs/Guides/train-troubleshooting.md#hotfix-fold-port`)).note;
35234
+ return (await enqueueAlignmentAutoMerge(deps, ctx, existing.number, existing.url, `development fold PR #${existing.number} for ${tag} is OPEN \u2014 reused${existing.url ? ` (${existing.url})` : ""} \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#hotfix-fold-port`)).note;
35092
35235
  }
35093
- if (existing) return `development fold PR #${existing.number} for ${tag} is ${existing.state} \u2014 reused${existing.url ? ` (${existing.url})` : ""} \u2014 see docs/Guides/train-troubleshooting.md#hotfix-fold-port`;
35236
+ if (existing) return `development fold PR #${existing.number} for ${tag} is ${existing.state} \u2014 reused${existing.url ? ` (${existing.url})` : ""} \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#hotfix-fold-port`;
35094
35237
  const previousRef = clean3(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
35095
35238
  await reapFoldScratchBranches(deps);
35096
35239
  const scratch = devFoldScratchBranch(tag, deps);
@@ -35118,7 +35261,7 @@ async function portFoldToDevelopment(deps, ctx, deployModel, tag) {
35118
35261
 
35119
35262
  Generated by \`mmi-cli devops hotfix release\` \u2014 no main-parented commit is merged in, so the squash parents stay clean (#4365/#4371). Auto-merge is enqueued through the same path as the release alignment PR (#6068); it lands once the checks it triggers pass.`
35120
35263
  ]));
35121
- return (await enqueueAlignmentAutoMerge(deps, ctx, parsePrNumber(prUrl), prUrl || void 0, `opened development fold PR ${prUrl} (${foldNote}) \u2014 see docs/Guides/train-troubleshooting.md#hotfix-fold-port`)).note;
35264
+ return (await enqueueAlignmentAutoMerge(deps, ctx, parsePrNumber(prUrl), prUrl || void 0, `opened development fold PR ${prUrl} (${foldNote}) \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#hotfix-fold-port`)).note;
35122
35265
  } finally {
35123
35266
  if (previousRef && previousRef !== "HEAD") await deps.run("git", ["checkout", previousRef]).catch(() => void 0);
35124
35267
  await deps.run("git", ["branch", "-D", scratch]).catch(() => void 0);
@@ -35306,6 +35449,41 @@ ${recovery.note}; the full required-check wall did not revalidate`);
35306
35449
  }, `the immutable ${anchors.tag} pushed at ${mergedSha.slice(0, 12)}`);
35307
35450
  return checks;
35308
35451
  }
35452
+ var FOLD_SOURCE_COMMIT_ENV = "MMI_FOLD_SOURCE_COMMIT";
35453
+ async function withFoldSourceCommit(durableSource, fn) {
35454
+ const prev = process.env[FOLD_SOURCE_COMMIT_ENV];
35455
+ process.env[FOLD_SOURCE_COMMIT_ENV] = durableSource;
35456
+ try {
35457
+ return await fn();
35458
+ } finally {
35459
+ if (prev === void 0) delete process.env[FOLD_SOURCE_COMMIT_ENV];
35460
+ else process.env[FOLD_SOURCE_COMMIT_ENV] = prev;
35461
+ }
35462
+ }
35463
+ async function assertHotfixFoldBomStamp(deps, tag) {
35464
+ let bomText;
35465
+ try {
35466
+ bomText = await deps.run("git", ["show", "HEAD:distribution-bom.json"]);
35467
+ } catch {
35468
+ return;
35469
+ }
35470
+ let stamp;
35471
+ try {
35472
+ stamp = JSON.parse(bomText).sourceCommit;
35473
+ } catch (e) {
35474
+ throw new Error(`hotfix ${tag}: distribution-bom.json at the fold commit is unparseable (${e.message}) \u2014 fix the BOM before the hotfix PR is opened, then rerun hotfix start`);
35475
+ }
35476
+ if (typeof stamp !== "string" || !/^[0-9a-f]{40}$/.test(stamp)) {
35477
+ throw new Error(`hotfix ${tag}: distribution-bom.json at the fold commit carries no 40-hex sourceCommit \u2014 the updater would refuse this release; fix the BOM before the hotfix PR is opened, then rerun hotfix start`);
35478
+ }
35479
+ try {
35480
+ await deps.run("git", ["merge-base", "--is-ancestor", stamp, "origin/main"]);
35481
+ } catch {
35482
+ throw new Error(
35483
+ `hotfix ${tag}: distribution-bom.json at the fold commit stamps sourceCommit ${stamp.slice(0, 12)}, which is NOT on origin/main \u2014 the squash merge of the hotfix PR would orphan it and hotfix release would refuse (hotfix-bom-lineage). Make the consumer stamp ${FOLD_SOURCE_COMMIT_ENV} when set (the train exports the durable merge-base during the fold, Jerv-Hub#1084), land that on development, and rerun hotfix start \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#hotfix-bom-lineage`
35484
+ );
35485
+ }
35486
+ }
35309
35487
  async function assertConsumerBomLineage(deps, mergedSha, tag) {
35310
35488
  let bomText;
35311
35489
  try {
@@ -35326,7 +35504,7 @@ async function assertConsumerBomLineage(deps, mergedSha, tag) {
35326
35504
  await deps.run("git", ["merge-base", "--is-ancestor", stamp, mergedSha]);
35327
35505
  } catch {
35328
35506
  throw new Error(
35329
- `hotfix ${tag}: distribution-bom.json at ${mergedSha.slice(0, 12)} stamps sourceCommit ${stamp.slice(0, 12)}, which the merged main commit does not contain \u2014 every updater would reject ${tag} (gateCandidate ancestry). The fold was stamped on the hotfix branch and squash-merged away: make the consumer stamp a durable merge-base (Jerv-Hub#1084), land that on development, and port it with the fix \u2014 see docs/Guides/train-troubleshooting.md#hotfix-bom-lineage`
35507
+ `hotfix ${tag}: distribution-bom.json at ${mergedSha.slice(0, 12)} stamps sourceCommit ${stamp.slice(0, 12)}, which the merged main commit does not contain \u2014 every updater would reject ${tag} (gateCandidate ancestry). The fold was stamped on the hotfix branch and squash-merged away: make the consumer stamp a durable merge-base (Jerv-Hub#1084), land that on development, and port it with the fix \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#hotfix-bom-lineage`
35330
35508
  );
35331
35509
  }
35332
35510
  }
@@ -35372,16 +35550,16 @@ async function runHotfixReleaseLocked(deps, versionInput, options, doctor) {
35372
35550
  const releaseExists = await hotfixReleaseExists(deps, ctx, tag);
35373
35551
  if (!releaseExists && isHubControlRepo(ctx.repo)) {
35374
35552
  if (!options.announceSummaryFile) {
35375
- throw new Error("a new MMI-Hub hotfix Release requires --announce-summary-file <path> with 3-6 short LLM-curated lines; create the summary file, then rerun the same hotfix release command \u2014 see docs/Guides/train-troubleshooting.md#announce-summary-missing");
35553
+ throw new Error("a new MMI-Hub hotfix Release requires --announce-summary-file <path> with 3-6 short LLM-curated lines; create the summary file, then rerun the same hotfix release command \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#announce-summary-missing");
35376
35554
  }
35377
35555
  let lines2;
35378
35556
  try {
35379
35557
  lines2 = summaryFileLines(await (deps.readFile ?? ((p) => (0, import_promises4.readFile)(p, "utf8")))(options.announceSummaryFile));
35380
35558
  } catch (e) {
35381
- throw new Error(`could not read --announce-summary-file ${options.announceSummaryFile}: ${e.message} \u2014 see docs/Guides/train-troubleshooting.md#announce-summary-missing`);
35559
+ throw new Error(`could not read --announce-summary-file ${options.announceSummaryFile}: ${e.message} \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#announce-summary-missing`);
35382
35560
  }
35383
35561
  if (lines2.length < 3 || lines2.length > 6) {
35384
- throw new Error(`--announce-summary-file must contain 3-6 non-empty LLM-curated lines (found ${lines2.length}) \u2014 see docs/Guides/train-troubleshooting.md#announce-summary-missing`);
35562
+ throw new Error(`--announce-summary-file must contain 3-6 non-empty LLM-curated lines (found ${lines2.length}) \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#announce-summary-missing`);
35385
35563
  }
35386
35564
  const jerv = hubSummaryJervRefusal(lines2);
35387
35565
  if (jerv) throw new Error(jerv);
@@ -35528,7 +35706,7 @@ ${decision.note}`);
35528
35706
  foldNote = await portFoldToDevelopment(deps, ctx, deployModel, tag);
35529
35707
  } catch (e) {
35530
35708
  foldStatus = "failure";
35531
- foldNote = `development fold port FAILED: ${e.message ?? e} \u2014 the release stands; port it by hand on a fresh branch: git checkout -B ${devFoldBranch(tag)}-manual origin/development && ${foldPortManualCommand(deployModel, version)}, then push it as the canonical head (git push origin ${devFoldBranch(tag)}-manual:refs/heads/${devFoldBranch(tag)}) and open a development-base PR \u2014 see docs/Guides/train-troubleshooting.md#hotfix-fold-port`;
35709
+ foldNote = `development fold port FAILED: ${e.message ?? e} \u2014 the release stands; port it by hand on a fresh branch: git checkout -B ${devFoldBranch(tag)}-manual origin/development && ${foldPortManualCommand(deployModel, version)}, then push it as the canonical head (git push origin ${devFoldBranch(tag)}-manual:refs/heads/${devFoldBranch(tag)}) and open a development-base PR \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#hotfix-fold-port`;
35532
35710
  }
35533
35711
  await recordHotfixPhase(ledger, anchors, "alignment", hotfixFoldAlignmentEntry(foldStatus, foldNote, mergedSha), "the tagged, released, deployed hotfix");
35534
35712
  const ledgerReport = releaseLedgerReport(await ledger.load(anchors).catch(() => void 0), ledger.path);
@@ -35577,7 +35755,7 @@ async function runHotfixRetryDeployLocked(deps, versionInput) {
35577
35755
  foldNote2 = await portFoldToDevelopment(deps, ctx, deployModel, prior.tag);
35578
35756
  } catch (e) {
35579
35757
  foldStatus2 = "failure";
35580
- foldNote2 = `development fold port FAILED: ${e.message ?? e} \u2014 the release stands; port it by hand on a fresh branch: git checkout -B ${devFoldBranch(prior.tag)}-manual origin/development && ${foldPortManualCommand(deployModel, prior.tag.replace(/^v/, ""))}, then push it as the canonical head (git push origin ${devFoldBranch(prior.tag)}-manual:refs/heads/${devFoldBranch(prior.tag)}) and open a development-base PR \u2014 see docs/Guides/train-troubleshooting.md#hotfix-fold-port`;
35758
+ foldNote2 = `development fold port FAILED: ${e.message ?? e} \u2014 the release stands; port it by hand on a fresh branch: git checkout -B ${devFoldBranch(prior.tag)}-manual origin/development && ${foldPortManualCommand(deployModel, prior.tag.replace(/^v/, ""))}, then push it as the canonical head (git push origin ${devFoldBranch(prior.tag)}-manual:refs/heads/${devFoldBranch(prior.tag)}) and open a development-base PR \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#hotfix-fold-port`;
35581
35759
  }
35582
35760
  return hotfixFoldAlignmentEntry(foldStatus2, foldNote2, prior.tagSha);
35583
35761
  }
@@ -36097,17 +36275,17 @@ function prHeadBehindBase(input) {
36097
36275
  return /out[- ]of[- ]date|not up to date|up to date with the base|update (?:the|your) branch/i.test(input.message ?? "");
36098
36276
  }
36099
36277
  function prHeadUpdateRemedy(head, base) {
36100
- return `git fetch origin ${base} && git merge origin/${base} (on ${head}), resolve the conflicts, push, then rerun \u2014 docs/Guides/train-troubleshooting.md#head-behind-base`;
36278
+ return `git fetch origin ${base} && git merge origin/${base} (on ${head}), resolve the conflicts, push, then rerun \u2014 https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#head-behind-base`;
36101
36279
  }
36102
36280
  function prLandedBranchFinality(prNumber, base) {
36103
- return `PR #${prNumber} is merged, and that is final \u2014 stopping a local waiter or monitor does not unmerge it. The head branch is now landed; further commits pushed to it reach nothing. If more work is needed, run \`git fetch origin ${base} && git switch -c <new-branch> origin/${base}\`, then cherry-pick anything already committed \u2014 docs/Guides/train-troubleshooting.md#merged-pr-head-reuse`;
36281
+ return `PR #${prNumber} is merged, and that is final \u2014 stopping a local waiter or monitor does not unmerge it. The head branch is now landed; further commits pushed to it reach nothing. If more work is needed, run \`git fetch origin ${base} && git switch -c <new-branch> origin/${base}\`, then cherry-pick anything already committed \u2014 https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#merged-pr-head-reuse`;
36104
36282
  }
36105
36283
  var PROTECTED_PR_HEAD_BRANCHES = ["main", "master", "development", "rc"];
36106
36284
  function isProtectedPrHead(head) {
36107
36285
  return PROTECTED_PR_HEAD_BRANCHES.includes(head);
36108
36286
  }
36109
36287
  function protectedPrHeadRemedy(head, base) {
36110
- return `PR head \`${head}\` is a train branch \u2014 updating it from \`${base}\` would merge \`${base}\` into \`${head}\`, promoting every unreleased commit. Never do that and never hand-merge on \`${head}\`. This is the pre-#6288 alignment shape: close this PR and let the train rebuild the alignment on a \`${base}\`-based branch that merges \`${head}\` into it (\`mmi-cli devops release --resume\`), then land THAT with a true merge \u2014 docs/Guides/train-troubleshooting.md#head-behind-base`;
36288
+ return `PR head \`${head}\` is a train branch \u2014 updating it from \`${base}\` would merge \`${base}\` into \`${head}\`, promoting every unreleased commit. Never do that and never hand-merge on \`${head}\`. This is the pre-#6288 alignment shape: close this PR and let the train rebuild the alignment on a \`${base}\`-based branch that merges \`${head}\` into it (\`mmi-cli devops release --resume\`), then land THAT with a true merge \u2014 https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#head-behind-base`;
36111
36289
  }
36112
36290
  var PR_HEAD_UPDATE_API_READ_RETRIES = 5;
36113
36291
  var PR_HEAD_UPDATE_API_READ_DELAY_MS = 2e3;
@@ -37597,7 +37775,7 @@ function readRepoVersion() {
37597
37775
  }
37598
37776
  function registerTrainCommands(program3, trainDeps) {
37599
37777
  const train = program3.command("train").description("release-train observability \u2014 track position, version lag, and the shared preflight-and-heal doctor (#2688, #6067)");
37600
- train.command("doctor").description("one shared preflight-and-heal verdict for the release, rcand and hotfix lanes; read-only unless --heal (#6067)").addOption(new Option("--lane <lane>", "train lane to check (defaults from the release track: direct \u2192 release)").choices([...TRAIN_LANES])).option("--dev", "judge the `release --dev` lane (development -> main, skipping rc): the lane starts from development and is read on development's workflows (#6318)").option("--heal", "repair safe local reconstructible state (scratch gitignore, churn, stray local tags, ff-only branch lag, finished scratch branches, stale alignment ledger leg); it never clears a ledger-pending, ledger-failed or branch-mismatch blocker (#6404, #6401, #6399) \u2014 those need `mmi-cli devops release --resume` (or, with fresh approval, `--abort --apply`) and `git checkout <start branch>`, so exit 1 after --heal is the gate working, not a failed heal").option("--repo <owner/repo>", "target repo (defaults to the current checkout)").option("--suite-solo", "judge an owner-approved solo release of one suite module, as `release --suite-solo --owner-go` does (requires --owner-go; never on rcand, #7327)").option("--owner-go", "explicit owner approval for the suite-solo lane (requires --suite-solo)").option("--json", "machine-readable output").option("--out <path>", "write the verdict to this file as UTF-8 (no BOM) instead of stdout \u2014 the shell-free receipt path (#5802/#6563); pair with --json for a machine-readable file (e.g. `--json --out .jerv/tmp/train-doctor.json`); the file is written even when the verdict is NOT READY").addHelpText("after", "\nExit codes:\n 0 ready \u2014 no blocker and origin verified\n 1 a blocker remains, or origin could not be verified (never green on an unread origin)\n\nEvery finding carries code, severity (blocker|warning|healed|info), source (local|origin), remedy and a\ndocs/Guides/train-troubleshooting.md#<code> anchor. `release --apply` and `rcand --apply` run this at step 0 with --heal.\n").action(async (o) => {
37778
+ train.command("doctor").description("one shared preflight-and-heal verdict for the release, rcand and hotfix lanes; read-only unless --heal (#6067)").addOption(new Option("--lane <lane>", "train lane to check (defaults from the release track: direct \u2192 release)").choices([...TRAIN_LANES])).option("--dev", "judge the `release --dev` lane (development -> main, skipping rc): the lane starts from development and is read on development's workflows (#6318)").option("--heal", "repair safe local reconstructible state (scratch gitignore, churn, stray local tags, ff-only branch lag, finished scratch branches, stale alignment ledger leg); it never clears a ledger-pending, ledger-failed or branch-mismatch blocker (#6404, #6401, #6399) \u2014 those need `mmi-cli devops release --resume` (or, with fresh approval, `--abort --apply`) and `git checkout <start branch>`, so exit 1 after --heal is the gate working, not a failed heal").option("--repo <owner/repo>", "target repo (defaults to the current checkout)").option("--suite-solo", "judge an owner-approved solo release of one suite module, as `release --suite-solo --owner-go` does (requires --owner-go; never on rcand, #7327)").option("--owner-go", "explicit owner approval for the suite-solo lane (requires --suite-solo)").option("--json", "machine-readable output").option("--out <path>", "write the verdict to this file as UTF-8 (no BOM) instead of stdout \u2014 the shell-free receipt path (#5802/#6563); pair with --json for a machine-readable file (e.g. `--json --out .jerv/tmp/train-doctor.json`); the file is written even when the verdict is NOT READY").addHelpText("after", "\nExit codes:\n 0 ready \u2014 no blocker and origin verified\n 1 a blocker remains, or origin could not be verified (never green on an unread origin)\n\nEvery finding carries code, severity (blocker|warning|healed|info), source (local|origin), remedy and a\nhttps://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#<code> anchor. `release --apply` and `rcand --apply` run this at step 0 with --heal.\n").action(async (o) => {
37601
37779
  try {
37602
37780
  if (o.suiteSolo !== o.ownerGo || o.lane === "rcand" && o.suiteSolo) {
37603
37781
  return failGraceful("train doctor: a suite-solo verdict needs BOTH --suite-solo --owner-go, and never on the rcand lane");
@@ -37777,7 +37955,7 @@ async function openSuiteCutPinPr(deps, input) {
37777
37955
  }
37778
37956
  async function openSuitePinPr(deps, input) {
37779
37957
  const { repo, tag, stage } = input;
37780
- 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 docs/Guides/train-troubleshooting.md#suite-pin-pr`;
37958
+ 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`;
37781
37959
  let scratch = "";
37782
37960
  try {
37783
37961
  const get = JSON.parse(await deps.run("gh", ["api", `repos/${SUITE_REPO}/contents/seed/modules.json?ref=development`]));
@@ -39836,7 +40014,7 @@ function withRateLimitRetry(client, seams = {}) {
39836
40014
  };
39837
40015
  }
39838
40016
  async function prCreateMergedHeadWarning(input, deps = {}) {
39839
- const guide = "docs/Guides/train-troubleshooting.md#merged-pr-head-reuse";
40017
+ const guide = "https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#merged-pr-head-reuse";
39840
40018
  if (!input.head) {
39841
40019
  return `pr create: WARNING \u2014 could not check whether the current head branch already landed (the current branch could not be resolved); continuing with PR creation \u2014 ${guide}`;
39842
40020
  }
@@ -39861,6 +40039,11 @@ async function prCreateMergedHeadWarning(input, deps = {}) {
39861
40039
  return `pr create: WARNING \u2014 could not check whether head branch '${input.head}' already landed (${detail}); continuing with PR creation \u2014 ${guide}`;
39862
40040
  }
39863
40041
  }
40042
+ function isIssueMissingError(e) {
40043
+ if (typeof e.status === "number") return e.status === 404;
40044
+ const msg = e instanceof Error ? e.message : String(e);
40045
+ return /HTTP 404|Not Found|\(404\)/i.test(msg);
40046
+ }
39864
40047
  async function prCreateClaimRefusal(body, repoOption, deps = {}) {
39865
40048
  const issues = [...new Set(findClosingMentions(body).map((mention) => mention.issue))];
39866
40049
  if (!issues.length) return void 0;
@@ -39871,7 +40054,14 @@ async function prCreateClaimRefusal(body, repoOption, deps = {}) {
39871
40054
  const requester = `requesting lane: ${actor.surface}/${actor.session}@${actor.host} (session id from ${source})`;
39872
40055
  const checkContest = deps.checkContest ?? checkLaneContest;
39873
40056
  for (const number of issues) {
39874
- const state = await client.rest("GET", `repos/${repo}/issues/${number}`).then((issue) => issue?.state, () => void 0);
40057
+ let state;
40058
+ try {
40059
+ state = (await client.rest("GET", `repos/${repo}/issues/${number}`))?.state;
40060
+ } catch (e) {
40061
+ if (isIssueMissingError(e)) {
40062
+ return `pr create: REFUSED \u2014 ${repo}#${number} does not exist \u2014 the PR body closes an issue that was never filed; file the issue first, or drop the closing line before creating this PR. There is no lane to claim, so claiming cannot create it; ${requester}`;
40063
+ }
40064
+ }
39875
40065
  if (state?.toLowerCase() === "closed") continue;
39876
40066
  const contest = await checkContest(client, { repository: repo, number }, actor);
39877
40067
  if (!contest.contested) continue;
@@ -43364,10 +43554,10 @@ async function announceSummaryRefusal(path2, isHub = false) {
43364
43554
  try {
43365
43555
  lines2 = summaryFileLines(await (0, import_promises8.readFile)(path2, "utf8"));
43366
43556
  } catch (e) {
43367
- return `release: could not read --announce-summary-file ${path2}: ${e.message} \u2014 see docs/Guides/train-troubleshooting.md#announce-summary-missing`;
43557
+ return `release: could not read --announce-summary-file ${path2}: ${e.message} \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#announce-summary-missing`;
43368
43558
  }
43369
43559
  if (lines2.length < 3 || lines2.length > 6) {
43370
- return `release: --announce-summary-file must contain 3-6 non-empty LLM-curated lines (found ${lines2.length}) \u2014 see docs/Guides/train-troubleshooting.md#announce-summary-missing`;
43560
+ return `release: --announce-summary-file must contain 3-6 non-empty LLM-curated lines (found ${lines2.length}) \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#announce-summary-missing`;
43371
43561
  }
43372
43562
  if (isHub) {
43373
43563
  const jerv = hubSummaryJervRefusal(lines2);
@@ -43786,7 +43976,7 @@ function registerTrainCommandsAndInventory(program3) {
43786
43976
  function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
43787
43977
  async function pinSoloMember(repo, tag) {
43788
43978
  const read = await fetchProjectBySlugChecked(slugOf(repo), registryClientDeps(await loadConfig()));
43789
- 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 docs/Guides/train-troubleshooting.md#suite-pin-pr`);
43979
+ 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`);
43790
43980
  if (!suiteMemberRefusal(repo, read.project, "release")) return void 0;
43791
43981
  const root = (await execFileP("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
43792
43982
  return openSuitePinPr({ root, run: async (file, args, cwd) => (await execFileP(file, args, { cwd, timeout: GIT_TIMEOUT_MS })).stdout }, { repo, tag, stage: "main" });
@@ -44031,7 +44221,7 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
44031
44221
  if (announcesToChannel) {
44032
44222
  if (!o.announceSummaryFile) {
44033
44223
  return fail(
44034
- isHub ? "release: a new MMI-Hub release requires --announce-summary-file <path> with 3-6 short LLM-curated lines; create the summary file, then rerun the same release command \u2014 see docs/Guides/train-troubleshooting.md#announce-summary-missing" : `release: ${slug} announces to its project release channel, so a new release requires --announce-summary-file <path> with 3-6 short plain-language lines in the project's releaseLanguage (${releaseLanguage2}); create the summary file, then rerun the same release command \u2014 see docs/Guides/train-troubleshooting.md#announce-summary-missing`
44224
+ isHub ? "release: a new MMI-Hub release requires --announce-summary-file <path> with 3-6 short LLM-curated lines; create the summary file, then rerun the same release command \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#announce-summary-missing" : `release: ${slug} announces to its project release channel, so a new release requires --announce-summary-file <path> with 3-6 short plain-language lines in the project's releaseLanguage (${releaseLanguage2}); create the summary file, then rerun the same release command \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#announce-summary-missing`
44035
44225
  );
44036
44226
  }
44037
44227
  const refusal = await announceSummaryRefusal(o.announceSummaryFile, isHub);
@@ -44214,7 +44404,7 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
44214
44404
  if (o.suiteSolo !== o.ownerGo) return fail("hotfix start: solo suite hotfix requires BOTH --suite-solo --owner-go");
44215
44405
  return runHotfixSub("start", () => runHotfixStart(trainApplyDeps(), { from: o.from, suiteSolo: o.suiteSolo, ack: o.ack ? o.ack.split(",").map((s) => s.trim()).filter(Boolean) : void 0 }), o, renderHotfixStart);
44216
44406
  });
44217
- hotfixCmd.command("release <version>").description("after the hotfix PR is merged + checks green: tag, GitHub Release, watch deploy/publish, verify distribution (idempotent)").option("--json", "machine-readable output").option("--announce-summary-file <path>", "agent-curated 3-6 line release Slack summary; required for a NEW MMI-Hub hotfix Release (#883/#3901/#6068), optional for a product repo announcing to its project release channel (#6517)").option("--carries <pr#|sha[,pr#|sha...]>", "declared fix target(s) this hotfix must carry; each must be proven present before tagging (#3056)").option("--retry-deploy", "re-run only the failed deploy and/or fold-port alignment legs of a published hotfix of this same tag (#6985)").option("--suite-solo", "release one suite module alone (requires --owner-go)").option("--owner-go", "explicit owner approval for a solo backend hotfix").option("--out <path>", "write the result to this file as UTF-8 (no BOM) instead of stdout \u2014 the shell-free receipt path (#5983/#6068)").action(async (version, o) => {
44407
+ hotfixCmd.command("release <version>").description("after the hotfix PR is merged + checks green: tag, GitHub Release, watch deploy/publish, verify distribution (idempotent)").option("--json", "machine-readable output").option("--announce-summary-file <path>", "agent-curated 3-6 line release Slack summary; required for a NEW MMI-Hub hotfix Release (#883/#3901/#6068), optional for a product repo announcing to its project release channel (#6517)").option("--carries <pr#|sha[,pr#|sha...]>", "declared fix target(s) this hotfix must carry; each must be proven present before tagging (#3056)").option("--ack <shas>", "accepted for flag symmetry with hotfix start; hotfix release proves every carry by ancestry, so it is ignored here, never an error (#7338)").option("--retry-deploy", "re-run only the failed deploy and/or fold-port alignment legs of a published hotfix of this same tag (#6985)").option("--suite-solo", "release one suite module alone (requires --owner-go)").option("--owner-go", "explicit owner approval for a solo backend hotfix").option("--out <path>", "write the result to this file as UTF-8 (no BOM) instead of stdout \u2014 the shell-free receipt path (#5983/#6068)").action(async (version, o) => {
44218
44408
  if (o.suiteSolo !== o.ownerGo) return fail("hotfix release: solo suite release requires BOTH --suite-solo --owner-go");
44219
44409
  if (o.retryDeploy) {
44220
44410
  if (o.announceSummaryFile || o.carries) {
@@ -44223,7 +44413,7 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
44223
44413
  return runHotfixSub("retry-deploy", () => runHotfixRetryDeploy(trainApplyDeps(), version), o, renderHotfixRetryDeploy);
44224
44414
  }
44225
44415
  return runHotfixSub("release", async () => {
44226
- const result = await runHotfixRelease(trainApplyDeps(), version, { announceSummaryFile: o.announceSummaryFile, carries: o.carries ? [o.carries] : [], suiteSolo: o.suiteSolo });
44416
+ const result = await runHotfixRelease(trainApplyDeps(), version, { announceSummaryFile: o.announceSummaryFile, carries: o.carries ? [o.carries] : [], suiteSolo: o.suiteSolo, ...o.ack ? { ack: o.ack.split(",").map((s) => s.trim()).filter(Boolean) } : {} });
44227
44417
  const suitePin = o.suiteSolo ? await pinSoloMember(result.repo, result.tag) : void 0;
44228
44418
  const projectInfoSync = await runProjectInfoSyncLeg(runProjectInfoSyncCallback, result.repo);
44229
44419
  return { ...result, projectInfoSync, ...o.suiteSolo ? { suiteSolo: true, suitePin } : {} };
@@ -44259,6 +44449,10 @@ async function exec(file, args, cwd, timeout = GH_TIMEOUT) {
44259
44449
  async function gh(args) {
44260
44450
  return exec("gh", args);
44261
44451
  }
44452
+ function rcRunCauseFromLog(log) {
44453
+ const causes = [...new Set([...log.matchAll(/##\[error\](?!Process completed with exit code)([^\r\n]+)/g)].map((m) => m[1].trim()).filter(Boolean))];
44454
+ return causes.length ? `: ${causes.join("; ")}` : "";
44455
+ }
44262
44456
  function suiteTrainIo(root, summaryFile) {
44263
44457
  const recordPath = repoRuntimeStatePath(root, "suite", "cut.json");
44264
44458
  const base = (0, import_node_path42.join)(repoRuntimeStatePath(root, "suite"), "checkouts");
@@ -44372,23 +44566,41 @@ function suiteTrainIo(root, summaryFile) {
44372
44566
  if (result.tag !== expected || result.deployStatus !== "success") throw new Error(`${repo}: release is not green at expected ${expected} (${result.tag}/${result.deployStatus})`);
44373
44567
  }
44374
44568
  });
44375
- const publish = async (repo, tag) => {
44376
- const title = `module-rc-publish ${repo} @ ${tag}`;
44377
- const rows = async () => JSON.parse(await gh(["run", "list", "--repo", "mutmutco/MMI-Hub", "--workflow", "module-rc-publish.yml", "--event", "workflow_dispatch", "--limit", "100", "--json", "databaseId,displayTitle,conclusion,status,createdAt"]));
44378
- const existing = (await rows()).filter((r) => r.displayTitle === title).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
44379
- if (existing[0]?.conclusion === "success") return;
44380
- if (existing[0]?.conclusion === "failure") throw new Error(`${repo}: prior module-rc-publish ${tag} failed; inspect the run and retry it explicitly before continuing`);
44569
+ const rcRuns = async () => JSON.parse(await gh(["run", "list", "--repo", "mutmutco/MMI-Hub", "--workflow", "module-rc-publish.yml", "--event", "workflow_dispatch", "--limit", "100", "--json", "databaseId,displayTitle,conclusion,status,createdAt"]));
44570
+ const watchRcRun = async (title, fields, reuse) => {
44381
44571
  const since = Date.now() - 1e4;
44382
- if (!existing.length) await gh(["workflow", "run", "module-rc-publish.yml", "--repo", "mutmutco/MMI-Hub", "--field", `repo=${repo}`, "--field", `tag=${tag}`]);
44383
- let candidate = existing[0];
44572
+ if (!reuse) await gh(["workflow", "run", "module-rc-publish.yml", "--repo", "mutmutco/MMI-Hub", ...fields.flatMap((f) => ["--field", f])]);
44573
+ let candidate = reuse;
44384
44574
  for (let i = 0; !candidate && i < 25; i++) {
44385
44575
  await new Promise((resolve7) => setTimeout(resolve7, 1e3));
44386
- candidate = (await rows()).find((r) => r.displayTitle === title && Date.parse(r.createdAt) >= since);
44576
+ candidate = (await rcRuns()).find((r) => r.displayTitle === title && Date.parse(r.createdAt) >= since);
44577
+ }
44578
+ if (!candidate) throw new Error(`workflow dispatch outcome ambiguous for "${title}"; inspect Actions before retrying`);
44579
+ try {
44580
+ await exec("gh", ["run", "watch", String(candidate.databaseId), "--repo", "mutmutco/MMI-Hub", "--exit-status"], void 0, WATCH_TIMEOUT);
44581
+ } catch {
44387
44582
  }
44388
- if (!candidate) throw new Error(`${repo}: workflow dispatch outcome ambiguous for ${tag}; inspect Actions before retrying`);
44389
- await exec("gh", ["run", "watch", String(candidate.databaseId), "--repo", "mutmutco/MMI-Hub", "--exit-status"], void 0, WATCH_TIMEOUT);
44390
44583
  const verdict = JSON.parse(await gh(["run", "view", String(candidate.databaseId), "--repo", "mutmutco/MMI-Hub", "--json", "conclusion"]));
44391
- if (verdict.conclusion !== "success") throw new Error(`${repo}: module rc publish run ${candidate.databaseId} not green`);
44584
+ return { id: candidate.databaseId, conclusion: verdict.conclusion };
44585
+ };
44586
+ const publish = async (repo, tag) => {
44587
+ const title = `module-rc-publish ${repo} @ ${tag}`;
44588
+ const existing = (await rcRuns()).filter((r) => r.displayTitle === title).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
44589
+ if (existing[0]?.conclusion === "success") return;
44590
+ if (existing[0]?.conclusion === "failure") throw new Error(`${repo}: prior module-rc-publish ${tag} failed; inspect the run and retry it explicitly before continuing`);
44591
+ const run = await watchRcRun(title, [`repo=${repo}`, `tag=${tag}`], existing[0]);
44592
+ if (run.conclusion !== "success") throw new Error(`${repo}: module rc publish run ${run.id} not green${await rcRunCause(run.id)}`);
44593
+ };
44594
+ const preflight2 = async (repo) => {
44595
+ const run = await watchRcRun(`module-rc-preflight ${repo}`, [`repo=${repo}`, "preflight=true"], void 0);
44596
+ if (run.conclusion !== "success") throw new Error(`rc serving preflight run ${run.id} failed${await rcRunCause(run.id)}`);
44597
+ };
44598
+ const rcRunCause = async (id) => {
44599
+ try {
44600
+ return rcRunCauseFromLog(await exec("gh", ["run", "view", String(id), "--repo", "mutmutco/MMI-Hub", "--log-failed"]));
44601
+ } catch {
44602
+ return "";
44603
+ }
44392
44604
  };
44393
44605
  const registry2 = async () => {
44394
44606
  const file = await defaultGitHubClient().rest("GET", `repos/${SUITE_REPO}/contents/seed/modules.json?ref=development`);
@@ -44422,6 +44634,7 @@ function suiteTrainIo(root, summaryFile) {
44422
44634
  },
44423
44635
  rcand: runRcand,
44424
44636
  publish,
44637
+ preflight: preflight2,
44425
44638
  pin: (input) => openSuiteCutPinPr({ root, run: (file, args, cwd) => exec(file, args, cwd, GIT_TIMEOUT_MS) }, input),
44426
44639
  verifyLeadCut: async (sha) => {
44427
44640
  const current = await gh(["api", `repos/${SUITE_REPO}/git/ref/heads/development`, "--jq", ".object.sha"]);
@@ -44471,7 +44684,7 @@ function registerSuiteCommands(program3) {
44471
44684
  console.log(o.json ? JSON.stringify(result, null, 2) : [
44472
44685
  `suite rcand ${o.dryRun ? "dry-run" : "receipt"}:`,
44473
44686
  ...result.plan.map((line) => ` - ${line}`),
44474
- ...result.record?.members.map((m) => ` - ${m.repo}: ${m.tag} (${m.sha?.slice(0, 12)}) ${m.published ? "served" : "pending"}`) ?? [],
44687
+ ...result.record?.members.map((m) => m.carried ? ` - ${m.repo}: carried (nothing new) at ${m.mainPin}` : ` - ${m.repo}: ${m.tag} (${m.sha?.slice(0, 12)}) ${m.published ? "served" : "pending"}`) ?? [],
44475
44688
  ...result.record ? [` - pin PR ${result.record.pinPr}: merged; MM-Work ${result.record.leadRcTag}`] : []
44476
44689
  ].join("\n"));
44477
44690
  } catch (e) {
@@ -44494,7 +44707,7 @@ function registerSuiteCommands(program3) {
44494
44707
  }
44495
44708
  console.log(o.json ? JSON.stringify(result, null, 2) : [
44496
44709
  `suite release: ${result.leadRcTag} \u2192 main`,
44497
- ...result.members.map((m) => ` - ${m.repo}: ${m.tag} \u2192 main ${m.released ? "released" : "pending"}`),
44710
+ ...result.members.map((m) => m.carried ? ` - ${m.repo}: carried, untouched` : ` - ${m.repo}: ${m.tag} \u2192 main ${m.released ? "released" : "pending"}`),
44498
44711
  ` - MM-Work: ${result.leadReleased ? "released" : "pending"}; announcement: ${result.announcement}`
44499
44712
  ].join("\n"));
44500
44713
  } catch (e) {
@@ -50484,7 +50697,7 @@ fullTrack.command("readiness <owner/repo>").description("aggregate branch topolo
50484
50697
  console.log(JSON.stringify(report, null, 2));
50485
50698
  if (!report.rcand.canApply) process.exitCode = 1;
50486
50699
  });
50487
- project.command("set-deploy [owner/repo]").description("patch a tenant DEPLOY row \u2014 project-admin may set only --no-env-file on their own existing dev/rc row; master may seed/change all coords and main; defaults to the current repo").addOption(new Option("--stage <stage>", "dev | rc | main").makeOptionMandatory().choices(["dev", "rc", "main"])).option("--ssh-host <host>", "the box address the deploy ssh-es into; omit to keep the stored value (required only for a NEW hetzner-ssh row \u2014 `mmi-cli devops runtime box list` finds it)").option("--ssh-user <user>", "ssh user; omit to leave the row unchanged (default root on a new row)").option("--port <port>", "loopback port the container binds / Caddy upstream (1..65535); omit to leave the row unchanged").addOption(new Option("--substrate <substrate>", "hetzner-ssh | s3-cloudfront; omit to leave the row unchanged").choices([...DEPLOY_SUBSTRATES])).option("--deploy-role-arn <arn>", "s3-cloudfront: the scoped OIDC deployer role tenant-deploy.yml assumes for this stage").option("--app-bucket <bucket>", "s3-cloudfront: the S3 bucket the built dist/ is synced into").option("--distribution-id <id>", "s3-cloudfront: the CloudFront distribution invalidated after the sync").option("--distribution-domain <domain>", "s3-cloudfront: the host the post-deploy security-header proof is measured against").option("--deploy-path <path>", "on-box per-stage release root; omit to leave the row unchanged (default /opt/mmi/<slug>/<stage> on a new row)").option("--service <name>", "systemd/compose service name; omit to leave the row unchanged (default the slug on a new row)").option("--domain <domain>", "canonical serving host; omit to leave the row unchanged").option("--alias <domain...>", "extra serving hostname the box Caddy answers (repeatable); omit to leave the stored aliases unchanged").option("--clear-aliases", "remove EVERY serving alias from the row (#2986) \u2014 omitting --alias preserves them, so clearing needs saying out loud").option("--no-env-file <bool>", "set the DEPLOY# fileless flag (true|false) \u2014 own-repo project-admin dev/rc; master main; true = env passthrough with no .env symlink").option("--force", "explicit recovery override: skip fileless-compose verification only after independent proof").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
50700
+ project.command("set-deploy [owner/repo]").description("patch a tenant DEPLOY row \u2014 project-admin may set only --no-env-file/--compose-profiles on their own existing dev/rc row; master may seed/change all coords and main; defaults to the current repo").addOption(new Option("--stage <stage>", "dev | rc | main").makeOptionMandatory().choices(["dev", "rc", "main"])).option("--ssh-host <host>", "the box address the deploy ssh-es into; omit to keep the stored value (required only for a NEW hetzner-ssh row \u2014 `mmi-cli devops runtime box list` finds it)").option("--ssh-user <user>", "ssh user; omit to leave the row unchanged (default root on a new row)").option("--port <port>", "loopback port the container binds / Caddy upstream (1..65535); omit to leave the row unchanged").addOption(new Option("--substrate <substrate>", "hetzner-ssh | s3-cloudfront; omit to leave the row unchanged").choices([...DEPLOY_SUBSTRATES])).option("--deploy-role-arn <arn>", "s3-cloudfront: the scoped OIDC deployer role tenant-deploy.yml assumes for this stage").option("--app-bucket <bucket>", "s3-cloudfront: the S3 bucket the built dist/ is synced into").option("--distribution-id <id>", "s3-cloudfront: the CloudFront distribution invalidated after the sync").option("--distribution-domain <domain>", "s3-cloudfront: the host the post-deploy security-header proof is measured against").option("--deploy-path <path>", "on-box per-stage release root; omit to leave the row unchanged (default /opt/mmi/<slug>/<stage> on a new row)").option("--service <name>", "systemd/compose service name; omit to leave the row unchanged (default the slug on a new row)").option("--domain <domain>", "canonical serving host; omit to leave the row unchanged").option("--alias <domain...>", "extra serving hostname the box Caddy answers (repeatable); omit to leave the stored aliases unchanged").option("--clear-aliases", "remove EVERY serving alias from the row (#2986) \u2014 omitting --alias preserves them, so clearing needs saying out loud").option("--no-env-file <bool>", "set the DEPLOY# fileless flag (true|false) \u2014 own-repo project-admin dev/rc; master main; true = env passthrough with no .env symlink").option("--compose-profiles <profiles>", "comma-separated compose profiles to enable for this stage (e.g. huddles,slack-bridge); omit to leave unchanged").option("--clear-compose-profiles", "remove EVERY compose profile from the row \u2014 omitting --compose-profiles preserves them, so clearing needs saying out loud").option("--force", "explicit recovery override: skip fileless-compose verification only after independent proof").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
50488
50701
  const cfg = await loadConfig();
50489
50702
  let target;
50490
50703
  try {
@@ -50498,6 +50711,10 @@ project.command("set-deploy [owner/repo]").description("patch a tenant DEPLOY ro
50498
50711
  if (v !== "true" && v !== "false") return fail("org project set-deploy: --no-env-file must be true or false");
50499
50712
  noEnvFile = v === "true";
50500
50713
  }
50714
+ let composeProfiles;
50715
+ if (typeof o.composeProfiles === "string") {
50716
+ composeProfiles = o.composeProfiles.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
50717
+ }
50501
50718
  if (noEnvFile !== void 0) {
50502
50719
  const branch = DEPLOY_STAGE_BRANCH[o.stage.trim().toLowerCase()];
50503
50720
  if (branch) {
@@ -50536,7 +50753,9 @@ ${filelessTransitionGuide(target, o.stage)}`
50536
50753
  distributionDomain: o.distributionDomain,
50537
50754
  aliases: o.alias,
50538
50755
  clearAliases: o.clearAliases,
50539
- noEnvFile
50756
+ noEnvFile,
50757
+ composeProfiles,
50758
+ clearComposeProfiles: o.clearComposeProfiles
50540
50759
  });
50541
50760
  if (target.includes("/")) body.repo = target;
50542
50761
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "4.5.34",
3
+ "version": "4.5.36",
4
4
  "description": "MMI Future CLI — the org dev toolbox and shared cross-IDE engine for every registry-declared MMI coding surface.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",