@mutmutco/cli 4.5.35 → 4.5.37

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 +543 -149
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -863,6 +863,9 @@ var init_house_map = __esm({
863
863
  });
864
864
 
865
865
  // src/stdin-inject.ts
866
+ function setInjectedStdin(payload) {
867
+ injectedStdin = payload;
868
+ }
866
869
  function stdinHasPipedInput(statFd = () => (0, import_node_fs4.fstatSync)(0), getIsTTY = () => process.stdin.isTTY) {
867
870
  try {
868
871
  const stat4 = statFd();
@@ -922,6 +925,34 @@ var init_stdin_inject = __esm({
922
925
  });
923
926
 
924
927
  // src/cli-shared.ts
928
+ var cli_shared_exports = {};
929
+ __export(cli_shared_exports, {
930
+ DEFAULT_EXEC_TIMEOUT_MS: () => DEFAULT_EXEC_TIMEOUT_MS,
931
+ ExecDeadlineError: () => ExecDeadlineError,
932
+ GIT_TIMEOUT_MS: () => GIT_TIMEOUT_MS,
933
+ consoleIo: () => consoleIo,
934
+ execFileHard: () => execFileHard,
935
+ execFileP: () => execFileP,
936
+ fail: () => fail,
937
+ failGraceful: () => failGraceful,
938
+ failGracefulEnvelope: () => failGracefulEnvelope,
939
+ gitOut: () => gitOut,
940
+ githubLogin: () => githubLogin,
941
+ hubHeaders: () => hubHeaders,
942
+ installBrokenPipeGuard: () => installBrokenPipeGuard,
943
+ installProcessBackstop: () => installProcessBackstop,
944
+ isOrgRepoRoot: () => isOrgRepoRoot,
945
+ jsonParity: () => jsonParity,
946
+ loadConfig: () => loadConfig,
947
+ mutating: () => mutating,
948
+ planMutation: () => planMutation,
949
+ printLine: () => printLine,
950
+ rawFlag: () => rawFlag,
951
+ rawValue: () => rawValue,
952
+ readStdin: () => readStdin,
953
+ repoProject: () => repoProject,
954
+ setInjectedStdin: () => setInjectedStdin
955
+ });
925
956
  function killProcessTree(pid) {
926
957
  try {
927
958
  if (process.platform === "win32") {
@@ -7295,11 +7326,11 @@ function decorateAwsModel(entries, registry2, verdicts) {
7295
7326
  });
7296
7327
  }
7297
7328
  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)" },
7329
+ { 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
7330
  // #7148: a backup nobody restored is a hope. Monthly, restore the latest artifact of every
7300
7331
  // archived stage (including a stage whose containers are down) into scratch Postgres and compare
7301
7332
  // 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)" },
7333
+ { 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
7334
  // #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
7335
  // setup-node before it can prune, so at 100% it dies at checkout and never prunes — the cleanup is
7305
7336
  // unavailable exactly when it is needed. This one is root cron on the box: no checkout, no node, and
@@ -11654,11 +11685,11 @@ function writeMarketplacePinsOnDisk(path2, pins, succeeded, failedVerb, declineW
11654
11685
  return `could NOT ${failedVerb} ${[...pins.keys()].join(", ")} \u2014 set it by hand`;
11655
11686
  }
11656
11687
  const verify = readKnownMarketplacesFile(path2);
11657
- const failed = [...pins].filter(([name, want]) => {
11688
+ const failed2 = [...pins].filter(([name, want]) => {
11658
11689
  const got = readKnownMarketplace(verify, name);
11659
11690
  return typeof want.autoUpdate === "boolean" && got.declared !== want.autoUpdate || want.ref !== void 0 && got.ref !== want.ref;
11660
11691
  });
11661
- return failed.length ? `${failedVerb} did NOT take for ${failed.map(([n]) => n).join(", ")} \u2014 set it by hand` : succeeded(pins);
11692
+ return failed2.length ? `${failedVerb} did NOT take for ${failed2.map(([n]) => n).join(", ")} \u2014 set it by hand` : succeeded(pins);
11662
11693
  });
11663
11694
  } catch (error) {
11664
11695
  if (error instanceof Error && error.message.startsWith("shared-document lock timeout:")) return error.message;
@@ -15715,9 +15746,9 @@ function trainFollowUpExitCode(status) {
15715
15746
  }
15716
15747
  function trainFollowUpNote(status) {
15717
15748
  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`;
15749
+ 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
15750
  }
15720
- var TRAIN_DEPLOY_FAILED_NOTE = "promotion stands; retry the deploy, do not re-tag \u2014 see docs/Guides/train-troubleshooting.md#deploy-failed";
15751
+ 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
15752
  function missingRunUrlNote(runId) {
15722
15753
  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
15754
  }
@@ -16840,10 +16871,10 @@ var rollout_plan_default = {
16840
16871
  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
16872
  },
16842
16873
  baseline: {
16843
- version: "4.5.35",
16844
- tag: "v4.5.35",
16845
- commit: "f10f5c65382f",
16846
- npm: "@mutmutco/cli@4.5.35"
16874
+ version: "4.5.37",
16875
+ tag: "v4.5.37",
16876
+ commit: "75086128c3ba",
16877
+ npm: "@mutmutco/cli@4.5.37"
16847
16878
  },
16848
16879
  exitCriterion: "fleet-n-of-n",
16849
16880
  hubOnlyShortcut: "forbidden",
@@ -16860,14 +16891,14 @@ var rollout_plan_default = {
16860
16891
  repo: "mutmutco/mmi-hub",
16861
16892
  role: "canary",
16862
16893
  schedule: "train",
16863
- v3Target: "v4.5.35"
16894
+ v3Target: "v4.5.37"
16864
16895
  }
16865
16896
  ],
16866
16897
  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
16898
  rollback: {
16868
16899
  independent: true,
16869
- mechanism: "npm dist-tag latest -> 4.5.35 and redeploy the Hub Lambda from tag v4.5.35 (f10f5c65382f); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
16870
- v3Target: "v4.5.35 (@mutmutco/cli@4.5.35, tag commit f10f5c65382f \u2014 last known-good release carrying the repo-index v4-only contract)"
16900
+ mechanism: "npm dist-tag latest -> 4.5.37 and redeploy the Hub Lambda from tag v4.5.37 (75086128c3ba); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
16901
+ v3Target: "v4.5.37 (@mutmutco/cli@4.5.37, tag commit 75086128c3ba \u2014 last known-good release carrying the repo-index v4-only contract)"
16871
16902
  }
16872
16903
  },
16873
16904
  {
@@ -19633,9 +19664,9 @@ async function waitForRequiredTrainChecks(deps, ctx, sha, required, freshSince)
19633
19664
  });
19634
19665
  const satisfiedNote = autoSatisfied.size ? `, auto-satisfied (never runs on a tag, see #2404): ${[...autoSatisfied].join(", ")}` : "";
19635
19666
  lastStatus = `${states.map(([c, s]) => `${c}=${s}`).join(", ")}${satisfiedNote}`;
19636
- const failed = states.filter(([, s]) => s === "failed").map(([c]) => c);
19637
- if (failed.length > 0) {
19638
- throw new Error(`required train check failed: ${failed.join(", ")} (${lastStatus}${fleetNote})`);
19667
+ const failed2 = states.filter(([, s]) => s === "failed").map(([c]) => c);
19668
+ if (failed2.length > 0) {
19669
+ throw new Error(`required train check failed: ${failed2.join(", ")} (${lastStatus}${fleetNote})`);
19639
19670
  }
19640
19671
  if (states.every(([, s]) => s === "success")) {
19641
19672
  return `required checks passed: ${pending.join(", ") || "(none pending)"}${satisfiedNote}${fleetNote}`;
@@ -19826,7 +19857,7 @@ function installerDeclarationError(value, division) {
19826
19857
 
19827
19858
  // src/train-suite.ts
19828
19859
  var SUITE_GUARD_CODE = "suite-member-solo";
19829
- var SUITE_GUARD_ANCHOR = `docs/Guides/train-troubleshooting.md#${SUITE_GUARD_CODE}`;
19860
+ var SUITE_GUARD_ANCHOR = `https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#${SUITE_GUARD_CODE}`;
19830
19861
  function suiteLeadRepo(repo, suite) {
19831
19862
  const owner = repo.split("/")[0];
19832
19863
  return `${owner}/${suite === "mm-work" ? "MM-Work" : suite}`;
@@ -19937,9 +19968,9 @@ async function readVercelDeployment(api, repo, sha, target, since) {
19937
19968
  }
19938
19969
  if (newest.size === 0) return void 0;
19939
19970
  const reads = await Promise.all([...newest].map(async ([env, id]) => ({ env, id, ...await readOne(api, repo, id, since) })));
19940
- const failed = reads.find((r) => FAILED.has(r.state));
19971
+ const failed2 = reads.find((r) => FAILED.has(r.state));
19941
19972
  const unfinished = reads.find((r) => !isTerminalVercelState(r.state));
19942
- const pick = failed ?? unfinished ?? reads[0];
19973
+ const pick = failed2 ?? unfinished ?? reads[0];
19943
19974
  return {
19944
19975
  id: pick.id,
19945
19976
  environment: reads.map((r) => r.env).join(", "),
@@ -20332,11 +20363,11 @@ async function appendServedChannelProof(deps, input, dispatch) {
20332
20363
  const channel = servedChannelFor(input.repo, input.installer);
20333
20364
  if (!channel) return dispatch;
20334
20365
  const expected = input.tag.replace(/^v/, "");
20335
- const withProof = (conclusion, evidence, note, failed = false) => ({
20366
+ const withProof = (conclusion, evidence, note, failed2 = false) => ({
20336
20367
  ...dispatch,
20337
20368
  note: `${dispatch.note}; ${note}`,
20338
20369
  workflowRuns: [...dispatch.workflowRuns ?? [], servedChannelRow(channel.product, conclusion, evidence)],
20339
- ...failed ? { deployStatus: "failure" } : {}
20370
+ ...failed2 ? { deployStatus: "failure" } : {}
20340
20371
  });
20341
20372
  if (!input.shipped) {
20342
20373
  return withProof("pending", `${channel.url} not probed`, `served channel ${channel.url} not probed \u2014 deploy/publish are not proven green, so it is not expected to have moved`);
@@ -20521,7 +20552,7 @@ async function appendJervGatewayReleaseDeploy(deps, repo, tag, tagSha, dispatch)
20521
20552
  ...dispatch,
20522
20553
  // #6095: the helper's receipt, health or rollback failed after a proven publish — the release resumes, never re-tags.
20523
20554
  // #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`,
20555
+ 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
20556
  workflowRuns: [...dispatch.workflowRuns ?? [], { workflow: "jerv-gateway", runUrlNote: JERV_GATEWAY_RUN_URL_NOTE, conclusion: "failure" }],
20526
20557
  deployStatus: "failure"
20527
20558
  };
@@ -20611,8 +20642,8 @@ function shipLegsGreen(deploy, publish) {
20611
20642
  async function announceShippedRelease(deps, args, model, legs, deferred) {
20612
20643
  if (!deps.announce) return void 0;
20613
20644
  if (trainObservesShipment(model) && !shipLegsGreen(legs.deploy, legs.publish)) {
20614
- const failed = legs.deploy.state === "failed" || legs.publish.state === "failed";
20615
- return `not announced: deploy/publish ${failed ? "FAILED" : "not proven green"} \u2014 ${deferred}`;
20645
+ const failed2 = legs.deploy.state === "failed" || legs.publish.state === "failed";
20646
+ return `not announced: deploy/publish ${failed2 ? "FAILED" : "not proven green"} \u2014 ${deferred}`;
20616
20647
  }
20617
20648
  return (await deps.announce(args)).note;
20618
20649
  }
@@ -20742,7 +20773,7 @@ async function dispatchDeploy(deps, ctx, stage, ref, model, watch, autoRunSince,
20742
20773
  const deployStatus = watch ? await watchTenantRun(deps, runId) : "pending";
20743
20774
  let note = `dispatched tenant-deploy.yml (slug=${ctx.slug}, ref=${ref}, stage=${stage})`;
20744
20775
  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`;
20776
+ 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
20777
  }
20747
20778
  return { note, runId, runUrl, deployStatus };
20748
20779
  }
@@ -21057,7 +21088,7 @@ function isCanaryAccessBlockText(text) {
21057
21088
  }
21058
21089
  function actionsCanaryUndispatchedRefusal(detail) {
21059
21090
  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`
21091
+ `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
21092
  );
21062
21093
  }
21063
21094
  function actionsBillingRefusal(detail) {
@@ -21221,11 +21252,11 @@ async function assertActionsJobsCanStart(deps, targetRepo2) {
21221
21252
  return;
21222
21253
  }
21223
21254
  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`);
21255
+ 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
21256
  }
21226
21257
  }
21227
21258
  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`
21259
+ `${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
21260
  );
21230
21261
  }
21231
21262
 
@@ -23378,8 +23409,8 @@ function ledgerFailedRemedy(ledger) {
23378
23409
  const openLegs = derived.legs.filter((l) => l.state === "pending" || l.state === "failed");
23379
23410
  const tails = formatFailedLegTails(ledger);
23380
23411
  if (hotfixRetryDeployShape(ledger)) {
23381
- const failed = openLegs.filter((l) => l.state === "failed").map((l) => l.phase);
23382
- return `the published ${ledger.tag} hotfix can retry the failed ${joinLegNames(failed)} of the same tag: \`mmi-cli devops hotfix release ${ledger.tag} --retry-deploy\` (the deploy helper is idempotent; alignment re-attempts the fold-port PR) (#6985)${tails}`;
23412
+ const failed2 = openLegs.filter((l) => l.state === "failed").map((l) => l.phase);
23413
+ return `the published ${ledger.tag} hotfix can retry the failed ${joinLegNames(failed2)} of the same tag: \`mmi-cli devops hotfix release ${ledger.tag} --retry-deploy\` (the deploy helper is idempotent; alignment re-attempts the fold-port PR) (#6985)${tails}`;
23383
23414
  }
23384
23415
  if (rcandRetryDeployShape(ledger)) {
23385
23416
  return `fix the rc deploy failure, then retry the failed deploy of the promoted ${ledger.tag} candidate (same tag and SHA, never re-cut): \`mmi-cli devops rcand --retry-deploy --watch\` (#7053)${tails}`;
@@ -23547,8 +23578,8 @@ async function supersedeContentCausedDeployLeg(deps, cwd) {
23547
23578
  `release --supersede-deploy: origin reports ${prior.tag} at ${liveSha.slice(0, 12) || "no tag"} but the ledger anchors ${prior.tagSha.slice(0, 12)} \u2014 an absent or divergent release is NOT supersedeable (release --abort --apply is the unpublished candidate's own exit); nothing was written (#6553)`
23548
23579
  );
23549
23580
  }
23550
- const failed = prior.phases.deploy.state === "failed";
23551
- const note = failed ? `superseded: content-caused deploy failure on the immutable promoted ${prior.tag} \u2014 the cause fix rides the next patch train (master-approved closure, #6553)` : `superseded: ${prior.tag} deploy outcome uncorrelatable from the release SHA (no failure recorded) \u2014 closed on the owner's evidence (master-approved closure, #6553, #7327)`;
23581
+ const failed2 = prior.phases.deploy.state === "failed";
23582
+ const note = failed2 ? `superseded: content-caused deploy failure on the immutable promoted ${prior.tag} \u2014 the cause fix rides the next patch train (master-approved closure, #6553)` : `superseded: ${prior.tag} deploy outcome uncorrelatable from the release SHA (no failure recorded) \u2014 closed on the owner's evidence (master-approved closure, #6553, #7327)`;
23552
23583
  await fence(() => atomicWriteJson(path2, {
23553
23584
  ...prior,
23554
23585
  phases: {
@@ -23562,7 +23593,7 @@ async function supersedeContentCausedDeployLeg(deps, cwd) {
23562
23593
  repo: prior.repo,
23563
23594
  tag: prior.tag,
23564
23595
  tagSha: prior.tagSha,
23565
- note: failed ? `closed the ${prior.tag} deploy 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` : `closed the ${prior.tag} uncorrelatable deploy leg as superseded (skipped) \u2014 the ledger is phases-green; rerun the train doctor to confirm`
23596
+ note: failed2 ? `closed the ${prior.tag} deploy 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` : `closed the ${prior.tag} uncorrelatable deploy leg as superseded (skipped) \u2014 the ledger is phases-green; rerun the train doctor to confirm`
23566
23597
  };
23567
23598
  });
23568
23599
  }
@@ -23641,7 +23672,7 @@ async function supersedeContentCausedPublishLeg(deps, cwd) {
23641
23672
  }
23642
23673
  let gatewayDeploy;
23643
23674
  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");
23675
+ 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
23676
  if (unresolved.length > 0) {
23646
23677
  throw new Error(
23647
23678
  `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 +23720,7 @@ async function supersedeContentCausedPublishLeg(deps, cwd) {
23689
23720
  repo: prior.repo,
23690
23721
  tag: prior.tag,
23691
23722
  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`,
23723
+ 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
23724
  ...gatewayDeploy ? { gatewayDeploy } : {}
23694
23725
  };
23695
23726
  });
@@ -24537,17 +24568,17 @@ async function runTrainDoctor(input) {
24537
24568
  if (derived.phaseStatus !== "phases-green") {
24538
24569
  const openLegs = derived.legs.filter((l) => l.state === "pending" || l.state === "failed");
24539
24570
  const open2 = openLegs.map((l) => `${l.phase}=${l.state}`).join(", ");
24540
- const failed = derived.phaseStatus === "phases-failed";
24571
+ const failed2 = derived.phaseStatus === "phases-failed";
24541
24572
  const deployOnlyDeadlock = openLegs.length > 0 && openLegs.every((l) => l.phase === "deploy" && supersedeableDeployLegShape(l));
24542
24573
  const supersession = deployOnlyDeadlock ? `; when the deploy failure is content-caused on the already-published ${ledger.tag} (the immutable tag can never deploy green), the sanctioned exit is \`mmi-cli devops release --supersede-deploy --apply\` after explicit master approval \u2014 the next patch train carries the cause fix (#6553)` : "";
24543
24574
  add({
24544
- code: failed ? "ledger-failed" : "ledger-pending",
24575
+ code: failed2 ? "ledger-failed" : "ledger-pending",
24545
24576
  severity: "blocker",
24546
24577
  source: "local",
24547
24578
  // #6429: each lane finishes its own run (#6068) — an rcand-lane ledger named as a release
24548
24579
  // ledger, with `release --resume` as its remedy, sends the operator to a verb that refuses it.
24549
24580
  title: `a prior ${ledger.lane ?? "release"} ledger for ${ledger.tag} is ${derived.phaseStatus} (${open2}) \u2014 a new train must not stack on it (#5987)`,
24550
- remedy: failed ? ledgerFailedRemedy(ledger) : `finish it first: ${ledgerLaneResumeCommand(ledger)} (alignment-only pending legs close themselves with \`train doctor --heal\` once the PR merges, and a deploy/publish leg whose run already concluded folds under the same heal)${supersession}`
24581
+ remedy: failed2 ? ledgerFailedRemedy(ledger) : `finish it first: ${ledgerLaneResumeCommand(ledger)} (alignment-only pending legs close themselves with \`train doctor --heal\` once the PR merges, and a deploy/publish leg whose run already concluded folds under the same heal)${supersession}`
24551
24582
  });
24552
24583
  } else if (newestRemoteReleaseTag && newestRemoteReleaseTag !== ledger.tag) {
24553
24584
  add({
@@ -24594,21 +24625,21 @@ regenerate the projections (\`node scripts/check-skill-payload.mjs\`) and commit
24594
24625
  const installed = [];
24595
24626
  let pending = firstDir;
24596
24627
  let outstanding = failure;
24597
- let failed = null;
24628
+ let failed2 = null;
24598
24629
  while (pending && !installed.includes(pending)) {
24599
24630
  const dir = pending;
24600
24631
  try {
24601
24632
  await train.run("npm", ["--prefix", dir, "ci"]);
24602
24633
  } catch (e) {
24603
- failed = { dir, detail: `\`npm --prefix ${dir} ci\` failed: ${message(e)}` };
24634
+ failed2 = { dir, detail: `\`npm --prefix ${dir} ci\` failed: ${message(e)}` };
24604
24635
  break;
24605
24636
  }
24606
24637
  installed.push(dir);
24607
24638
  outstanding = await verifyDeps();
24608
24639
  pending = outstanding ? /in (cli|updater)\/node_modules/u.exec(outstanding)?.[1] ?? null : null;
24609
24640
  }
24610
- if (failed) {
24611
- add({ code: "hub-build-deps", severity: "blocker", source: "local", title: failed.detail, remedy: `repair ${failed.dir}/node_modules by hand, then rerun` });
24641
+ if (failed2) {
24642
+ add({ code: "hub-build-deps", severity: "blocker", source: "local", title: failed2.detail, remedy: `repair ${failed2.dir}/node_modules by hand, then rerun` });
24612
24643
  } else if (outstanding) {
24613
24644
  add({ code: "hub-build-deps", severity: "blocker", source: "local", title: `installing ${installed.join(", ")} from ${installed.length === 1 ? "its lockfile" : "their lockfiles"} did not resolve the build dependencies`, remedy: outstanding });
24614
24645
  } else {
@@ -24709,6 +24740,133 @@ function formatTrainDoctor(v) {
24709
24740
  return lines2.join("\n");
24710
24741
  }
24711
24742
 
24743
+ // src/vercel-version-env.ts
24744
+ var SKIPPED = { status: "skipped", note: "" };
24745
+ function vercelVersionEnvAheadNote(key) {
24746
+ return `a failed branch push can leave ${key} ahead of what is live`;
24747
+ }
24748
+ function safeReason(reason, token) {
24749
+ const compact = reason.replace(/\s+/g, " ").trim().slice(0, 180);
24750
+ const scrubbed = token && compact.includes(token) ? compact.split(token).join("[redacted]") : compact;
24751
+ return scrubbed || "unknown error";
24752
+ }
24753
+ function failed(deps, key, tag, reason) {
24754
+ const note = `vercel version env: FAILED to set ${key}=${tag} (${reason}) \u2014 /healthz will show the commit, not the tag; fix: set it and redeploy once`;
24755
+ deps.warn?.(note);
24756
+ return { status: "failed", note };
24757
+ }
24758
+ function targetLabel(stage) {
24759
+ return stage === "main" ? "production" : "preview";
24760
+ }
24761
+ function envCollectionUrl(project2, team, extra) {
24762
+ const url = new URL(`https://api.vercel.com/v10/projects/${encodeURIComponent(project2)}/env`);
24763
+ if (team) url.searchParams.set("teamId", team);
24764
+ for (const [key, value] of Object.entries(extra ?? {})) url.searchParams.set(key, value);
24765
+ return url.toString();
24766
+ }
24767
+ function envItemUrl(project2, id, team) {
24768
+ const url = new URL(`https://api.vercel.com/v9/projects/${encodeURIComponent(project2)}/env/${encodeURIComponent(id)}`);
24769
+ if (team) url.searchParams.set("teamId", team);
24770
+ return url.toString();
24771
+ }
24772
+ function isStageEnv(row, stage, key) {
24773
+ if (row.key !== key) return false;
24774
+ const target = Array.isArray(row.target) ? row.target.filter((item) => typeof item === "string") : [];
24775
+ if (stage === "main") return target.length === 1 && target[0] === "production";
24776
+ return target.length === 1 && target[0] === "preview" && row.gitBranch === "rc";
24777
+ }
24778
+ function envsOf(body) {
24779
+ if (Array.isArray(body)) return body;
24780
+ if (body && typeof body === "object" && Array.isArray(body.envs)) {
24781
+ return body.envs;
24782
+ }
24783
+ return null;
24784
+ }
24785
+ async function readBody(res, token) {
24786
+ let raw = "";
24787
+ try {
24788
+ raw = await res.text();
24789
+ } catch (e) {
24790
+ return { ok: false, reason: safeReason(e instanceof Error ? e.message : String(e), token) };
24791
+ }
24792
+ if (!res.ok) return { ok: false, reason: `HTTP ${res.status}${raw ? `: ${safeReason(raw, token)}` : ""}` };
24793
+ if (!raw.trim()) return { ok: true, body: {} };
24794
+ try {
24795
+ return { ok: true, body: JSON.parse(raw) };
24796
+ } catch {
24797
+ return { ok: false, reason: "response was not JSON" };
24798
+ }
24799
+ }
24800
+ async function setVercelVersionEnv(deps, meta, stage, tag) {
24801
+ if (meta?.deployModel !== "vercel" || !meta.vercelVersionEnv) return SKIPPED;
24802
+ const { project: project2, key, team } = meta.vercelVersionEnv;
24803
+ if (!project2 || !key) return failed(deps, key || "(missing key)", tag, "vercelVersionEnv is missing project or key");
24804
+ let token;
24805
+ try {
24806
+ token = await deps.readToken();
24807
+ } catch (e) {
24808
+ return failed(deps, key, tag, safeReason(e instanceof Error ? e.message : String(e), null));
24809
+ }
24810
+ if (!token) return failed(deps, key, tag, "no _org/vercel/API_TOKEN");
24811
+ const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
24812
+ let listed;
24813
+ try {
24814
+ const res = await deps.fetch(envCollectionUrl(project2, team, stage === "rc" ? { gitBranch: "rc" } : void 0), { method: "GET", headers });
24815
+ listed = await readBody(res, token);
24816
+ } catch (e) {
24817
+ return failed(deps, key, tag, safeReason(e instanceof Error ? e.message : String(e), token));
24818
+ }
24819
+ if (!listed.ok) return failed(deps, key, tag, listed.reason);
24820
+ const rows = envsOf(listed.body);
24821
+ if (!rows) return failed(deps, key, tag, "env list returned an unexpected shape");
24822
+ const existing = rows.find((row) => isStageEnv(row, stage, key) && typeof row.id === "string" && row.id.length > 0);
24823
+ const createBody = stage === "main" ? { key, value: tag, type: "plain", target: ["production"] } : { key, value: tag, type: "plain", target: ["preview"], gitBranch: "rc" };
24824
+ try {
24825
+ const res = existing ? await deps.fetch(envItemUrl(project2, existing.id, team), {
24826
+ method: "PATCH",
24827
+ headers,
24828
+ body: JSON.stringify({ value: tag })
24829
+ }) : await deps.fetch(envCollectionUrl(project2, team), {
24830
+ method: "POST",
24831
+ headers,
24832
+ body: JSON.stringify(createBody)
24833
+ });
24834
+ const written = await readBody(res, token);
24835
+ if (!written.ok) return failed(deps, key, tag, written.reason);
24836
+ } catch (e) {
24837
+ return failed(deps, key, tag, safeReason(e instanceof Error ? e.message : String(e), token));
24838
+ }
24839
+ const note = `vercel version env: ${key}=${tag} set on ${targetLabel(stage)}`;
24840
+ deps.warn?.(note);
24841
+ return { status: "set", note };
24842
+ }
24843
+ async function defaultVercelFetch(url, init) {
24844
+ const res = await fetch(url, { ...init, signal: AbortSignal.timeout(15e3) });
24845
+ return { ok: res.ok, status: res.status, text: () => res.text() };
24846
+ }
24847
+ async function readOrgVercelApiToken() {
24848
+ const { loadConfig: loadConfig2, hubHeaders: hubHeaders2, repoProject: repoProject2 } = await Promise.resolve().then(() => (init_cli_shared(), cli_shared_exports));
24849
+ const cfg = await loadConfig2();
24850
+ if (!cfg.sagaApiUrl) return null;
24851
+ const repo = `mutmutco/${(await repoProject2(cfg)).toLowerCase()}`;
24852
+ const res = await fetch(`${cfg.sagaApiUrl}/secrets/get`, {
24853
+ method: "POST",
24854
+ headers: await hubHeaders2({ "content-type": "application/json" }),
24855
+ body: JSON.stringify({ repo, key: "vercel/API_TOKEN", use: true, slug: "_org" }),
24856
+ signal: AbortSignal.timeout(8e3)
24857
+ });
24858
+ if (!res.ok) return null;
24859
+ const body = await res.json();
24860
+ return typeof body.value === "string" ? body.value : null;
24861
+ }
24862
+ function vercelVersionEnvDeps(deps) {
24863
+ return {
24864
+ fetch: deps.vercelFetch ?? defaultVercelFetch,
24865
+ readToken: deps.readVercelApiToken ?? readOrgVercelApiToken,
24866
+ warn: (message2) => deps.warn?.(message2)
24867
+ };
24868
+ }
24869
+
24712
24870
  // src/train-apply-phases.ts
24713
24871
  var TRAIN_BUMP_INTENTS = ["major", "minor", "patch"];
24714
24872
  function readLocalGateWorkflows() {
@@ -24793,7 +24951,7 @@ async function preflightMergeToMain(deps, deployModel, remoteRef, blockingPrefix
24793
24951
  const predicted = await predictMergeConflicts(deps, ours, remoteRef);
24794
24952
  const predictedBlocking = predicted.filter((f) => !pathIsTolerated(f, tolerated));
24795
24953
  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`);
24954
+ 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
24955
  }
24798
24956
  return { foldPaths, tolerated, predicted };
24799
24957
  }
@@ -25022,7 +25180,7 @@ async function recoverFailedFold(deps, cause, startBranch, preFoldMainSha, resum
25022
25180
  const causeMessage = anchor ? `${rawCause} \u2014 see ${anchor}` : rawCause;
25023
25181
  const probe = await probeFoldFailureState(deps);
25024
25182
  if (probe.mergeInProgress) {
25025
- const conflictCause = `${rawCause} \u2014 see docs/Guides/train-troubleshooting.md#merge-conflict`;
25183
+ const conflictCause = `${rawCause} \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#merge-conflict`;
25026
25184
  try {
25027
25185
  await deps.run("git", ["merge", "--abort"]);
25028
25186
  } catch (e) {
@@ -25313,14 +25471,14 @@ async function recoverFailedDispatchPhase(deps, ledger, anchors, phase, dispatch
25313
25471
  let fallbackRecovered = false;
25314
25472
  const failedRows = workflowRuns?.filter((run) => run.conclusion === "failure") ?? [];
25315
25473
  if (failedRows.length > 0) {
25316
- for (const failed of failedRows) {
25317
- const rowPhase = failed.workflow.includes("publish") ? "publish" : phase;
25474
+ for (const failed2 of failedRows) {
25475
+ const rowPhase = failed2.workflow.includes("publish") ? "publish" : phase;
25318
25476
  if (persisted?.phases[rowPhase]?.superseded === true) continue;
25319
25477
  const recovery = await recover(deps, ledger, anchors, rowPhase, {
25320
25478
  repo: input.repo,
25321
25479
  sha: input.sha,
25322
- failure: `watched ${rowPhase} run failed: ${failed.workflow} (${dispatch.note})`,
25323
- runs: [{ ...failed, repo: input.repo }],
25480
+ failure: `watched ${rowPhase} run failed: ${failed2.workflow} (${dispatch.note})`,
25481
+ runs: [{ ...failed2, repo: input.repo }],
25324
25482
  watched: input.watched
25325
25483
  });
25326
25484
  if (recovery.note) notes.push(recovery.note);
@@ -25386,6 +25544,27 @@ async function recordRcandDeployLegs(ledger, deps, anchors, deployModel, d, opts
25386
25544
  function releaseAnnounceDeferral(model, summaryFile) {
25387
25545
  return prodReleaseTargets(model).length > 0 || model === "vercel" ? `\`mmi-cli devops release --resume --watch${summaryFile ? ` --announce-summary-file ${summaryFile}` : ""}\` announces it once they are green` : "a central deploy is proven only under --watch, so this release stays unannounced";
25388
25546
  }
25547
+ function annotateVersionEnvDispatch(dispatch, result) {
25548
+ if (!result.note) return dispatch;
25549
+ return { ...dispatch, note: `${dispatch.note}; ${result.note}` };
25550
+ }
25551
+ function annotateVersionEnvAnnounce(note, result) {
25552
+ if (!result.note) return note;
25553
+ return note ? `${note}; ${result.note}` : result.note;
25554
+ }
25555
+ async function pushAfterVercelVersionEnv(deps, meta, stage, tag, pushArgs) {
25556
+ const result = await setVercelVersionEnv(vercelVersionEnvDeps(deps), meta, stage, tag);
25557
+ try {
25558
+ await runGitPush(deps, pushArgs);
25559
+ } catch (e) {
25560
+ const base = e instanceof Error ? e.message : String(e);
25561
+ const parts = [base];
25562
+ if (result.note) parts.push(result.note);
25563
+ if (result.status === "set" && meta.vercelVersionEnv?.key) parts.push(vercelVersionEnvAheadNote(meta.vercelVersionEnv.key));
25564
+ throw new Error(parts.join(" \u2014 "));
25565
+ }
25566
+ return result;
25567
+ }
25389
25568
  async function completeMainRelease(deps, ctx, meta, deployModel, watch, options, tag, releaseSha, startBranch, preFold, tagProbe, branchHints, ledger, alignEarly) {
25390
25569
  const resumeCommand = options.dev ? "mmi-cli devops release --dev --apply" : "mmi-cli devops release --apply";
25391
25570
  const ledgerAnchors = { repo: ctx.repo, tag, tagSha: releaseSha };
@@ -25482,9 +25661,10 @@ ${recovery.note}`), recoveryInput);
25482
25661
  landed: "the immutable tag pushed at " + releaseSha.slice(0, 12)
25483
25662
  });
25484
25663
  if (trueMergeGateNote) checks = `${trueMergeGateNote}; ${checks}`;
25664
+ let versionEnv = { status: "skipped", note: "" };
25485
25665
  try {
25486
25666
  deps.warn?.("Release: Promoting the verified release");
25487
- await runGitPush(deps, ["push", "origin", "main"]);
25667
+ versionEnv = await pushAfterVercelVersionEnv(deps, meta, "main", tag, ["push", "origin", "main"]);
25488
25668
  } catch (e) {
25489
25669
  await recordPhase(ledger, deps, ledgerAnchors, "promotion", phaseEntry({ state: "failed", sha: releaseSha, error: `origin/main push failed after ${tag} was pushed: ${e instanceof Error ? e.message : String(e)}` }), { landed: "the immutable tag pushed at " + releaseSha.slice(0, 12) });
25490
25670
  throw partialTrainRecoveryError(e, recoveryInput);
@@ -25517,7 +25697,7 @@ ${recovery.note}`), recoveryInput);
25517
25697
  workflow: failedReleaseRun?.workflow ?? (isCentralDispatchModel(deployModel) ? "tenant-deploy.yml" : "deploy workflow")
25518
25698
  }
25519
25699
  );
25520
- const deployDispatch = await appendJervGatewayReleaseDeploy(deps, ctx.repo, tag, releaseSha, recoveredDeployDispatch);
25700
+ const deployDispatch = annotateVersionEnvDispatch(await appendJervGatewayReleaseDeploy(deps, ctx.repo, tag, releaseSha, recoveredDeployDispatch), versionEnv);
25521
25701
  const deployLeg = phaseEntry(validSuiteCutContext(ctx.repo, deps.suiteCut) && ctx.slug !== "mmi-katip" ? { state: "skipped", sha: releaseSha, note: "suite bundle-only: live backend not deployed (#7263)" } : deployPhaseInput(deployModel, deployDispatch, { releaseSha }));
25522
25702
  await recordPhase(ledger, deps, ledgerAnchors, "deploy", deployLeg, {
25523
25703
  landed: "the immutable tag, the green required-check wall, the origin/main fast-forward, and the verified GitHub Release"
@@ -25551,7 +25731,7 @@ ${recovery.note}`), recoveryInput);
25551
25731
  appendPublishDispatch(deployDispatch, publishDispatch)
25552
25732
  );
25553
25733
  alignment = await aligning;
25554
- const announceNote = await announcing;
25734
+ const announceNote = annotateVersionEnvAnnounce(await announcing, versionEnv);
25555
25735
  return { checks, releaseUrl, announceNote, dispatch, ledgerAnchors, ...alignment ? { alignment } : {} };
25556
25736
  }
25557
25737
  function resumeStateOf(status) {
@@ -25754,10 +25934,10 @@ ${spent.note}`);
25754
25934
  await recordPhase(ledger, deps, anchors, "ordinaryCi", phaseEntry({ state: "complete", sha: tagSha, note: "re-verified the required-check wall live on this resume" }), { strict, landed: "the immutable rc tag on origin at the resumed SHA" });
25755
25935
  const autoRunSince = (deps.now ?? Date.now)();
25756
25936
  deps.warn?.("Release: Promoting the verified release candidate");
25757
- await runGitPush(deps, ["push", "origin", `${tagSha}:refs/heads/rc`]);
25937
+ const versionEnv = await pushAfterVercelVersionEnv(deps, meta, "rc", tag, ["push", "origin", `${tagSha}:refs/heads/rc`]);
25758
25938
  await recordPhase(ledger, deps, anchors, "promotion", phaseEntry({ state: "complete", sha: tagSha, note: `resumed: ${tagSha.slice(0, 12)} pushed to origin/rc (tag preserved, never re-cut)` }), { strict, landed: "the immutable rc tag and the re-proven required-check wall" });
25759
25939
  await recordPhase(ledger, deps, anchors, "githubRelease", phaseEntry({ state: "skipped", sha: tagSha, note: "rcand mints no GitHub Release \u2014 the rc tag is the candidate" }), { strict, landed: "the immutable rc tag, the green wall, and origin/rc" });
25760
- const dispatch0 = await dispatchDeploy(deps, ctx, "rc", "rc", deployModel, watch, autoRunSince, tagSha, "report");
25940
+ const dispatch0 = annotateVersionEnvDispatch(await dispatchDeploy(deps, ctx, "rc", "rc", deployModel, watch, autoRunSince, tagSha, "report"), versionEnv);
25761
25941
  const dispatch = await recordRcandDeployLegs(ledger, deps, anchors, deployModel, dispatch0, { strict });
25762
25942
  const ledgerReport = releaseLedgerReport(await ledger.load(anchors).catch(() => void 0), ledger.path);
25763
25943
  return {
@@ -26037,7 +26217,7 @@ ${recovery.note}`), recoveryInput);
26037
26217
  }
26038
26218
  await recordPhase(ledger, deps, anchors, "ordinaryCi", phaseEntry({ state: "complete", sha: tagSha, note: "re-verified the required-check wall live on this resume" }), { strict: ledgerMode === "strict", landed: "the immutable tag on origin at the resumed SHA" });
26039
26219
  deps.warn?.("Release: Promoting the verified release");
26040
- await runGitPush(deps, ["push", "origin", `${tagSha}:refs/heads/main`]);
26220
+ const versionEnv = await pushAfterVercelVersionEnv(deps, meta, "main", tag, ["push", "origin", `${tagSha}:refs/heads/main`]);
26041
26221
  steps.push(`pushed ${tagSha.slice(0, 12)} to origin/main`);
26042
26222
  await recordPhase(ledger, deps, anchors, "promotion", phaseEntry({ state: "complete", sha: tagSha, note: `resumed: ${tagSha.slice(0, 12)} pushed to origin/main (tag preserved, never re-cut)` }), { strict: ledgerMode === "strict", landed: "the immutable tag and the re-proven required-check wall" });
26043
26223
  deps.warn?.("Release: Creating the GitHub Release");
@@ -26067,7 +26247,7 @@ ${recovery.note}`), recoveryInput);
26067
26247
  workflow: isCentralDispatchModel(deployModel) ? "tenant-deploy.yml" : "deploy workflow"
26068
26248
  }
26069
26249
  );
26070
- const deployDispatch = await appendJervGatewayReleaseDeploy(deps, ctx.repo, tag, tagSha, recoveredDeployDispatch);
26250
+ const deployDispatch = annotateVersionEnvDispatch(await appendJervGatewayReleaseDeploy(deps, ctx.repo, tag, tagSha, recoveredDeployDispatch), versionEnv);
26071
26251
  steps.push(`dispatched the ${deployModel} deploy path`);
26072
26252
  const deployLeg = persisted?.phases.deploy.superseded === true ? persisted.phases.deploy : phaseEntry(validSuiteCutContext(ctx.repo, deps.suiteCut) && ctx.slug !== "mmi-katip" ? { state: "skipped", sha: tagSha, note: "suite bundle-only: live backend not deployed (#7263)" } : deployPhaseInput(deployModel, deployDispatch, { releaseSha: tagSha }));
26073
26253
  if (persisted?.phases.deploy.superseded !== true) {
@@ -26108,7 +26288,7 @@ ${recovery.note}`), recoveryInput);
26108
26288
  devRollForward = landed.dev;
26109
26289
  rcAlignment = landed.rc;
26110
26290
  }
26111
- const announceNote = await announcing;
26291
+ const announceNote = annotateVersionEnvAnnounce(await announcing, versionEnv);
26112
26292
  steps.push(`development roll-forward: ${devRollForward.status}`);
26113
26293
  if (rcAlignment) steps.push(`rc alignment: ${rcAlignment.status}`);
26114
26294
  const followUp = deriveTrainFollowUpStatus({
@@ -26448,15 +26628,16 @@ ${spent.note}`), rcandRecovery(tag2));
26448
26628
  await recordPhase(ledger, deps, anchors, "ordinaryCi", phaseEntry({ state: "complete", sha: rcSha, note: checks2 }), { landed: "the immutable rc tag pushed at " + rcSha.slice(0, 12) });
26449
26629
  const autoRunSince = (deps.now ?? Date.now)();
26450
26630
  deps.warn?.("Release: Promoting the verified release candidate");
26631
+ let versionEnv = { status: "skipped", note: "" };
26451
26632
  try {
26452
- await runGitPush(deps, ["push", "origin", "rc"]);
26633
+ versionEnv = await pushAfterVercelVersionEnv(deps, meta, "rc", tag2, ["push", "origin", "rc"]);
26453
26634
  } catch (e) {
26454
26635
  await recordPhase(ledger, deps, anchors, "promotion", phaseEntry({ state: "failed", sha: rcSha, error: `origin/rc push failed after ${tag2} was pushed: ${e instanceof Error ? e.message : String(e)}` }), { landed: "the immutable rc tag pushed at " + rcSha.slice(0, 12) });
26455
26636
  throw partialTrainRecoveryError(e, rcandRecovery(tag2));
26456
26637
  }
26457
26638
  await recordPhase(ledger, deps, anchors, "promotion", phaseEntry({ state: "complete", sha: rcSha, note: `immutable ${tag2} pushed at ${rcSha.slice(0, 12)}; origin/rc fast-forwarded to it` }), { landed: "the immutable rc tag and the green required-check wall" });
26458
26639
  await recordPhase(ledger, deps, anchors, "githubRelease", phaseEntry({ state: "skipped", sha: rcSha, note: "rcand mints no GitHub Release \u2014 the rc tag is the candidate" }), { landed: "the immutable rc tag, the green wall, and origin/rc" });
26459
- const d0 = await dispatchDeploy(deps, ctx, "rc", "rc", deployModel2, watch, autoRunSince, rcSha, "report");
26640
+ const d0 = annotateVersionEnvDispatch(await dispatchDeploy(deps, ctx, "rc", "rc", deployModel2, watch, autoRunSince, rcSha, "report"), versionEnv);
26460
26641
  const d2 = await recordRcandDeployLegs(ledger, deps, anchors, deployModel2, d0);
26461
26642
  const rcLedgerReport = releaseLedgerReport(await ledger?.load(anchors).catch(() => void 0), ledger?.path ?? "(no ledger)");
26462
26643
  return { ...ctx, command, stage: "rc", ref: "rc", tag: tag2, bumpIntent, deployModel: deployModel2, promoted: true, checks: checks2, resumeNote, dispatch: d2.note, runId: d2.runId, runUrl: d2.runUrl, workflowRuns: d2.workflowRuns, deployStatus: d2.deployStatus, ...rcLedgerReport ? { ledger: rcLedgerReport } : {} };
@@ -26593,7 +26774,7 @@ ${spent.note}`), rcandRecovery(tag2));
26593
26774
  if (!coverage.ok) {
26594
26775
  const list = coverage.uncovered.map((c) => `${c.sha.slice(0, 8)} ${c.subject}`).join("; ");
26595
26776
  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`
26777
+ `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
26778
  // #6095: the guard names its lesson
26598
26779
  );
26599
26780
  }
@@ -26673,7 +26854,7 @@ async function resolveTrainBumpIntent(deps, env = process.env, latestRelease) {
26673
26854
  const explicitIntent = (env.MMI_BUMP_INTENT ?? "").trim();
26674
26855
  if (explicitIntent) {
26675
26856
  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");
26857
+ 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
26858
  }
26678
26859
  return explicitIntent;
26679
26860
  }
@@ -26718,12 +26899,12 @@ async function resolveHotfixVersion(deps, env = process.env) {
26718
26899
  }
26719
26900
  const nonPatch = declared.find((d) => d.intent !== "patch");
26720
26901
  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`);
26902
+ 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
26903
  }
26723
26904
  const explicit = explicitVersion.replace(/^v/, "");
26724
26905
  const nextPatch = `${latest.major}.${latest.minor}.${latest.patch + 1}`;
26725
26906
  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`);
26907
+ 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
26908
  }
26728
26909
  const version = explicit || nextPatch;
26729
26910
  return { tag: `v${version}`, version, baseTag };
@@ -28643,13 +28824,13 @@ function buildPluginCachePlan(home, running, deps, opts = {}) {
28643
28824
  }
28644
28825
  function applyPluginCachePlan(plan, remove2, stagingGuard) {
28645
28826
  const removed = [];
28646
- const failed = [];
28827
+ const failed2 = [];
28647
28828
  for (const version of plan.prune) {
28648
28829
  try {
28649
28830
  remove2(`${plan.cacheRoot}/${version}`);
28650
28831
  removed.push(version);
28651
28832
  } catch (e) {
28652
- failed.push({ version, error: e.message });
28833
+ failed2.push({ version, error: e.message });
28653
28834
  }
28654
28835
  }
28655
28836
  const removedStaging = [];
@@ -28682,7 +28863,7 @@ function applyPluginCachePlan(plan, remove2, stagingGuard) {
28682
28863
  }
28683
28864
  return {
28684
28865
  removed,
28685
- failed,
28866
+ failed: failed2,
28686
28867
  bytes: plan.bytes,
28687
28868
  removedStaging,
28688
28869
  failedStaging,
@@ -29694,7 +29875,7 @@ init_hub_auth();
29694
29875
  init_github_client();
29695
29876
 
29696
29877
  // 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"];
29878
+ 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", "vercelVersionEnv"];
29698
29879
  var UNSET_KEY_SET = new Set(UNSET_KEYS);
29699
29880
  var RUNTIME_SECRET_STAGES = ["dev", "rc", "main"];
29700
29881
  var SECRET_CONSUMERS = ["runtime", "build", "lambda", "actions", "agent", "box"];
@@ -30033,6 +30214,47 @@ function parseRequiredCheckBranchesVar(raw) {
30033
30214
  }
30034
30215
  return REQUIRED_CHECK_BRANCHES.filter((branch) => parsed.includes(branch));
30035
30216
  }
30217
+ function parseNotifyOnDeployVar(raw) {
30218
+ let parsed;
30219
+ try {
30220
+ parsed = JSON.parse(raw);
30221
+ } catch {
30222
+ throw new Error('org project set: notifyOnDeploy must be a JSON array, e.g. ["dev"]');
30223
+ }
30224
+ if (!Array.isArray(parsed) || parsed.some((s) => s !== "dev" && s !== "rc" && s !== "main")) {
30225
+ throw new Error('org project set: notifyOnDeploy must be a JSON array of stages: dev, rc, main (e.g. ["dev"])');
30226
+ }
30227
+ if (new Set(parsed).size !== parsed.length) {
30228
+ throw new Error("org project set: notifyOnDeploy must not contain duplicate stages");
30229
+ }
30230
+ return parsed;
30231
+ }
30232
+ var VERCEL_VERSION_ENV_NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
30233
+ var VERCEL_VERSION_ENV_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
30234
+ function parseVercelVersionEnvVar(raw) {
30235
+ let parsed;
30236
+ try {
30237
+ parsed = JSON.parse(raw);
30238
+ } catch {
30239
+ throw new Error('org project set: vercelVersionEnv must be JSON, e.g. {"project":"mm-work","key":"MMW_VERSION","team":"team_xxx"}');
30240
+ }
30241
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
30242
+ throw new Error("org project set: vercelVersionEnv must be a JSON object { project, key, team? }");
30243
+ }
30244
+ const row = parsed;
30245
+ const unknown = Object.keys(row).filter((key) => key !== "project" && key !== "key" && key !== "team");
30246
+ if (unknown.length > 0) throw new Error(`org project set: vercelVersionEnv has unknown field(s): ${unknown.join(", ")}`);
30247
+ if (typeof row.project !== "string" || !VERCEL_VERSION_ENV_ID_RE.test(row.project)) {
30248
+ throw new Error("org project set: vercelVersionEnv.project must be a Vercel project id or name");
30249
+ }
30250
+ if (typeof row.key !== "string" || !VERCEL_VERSION_ENV_NAME_RE.test(row.key)) {
30251
+ throw new Error("org project set: vercelVersionEnv.key must be an env name (e.g. MMW_VERSION)");
30252
+ }
30253
+ if (row.team !== void 0 && (typeof row.team !== "string" || !VERCEL_VERSION_ENV_ID_RE.test(row.team))) {
30254
+ throw new Error("org project set: vercelVersionEnv.team must be a Vercel team id or slug");
30255
+ }
30256
+ return { project: row.project, key: row.key, ...typeof row.team === "string" ? { team: row.team } : {} };
30257
+ }
30036
30258
  function parseCiExemptReasonVar(raw) {
30037
30259
  const trimmed = raw.trim();
30038
30260
  if (!trimmed) throw new Error("org project set: ciExemptReason must be a non-empty reason (or use --unset ciExemptReason to clear it)");
@@ -30173,7 +30395,9 @@ var SETTABLE_VAR_KEYS = [
30173
30395
  "gate",
30174
30396
  "secrets",
30175
30397
  "seedCanary",
30176
- "installer"
30398
+ "installer",
30399
+ "notifyOnDeploy",
30400
+ "vercelVersionEnv"
30177
30401
  ];
30178
30402
  var SETTABLE_VAR_KEY_SET = new Set(SETTABLE_VAR_KEYS);
30179
30403
  var SETTABLE_VAR_HINTS = {
@@ -30202,6 +30426,8 @@ var SETTABLE_VAR_HINTS = {
30202
30426
  ci: "none \u2014 declare intentional no-ci",
30203
30427
  requiredChecks: 'JSON array, e.g. ["gate"] or [] for no-ci',
30204
30428
  requiredCheckBranches: "non-empty JSON branch-name array; exact product-ruleset scope (#5210)",
30429
+ notifyOnDeploy: 'JSON stage array, e.g. ["dev"] \u2014 successful tenant-deploy sends <stage>-deployed to the project repo (#7358)',
30430
+ vercelVersionEnv: "JSON {project,key,team?} \u2014 train sets this Vercel env to the release tag before the main/rc push (#7362)",
30205
30431
  ciExemptReason: "non-empty string \u2014 declares this deployable repo has no CI surface (#4265)",
30206
30432
  gate: "JSON {runtime,cmd,affectedCmd,workdir,cacheDepPath,pyVersion}"
30207
30433
  };
@@ -30345,6 +30571,10 @@ function buildProjectSetPatch(input) {
30345
30571
  patch[key] = parseRequiredChecksVar(raw);
30346
30572
  } else if (key === "requiredCheckBranches") {
30347
30573
  patch[key] = parseRequiredCheckBranchesVar(raw);
30574
+ } else if (key === "notifyOnDeploy") {
30575
+ patch[key] = parseNotifyOnDeployVar(raw);
30576
+ } else if (key === "vercelVersionEnv") {
30577
+ patch[key] = parseVercelVersionEnvVar(raw);
30348
30578
  } else if (key === "ciExemptReason") {
30349
30579
  patch[key] = parseCiExemptReasonVar(raw);
30350
30580
  } else if (key === "gate") {
@@ -30389,6 +30619,8 @@ var DEPLOY_SHELL_SAFE_RE = /^[A-Za-z0-9./:_?&=%-]*$/;
30389
30619
  var DEPLOY_ROLE_ARN_RE = /^arn:aws:iam::\d{12}:role\/[A-Za-z0-9+=,.@_/-]{1,128}$/;
30390
30620
  var DEPLOY_BUCKET_RE = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/;
30391
30621
  var DEPLOY_DISTRIBUTION_ID_RE = /^[A-Z0-9]{5,32}$/;
30622
+ var COMPOSE_PROFILE_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
30623
+ var MAX_COMPOSE_PROFILES = 8;
30392
30624
  function buildSetDeployPatch(_slug, input) {
30393
30625
  const clean4 = (v) => typeof v === "string" && v.trim() !== "" ? v.trim() : void 0;
30394
30626
  const stage = clean4(input.stage);
@@ -30432,6 +30664,23 @@ function buildSetDeployPatch(_slug, input) {
30432
30664
  if (input.noEnvFile !== void 0 && typeof input.noEnvFile !== "boolean") {
30433
30665
  throw new Error("org project set-deploy: --no-env-file must be true or false");
30434
30666
  }
30667
+ if (input.clearComposeProfiles && (input.composeProfiles?.length ?? 0) > 0) {
30668
+ throw new Error("org project set-deploy: --clear-compose-profiles cannot be combined with --compose-profiles (they contradict)");
30669
+ }
30670
+ let composeProfiles;
30671
+ if (input.clearComposeProfiles) {
30672
+ composeProfiles = [];
30673
+ } else if (input.composeProfiles !== void 0) {
30674
+ const names = input.composeProfiles.map((p) => typeof p === "string" ? p.trim() : "");
30675
+ if (names.length > MAX_COMPOSE_PROFILES) throw new Error(`org project set-deploy: --compose-profiles carries at most ${MAX_COMPOSE_PROFILES} profiles`);
30676
+ const seen = /* @__PURE__ */ new Set();
30677
+ for (const name of names) {
30678
+ 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)}`);
30679
+ if (seen.has(name)) throw new Error(`org project set-deploy: --compose-profiles carries a duplicate profile ${JSON.stringify(name)}`);
30680
+ seen.add(name);
30681
+ }
30682
+ composeProfiles = [...seen];
30683
+ }
30435
30684
  return {
30436
30685
  stage,
30437
30686
  ...substrate !== void 0 ? { substrate } : {},
@@ -30448,7 +30697,10 @@ function buildSetDeployPatch(_slug, input) {
30448
30697
  // #2986: an explicit `[]` is what the route reads as "clear them"; omitting the key is what it reads as
30449
30698
  // "keep them". Those are different instructions and they now have different flags.
30450
30699
  ...input.clearAliases ? { aliases: [] } : uniqueAliases.length ? { aliases: uniqueAliases } : {},
30451
- ...input.noEnvFile !== void 0 ? { noEnvFile: input.noEnvFile } : {}
30700
+ ...input.noEnvFile !== void 0 ? { noEnvFile: input.noEnvFile } : {},
30701
+ // #7353: an explicit `[]` is what the route reads as "clear them"; omitting the key is what it reads as
30702
+ // "keep them" — same patch contract as aliases (#2986/#2984).
30703
+ ...composeProfiles !== void 0 ? { composeProfiles } : {}
30452
30704
  };
30453
30705
  }
30454
30706
  var DEPLOY_STAGE_BRANCH = { dev: "development", rc: "rc", main: "main" };
@@ -34422,6 +34674,22 @@ async function suiteRcand(deps, opts) {
34422
34674
  Object.assign(member, { carried: true }, await carriedPins(deps, member, modules.find((m) => m.repo === member.repo).bundles));
34423
34675
  writeSuiteCut(deps.recordPath, record);
34424
34676
  }
34677
+ const refusals = [];
34678
+ for (const member of record.members) {
34679
+ if (member.carried || member.published || member.id === "katip") continue;
34680
+ try {
34681
+ await deps.preflight(member.repo);
34682
+ } catch (e) {
34683
+ refusals.push(`${member.repo}: ${e.message}`);
34684
+ }
34685
+ }
34686
+ if (refusals.length) {
34687
+ const untouched = record.members.every((m) => !m.tag) && !record.pinPr;
34688
+ if (untouched) (0, import_node_fs29.rmSync)(deps.recordPath, { force: true });
34689
+ throw new Error(`module rc serving is not ready; ${untouched ? "no suite cut started" : "no new tag was cut"}:
34690
+ ${refusals.join("\n")}
34691
+ 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).`);
34692
+ }
34425
34693
  for (const member of record.members) {
34426
34694
  if ((await deps.head(member.repo, "development"))?.toLowerCase() !== member.development.toLowerCase())
34427
34695
  throw new Error(`${member.repo}: development moved after the cut snapshot; refuse to add untested code`);
@@ -34631,7 +34899,7 @@ function announceSummaryFidelity(lines2, rangeLog) {
34631
34899
  if (shipped.size === 0) return null;
34632
34900
  const invented = [...new Set(lines2.flatMap((l) => l.match(/#\d+/g) ?? []))].filter((ref) => !shipped.has(ref));
34633
34901
  if (invented.length === 0) return null;
34634
- 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`;
34902
+ 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`;
34635
34903
  }
34636
34904
  async function resolveAnnounceTarget(deps, repo) {
34637
34905
  if (repo.toLowerCase() === ANNOUNCE_REPO.toLowerCase()) return { kind: "alerts" };
@@ -34709,6 +34977,12 @@ function normalizeHotfixVersion(input) {
34709
34977
  return { tag: `v${m[1]}`, version: m[1] };
34710
34978
  }
34711
34979
  var deriveHotfixVersion = resolveHotfixVersion;
34980
+ function bumpHotfixPatchVersion(version) {
34981
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
34982
+ if (!m) throw new Error(`cannot bump non-X.Y.Z hotfix version "${version}"`);
34983
+ const next = `${m[1]}.${m[2]}.${Number(m[3]) + 1}`;
34984
+ return { tag: `v${next}`, version: next };
34985
+ }
34712
34986
  function hotfixBranch(tag) {
34713
34987
  return `hotfix/${tag}`;
34714
34988
  }
@@ -34762,7 +35036,7 @@ function formatHotfixBatchPreflightRefusal(failures, fromSpecs) {
34762
35036
  const batchFrom = fromSpecs.join(",");
34763
35037
  return `hotfix start batch preflight refused: ${failures.length} pick(s) would hard-stop on origin/main:
34764
35038
  ${lines2.join("\n")}
34765
- 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`;
35039
+ 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`;
34766
35040
  }
34767
35041
  var CHERRY_PICKED_FROM = /\(cherry picked from commit ([0-9a-f]{7,40})\)/g;
34768
35042
  async function findHotfixPickGaps(deps, sources, tolerated) {
@@ -34791,32 +35065,44 @@ ${lines2.join("\n")}
34791
35065
  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.`;
34792
35066
  }
34793
35067
  async function preflightHotfixBatchPicks(deps, sources, pickTolerated) {
34794
- if (sources.length === 0) return [];
34795
- const startBranch = clean3(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
34796
- const preflightBranch = `__mmi_hotfix_preflight_${Date.now()}`;
35068
+ if (sources.length === 0) return { failures: [], skipped: [] };
35069
+ const { mkdtemp: mkdtemp2, rm: rm2 } = await import("node:fs/promises");
35070
+ const { tmpdir: tmpdir10 } = await import("node:os");
35071
+ const { join: join44 } = await import("node:path");
35072
+ const dir = await mkdtemp2(join44(tmpdir10(), "mmi-hotfix-preflight-"));
35073
+ const wt = { run: (file, args) => deps.run(file, file === "git" ? ["-C", dir, ...args] : args) };
34797
35074
  const failures = [];
35075
+ const skipped = [];
34798
35076
  try {
34799
- await deps.run("git", ["checkout", "-B", preflightBranch, "origin/main"]);
34800
- for (const source of sources) {
34801
- try {
34802
- await cherryPickWithToleratedPaths(deps, source.sha, pickTolerated);
34803
- } catch (e) {
34804
- const message2 = e.message ?? String(e);
34805
- const blocking = parseCherryPickBlockingPaths(message2);
34806
- failures.push({
34807
- label: source.label,
34808
- sha: source.sha,
34809
- blocking: blocking.length > 0 ? blocking : [message2.split("\n")[0] ?? message2]
34810
- });
35077
+ await deps.run("git", ["worktree", "add", "--detach", dir, "origin/main"]);
35078
+ try {
35079
+ for (const source of sources) {
35080
+ const before = await wt.run("git", ["status", "--porcelain"]);
35081
+ try {
35082
+ await cherryPickWithToleratedPaths(wt, source.sha, pickTolerated);
35083
+ } catch (e) {
35084
+ const message2 = e.message ?? String(e);
35085
+ const after = await wt.run("git", ["status", "--porcelain"]).catch(() => null);
35086
+ if (after !== null && after === before && /empty|nothing to commit/i.test(message2)) {
35087
+ await wt.run("git", ["cherry-pick", "--quit"]).catch(() => void 0);
35088
+ skipped.push({ sha: source.sha, label: source.label });
35089
+ continue;
35090
+ }
35091
+ const blocking = parseCherryPickBlockingPaths(message2);
35092
+ failures.push({
35093
+ label: source.label,
35094
+ sha: source.sha,
35095
+ blocking: blocking.length > 0 ? blocking : [message2.split("\n")[0] ?? message2]
35096
+ });
35097
+ }
34811
35098
  }
35099
+ } finally {
35100
+ await deps.run("git", ["worktree", "remove", "--force", dir]).catch(() => void 0);
34812
35101
  }
34813
35102
  } finally {
34814
- if (startBranch && startBranch !== "HEAD") {
34815
- await deps.run("git", ["checkout", startBranch]).catch(() => void 0);
34816
- }
34817
- await deps.run("git", ["branch", "-D", preflightBranch]).catch(() => void 0);
35103
+ await rm2(dir, { recursive: true, force: true }).catch(() => void 0);
34818
35104
  }
34819
- return failures;
35105
+ return { failures, skipped };
34820
35106
  }
34821
35107
  async function restoreAfterFailedPort(deps, opts) {
34822
35108
  const { startBranch, branch, created } = opts;
@@ -34939,10 +35225,20 @@ async function runHotfixStartLocked(deps, options, doctor) {
34939
35225
  throw new Error(`working tree must be clean before hotfix start${describeBlockingPaths(status)}`);
34940
35226
  }
34941
35227
  await deps.run("git", ["fetch", "origin", "--tags"]);
34942
- const { deployModel } = await hotfixPreflight(deps, ctx, "start");
34943
- const { tag, version } = await resolveHotfixVersion(deps);
35228
+ const { deployModel, meta } = await hotfixPreflight(deps, ctx, "start");
35229
+ const bumpNotes = [];
35230
+ let resolved = await resolveHotfixVersion(deps);
35231
+ for (; ; ) {
35232
+ const priorPr = await findHotfixPr(deps, ctx, resolved.tag);
35233
+ if ((priorPr?.state ?? "").toUpperCase() !== "MERGED") break;
35234
+ const priorReleased = await hotfixReleaseExists(deps, ctx, resolved.tag);
35235
+ const next = bumpHotfixPatchVersion(resolved.version);
35236
+ 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)`);
35237
+ resolved = next;
35238
+ }
35239
+ const { tag, version } = resolved;
34944
35240
  const branch = hotfixBranch(tag);
34945
- const notes = [];
35241
+ const notes = [...bumpNotes];
34946
35242
  const existingPr = await findHotfixPr(deps, ctx, tag);
34947
35243
  const existingState = (existingPr?.state ?? "").toUpperCase();
34948
35244
  const releaseExists = existingState === "MERGED" ? await hotfixReleaseExists(deps, ctx, tag) : false;
@@ -34974,8 +35270,8 @@ async function runHotfixStartLocked(deps, options, doctor) {
34974
35270
  if (specs.length === 0) throw new Error("hotfix start: --from named no PR or SHA");
34975
35271
  const sources = [];
34976
35272
  for (const spec of specs) {
34977
- const resolved = await resolveHotfixSource(deps, ctx, spec);
34978
- if (!sources.some((s) => s.sha === resolved.sha)) sources.push(resolved);
35273
+ const resolved2 = await resolveHotfixSource(deps, ctx, spec);
35274
+ if (!sources.some((s) => s.sha === resolved2.sha)) sources.push(resolved2);
34979
35275
  }
34980
35276
  const label = sources.map((s) => s.label).join(", ");
34981
35277
  const { foldPaths, pickTolerated } = await resolveHotfixPickTolerated(deps, deployModel);
@@ -34991,21 +35287,33 @@ async function runHotfixStartLocked(deps, options, doctor) {
34991
35287
  if (pickGaps.length > 0) {
34992
35288
  notes.push(`pick-completeness ack: excluded ${pickGaps.map((g) => `${g.sha.slice(0, 7)} ${g.subject}`).join("; ")} by --ack (#6994)`);
34993
35289
  }
35290
+ let activeSources = sources;
35291
+ let activeLabel = label;
34994
35292
  const remoteBranch = clean3(await deps.run("git", ["ls-remote", "origin", `refs/heads/${branch}`]));
34995
35293
  if (remoteBranch) {
34996
35294
  await deps.run("git", ["checkout", branch]);
34997
35295
  await deps.run("git", ["pull", "--ff-only", "origin", branch]);
34998
35296
  notes.push(`branch ${branch} already on origin \u2014 reused (cherry-pick/bump assumed present; PR step resumes)`);
34999
35297
  } else {
35000
- const preflightFailures = await preflightHotfixBatchPicks(deps, sources, pickTolerated);
35001
- if (preflightFailures.length > 0) {
35002
- throw new Error(formatHotfixBatchPreflightRefusal(preflightFailures, specs));
35298
+ const preflight2 = await preflightHotfixBatchPicks(deps, sources, pickTolerated);
35299
+ if (preflight2.failures.length > 0) {
35300
+ throw new Error(formatHotfixBatchPreflightRefusal(preflight2.failures, specs));
35301
+ }
35302
+ if (preflight2.skipped.length > 0) {
35303
+ const skippedShas = new Set(preflight2.skipped.map((s) => s.sha));
35304
+ const remaining = sources.filter((s) => !skippedShas.has(s.sha));
35305
+ 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(", ")}`);
35306
+ if (remaining.length === 0) {
35307
+ 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`);
35308
+ }
35309
+ activeSources = remaining;
35310
+ activeLabel = remaining.map((s) => s.label).join(", ");
35003
35311
  }
35004
35312
  const startBranch = clean3(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
35005
35313
  const preexistingLocal = clean3(await deps.run("git", ["branch", "--list", branch]));
35006
35314
  await deps.run("git", ["checkout", "-B", branch, "origin/main"]);
35007
35315
  try {
35008
- for (const source of sources) {
35316
+ for (const source of activeSources) {
35009
35317
  const autoResolved = await cherryPickWithToleratedPaths(deps, source.sha, pickTolerated);
35010
35318
  if (autoResolved.length > 0) {
35011
35319
  notes.push(`auto-resolved regenerable cherry-pick conflict(s) for ${source.label}: ${autoResolved.join(", ")} (regenerated in bump step)`);
@@ -35014,12 +35322,12 @@ async function runHotfixStartLocked(deps, options, doctor) {
35014
35322
  } catch (e) {
35015
35323
  const recovery = await restoreAfterFailedPort(deps, { startBranch, branch, created: !preexistingLocal });
35016
35324
  throw new Error(
35017
- `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})`
35325
+ `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})`
35018
35326
  );
35019
35327
  }
35020
- notes.push(`cherry-picked ${label} onto ${branch} (from origin/main, -x trailer recorded)`);
35328
+ notes.push(`cherry-picked ${activeLabel} onto ${branch} (from origin/main, -x trailer recorded)`);
35329
+ const durableSource = clean3(await deps.run("git", ["merge-base", "HEAD", "origin/main"]));
35021
35330
  if (deployModel === "hub-serverless") {
35022
- const durableSource = clean3(await deps.run("git", ["merge-base", "HEAD", "origin/main"]));
35023
35331
  await deps.run("node", ["scripts/release-distribution.mjs", "prepare", version, "--source-commit", durableSource]);
35024
35332
  const changedFiles = (await deps.run("node", ["scripts/release-distribution.mjs", "changed-files"])).split("\n").map((s) => s.trim()).filter(Boolean);
35025
35333
  await deps.run("git", ["add", "-f", "--", ...changedFiles]);
@@ -35031,13 +35339,16 @@ async function runHotfixStartLocked(deps, options, doctor) {
35031
35339
  notes.push("distribution prepare produced no changes \u2014 nothing extra committed");
35032
35340
  }
35033
35341
  } else if (foldPaths.length > 0) {
35034
- const foldNote = await foldReleaseVersion(deps, deployModel, tag, foldPaths);
35342
+ const foldNote = await withFoldSourceCommit(durableSource, () => foldReleaseVersion(deps, deployModel, tag, foldPaths));
35035
35343
  notes.push(foldNote);
35036
35344
  } else {
35037
35345
  notes.push(`version fold skipped (deployModel=${deployModel} carries no version manifest \u2014 the tag is the version)`);
35038
35346
  }
35347
+ await assertHotfixFoldBomStamp(deps, tag);
35039
35348
  await deps.run("git", ["push", "-u", "origin", branch]);
35040
35349
  }
35350
+ const versionEnv = await setVercelVersionEnv(vercelVersionEnvDeps(deps), meta, "main", tag);
35351
+ if (versionEnv.note) notes.push(versionEnv.note);
35041
35352
  const bumpNote = deployModel === "hub-serverless" ? " with the locked distribution bump" : foldPaths.length > 0 ? " with the package version bump" : "";
35042
35353
  const prUrl = clean3(await deps.run("gh", [
35043
35354
  "pr",
@@ -35053,16 +35364,16 @@ async function runHotfixStartLocked(deps, options, doctor) {
35053
35364
  // Every picked sha goes in the marker (#4411) — `hotfix release` reads it when --carries is omitted,
35054
35365
  // so a multi-fix cycle proves ALL of its targets present before tagging, not just the first.
35055
35366
  "--body",
35056
- `Hotfix ${tag}: cherry-pick of ${label} onto origin/main${bumpNote}.
35367
+ `Hotfix ${tag}: cherry-pick of ${activeLabel} onto origin/main${bumpNote}.
35057
35368
 
35058
- <!-- mmi-hotfix-carries: ${sources.map((s) => s.sha).join(",")} -->${pickGaps.length > 0 ? `
35369
+ <!-- mmi-hotfix-carries: ${activeSources.map((s) => s.sha).join(",")} -->${pickGaps.length > 0 ? `
35059
35370
 
35060
35371
  <!-- mmi-hotfix-acked-excluded: ${pickGaps.map((g) => g.sha).join(",")} (#6994 pick-completeness) -->` : ""}
35061
35372
 
35062
35373
  Merge this PR (human-initiated), then run \`mmi-cli devops hotfix release ${tag}\`.`
35063
35374
  ]));
35064
35375
  notes.push(`opened hotfix PR ${prUrl} \u2014 merge it (human-initiated), then: mmi-cli devops hotfix release ${tag}`);
35065
- return { ...ctx, command: "hotfix-start", tag, version, branch, source: label, prUrl, reused: Boolean(remoteBranch), notes };
35376
+ return { ...ctx, command: "hotfix-start", tag, version, branch, source: activeLabel, prUrl, reused: Boolean(remoteBranch), notes };
35066
35377
  }
35067
35378
  function hotfixWorkflowRunFromDispatch(workflow, dispatch) {
35068
35379
  return {
@@ -35099,6 +35410,13 @@ async function watchReleaseRun(deps, ctx, workflow, sha) {
35099
35410
  const run = rows.find((r) => r.headSha === sha && typeof r.databaseId === "number");
35100
35411
  if (!run) continue;
35101
35412
  if (run.status === "completed") return { workflow, runId: run.databaseId, url: run.url, conclusion: run.conclusion ?? "unknown" };
35413
+ try {
35414
+ const viewed = JSON.parse(await deps.run("gh", ["run", "view", String(run.databaseId), "--repo", ctx.repo, "--json", "status,conclusion"]));
35415
+ if (viewed.status === "completed") {
35416
+ return { workflow, runId: run.databaseId, url: run.url, conclusion: typeof viewed.conclusion === "string" ? viewed.conclusion : "unknown" };
35417
+ }
35418
+ } catch {
35419
+ }
35102
35420
  try {
35103
35421
  await deps.run("gh", ghRunWatchArgs(run.databaseId, ctx.repo));
35104
35422
  return { workflow, runId: run.databaseId, url: run.url, conclusion: "success" };
@@ -35126,9 +35444,9 @@ async function portFoldToDevelopment(deps, ctx, deployModel, tag) {
35126
35444
  const branch = devFoldBranch(tag);
35127
35445
  const existing = await findFoldPr(deps, ctx, tag);
35128
35446
  if (existing?.state === "OPEN") {
35129
- 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;
35447
+ 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;
35130
35448
  }
35131
- 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`;
35449
+ 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`;
35132
35450
  const previousRef = clean3(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
35133
35451
  await reapFoldScratchBranches(deps);
35134
35452
  const scratch = devFoldScratchBranch(tag, deps);
@@ -35156,7 +35474,7 @@ async function portFoldToDevelopment(deps, ctx, deployModel, tag) {
35156
35474
 
35157
35475
  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.`
35158
35476
  ]));
35159
- 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;
35477
+ 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;
35160
35478
  } finally {
35161
35479
  if (previousRef && previousRef !== "HEAD") await deps.run("git", ["checkout", previousRef]).catch(() => void 0);
35162
35480
  await deps.run("git", ["branch", "-D", scratch]).catch(() => void 0);
@@ -35344,6 +35662,41 @@ ${recovery.note}; the full required-check wall did not revalidate`);
35344
35662
  }, `the immutable ${anchors.tag} pushed at ${mergedSha.slice(0, 12)}`);
35345
35663
  return checks;
35346
35664
  }
35665
+ var FOLD_SOURCE_COMMIT_ENV = "MMI_FOLD_SOURCE_COMMIT";
35666
+ async function withFoldSourceCommit(durableSource, fn) {
35667
+ const prev = process.env[FOLD_SOURCE_COMMIT_ENV];
35668
+ process.env[FOLD_SOURCE_COMMIT_ENV] = durableSource;
35669
+ try {
35670
+ return await fn();
35671
+ } finally {
35672
+ if (prev === void 0) delete process.env[FOLD_SOURCE_COMMIT_ENV];
35673
+ else process.env[FOLD_SOURCE_COMMIT_ENV] = prev;
35674
+ }
35675
+ }
35676
+ async function assertHotfixFoldBomStamp(deps, tag) {
35677
+ let bomText;
35678
+ try {
35679
+ bomText = await deps.run("git", ["show", "HEAD:distribution-bom.json"]);
35680
+ } catch {
35681
+ return;
35682
+ }
35683
+ let stamp;
35684
+ try {
35685
+ stamp = JSON.parse(bomText).sourceCommit;
35686
+ } catch (e) {
35687
+ 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`);
35688
+ }
35689
+ if (typeof stamp !== "string" || !/^[0-9a-f]{40}$/.test(stamp)) {
35690
+ 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`);
35691
+ }
35692
+ try {
35693
+ await deps.run("git", ["merge-base", "--is-ancestor", stamp, "origin/main"]);
35694
+ } catch {
35695
+ throw new Error(
35696
+ `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`
35697
+ );
35698
+ }
35699
+ }
35347
35700
  async function assertConsumerBomLineage(deps, mergedSha, tag) {
35348
35701
  let bomText;
35349
35702
  try {
@@ -35364,7 +35717,7 @@ async function assertConsumerBomLineage(deps, mergedSha, tag) {
35364
35717
  await deps.run("git", ["merge-base", "--is-ancestor", stamp, mergedSha]);
35365
35718
  } catch {
35366
35719
  throw new Error(
35367
- `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`
35720
+ `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`
35368
35721
  );
35369
35722
  }
35370
35723
  }
@@ -35410,16 +35763,16 @@ async function runHotfixReleaseLocked(deps, versionInput, options, doctor) {
35410
35763
  const releaseExists = await hotfixReleaseExists(deps, ctx, tag);
35411
35764
  if (!releaseExists && isHubControlRepo(ctx.repo)) {
35412
35765
  if (!options.announceSummaryFile) {
35413
- 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");
35766
+ 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");
35414
35767
  }
35415
35768
  let lines2;
35416
35769
  try {
35417
35770
  lines2 = summaryFileLines(await (deps.readFile ?? ((p) => (0, import_promises4.readFile)(p, "utf8")))(options.announceSummaryFile));
35418
35771
  } catch (e) {
35419
- throw new Error(`could not read --announce-summary-file ${options.announceSummaryFile}: ${e.message} \u2014 see docs/Guides/train-troubleshooting.md#announce-summary-missing`);
35772
+ 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`);
35420
35773
  }
35421
35774
  if (lines2.length < 3 || lines2.length > 6) {
35422
- 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`);
35775
+ 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`);
35423
35776
  }
35424
35777
  const jerv = hubSummaryJervRefusal(lines2);
35425
35778
  if (jerv) throw new Error(jerv);
@@ -35566,7 +35919,7 @@ ${decision.note}`);
35566
35919
  foldNote = await portFoldToDevelopment(deps, ctx, deployModel, tag);
35567
35920
  } catch (e) {
35568
35921
  foldStatus = "failure";
35569
- 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`;
35922
+ 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`;
35570
35923
  }
35571
35924
  await recordHotfixPhase(ledger, anchors, "alignment", hotfixFoldAlignmentEntry(foldStatus, foldNote, mergedSha), "the tagged, released, deployed hotfix");
35572
35925
  const ledgerReport = releaseLedgerReport(await ledger.load(anchors).catch(() => void 0), ledger.path);
@@ -35615,7 +35968,7 @@ async function runHotfixRetryDeployLocked(deps, versionInput) {
35615
35968
  foldNote2 = await portFoldToDevelopment(deps, ctx, deployModel, prior.tag);
35616
35969
  } catch (e) {
35617
35970
  foldStatus2 = "failure";
35618
- 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`;
35971
+ 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`;
35619
35972
  }
35620
35973
  return hotfixFoldAlignmentEntry(foldStatus2, foldNote2, prior.tagSha);
35621
35974
  }
@@ -36135,17 +36488,17 @@ function prHeadBehindBase(input) {
36135
36488
  return /out[- ]of[- ]date|not up to date|up to date with the base|update (?:the|your) branch/i.test(input.message ?? "");
36136
36489
  }
36137
36490
  function prHeadUpdateRemedy(head, base) {
36138
- 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`;
36491
+ 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`;
36139
36492
  }
36140
36493
  function prLandedBranchFinality(prNumber, base) {
36141
- 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`;
36494
+ 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`;
36142
36495
  }
36143
36496
  var PROTECTED_PR_HEAD_BRANCHES = ["main", "master", "development", "rc"];
36144
36497
  function isProtectedPrHead(head) {
36145
36498
  return PROTECTED_PR_HEAD_BRANCHES.includes(head);
36146
36499
  }
36147
36500
  function protectedPrHeadRemedy(head, base) {
36148
- 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`;
36501
+ 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`;
36149
36502
  }
36150
36503
  var PR_HEAD_UPDATE_API_READ_RETRIES = 5;
36151
36504
  var PR_HEAD_UPDATE_API_READ_DELAY_MS = 2e3;
@@ -37247,12 +37600,12 @@ async function resolveBoardAdvanceForPr(prNumber, repoOption) {
37247
37600
  }
37248
37601
  function evaluatePrMergeHousekeeping(report, context, force = false) {
37249
37602
  const scratch = report.scratch;
37250
- const failed = scratch?.status === "failed";
37603
+ const failed2 = scratch?.status === "failed";
37251
37604
  const prunable = scratch?.safeAuto ?? 0;
37252
37605
  const unprunable = scratch?.skipped ?? 0;
37253
- if (!(failed || prunable > 0 || unprunable > 0)) return { blocked: false };
37606
+ if (!(failed2 || prunable > 0 || unprunable > 0)) return { blocked: false };
37254
37607
  if (force) return { blocked: false };
37255
- const detail = failed ? `scratch housekeeping failed${scratch?.error ? `: ${scratch.error}` : ""}` : `${prunable} prunable + ${unprunable} un-prunable scratch item(s) remain after cleanup`;
37608
+ const detail = failed2 ? `scratch housekeeping failed${scratch?.error ? `: ${scratch.error}` : ""}` : `${prunable} prunable + ${unprunable} un-prunable scratch item(s) remain after cleanup`;
37256
37609
  return {
37257
37610
  blocked: true,
37258
37611
  reason: `${context} housekeeping blocked merge: ${detail}. Run host-owned scratch cleanup, then retry \u2014 or re-run \`mmi-cli ${context} --force\` to acknowledge and land anyway. (Kept advisory plans/ scratch never blocks: it is gitignored and can never reach origin.)`
@@ -37635,7 +37988,7 @@ function readRepoVersion() {
37635
37988
  }
37636
37989
  function registerTrainCommands(program3, trainDeps) {
37637
37990
  const train = program3.command("train").description("release-train observability \u2014 track position, version lag, and the shared preflight-and-heal doctor (#2688, #6067)");
37638
- 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) => {
37991
+ 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) => {
37639
37992
  try {
37640
37993
  if (o.suiteSolo !== o.ownerGo || o.lane === "rcand" && o.suiteSolo) {
37641
37994
  return failGraceful("train doctor: a suite-solo verdict needs BOTH --suite-solo --owner-go, and never on the rcand lane");
@@ -37815,7 +38168,7 @@ async function openSuiteCutPinPr(deps, input) {
37815
38168
  }
37816
38169
  async function openSuitePinPr(deps, input) {
37817
38170
  const { repo, tag, stage } = input;
37818
- 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`;
38171
+ 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`;
37819
38172
  let scratch = "";
37820
38173
  try {
37821
38174
  const get = JSON.parse(await deps.run("gh", ["api", `repos/${SUITE_REPO}/contents/seed/modules.json?ref=development`]));
@@ -39874,7 +40227,7 @@ function withRateLimitRetry(client, seams = {}) {
39874
40227
  };
39875
40228
  }
39876
40229
  async function prCreateMergedHeadWarning(input, deps = {}) {
39877
- const guide = "docs/Guides/train-troubleshooting.md#merged-pr-head-reuse";
40230
+ const guide = "https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#merged-pr-head-reuse";
39878
40231
  if (!input.head) {
39879
40232
  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}`;
39880
40233
  }
@@ -39899,6 +40252,11 @@ async function prCreateMergedHeadWarning(input, deps = {}) {
39899
40252
  return `pr create: WARNING \u2014 could not check whether head branch '${input.head}' already landed (${detail}); continuing with PR creation \u2014 ${guide}`;
39900
40253
  }
39901
40254
  }
40255
+ function isIssueMissingError(e) {
40256
+ if (typeof e.status === "number") return e.status === 404;
40257
+ const msg = e instanceof Error ? e.message : String(e);
40258
+ return /HTTP 404|Not Found|\(404\)/i.test(msg);
40259
+ }
39902
40260
  async function prCreateClaimRefusal(body, repoOption, deps = {}) {
39903
40261
  const issues = [...new Set(findClosingMentions(body).map((mention) => mention.issue))];
39904
40262
  if (!issues.length) return void 0;
@@ -39909,7 +40267,14 @@ async function prCreateClaimRefusal(body, repoOption, deps = {}) {
39909
40267
  const requester = `requesting lane: ${actor.surface}/${actor.session}@${actor.host} (session id from ${source})`;
39910
40268
  const checkContest = deps.checkContest ?? checkLaneContest;
39911
40269
  for (const number of issues) {
39912
- const state = await client.rest("GET", `repos/${repo}/issues/${number}`).then((issue) => issue?.state, () => void 0);
40270
+ let state;
40271
+ try {
40272
+ state = (await client.rest("GET", `repos/${repo}/issues/${number}`))?.state;
40273
+ } catch (e) {
40274
+ if (isIssueMissingError(e)) {
40275
+ 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}`;
40276
+ }
40277
+ }
39913
40278
  if (state?.toLowerCase() === "closed") continue;
39914
40279
  const contest = await checkContest(client, { repository: repo, number }, actor);
39915
40280
  if (!contest.contested) continue;
@@ -43402,10 +43767,10 @@ async function announceSummaryRefusal(path2, isHub = false) {
43402
43767
  try {
43403
43768
  lines2 = summaryFileLines(await (0, import_promises8.readFile)(path2, "utf8"));
43404
43769
  } catch (e) {
43405
- return `release: could not read --announce-summary-file ${path2}: ${e.message} \u2014 see docs/Guides/train-troubleshooting.md#announce-summary-missing`;
43770
+ 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`;
43406
43771
  }
43407
43772
  if (lines2.length < 3 || lines2.length > 6) {
43408
- 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`;
43773
+ 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`;
43409
43774
  }
43410
43775
  if (isHub) {
43411
43776
  const jerv = hubSummaryJervRefusal(lines2);
@@ -43824,7 +44189,7 @@ function registerTrainCommandsAndInventory(program3) {
43824
44189
  function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
43825
44190
  async function pinSoloMember(repo, tag) {
43826
44191
  const read = await fetchProjectBySlugChecked(slugOf(repo), registryClientDeps(await loadConfig()));
43827
- 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`);
44192
+ 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`);
43828
44193
  if (!suiteMemberRefusal(repo, read.project, "release")) return void 0;
43829
44194
  const root = (await execFileP("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
43830
44195
  return openSuitePinPr({ root, run: async (file, args, cwd) => (await execFileP(file, args, { cwd, timeout: GIT_TIMEOUT_MS })).stdout }, { repo, tag, stage: "main" });
@@ -44069,7 +44434,7 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
44069
44434
  if (announcesToChannel) {
44070
44435
  if (!o.announceSummaryFile) {
44071
44436
  return fail(
44072
- 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`
44437
+ 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`
44073
44438
  );
44074
44439
  }
44075
44440
  const refusal = await announceSummaryRefusal(o.announceSummaryFile, isHub);
@@ -44252,7 +44617,7 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
44252
44617
  if (o.suiteSolo !== o.ownerGo) return fail("hotfix start: solo suite hotfix requires BOTH --suite-solo --owner-go");
44253
44618
  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);
44254
44619
  });
44255
- 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) => {
44620
+ 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) => {
44256
44621
  if (o.suiteSolo !== o.ownerGo) return fail("hotfix release: solo suite release requires BOTH --suite-solo --owner-go");
44257
44622
  if (o.retryDeploy) {
44258
44623
  if (o.announceSummaryFile || o.carries) {
@@ -44261,7 +44626,7 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
44261
44626
  return runHotfixSub("retry-deploy", () => runHotfixRetryDeploy(trainApplyDeps(), version), o, renderHotfixRetryDeploy);
44262
44627
  }
44263
44628
  return runHotfixSub("release", async () => {
44264
- const result = await runHotfixRelease(trainApplyDeps(), version, { announceSummaryFile: o.announceSummaryFile, carries: o.carries ? [o.carries] : [], suiteSolo: o.suiteSolo });
44629
+ 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) } : {} });
44265
44630
  const suitePin = o.suiteSolo ? await pinSoloMember(result.repo, result.tag) : void 0;
44266
44631
  const projectInfoSync = await runProjectInfoSyncLeg(runProjectInfoSyncCallback, result.repo);
44267
44632
  return { ...result, projectInfoSync, ...o.suiteSolo ? { suiteSolo: true, suitePin } : {} };
@@ -44297,6 +44662,10 @@ async function exec(file, args, cwd, timeout = GH_TIMEOUT) {
44297
44662
  async function gh(args) {
44298
44663
  return exec("gh", args);
44299
44664
  }
44665
+ function rcRunCauseFromLog(log) {
44666
+ const causes = [...new Set([...log.matchAll(/##\[error\](?!Process completed with exit code)([^\r\n]+)/g)].map((m) => m[1].trim()).filter(Boolean))];
44667
+ return causes.length ? `: ${causes.join("; ")}` : "";
44668
+ }
44300
44669
  function suiteTrainIo(root, summaryFile) {
44301
44670
  const recordPath = repoRuntimeStatePath(root, "suite", "cut.json");
44302
44671
  const base = (0, import_node_path42.join)(repoRuntimeStatePath(root, "suite"), "checkouts");
@@ -44410,23 +44779,41 @@ function suiteTrainIo(root, summaryFile) {
44410
44779
  if (result.tag !== expected || result.deployStatus !== "success") throw new Error(`${repo}: release is not green at expected ${expected} (${result.tag}/${result.deployStatus})`);
44411
44780
  }
44412
44781
  });
44413
- const publish = async (repo, tag) => {
44414
- const title = `module-rc-publish ${repo} @ ${tag}`;
44415
- 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"]));
44416
- const existing = (await rows()).filter((r) => r.displayTitle === title).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
44417
- if (existing[0]?.conclusion === "success") return;
44418
- if (existing[0]?.conclusion === "failure") throw new Error(`${repo}: prior module-rc-publish ${tag} failed; inspect the run and retry it explicitly before continuing`);
44782
+ 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"]));
44783
+ const watchRcRun = async (title, fields, reuse) => {
44419
44784
  const since = Date.now() - 1e4;
44420
- if (!existing.length) await gh(["workflow", "run", "module-rc-publish.yml", "--repo", "mutmutco/MMI-Hub", "--field", `repo=${repo}`, "--field", `tag=${tag}`]);
44421
- let candidate = existing[0];
44785
+ if (!reuse) await gh(["workflow", "run", "module-rc-publish.yml", "--repo", "mutmutco/MMI-Hub", ...fields.flatMap((f) => ["--field", f])]);
44786
+ let candidate = reuse;
44422
44787
  for (let i = 0; !candidate && i < 25; i++) {
44423
44788
  await new Promise((resolve7) => setTimeout(resolve7, 1e3));
44424
- candidate = (await rows()).find((r) => r.displayTitle === title && Date.parse(r.createdAt) >= since);
44789
+ candidate = (await rcRuns()).find((r) => r.displayTitle === title && Date.parse(r.createdAt) >= since);
44790
+ }
44791
+ if (!candidate) throw new Error(`workflow dispatch outcome ambiguous for "${title}"; inspect Actions before retrying`);
44792
+ try {
44793
+ await exec("gh", ["run", "watch", String(candidate.databaseId), "--repo", "mutmutco/MMI-Hub", "--exit-status"], void 0, WATCH_TIMEOUT);
44794
+ } catch {
44425
44795
  }
44426
- if (!candidate) throw new Error(`${repo}: workflow dispatch outcome ambiguous for ${tag}; inspect Actions before retrying`);
44427
- await exec("gh", ["run", "watch", String(candidate.databaseId), "--repo", "mutmutco/MMI-Hub", "--exit-status"], void 0, WATCH_TIMEOUT);
44428
44796
  const verdict = JSON.parse(await gh(["run", "view", String(candidate.databaseId), "--repo", "mutmutco/MMI-Hub", "--json", "conclusion"]));
44429
- if (verdict.conclusion !== "success") throw new Error(`${repo}: module rc publish run ${candidate.databaseId} not green`);
44797
+ return { id: candidate.databaseId, conclusion: verdict.conclusion };
44798
+ };
44799
+ const publish = async (repo, tag) => {
44800
+ const title = `module-rc-publish ${repo} @ ${tag}`;
44801
+ const existing = (await rcRuns()).filter((r) => r.displayTitle === title).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
44802
+ if (existing[0]?.conclusion === "success") return;
44803
+ if (existing[0]?.conclusion === "failure") throw new Error(`${repo}: prior module-rc-publish ${tag} failed; inspect the run and retry it explicitly before continuing`);
44804
+ const run = await watchRcRun(title, [`repo=${repo}`, `tag=${tag}`], existing[0]);
44805
+ if (run.conclusion !== "success") throw new Error(`${repo}: module rc publish run ${run.id} not green${await rcRunCause(run.id)}`);
44806
+ };
44807
+ const preflight2 = async (repo) => {
44808
+ const run = await watchRcRun(`module-rc-preflight ${repo}`, [`repo=${repo}`, "preflight=true"], void 0);
44809
+ if (run.conclusion !== "success") throw new Error(`rc serving preflight run ${run.id} failed${await rcRunCause(run.id)}`);
44810
+ };
44811
+ const rcRunCause = async (id) => {
44812
+ try {
44813
+ return rcRunCauseFromLog(await exec("gh", ["run", "view", String(id), "--repo", "mutmutco/MMI-Hub", "--log-failed"]));
44814
+ } catch {
44815
+ return "";
44816
+ }
44430
44817
  };
44431
44818
  const registry2 = async () => {
44432
44819
  const file = await defaultGitHubClient().rest("GET", `repos/${SUITE_REPO}/contents/seed/modules.json?ref=development`);
@@ -44460,6 +44847,7 @@ function suiteTrainIo(root, summaryFile) {
44460
44847
  },
44461
44848
  rcand: runRcand,
44462
44849
  publish,
44850
+ preflight: preflight2,
44463
44851
  pin: (input) => openSuiteCutPinPr({ root, run: (file, args, cwd) => exec(file, args, cwd, GIT_TIMEOUT_MS) }, input),
44464
44852
  verifyLeadCut: async (sha) => {
44465
44853
  const current = await gh(["api", `repos/${SUITE_REPO}/git/ref/heads/development`, "--jq", ".object.sha"]);
@@ -44962,15 +45350,15 @@ function renderReport(checks, opts) {
44962
45350
  }
44963
45351
  function renderTally(checks) {
44964
45352
  const total = checks.length;
44965
- const failed = checks.filter((c) => !c.ok).length;
45353
+ const failed2 = checks.filter((c) => !c.ok).length;
44966
45354
  const unverified = checks.filter((c) => c.ok && c.verified === false).length;
44967
45355
  const warned = checks.filter((c) => c.ok && c.verified !== false && c.warn).length;
44968
- const healthy = total - failed - unverified - warned;
44969
- if (failed === 0 && unverified === 0 && warned === 0) return `\u2713 all ${total} checks healthy`;
45356
+ const healthy = total - failed2 - unverified - warned;
45357
+ if (failed2 === 0 && unverified === 0 && warned === 0) return `\u2713 all ${total} checks healthy`;
44970
45358
  const parts = [`\u2713 ${healthy} verified healthy`];
44971
45359
  if (warned > 0) parts.push(`\u26A0 ${warned} warn`);
44972
45360
  if (unverified > 0) parts.push(`? ${unverified} unverified`);
44973
- if (failed > 0) parts.push(`\u2717 ${failed} failed`);
45361
+ if (failed2 > 0) parts.push(`\u2717 ${failed2} failed`);
44974
45362
  return `${parts.join(" \xB7 ")} \u2014 ${total} checks`;
44975
45363
  }
44976
45364
  function doctorReportExitCode(checks) {
@@ -50522,7 +50910,7 @@ fullTrack.command("readiness <owner/repo>").description("aggregate branch topolo
50522
50910
  console.log(JSON.stringify(report, null, 2));
50523
50911
  if (!report.rcand.canApply) process.exitCode = 1;
50524
50912
  });
50525
- 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) => {
50913
+ 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) => {
50526
50914
  const cfg = await loadConfig();
50527
50915
  let target;
50528
50916
  try {
@@ -50536,6 +50924,10 @@ project.command("set-deploy [owner/repo]").description("patch a tenant DEPLOY ro
50536
50924
  if (v !== "true" && v !== "false") return fail("org project set-deploy: --no-env-file must be true or false");
50537
50925
  noEnvFile = v === "true";
50538
50926
  }
50927
+ let composeProfiles;
50928
+ if (typeof o.composeProfiles === "string") {
50929
+ composeProfiles = o.composeProfiles.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
50930
+ }
50539
50931
  if (noEnvFile !== void 0) {
50540
50932
  const branch = DEPLOY_STAGE_BRANCH[o.stage.trim().toLowerCase()];
50541
50933
  if (branch) {
@@ -50574,7 +50966,9 @@ ${filelessTransitionGuide(target, o.stage)}`
50574
50966
  distributionDomain: o.distributionDomain,
50575
50967
  aliases: o.alias,
50576
50968
  clearAliases: o.clearAliases,
50577
- noEnvFile
50969
+ noEnvFile,
50970
+ composeProfiles,
50971
+ clearComposeProfiles: o.clearComposeProfiles
50578
50972
  });
50579
50973
  if (target.includes("/")) body.repo = target;
50580
50974
  } catch (e) {