@mutmutco/cli 4.3.42 → 4.3.44

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 +155 -51
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -15710,10 +15710,10 @@ var rollout_plan_default = {
15710
15710
  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)."
15711
15711
  },
15712
15712
  baseline: {
15713
- version: "4.3.42",
15714
- tag: "v4.3.42",
15715
- commit: "d890700b4096",
15716
- npm: "@mutmutco/cli@4.3.42"
15713
+ version: "4.3.44",
15714
+ tag: "v4.3.44",
15715
+ commit: "405d6e3283b9",
15716
+ npm: "@mutmutco/cli@4.3.44"
15717
15717
  },
15718
15718
  exitCriterion: "fleet-n-of-n",
15719
15719
  hubOnlyShortcut: "forbidden",
@@ -15730,14 +15730,14 @@ var rollout_plan_default = {
15730
15730
  repo: "mutmutco/mmi-hub",
15731
15731
  role: "canary",
15732
15732
  schedule: "train",
15733
- v3Target: "v4.3.42"
15733
+ v3Target: "v4.3.44"
15734
15734
  }
15735
15735
  ],
15736
15736
  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.",
15737
15737
  rollback: {
15738
15738
  independent: true,
15739
- mechanism: "npm dist-tag latest -> 4.3.42 and redeploy the Hub Lambda from tag v4.3.42 (d890700b4096); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
15740
- v3Target: "v4.3.42 (@mutmutco/cli@4.3.42, tag commit d890700b4096 \u2014 last known-good release carrying the repo-index v4-only contract)"
15739
+ mechanism: "npm dist-tag latest -> 4.3.44 and redeploy the Hub Lambda from tag v4.3.44 (405d6e3283b9); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
15740
+ v3Target: "v4.3.44 (@mutmutco/cli@4.3.44, tag commit 405d6e3283b9 \u2014 last known-good release carrying the repo-index v4-only contract)"
15741
15741
  }
15742
15742
  },
15743
15743
  {
@@ -29159,7 +29159,7 @@ function registerDeployCommands(program3) {
29159
29159
  }
29160
29160
 
29161
29161
  // src/project-set.ts
29162
- var UNSET_KEYS = ["oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "tenantTasks", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "dsManifestPath", "fofuEnabled", "consumesDesignSystem", "ci", "requiredChecks", "requiredCheckBranches", "ciExemptReason", "gate", "seedCanary"];
29162
+ var UNSET_KEYS = ["oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "tenantTasks", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "releaseChannel", "releaseLanguage", "dsManifestPath", "fofuEnabled", "consumesDesignSystem", "ci", "requiredChecks", "requiredCheckBranches", "ciExemptReason", "gate", "seedCanary"];
29163
29163
  var UNSET_KEY_SET = new Set(UNSET_KEYS);
29164
29164
  var RUNTIME_SECRET_STAGES = ["dev", "rc", "main"];
29165
29165
  var SECRET_CONSUMERS = ["runtime", "build", "lambda", "actions", "agent", "box"];
@@ -29443,6 +29443,20 @@ function parsePublishDirVar(raw) {
29443
29443
  }
29444
29444
  return v;
29445
29445
  }
29446
+ function parseReleaseChannelVar(raw) {
29447
+ const v = raw.trim();
29448
+ if (!/^[CG][A-Z0-9]{8,}$/.test(v)) {
29449
+ throw new Error("org project set: releaseChannel must be a Slack channel id (e.g. C0BE0BRGAT0); --unset releaseChannel disables release notes");
29450
+ }
29451
+ return v;
29452
+ }
29453
+ function parseReleaseLanguageVar(raw) {
29454
+ const v = raw.trim();
29455
+ if (!/^[a-z]{2}$/.test(v)) {
29456
+ throw new Error("org project set: releaseLanguage must be a 2-letter lowercase language code (e.g. tr); --unset releaseLanguage returns to en");
29457
+ }
29458
+ return v;
29459
+ }
29446
29460
  function parseDsManifestPathVar(raw) {
29447
29461
  const v = raw.trim();
29448
29462
  if (v === "" || !/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/.test(v) || /(^|\/)\.\.(\/|$)/.test(v)) {
@@ -29559,6 +29573,8 @@ var SETTABLE_VAR_KEYS = [
29559
29573
  "oauth",
29560
29574
  "publishRequired",
29561
29575
  "publishDir",
29576
+ "releaseChannel",
29577
+ "releaseLanguage",
29562
29578
  "dsManifestPath",
29563
29579
  "fofuEnabled",
29564
29580
  "consumesDesignSystem",
@@ -29586,6 +29602,8 @@ var SETTABLE_VAR_HINTS = {
29586
29602
  projectNumber: "numeric",
29587
29603
  publishRequired: "true|false",
29588
29604
  publishDir: "relative subpath, e.g. packages/ui",
29605
+ releaseChannel: "Slack channel id, e.g. C0BE0BRGAT0",
29606
+ releaseLanguage: "2-letter language code for release notes, e.g. tr (default en)",
29589
29607
  dsManifestPath: "relative path to a package.json, e.g. web/package.json",
29590
29608
  fofuEnabled: "true|false",
29591
29609
  consumesDesignSystem: '"fofu"',
@@ -29698,6 +29716,10 @@ function buildProjectSetPatch(input) {
29698
29716
  patch[key] = parseConsumesDesignSystemVar(raw);
29699
29717
  } else if (key === "publishDir") {
29700
29718
  patch[key] = parsePublishDirVar(raw);
29719
+ } else if (key === "releaseChannel") {
29720
+ patch[key] = parseReleaseChannelVar(raw);
29721
+ } else if (key === "releaseLanguage") {
29722
+ patch[key] = parseReleaseLanguageVar(raw);
29701
29723
  } else if (key === "dsManifestPath") {
29702
29724
  patch[key] = parseDsManifestPathVar(raw);
29703
29725
  } else if (key === "ci") {
@@ -42357,7 +42379,7 @@ var SSM_REGION = "eu-central-1";
42357
42379
  var SSM_TOKEN_PARAM = "/mmi-future/_org/slack/SLACK_BOT_TOKEN";
42358
42380
  var SSM_CHANNEL_PARAM = "/mmi-future/_org/slack/SLACK_ALERTS_CHANNEL";
42359
42381
  var SLACK_TIMEOUT_MS = 1e4;
42360
- async function ssmParameter(deps, name, decrypt) {
42382
+ async function readSsmParameter(deps, name, decrypt) {
42361
42383
  const args = [
42362
42384
  "ssm",
42363
42385
  "get-parameter",
@@ -42375,18 +42397,24 @@ async function ssmParameter(deps, name, decrypt) {
42375
42397
  if (!value || value === "None") throw new Error(`SSM parameter ${name} is empty`);
42376
42398
  return value;
42377
42399
  }
42378
- async function postToAlertsChannel(deps, text) {
42379
- const token = await ssmParameter(deps, SSM_TOKEN_PARAM, true);
42380
- const channel = await ssmParameter(deps, SSM_CHANNEL_PARAM, false);
42400
+ async function postToChannel(deps, channel, text) {
42401
+ const resolved = channel.trim();
42402
+ if (!resolved || resolved === "None") throw new Error("no Slack channel resolved");
42403
+ const token = await readSsmParameter(deps, SSM_TOKEN_PARAM, true);
42381
42404
  const fetchImpl = deps.fetchImpl ?? fetch;
42382
42405
  const res = await fetchImpl("https://slack.com/api/chat.postMessage", {
42383
42406
  method: "POST",
42384
42407
  headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json; charset=utf-8" },
42385
- body: JSON.stringify({ channel, text, unfurl_links: false }),
42408
+ body: JSON.stringify({ channel: resolved, text, unfurl_links: false }),
42386
42409
  signal: AbortSignal.timeout(SLACK_TIMEOUT_MS)
42387
42410
  });
42388
42411
  const json = await res.json().catch(() => ({}));
42389
42412
  if (!json.ok) throw new Error(`slack postMessage failed: ${json.error ?? `http ${res.status}`}`);
42413
+ return { status: "posted", note: "posted" };
42414
+ }
42415
+ async function postToAlertsChannel(deps, text) {
42416
+ const channel = await readSsmParameter(deps, SSM_CHANNEL_PARAM, false);
42417
+ await postToChannel(deps, channel, text);
42390
42418
  return { status: "posted", note: "posted to the alerts channel" };
42391
42419
  }
42392
42420
 
@@ -42402,7 +42430,7 @@ function neutralizeHubProductBrands(text) {
42402
42430
  function escapeMrkdwn(text) {
42403
42431
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
42404
42432
  }
42405
- function bulletToLine(raw) {
42433
+ function bulletToLine(raw, neutralize) {
42406
42434
  const m = /^[*-]\s+(.*)$/.exec(raw.trim());
42407
42435
  if (!m) return null;
42408
42436
  let text = m[1].trim();
@@ -42417,9 +42445,9 @@ function bulletToLine(raw) {
42417
42445
  text = text.slice(0, tail.index).trim();
42418
42446
  }
42419
42447
  if (!text) return null;
42420
- return `\u2022 ${escapeMrkdwn(neutralizeHubProductBrands(text))}${link}`;
42448
+ return `\u2022 ${escapeMrkdwn(neutralize ? neutralizeHubProductBrands(text) : text)}${link}`;
42421
42449
  }
42422
- function curateReleaseNotes(notesMd, maxBullets = MAX_BULLETS) {
42450
+ function curateReleaseNotes(notesMd, maxBullets = MAX_BULLETS, neutralize = true) {
42423
42451
  const lines2 = [];
42424
42452
  let bullets = 0;
42425
42453
  let dropped = 0;
@@ -42437,7 +42465,7 @@ function curateReleaseNotes(notesMd, maxBullets = MAX_BULLETS) {
42437
42465
  continue;
42438
42466
  }
42439
42467
  if (/new contributors/i.test(section)) continue;
42440
- const line = bulletToLine(raw);
42468
+ const line = bulletToLine(raw, neutralize);
42441
42469
  if (!line) continue;
42442
42470
  if (bullets >= maxBullets) {
42443
42471
  dropped += 1;
@@ -42453,31 +42481,49 @@ function curateReleaseNotes(notesMd, maxBullets = MAX_BULLETS) {
42453
42481
  if (dropped > 0) lines2.push(`\u2026and ${dropped} more`);
42454
42482
  return lines2;
42455
42483
  }
42484
+ var ANNOUNCE_FRAMES = {
42485
+ en: { released: "released", notes: "Release notes" },
42486
+ tr: { released: "yay\u0131nda", notes: "S\xFCr\xFCm notlar\u0131" }
42487
+ };
42456
42488
  function formatAnnouncement(args) {
42457
42489
  const name = args.repo.split("/")[1] ?? args.repo;
42490
+ const code = args.language ?? "en";
42491
+ const frame = Object.hasOwn(ANNOUNCE_FRAMES, code) ? ANNOUNCE_FRAMES[code] : ANNOUNCE_FRAMES.en;
42458
42492
  return [
42459
- `:rocket: *${name} ${args.tag} released*`,
42493
+ `:rocket: *${name} ${args.tag} ${frame.released}*`,
42460
42494
  "",
42461
42495
  ...args.lines,
42462
42496
  "",
42463
- `<${args.releaseUrl}|Release notes>`
42497
+ `<${args.releaseUrl}|${frame.notes}>`
42464
42498
  ].join("\n");
42465
42499
  }
42466
- function summaryFileLines(content) {
42467
- return content.split("\n").map((l) => l.trim()).filter(Boolean).slice(0, MAX_SUMMARY_LINES).map((l) => escapeMrkdwn(neutralizeHubProductBrands(l.replace(/^[•*-]\s*/, "")))).filter(Boolean).map((l) => `\u2022 ${l}`);
42500
+ function summaryFileLines(content, neutralize = true) {
42501
+ return content.split("\n").map((l) => l.trim()).filter(Boolean).slice(0, MAX_SUMMARY_LINES).map((l) => {
42502
+ const bare = l.replace(/^[•*-]\s*/, "");
42503
+ return escapeMrkdwn(neutralize ? neutralizeHubProductBrands(bare) : bare);
42504
+ }).filter(Boolean).map((l) => `\u2022 ${l}`);
42505
+ }
42506
+ async function resolveAnnounceTarget(deps, repo) {
42507
+ if (repo.toLowerCase() === ANNOUNCE_REPO.toLowerCase()) return { kind: "alerts" };
42508
+ const target = deps.fetchReleaseTarget ? await deps.fetchReleaseTarget(slugOf(repo)) : { channel: null, language: "en" };
42509
+ return target.channel ? { kind: "channel", channel: target.channel, language: target.language } : { kind: "none" };
42510
+ }
42511
+ function missingChannelNote(repo) {
42512
+ const slug = slugOf(repo);
42513
+ return `no releaseChannel configured for ${slug} \u2014 enable release notes with \`mmi-cli oracle org project set --var releaseChannel=<slack channel id> ${repo}\``;
42468
42514
  }
42469
42515
  async function announceRelease(deps, args) {
42470
- if (args.repo !== ANNOUNCE_REPO) {
42471
- return { status: "skipped", note: `announce is Hub-only \u2014 ${args.repo} skipped` };
42472
- }
42516
+ const neutralize = args.repo.toLowerCase() === ANNOUNCE_REPO.toLowerCase();
42473
42517
  try {
42518
+ const target = await resolveAnnounceTarget(deps, args.repo);
42519
+ if (target.kind === "none") return { status: "skipped", note: missingChannelNote(args.repo) };
42474
42520
  const viewRaw = await deps.run("gh", ["release", "view", args.tag, "--repo", args.repo, "--json", "body,url"]);
42475
42521
  const view = JSON.parse(viewRaw);
42476
42522
  const releaseUrl = view.url ?? `https://github.com/${args.repo}/releases/tag/${args.tag}`;
42477
42523
  let lines2 = [];
42478
42524
  if (args.summaryFile) {
42479
42525
  if (!deps.readFile) throw new Error("summary file given but deps.readFile is missing");
42480
- lines2 = summaryFileLines(await deps.readFile(args.summaryFile));
42526
+ lines2 = summaryFileLines(await deps.readFile(args.summaryFile), neutralize);
42481
42527
  if (deps.removeFile) {
42482
42528
  try {
42483
42529
  await deps.removeFile(args.summaryFile);
@@ -42485,11 +42531,26 @@ async function announceRelease(deps, args) {
42485
42531
  }
42486
42532
  }
42487
42533
  }
42488
- if (lines2.length === 0) lines2 = curateReleaseNotes(view.body ?? "");
42534
+ if (lines2.length === 0) {
42535
+ if (target.kind !== "alerts") {
42536
+ return { status: "skipped", note: "no --announce-summary-file given \u2014 raw release notes are never posted to a project channel" };
42537
+ }
42538
+ lines2 = curateReleaseNotes(view.body ?? "", MAX_BULLETS, neutralize);
42539
+ }
42489
42540
  if (lines2.length === 0) lines2 = ["\u2022 maintenance release \u2014 see the notes for details"];
42490
- const text = formatAnnouncement({ repo: args.repo, tag: args.tag, lines: lines2, releaseUrl });
42491
- await postToAlertsChannel(deps, text);
42492
- return { status: "announced", note: `announced ${args.tag} to the alerts channel` };
42541
+ const text = formatAnnouncement({
42542
+ repo: args.repo,
42543
+ tag: args.tag,
42544
+ lines: lines2,
42545
+ releaseUrl,
42546
+ ...target.kind === "channel" ? { language: target.language } : {}
42547
+ });
42548
+ if (target.kind === "alerts") {
42549
+ await postToAlertsChannel(deps, text);
42550
+ return { status: "announced", note: `announced ${args.tag} to the alerts channel` };
42551
+ }
42552
+ await postToChannel(deps, target.channel, text);
42553
+ return { status: "announced", note: `announced ${args.tag} to the project release channel` };
42493
42554
  } catch (e) {
42494
42555
  return { status: "failed", note: `announce failed (release unaffected): ${e.message}` };
42495
42556
  }
@@ -43842,6 +43903,18 @@ var NODE_PREPARE_TIMEOUT_MS = 10 * 6e4;
43842
43903
  var NODE_VERIFY_TIMEOUT_MS = 5 * 6e4;
43843
43904
  var NPM_TRAIN_TIMEOUT_MS = 6e4;
43844
43905
  var JERV_GATEWAY_DEPLOY_TIMEOUT_MS = 20 * 6e4;
43906
+ async function announceSummaryRefusal(path2) {
43907
+ let lines2;
43908
+ try {
43909
+ lines2 = summaryFileLines(await (0, import_promises10.readFile)(path2, "utf8"));
43910
+ } catch (e) {
43911
+ return `release: could not read --announce-summary-file ${path2}: ${e.message} \u2014 see docs/Guides/train-troubleshooting.md#announce-summary-missing`;
43912
+ }
43913
+ if (lines2.length < 3 || lines2.length > 6) {
43914
+ 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`;
43915
+ }
43916
+ return null;
43917
+ }
43845
43918
  function trainApplyDeps() {
43846
43919
  return {
43847
43920
  run: async (file, args) => {
@@ -43904,11 +43977,25 @@ function trainApplyDeps() {
43904
43977
  // train lands the back-merge itself instead of leaving a manual step. Same enqueue path `pr land` uses,
43905
43978
  // but method `--merge` (NOT squash): the misalignment guard needs the merge parentage to survive.
43906
43979
  mergeAuto: (prNumber, repo) => ghMergeAutoEnqueue(prNumber, repo, "--merge"),
43907
- // Slack release announcement (#883): Hub-only + best-effort inside announceRelease itself.
43980
+ // Slack release announcement (#883/#6521/#6523): the org alerts channel for the Hub, the project's own
43981
+ // registry releaseChannel META for a product repo — best-effort inside announceRelease itself.
43908
43982
  announce: (args) => announceRelease({
43909
43983
  run: async (file, cmdArgs) => (await execFileP(file, cmdArgs, { timeout: GH_TRAIN_TIMEOUT_MS })).stdout,
43910
43984
  readFile: (path2) => (0, import_promises10.readFile)(path2, "utf8"),
43911
- removeFile: (path2) => (0, import_promises10.unlink)(path2)
43985
+ removeFile: (path2) => (0, import_promises10.unlink)(path2),
43986
+ // #6521/#6523: the project's own release channel AND releaseLanguage, read from registry META in ONE
43987
+ // checked read. Same shape as fetchEdgeDomains below: a FAILED read refuses by name so the announce
43988
+ // note reports a failed read, never a project that configured nothing.
43989
+ fetchReleaseTarget: async (slug) => {
43990
+ const read = await fetchProjectBySlugChecked(slug, registryClientDeps(await loadConfig()));
43991
+ if (!read.ok) {
43992
+ throw new Error(`release announcement: Hub registry read FAILED for ${slug} (${read.error}) \u2014 releaseChannel is UNREAD, not unset`);
43993
+ }
43994
+ return {
43995
+ channel: read.project?.releaseChannel?.trim() || null,
43996
+ language: read.project?.releaseLanguage?.trim() || "en"
43997
+ };
43998
+ }
43912
43999
  }, args),
43913
44000
  // #4713 (I/O-boundary census): `null` used to mean BOTH "this project configures no edge domains"
43914
44001
  // (a real answer) and "the registry read missed" — so a release verdict printed an environments block
@@ -44168,7 +44255,7 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
44168
44255
  for (const commandName of ["rcand", "release"]) {
44169
44256
  const trainCmd = program3.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--watch", "block on the deploy/publish workflow runs and report their outcomes").option("--apply", "execute the guarded master-only train after explicit approval").option("--resume", commandName === "rcand" ? "finish a candidate whose immutable public rc tag passed policy but origin/rc was not pushed (#3881)" : "finish a partial release or its protected post-release alignment without re-cutting, republishing, or redeploying (#3851/#3885)").option("--out <path>", "write the terminal result (--apply, --resume, --abort, --retry-publish) to this file as UTF-8 (no BOM) instead of stdout \u2014 the shell-free receipt path (#5983/#6068)");
44170
44257
  const RELEASE_ONLY_FLAGS = [
44171
- { flags: "--announce-summary-file <path>", description: "agent-curated 3-6 line Hub Slack summary; required for a new MMI-Hub --apply (#883/#3901)" },
44258
+ { flags: "--announce-summary-file <path>", description: "agent-curated 3-6 line release Slack summary; required for a new MMI-Hub --apply (#883/#3901) and for a product repo whose registry META sets `releaseChannel` (#6523), in its `releaseLanguage`; nothing is posted to a project channel without it" },
44172
44259
  { flags: "--ack <shas>", description: "comma-separated dev shas a human verified are in the candidate, overriding the hotfix-coverage guard for a conflicted port whose -x trailer was lost (#958)" },
44173
44260
  { flags: "--dev", description: "full-track repos release development -> main directly, skipping rc (refuses if rc carries content not in development; no-op on direct-track repos) (#1062)" },
44174
44261
  { flags: "--abort", description: "with --apply, delete only a proven unpublished failed release candidate on any registered deploy model and restore local main for a same-version recut (#3944/#5650/#5657)" },
@@ -44194,7 +44281,7 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
44194
44281
  return fail(`${commandName}: --dev applies only to release \u2014 it ships development -> main skipping rc, which rcand cannot do. Run: mmi-cli devops release --dev`);
44195
44282
  }
44196
44283
  if (o.announceSummaryFile && commandName !== "release") {
44197
- return fail(`${commandName}: --announce-summary-file applies only to release \u2014 rcand posts no Hub Slack announcement. Run: mmi-cli devops release --announce-summary-file <path>`);
44284
+ return fail(`${commandName}: --announce-summary-file applies only to release \u2014 rcand posts no release announcement. Run: mmi-cli devops release --announce-summary-file <path>`);
44198
44285
  }
44199
44286
  if (o.abort && commandName !== "release") {
44200
44287
  return fail(`${commandName}: --abort applies only to release \u2014 it rolls back a proven unpublished release tag. Run: mmi-cli devops release --abort --apply`);
@@ -44286,22 +44373,35 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
44286
44373
  const guard = planTrainApplyRepoGuard(o.repo, await resolveRepo(), rerun);
44287
44374
  if (!guard.ok) return fail(`${commandName}: ${guard.message}`);
44288
44375
  }
44289
- if (o.apply && commandName === "release" && (await resolveRepo())?.toLowerCase() === ANNOUNCE_REPO.toLowerCase()) {
44290
- if (!o.announceSummaryFile) {
44291
- return fail(
44292
- "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"
44293
- );
44294
- }
44295
- let summaryLines;
44296
- try {
44297
- summaryLines = summaryFileLines(await (0, import_promises10.readFile)(o.announceSummaryFile, "utf8"));
44298
- } catch (e) {
44299
- return fail(`release: could not read --announce-summary-file ${o.announceSummaryFile}: ${e.message} \u2014 see docs/Guides/train-troubleshooting.md#announce-summary-missing`);
44376
+ if (o.apply && commandName === "release") {
44377
+ const applyingRepo = await resolveRepo();
44378
+ const slug = applyingRepo ? slugOf(applyingRepo) : "";
44379
+ const isHub = applyingRepo?.toLowerCase() === ANNOUNCE_REPO.toLowerCase();
44380
+ let announcesToChannel = isHub;
44381
+ let releaseLanguage2 = "en";
44382
+ if (!isHub && slug) {
44383
+ let read;
44384
+ try {
44385
+ read = await fetchProjectBySlugChecked(slug, registryClientDeps(await loadConfig()));
44386
+ } catch (e) {
44387
+ read = { ok: false, error: e.message };
44388
+ }
44389
+ if (!read.ok) {
44390
+ return fail(
44391
+ `release: Hub registry read failed for ${slug} (${read.error}) \u2014 whether ${slug} announces to a project release channel is UNREAD, so the --announce-summary-file requirement is unproven. Likely transient (cold start, network, or auth blip); retry shortly.`
44392
+ );
44393
+ }
44394
+ announcesToChannel = Boolean(read.project?.releaseChannel?.trim());
44395
+ releaseLanguage2 = read.project?.releaseLanguage?.trim() || "en";
44300
44396
  }
44301
- if (summaryLines.length < 3 || summaryLines.length > 6) {
44302
- return fail(
44303
- `release: --announce-summary-file must contain 3-6 non-empty LLM-curated lines (found ${summaryLines.length}) \u2014 see docs/Guides/train-troubleshooting.md#announce-summary-missing`
44304
- );
44397
+ if (announcesToChannel) {
44398
+ if (!o.announceSummaryFile) {
44399
+ return fail(
44400
+ 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`
44401
+ );
44402
+ }
44403
+ const refusal = await announceSummaryRefusal(o.announceSummaryFile);
44404
+ if (refusal) return fail(refusal);
44305
44405
  }
44306
44406
  }
44307
44407
  if (o.apply) {
@@ -44333,6 +44433,8 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
44333
44433
  const repo = o.repo ?? await resolveRepo();
44334
44434
  const targets = commandName === "rcand" && !o.repo ? await resolveRcandPlanTargets() : void 0;
44335
44435
  let releaseTrack;
44436
+ let releaseChannel = null;
44437
+ let releaseLanguage = "en";
44336
44438
  if (repo) {
44337
44439
  let read;
44338
44440
  try {
@@ -44347,6 +44449,8 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
44347
44449
  }
44348
44450
  const raw = read.project?.releaseTrack;
44349
44451
  if (isReleaseTrack(raw)) releaseTrack = raw;
44452
+ releaseChannel = read.project?.releaseChannel?.trim() || null;
44453
+ releaseLanguage = read.project?.releaseLanguage?.trim() || "en";
44350
44454
  }
44351
44455
  const steps = trainPlan(commandName, { ...targets ?? {}, repo, dev: o.dev, releaseTrack });
44352
44456
  let doctor;
@@ -44358,7 +44462,7 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
44358
44462
  }
44359
44463
  }
44360
44464
  console.log(
44361
- o.json ? JSON.stringify({ command: commandName, ...targets ?? {}, repo, steps, ...doctor ? { doctor } : {} }, null, 2) : [renderSteps(`mmi-cli ${commandName}: dry-run plan`, steps), ...doctor ? ["", formatTrainDoctor(doctor)] : []].join("\n")
44465
+ o.json ? JSON.stringify({ command: commandName, ...targets ?? {}, repo, steps, ...commandName === "release" ? { releaseChannel, releaseLanguage } : {}, ...doctor ? { doctor } : {} }, null, 2) : [renderSteps(`mmi-cli ${commandName}: dry-run plan`, steps), ...doctor ? ["", formatTrainDoctor(doctor)] : []].join("\n")
44362
44466
  );
44363
44467
  });
44364
44468
  }
@@ -44446,7 +44550,7 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
44446
44550
  console.log(o.json ? JSON.stringify({ command: "hotfix", steps }, null, 2) : renderSteps("mmi-cli devops hotfix: dry-run plan", steps));
44447
44551
  });
44448
44552
  hotfixCmd.command("start").description("cherry-pick one or more merged development PRs (or SHAs) onto hotfix/vX.Y.Z from origin/main, bump the distribution, open the main-base PR").requiredOption("--from <pr#|sha[,pr#|sha...]>", "merged development PR number(s) or commit SHA(s) to cherry-pick, in pick order \u2014 one hotfix cycle carries as many fixes as you name (#4411)").option("--json", "machine-readable output").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 (o) => runHotfixSub("start", () => runHotfixStart(trainApplyDeps(), { from: o.from }), o, renderHotfixStart));
44449
- 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 Hub Slack summary; required for a NEW MMI-Hub hotfix Release (#883/#3901/#6068)").option("--carries <pr#|sha[,pr#|sha...]>", "declared fix target(s) this hotfix must carry; each must be proven present before tagging (#3056)").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) => runHotfixSub("release", async () => {
44553
+ 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("--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) => runHotfixSub("release", async () => {
44450
44554
  const result = await runHotfixRelease(trainApplyDeps(), version, { announceSummaryFile: o.announceSummaryFile, carries: o.carries ? [o.carries] : [] });
44451
44555
  const postReleaseJervDoctor = await runPostReleaseJervDoctor(result.repo);
44452
44556
  const projectInfoSync = await runProjectInfoSyncLeg(runProjectInfoSyncCallback, result.repo);
@@ -45278,7 +45382,7 @@ projectDeploy.command("doctor").description("read-only estate scan for duplicate
45278
45382
  if (report.error && report.status !== 403) return failGraceful(`org project deploy doctor: ${report.error}`);
45279
45383
  if (!report.ok && !report.error) process.exitCode = 1;
45280
45384
  });
45281
- project.command("set [owner/repo]").description("upsert project META (idempotent merge; master-gated)").addOption(new Option("--class <class>", "deployable | content").choices(["deployable", "content"])).addOption(new Option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (capability shape)`).choices([...PROJECT_TYPES])).addOption(new Option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path; none means no Hub deploy registration)`).choices([...DEPLOY_MODELS])).addOption(new Option("--release-track <track>", `${RELEASE_TRACKS.join(" | ")} (branch topology; direct skips rc)`).choices([...RELEASE_TRACKS])).option("--var <KEY=VALUE...>", settableVarHelp()).option("--set <KEY=VALUE...>", "alias of --var (one KEY=VALUE per flag; repeat the flag to set several)").option("--secrets-file <path>", 'read the #2244 secrets catalog map (JSON) from a file and merge it per entry into the existing catalog (clear all with --unset secrets). SHAPE: a JSON object keyed by env name, each entry {"key":"UPPER_SNAKE" (repeat the env name), "purpose":"<non-empty>", "group":"<e.g. auth|database>", "owner":"<github login>", "stages":[] (empty = the one stageless shared value; else any of dev/rc/main), "consumers":["runtime"|"box"|?], "provider":"<optional>"}').option("--unset <KEY...>", "META field to remove (repeatable): oauth, requiredRuntimeSecrets, requiredBuildSecrets, secrets, edgeDomains, requiredGcpApis, publishRequired").option("--clear-web-profile", "remove web-only registry fields (oauth, edgeDomains) for non-web/content projects").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
45385
+ project.command("set [owner/repo]").description("upsert project META (idempotent merge; master-gated)").addOption(new Option("--class <class>", "deployable | content").choices(["deployable", "content"])).addOption(new Option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (capability shape)`).choices([...PROJECT_TYPES])).addOption(new Option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path; none means no Hub deploy registration)`).choices([...DEPLOY_MODELS])).addOption(new Option("--release-track <track>", `${RELEASE_TRACKS.join(" | ")} (branch topology; direct skips rc)`).choices([...RELEASE_TRACKS])).option("--var <KEY=VALUE...>", settableVarHelp()).option("--set <KEY=VALUE...>", "alias of --var (one KEY=VALUE per flag; repeat the flag to set several)").option("--secrets-file <path>", 'read the #2244 secrets catalog map (JSON) from a file and merge it per entry into the existing catalog (clear all with --unset secrets). SHAPE: a JSON object keyed by env name, each entry {"key":"UPPER_SNAKE" (repeat the env name), "purpose":"<non-empty>", "group":"<e.g. auth|database>", "owner":"<github login>", "stages":[] (empty = the one stageless shared value; else any of dev/rc/main), "consumers":["runtime"|"box"|?], "provider":"<optional>"}').option("--unset <KEY...>", "META field to remove (repeatable): oauth, requiredRuntimeSecrets, requiredBuildSecrets, secrets, edgeDomains, requiredGcpApis, publishRequired, releaseChannel, releaseLanguage").option("--clear-web-profile", "remove web-only registry fields (oauth, edgeDomains) for non-web/content projects").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
45282
45386
  const cfg = await loadConfig();
45283
45387
  let target;
45284
45388
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "4.3.42",
3
+ "version": "4.3.44",
4
4
  "description": "MMI Future CLI — the org dev toolbox and shared cross-IDE engine for every registry-declared MMI coding surface.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",