@mutmutco/cli 4.0.15 → 4.0.17

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 +317 -23
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -5035,16 +5035,26 @@ function isAgentScratchPath(path2) {
5035
5035
  return /^tmp_[^/]+$/.test(normalized);
5036
5036
  }
5037
5037
  var PUSH_WALL_AGENT_CONFIG_DIRS = [".claude/", ".cursor/rules/", ".codex/", ".agents/"];
5038
+ var LIVE_SESSION_DIRS = [".jerv/", ".pi/"];
5039
+ function pathUnderDirPrefix(path2, dir) {
5040
+ return path2 === dir || path2 === dir.slice(0, -1) || path2.startsWith(dir) || path2.includes(`/${dir}`);
5041
+ }
5038
5042
  function isUncommittableAgentConfigPath(path2) {
5039
5043
  const normalized = path2.replace(/\\/g, "/").trim();
5040
- return PUSH_WALL_AGENT_CONFIG_DIRS.some((dir) => normalized === dir || normalized === dir.slice(0, -1) || normalized.startsWith(dir) || normalized.includes(`/${dir}`));
5044
+ return PUSH_WALL_AGENT_CONFIG_DIRS.some((dir) => pathUnderDirPrefix(normalized, dir));
5045
+ }
5046
+ function isLiveSessionPath(path2) {
5047
+ const normalized = path2.replace(/\\/g, "/").trim();
5048
+ return LIVE_SESSION_DIRS.some((dir) => pathUnderDirPrefix(normalized, dir));
5041
5049
  }
5042
5050
  function splitPorcelainLine(line) {
5043
5051
  return { status: line.slice(0, 2), path: line.slice(3).split(" -> ")[0]?.trim() ?? "" };
5044
5052
  }
5045
5053
  function isExemptPorcelainLine(status, path2) {
5046
5054
  if (isAgentScratchPath(path2)) return true;
5047
- return status === "??" && isUncommittableAgentConfigPath(path2);
5055
+ if (status !== "??") return false;
5056
+ if (isUncommittableAgentConfigPath(path2)) return true;
5057
+ return isLiveSessionPath(path2);
5048
5058
  }
5049
5059
  function porcelainBlockingPaths(porcelain) {
5050
5060
  const blocking = [];
@@ -5059,12 +5069,21 @@ function porcelainHasBlockingChanges(porcelain) {
5059
5069
  return porcelainBlockingPaths(porcelain).length > 0;
5060
5070
  }
5061
5071
  var BLOCKING_PATH_PREVIEW_LIMIT = 10;
5072
+ function porcelainHasBlockingUntracked(porcelain) {
5073
+ for (const line of porcelain.split("\n")) {
5074
+ if (!line.trim()) continue;
5075
+ const { status, path: path2 } = splitPorcelainLine(line);
5076
+ if (path2 !== "" && status === "??" && !isExemptPorcelainLine(status, path2)) return true;
5077
+ }
5078
+ return false;
5079
+ }
5062
5080
  function describeBlockingPaths(porcelain) {
5063
5081
  const paths = porcelainBlockingPaths(porcelain);
5064
5082
  if (paths.length === 0) return "";
5065
5083
  const shown = paths.slice(0, BLOCKING_PATH_PREVIEW_LIMIT);
5066
5084
  const rest = paths.length - shown.length;
5067
- return ` \u2014 blocking: ${shown.join(", ")}${rest > 0 ? `, \u2026 and ${rest} more` : ""}`;
5085
+ const hint = porcelainHasBlockingUntracked(porcelain) ? " (untracked: gitignore it, or add it to .git/info/exclude)" : "";
5086
+ return ` \u2014 blocking: ${shown.join(", ")}${rest > 0 ? `, \u2026 and ${rest} more` : ""}${hint}`;
5068
5087
  }
5069
5088
 
5070
5089
  // src/local-train-sync.ts
@@ -5614,6 +5633,12 @@ var PR_CHECKS_FAILURE_CONFIRMATIONS = 2;
5614
5633
  var PR_CHECKS_RATELIMIT_FLOOR = 50;
5615
5634
  var PR_CHECKS_RATELIMIT_JITTER_MAX_MS = 5e3;
5616
5635
  var NO_CI_LIVE_CHECKS_CONTRADICTION_REASON = "ci:none contradicted by live check runs on PR head";
5636
+ var PR_CHECKS_ZERO_RUNS_GRACE_MS = 2 * 6e4;
5637
+ var PR_CHECKS_ZERO_RUNS_CONFIRMATIONS = 2;
5638
+ function zeroRunsDeliveryMessage(headSha) {
5639
+ const tip = headSha ? ` (${headSha.slice(0, 7)})` : "";
5640
+ return `GitHub delivered ZERO workflow runs for this PR head${tip} \u2014 not "checks still pending". Sibling PRs on the same repo can still be healthy while this head is silent. Close/reopen is insufficient. Recourse: push an empty commit on the same branch (\`git commit --allow-empty -m "ci: re-trigger gates" && git push\`) or re-branch onto a new head and open a fresh PR. See docs/Guides/gh-runner-runbook.md \xA7 Zero workflow runs on a PR head.`;
5641
+ }
5617
5642
  function decidePrMergeNoCiGuard(checks, policyReason = "registry META ci:none") {
5618
5643
  if (checks === "no-checks-reported") return { action: "proceed" };
5619
5644
  const staleNote = `merge CI policy says no-ci (${policyReason}), but live PR checks exist`;
@@ -5661,6 +5686,7 @@ async function waitForPrChecks(deps) {
5661
5686
  let lastDetail = "pending";
5662
5687
  let successStreak = 0;
5663
5688
  let failureStreak = 0;
5689
+ let zeroRunsStreak = 0;
5664
5690
  report("starting");
5665
5691
  while (now() < deadline) {
5666
5692
  if (deps.pollRateLimit) {
@@ -5694,6 +5720,7 @@ async function waitForPrChecks(deps) {
5694
5720
  report(state);
5695
5721
  if (state !== "success") successStreak = 0;
5696
5722
  if (state !== "failure") failureStreak = 0;
5723
+ if (state !== "no-checks-reported") zeroRunsStreak = 0;
5697
5724
  if (state === "failure") {
5698
5725
  failureStreak += 1;
5699
5726
  if (failureStreak >= PR_CHECKS_FAILURE_CONFIRMATIONS) {
@@ -5726,6 +5753,30 @@ async function waitForPrChecks(deps) {
5726
5753
  }
5727
5754
  if (state === "no-checks-reported") {
5728
5755
  lastDetail = "no-checks-reported (waiting for workflow to queue)";
5756
+ const elapsed = now() - started;
5757
+ if (deps.pollHeadWorkflowRunCount && elapsed >= PR_CHECKS_ZERO_RUNS_GRACE_MS) {
5758
+ const count = await deps.pollHeadWorkflowRunCount().catch(() => null);
5759
+ if (count === 0) {
5760
+ zeroRunsStreak += 1;
5761
+ if (zeroRunsStreak >= PR_CHECKS_ZERO_RUNS_CONFIRMATIONS) {
5762
+ return {
5763
+ policy,
5764
+ status: "failure",
5765
+ reason: zeroRunsDeliveryMessage(),
5766
+ detail: "zero-runs",
5767
+ waitedMs: elapsed
5768
+ };
5769
+ }
5770
+ lastDetail = "no-checks-reported (confirming zero workflow runs on head)";
5771
+ } else {
5772
+ zeroRunsStreak = 0;
5773
+ if (count !== null && count > 0) {
5774
+ lastDetail = `no-checks-reported (${count} workflow run(s) on head; waiting for check-runs)`;
5775
+ }
5776
+ }
5777
+ } else {
5778
+ zeroRunsStreak = 0;
5779
+ }
5729
5780
  await deps.sleep(PR_CHECKS_POLL_MS);
5730
5781
  continue;
5731
5782
  }
@@ -10832,10 +10883,10 @@ var rollout_plan_default = {
10832
10883
  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)."
10833
10884
  },
10834
10885
  baseline: {
10835
- version: "4.0.15",
10836
- tag: "v4.0.15",
10837
- commit: "86f45af99b96",
10838
- npm: "@mutmutco/cli@4.0.15"
10886
+ version: "4.0.17",
10887
+ tag: "v4.0.17",
10888
+ commit: "df1e866df4f8",
10889
+ npm: "@mutmutco/cli@4.0.17"
10839
10890
  },
10840
10891
  exitCriterion: "fleet-n-of-n",
10841
10892
  hubOnlyShortcut: "forbidden",
@@ -10852,14 +10903,14 @@ var rollout_plan_default = {
10852
10903
  repo: "mutmutco/mmi-hub",
10853
10904
  role: "canary",
10854
10905
  schedule: "train",
10855
- v3Target: "v4.0.15"
10906
+ v3Target: "v4.0.17"
10856
10907
  }
10857
10908
  ],
10858
10909
  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.",
10859
10910
  rollback: {
10860
10911
  independent: true,
10861
- mechanism: "npm dist-tag latest -> 4.0.15 and redeploy the Hub Lambda from tag v4.0.15 (86f45af99b96); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
10862
- v3Target: "v4.0.15 (@mutmutco/cli@4.0.15, tag commit 86f45af99b96 \u2014 last known-good release carrying the repo-index v4-only contract)"
10912
+ mechanism: "npm dist-tag latest -> 4.0.17 and redeploy the Hub Lambda from tag v4.0.17 (df1e866df4f8); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
10913
+ v3Target: "v4.0.17 (@mutmutco/cli@4.0.17, tag commit df1e866df4f8 \u2014 last known-good release carrying the repo-index v4-only contract)"
10863
10914
  }
10864
10915
  },
10865
10916
  {
@@ -13926,8 +13977,39 @@ async function isStrayUnreleasedTag(deps, tag, remoteSha, repo) {
13926
13977
  }
13927
13978
  return deps.run("gh", ["release", "view", tag, "--repo", repo, "--json", "tagName"]).then(() => false).catch((e) => /not found|HTTP 404/i.test(e instanceof Error ? e.message : String(e)));
13928
13979
  }
13980
+ function cmpReleaseTag(a, b) {
13981
+ const parse = (t) => {
13982
+ const m = /^v(\d+)\.(\d+)\.(\d+)$/.exec(t.trim());
13983
+ return m ? { major: +m[1], minor: +m[2], patch: +m[3] } : null;
13984
+ };
13985
+ const left = parse(a);
13986
+ const right = parse(b);
13987
+ if (!left || !right) throw new Error(`cmpReleaseTag expects vX.Y.Z tags, got ${a} vs ${b}`);
13988
+ return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
13989
+ }
13990
+ async function listRemoteReleaseTags(deps) {
13991
+ const out = await runGitRemoteRead(deps, ["ls-remote", "--tags", "origin", "refs/tags/v*"]);
13992
+ const tags = /* @__PURE__ */ new Set();
13993
+ for (const line of clean2(out).split("\n")) {
13994
+ const ref = line.trim().split(/\s+/)[1] ?? "";
13995
+ const tag = ref.replace(/^refs\/tags\//, "").replace(/\^\{\}$/, "");
13996
+ if (/^v\d+\.\d+\.\d+$/.test(tag)) tags.add(tag);
13997
+ }
13998
+ return [...tags].sort(cmpReleaseTag);
13999
+ }
14000
+ async function assertComputedReleaseTagStillNext(deps, tag) {
14001
+ if (!/^v\d+\.\d+\.\d+$/.test(tag)) return;
14002
+ const remote = await listRemoteReleaseTags(deps);
14003
+ const newer = remote.filter((t) => cmpReleaseTag(t, tag) > 0);
14004
+ if (newer.length === 0) return;
14005
+ const latest = newer[newer.length - 1];
14006
+ throw new Error(
14007
+ `origin already has release tag ${latest} ahead of this run's computed ${tag} \u2014 another /release landed mid-flight. Refusing to mint a skip-version or hole-fill tag. Stop; let the other train finish (or abort its stray tag/Release with the authorized human's go if verify failed), then rerun mmi-cli devops release --apply so next-version re-derives from the settled tags. Do not run two /release trains on the same repo concurrently.`
14008
+ );
14009
+ }
13929
14010
  async function ensureTagPushed(deps, tag, sha, probed, releaseRepo) {
13930
- const remoteSha = probed ? probed.remoteSha : await probeRemoteTag(deps, tag);
14011
+ const remoteSha = await probeRemoteTag(deps, tag);
14012
+ const planRemoteSha = probed?.remoteSha ?? "";
13931
14013
  let localSha = "";
13932
14014
  try {
13933
14015
  localSha = clean2(await deps.run("git", ["rev-parse", "--verify", `refs/tags/${tag}^{commit}`]));
@@ -13936,6 +14018,11 @@ async function ensureTagPushed(deps, tag, sha, probed, releaseRepo) {
13936
14018
  if (remoteSha) {
13937
14019
  if (remoteSha !== sha) {
13938
14020
  const mismatch = `tag ${tag} already exists on origin at ${remoteSha}, but this run intends ${sha}`;
14021
+ if (!planRemoteSha && releaseRepo) {
14022
+ throw new Error(
14023
+ `${mismatch}. The tag appeared on origin while this train was promoting \u2014 another /release claimed this version mid-flight. Refusing to mint a competing or skip-version tag. Stop; let the other train finish, or if its tag/Release is a stray (publish verify failed, tagged SHA lacks the fold commit), delete that stray tag and its GitHub Release with the authorized human's go, then rerun mmi-cli devops release --apply. Do not start a second /release while one is still folding or tagging.`
14024
+ );
14025
+ }
13939
14026
  if (releaseRepo && await isStrayUnreleasedTag(deps, tag, remoteSha, releaseRepo)) {
13940
14027
  throw new Error(
13941
14028
  `${mismatch}. The existing tag is not reachable from origin/main and has no GitHub Release \u2014 it was pushed outside the train, not by a completed release. Sanctioned recovery: delete the stray tag (git push origin --delete ${tag}; git tag -d ${tag} if it exists locally), check the repo's Actions for any workflow the stray tag already triggered, then rerun mmi-cli devops release --apply (if a publish already ran off the stray tag, mint the next version instead of reusing this one). Never complete the release by hand \u2014 a manual GitHub Release or branch push bypasses the train and leaves main behind.`
@@ -14610,6 +14697,7 @@ async function completeMainRelease(deps, ctx, meta, deployModel, watch, options,
14610
14697
  }
14611
14698
  let tagPush;
14612
14699
  try {
14700
+ await assertComputedReleaseTagStillNext(deps, tag);
14613
14701
  tagPush = await ensureTagPushed(deps, tag, releaseSha, tagProbe, ctx.repo);
14614
14702
  } catch (e) {
14615
14703
  throw await recoverFailedFold(deps, e, startBranch, preFold.mainSha, resumeCommand);
@@ -14622,7 +14710,11 @@ async function completeMainRelease(deps, ctx, meta, deployModel, watch, options,
14622
14710
  throw partialTrainRecoveryError(e, { repo: ctx.repo, tag, stage: "main", deployModel });
14623
14711
  }
14624
14712
  if (trueMergeGateNote) checks = `${trueMergeGateNote}; ${checks}`;
14625
- await runGitPush(deps, ["push", "origin", "main"]);
14713
+ try {
14714
+ await runGitPush(deps, ["push", "origin", "main"]);
14715
+ } catch (e) {
14716
+ throw partialTrainRecoveryError(e, { repo: ctx.repo, tag, stage: "main", deployModel });
14717
+ }
14626
14718
  const releaseUrl = clean2(await deps.run("gh", ["release", "create", tag, "--target", "main", "--generate-notes", "--latest", "--repo", ctx.repo])) || void 0;
14627
14719
  await verifyPublishedRelease(deps, ctx.repo, tag, "main", releaseSha);
14628
14720
  const announceNote = deps.announce ? (await deps.announce({ repo: ctx.repo, tag, summaryFile: options.announceSummaryFile })).note : void 0;
@@ -15008,7 +15100,11 @@ async function runTrainApplyPipeline(mode, input) {
15008
15100
  throw partialTrainRecoveryError(e, { repo: ctx.repo, tag: tag2, stage: "rc", deployModel: deployModel2 });
15009
15101
  }
15010
15102
  const autoRunSince = (deps.now ?? Date.now)();
15011
- await runGitPush(deps, ["push", "origin", "rc"]);
15103
+ try {
15104
+ await runGitPush(deps, ["push", "origin", "rc"]);
15105
+ } catch (e) {
15106
+ throw partialTrainRecoveryError(e, { repo: ctx.repo, tag: tag2, stage: "rc", deployModel: deployModel2 });
15107
+ }
15012
15108
  const d2 = await dispatchDeploy(deps, ctx, "rc", "rc", deployModel2, watch, autoRunSince, rcSha);
15013
15109
  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 };
15014
15110
  }
@@ -17194,7 +17290,12 @@ async function runStage(config = {}, opts = {}) {
17194
17290
  const ranBuild = Boolean(build);
17195
17291
  try {
17196
17292
  await ensureStageRuntimeEnv(config, opts, cwd);
17197
- if (build) await shell(sub(build), cwd, timeoutMs, stageProcessEnv(stagePort, extraEnv));
17293
+ if (build) {
17294
+ await shell(sub(build), cwd, timeoutMs, {
17295
+ ...stageProcessEnv(stagePort, extraEnv),
17296
+ ...opts.buildEnvMerge ?? {}
17297
+ });
17298
+ }
17198
17299
  } catch (e) {
17199
17300
  (0, import_node_fs16.rmSync)(statePath2, { force: true });
17200
17301
  if (globalStatePath && globalStatePath !== statePath2) (0, import_node_fs16.rmSync)(globalStatePath, { force: true });
@@ -29221,6 +29322,85 @@ async function runStageLiveDown(deps, t) {
29221
29322
  };
29222
29323
  }
29223
29324
 
29325
+ // src/stage-build-secrets.ts
29326
+ var GITHUB_PACKAGES_TOKEN_REF = "@github-packages-token";
29327
+ var ENV_ID_RE = /^[A-Z_][A-Z0-9_]*$/;
29328
+ var BARE_VAULT_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
29329
+ var CROSS_SLUG_REF_RE = /^(?:mm-fofu|_org\/[a-z0-9][a-z0-9-]*):[A-Z_][A-Z0-9_]*$/;
29330
+ function parseStageBuildSecretEntry(entry) {
29331
+ const trimmed = entry.trim();
29332
+ if (!trimmed) return null;
29333
+ const eq = trimmed.indexOf("=");
29334
+ if (eq === -1) {
29335
+ if (!BARE_VAULT_KEY_RE.test(trimmed) && !CROSS_SLUG_REF_RE.test(trimmed)) return null;
29336
+ const envKey2 = trimmed.includes(":") ? trimmed.slice(trimmed.lastIndexOf(":") + 1) : trimmed;
29337
+ return { envKey: envKey2, ref: trimmed };
29338
+ }
29339
+ const envKey = trimmed.slice(0, eq);
29340
+ const ref = trimmed.slice(eq + 1);
29341
+ if (!ENV_ID_RE.test(envKey) || !ref) return null;
29342
+ if (ref === GITHUB_PACKAGES_TOKEN_REF) return { envKey, ref };
29343
+ if (!BARE_VAULT_KEY_RE.test(ref) && !CROSS_SLUG_REF_RE.test(ref)) return null;
29344
+ return { envKey, ref };
29345
+ }
29346
+ async function resolveStageBuildSecrets(input) {
29347
+ const entries = input.requiredBuildSecrets;
29348
+ if (!entries?.length) return {};
29349
+ const env = input.env ?? process.env;
29350
+ const out = {};
29351
+ const missing = [];
29352
+ for (const raw of entries) {
29353
+ const parsed = parseStageBuildSecretEntry(raw);
29354
+ if (!parsed) {
29355
+ missing.push(`${raw} (malformed requiredBuildSecrets entry)`);
29356
+ continue;
29357
+ }
29358
+ const { envKey, ref } = parsed;
29359
+ const fromEnv = env[envKey];
29360
+ if (typeof fromEnv === "string" && fromEnv.length > 0) {
29361
+ out[envKey] = fromEnv;
29362
+ continue;
29363
+ }
29364
+ if (ref === GITHUB_PACKAGES_TOKEN_REF) {
29365
+ const fromVault = await input.fetchVault(envKey);
29366
+ if (fromVault) {
29367
+ out[envKey] = fromVault;
29368
+ continue;
29369
+ }
29370
+ missing.push(
29371
+ `${envKey}=${GITHUB_PACKAGES_TOKEN_REF} (central deploy mints this; local /stage needs process env ${envKey} or a stageless project vault secret ${envKey} \u2014 a GitHub PAT with read:packages for npm.pkg.github.com; declare+set via vault secrets, never commit the value)`
29372
+ );
29373
+ continue;
29374
+ }
29375
+ const vault = await fetchVaultRef(input.fetchVault, ref);
29376
+ if (vault) {
29377
+ out[envKey] = vault;
29378
+ continue;
29379
+ }
29380
+ missing.push(
29381
+ `${envKey}=${ref} (not in process env and vault read returned nothing \u2014 declare+set the stageless secret, or export ${envKey} before mmi-cli stage run --apply)`
29382
+ );
29383
+ }
29384
+ if (missing.length) {
29385
+ throw new Error(
29386
+ `stage build secrets unresolved: ${missing.join("; ")}. Compose BuildKit mounts (e.g. NODE_AUTH_TOKEN) read the build process env only.`
29387
+ );
29388
+ }
29389
+ return out;
29390
+ }
29391
+ async function fetchVaultRef(fetchVault, ref) {
29392
+ const colon = ref.indexOf(":");
29393
+ if (colon === -1) return fetchVault(ref);
29394
+ const prefix = ref.slice(0, colon);
29395
+ const key = ref.slice(colon + 1);
29396
+ if (prefix === "mm-fofu") return fetchVault(key, { slug: "mm-fofu" });
29397
+ if (prefix.startsWith("_org/")) {
29398
+ const provider = prefix.slice("_org/".length);
29399
+ return fetchVault(`${provider}/${key}`, { slug: "_org" });
29400
+ }
29401
+ return null;
29402
+ }
29403
+
29224
29404
  // src/stage-commands.ts
29225
29405
  function registerStageCommands(program3) {
29226
29406
  function stagePortFromArgv() {
@@ -29270,6 +29450,30 @@ function registerStageCommands(program3) {
29270
29450
  }
29271
29451
  return Object.keys(merge).length ? merge : void 0;
29272
29452
  }
29453
+ async function fetchStageBuildEnvMerge() {
29454
+ const cfg = await loadConfig();
29455
+ if (!cfg.sagaApiUrl) return void 0;
29456
+ const read = await fetchProjectBySlugChecked(await repoSlug(), registryClientDeps(cfg)).catch(() => null);
29457
+ if (!read?.ok || !read.project) return void 0;
29458
+ const required = read.project.requiredBuildSecrets;
29459
+ if (!required?.length) return void 0;
29460
+ const d = makeSecretsDeps(cfg);
29461
+ const merge = await resolveStageBuildSecrets({
29462
+ requiredBuildSecrets: required,
29463
+ fetchVault: (key, opts) => fetchSecretValue(d, key, opts ?? {})
29464
+ });
29465
+ return Object.keys(merge).length ? merge : void 0;
29466
+ }
29467
+ async function stageVaultOpts() {
29468
+ const [vaultEnvMerge, buildEnvMerge] = await Promise.all([
29469
+ fetchStageVaultEnvMerge(),
29470
+ fetchStageBuildEnvMerge()
29471
+ ]);
29472
+ return {
29473
+ ...vaultEnvMerge ? { vaultEnvMerge } : {},
29474
+ ...buildEnvMerge ? { buildEnvMerge } : {}
29475
+ };
29476
+ }
29273
29477
  function stageStepsFor(res, stops = true) {
29274
29478
  if (res.source === "derived" && res.derived) return derivedStagePlan(res.derived, shellFor(), stops);
29275
29479
  return [{ label: `no local stage to run \u2014 ${res.gap ?? "stage config gap"}` }];
@@ -29367,7 +29571,8 @@ function registerStageCommands(program3) {
29367
29571
  const cfg = res.derived.config;
29368
29572
  const hold = stageKeepAlive();
29369
29573
  try {
29370
- const result = await runStage(cfg, stageScopedRunOpts({ timeoutMs: o.timeoutMs }));
29574
+ const vaultOpts = await stageVaultOpts();
29575
+ const result = await runStage(cfg, { ...stageScopedRunOpts({ timeoutMs: o.timeoutMs }), ...vaultOpts });
29371
29576
  const reportUrl = reportedStageUrl(res, result);
29372
29577
  const url = reportUrl ? ` \u2014 ${reportUrl}` : "";
29373
29578
  return printLine(o.json ? JSON.stringify({ ...result, source: res.source, url: reportUrl }) : `mmi-cli stage: ${result.message}${url}`);
@@ -29437,14 +29642,14 @@ function registerStageCommands(program3) {
29437
29642
  }
29438
29643
  if (res.source === "none") return failGraceful(`stage run: ${res.gap}`);
29439
29644
  const cfg = res.derived.config;
29440
- const vaultEnvMerge = await fetchStageVaultEnvMerge();
29645
+ const vaultOpts = await stageVaultOpts();
29441
29646
  try {
29442
29647
  const hold = stageKeepAlive();
29443
29648
  let printed = false;
29444
29649
  try {
29445
29650
  const result = await runStage(cfg, {
29446
29651
  ...stageScopedRunOpts({ timeoutMs: o.timeoutMs, allowStaleEnv: o.allowStaleEnv }),
29447
- vaultEnvMerge,
29652
+ ...vaultOpts,
29448
29653
  onReady: (ready) => {
29449
29654
  const reportUrl = reportedStageUrl(res, ready);
29450
29655
  const url = reportUrl ? ` \u2014 ${reportUrl}` : "";
@@ -29626,6 +29831,14 @@ function ghPrMergeLocalBranchDeleteWarning(message) {
29626
29831
  function mergeAutoRejectedPrAlreadyClean(message) {
29627
29832
  return /clean status \(enablePullRequestAutoMerge\)|is in clean status/i.test(message);
29628
29833
  }
29834
+ function mergeMethodFromParentCount(parentCount) {
29835
+ if (parentCount >= 2) return "merge";
29836
+ return void 0;
29837
+ }
29838
+ function reportedMergeMethod(input) {
29839
+ if (!input.alreadyMerged) return input.requestedMethod;
29840
+ return input.observedMethod;
29841
+ }
29629
29842
 
29630
29843
  // src/merge-cleanup.ts
29631
29844
  var GC_GH_TIMEOUT_MS = 2e4;
@@ -30264,6 +30477,25 @@ async function pollRestPrMergeable(prNumber, repo, gh = defaultGhApi) {
30264
30477
  return "UNKNOWN";
30265
30478
  }
30266
30479
  }
30480
+ async function countHeadWorkflowRuns(headSha, repo, gh = defaultGhApi) {
30481
+ if (!headSha) return { state: "failed", error: "no head SHA" };
30482
+ let raw;
30483
+ try {
30484
+ raw = await gh([`repos/${repo}/actions/runs?head_sha=${encodeURIComponent(headSha)}&per_page=1`]);
30485
+ } catch (e) {
30486
+ return { state: "failed", error: `actions/runs read failed for ${headSha.slice(0, 7)} on ${repo}: ${readErrorText(e)}` };
30487
+ }
30488
+ let count;
30489
+ try {
30490
+ count = JSON.parse(raw).total_count;
30491
+ } catch (e) {
30492
+ return { state: "failed", error: `actions/runs payload for ${headSha.slice(0, 7)} was not JSON: ${readErrorText(e)}` };
30493
+ }
30494
+ if (typeof count !== "number" || !Number.isFinite(count) || count < 0) {
30495
+ return { state: "failed", error: `actions/runs payload for ${headSha.slice(0, 7)} carried no usable total_count` };
30496
+ }
30497
+ return { state: "ok", count };
30498
+ }
30267
30499
  async function pollRestPrMerged(prNumber, repo, gh = defaultGhApi) {
30268
30500
  try {
30269
30501
  return (await fetchRestPrSnapshot(prNumber, repo, gh)).merged ? { state: "merged" } : { state: "open" };
@@ -33854,7 +34086,27 @@ var surfaces_default = {
33854
34086
  kind: "npm-pack",
33855
34087
  packagePath: "updater"
33856
34088
  },
33857
- publishVisibility: "public"
34089
+ publishVisibility: "public",
34090
+ prepare: [
34091
+ {
34092
+ command: "npm",
34093
+ args: [
34094
+ "--prefix",
34095
+ "updater",
34096
+ "run",
34097
+ "build"
34098
+ ],
34099
+ inputs: [
34100
+ "updater/src",
34101
+ "updater/build.mjs",
34102
+ "updater/package.json",
34103
+ "updater/tsconfig.json"
34104
+ ],
34105
+ outputs: [
34106
+ "updater/dist/index.cjs"
34107
+ ]
34108
+ }
34109
+ ]
33858
34110
  },
33859
34111
  {
33860
34112
  id: "mmi-cli-lock",
@@ -37891,12 +38143,29 @@ async function waitLoopDiagnosis(label, prNumber, repo) {
37891
38143
  }
37892
38144
  return null;
37893
38145
  }
38146
+ async function waitLoopHeadWorkflowRunCount(label, prNumber, repo) {
38147
+ let snapshot;
38148
+ try {
38149
+ snapshot = await fetchRestPrSnapshot(prNumber, repo);
38150
+ } catch (e) {
38151
+ console.warn(
38152
+ `${label}: zero-runs probe FAILED (pulls read: ${e?.message ?? e}) \u2014 not treating as delivery failure this cycle`
38153
+ );
38154
+ return null;
38155
+ }
38156
+ const read = await countHeadWorkflowRuns(snapshot.headSha, repo);
38157
+ if (read.state === "ok") return read.count;
38158
+ console.warn(
38159
+ `${label}: zero-runs probe FAILED (${read.error}) \u2014 not treating as delivery failure this cycle`
38160
+ );
38161
+ return null;
38162
+ }
37894
38163
  pr.command("ci-policy").description("report merge CI policy: wait-for-checks vs no-ci (for grind/build agents)").option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the current checkout)").action(async (o) => {
37895
38164
  const result = await resolveMergeCiPolicyForCheckout(o.repo);
37896
38165
  if (o.json) return printLine(JSON.stringify(result));
37897
38166
  printLine(`merge CI policy: ${result.policy} (${result.reason})`);
37898
38167
  });
37899
- pr.command("checks-wait <number>").description(`bounded wait for ALL checks on the PR head, required or not (#5336); skips immediately on no-ci repos (#1432), fails immediately on a CONFLICTING PR \u2014 GitHub never queues checks for one (#2970). REST-only polling with a pool floor (#3024). Default budget ${PR_CHECKS_TIMEOUT_MS / 6e4}m; --timeout raises it. Exit 1 = a check FAILED or the PR is CONFLICTING, exit ${PR_CHECKS_TIMEOUT_EXIT_CODE} = the wait window expired or the API pool ran dry (re-arm)`).option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--timeout <minutes>", `wait budget in minutes (default ${PR_CHECKS_TIMEOUT_MS / 6e4}) \u2014 raise it for serial self-hosted e2e queues`).action(async (number, o) => {
38168
+ pr.command("checks-wait <number>").description(`bounded wait for ALL checks on the PR head, required or not (#5336); skips immediately on no-ci repos (#1432), fails immediately on a CONFLICTING PR \u2014 GitHub never queues checks for one (#2970), and fails early on a confirmed zero-workflow-runs head instead of burning the full budget as pending (#5400). REST-only polling with a pool floor (#3024). Default budget ${PR_CHECKS_TIMEOUT_MS / 6e4}m; --timeout raises it. Exit 1 = a check FAILED, the PR is CONFLICTING, or GitHub delivered zero runs; exit ${PR_CHECKS_TIMEOUT_EXIT_CODE} = the wait window expired or the API pool ran dry (re-arm)`).option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--timeout <minutes>", `wait budget in minutes (default ${PR_CHECKS_TIMEOUT_MS / 6e4}) \u2014 raise it for serial self-hosted e2e queues`).action(async (number, o) => {
37900
38169
  let timeoutMs;
37901
38170
  if (o.timeout !== void 0) {
37902
38171
  const minutes = Number(o.timeout);
@@ -37922,6 +38191,8 @@ pr.command("checks-wait <number>").description(`bounded wait for ALL checks on t
37922
38191
  // #3388: on a confirmed red, read the failing runs' annotations so a wall-clock budget kill stops
37923
38192
  // reading as "your tests failed". One call per failing run, only at the verdict.
37924
38193
  diagnoseFailure: () => waitLoopDiagnosis("pr checks-wait", number, repo),
38194
+ // #5400: after grace, name "GitHub delivered zero runs" instead of burning the full budget as pending.
38195
+ pollHeadWorkflowRunCount: () => waitLoopHeadWorkflowRunCount("pr checks-wait", number, repo),
37925
38196
  baseBranch,
37926
38197
  sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)),
37927
38198
  log: (message) => console.warn(message),
@@ -37936,7 +38207,7 @@ pr.command("checks-wait <number>").description(`bounded wait for ALL checks on t
37936
38207
  } else if (result.status === "timeout") {
37937
38208
  const stuckQueuing = /no-checks-reported/i.test(result.detail ?? "");
37938
38209
  printLine(
37939
- `pr checks-wait: timeout \u2014 waited ${Math.round((result.waitedMs ?? 0) / 6e4)}m, last state: ${result.detail ?? "pending"}. No check failed; re-run to keep waiting, or pass --timeout <minutes>.` + (stuckQueuing ? " Tip: jobs never appeared \u2014 mmi-live may be saturated or Actions may be failing before checkout; check runner health (docs/Guides/gh-runner-runbook.md) and re-run the workflow." : "")
38210
+ `pr checks-wait: timeout \u2014 waited ${Math.round((result.waitedMs ?? 0) / 6e4)}m, last state: ${result.detail ?? "pending"}. No check failed; re-run to keep waiting, or pass --timeout <minutes>.` + (stuckQueuing ? " Tip: check-runs never appeared \u2014 if `gh api repos/<owner>/<repo>/actions/runs?head_sha=<sha>` shows total_count=0, see docs/Guides/gh-runner-runbook.md \xA7 Zero workflow runs; otherwise mmi-live may be saturated or Actions may be failing before checkout (docs/Guides/gh-runner-runbook.md)." : "")
37940
38211
  );
37941
38212
  } else if (result.status === "rate-limited") {
37942
38213
  printLine(`pr checks-wait: rate-limited \u2014 ${result.reason ?? "REST pool below floor"}. No check failed; re-run after the pool resets.`);
@@ -37944,6 +38215,8 @@ pr.command("checks-wait <number>").description(`bounded wait for ALL checks on t
37944
38215
  printLine(`pr checks-wait: failure (runner infrastructure, NOT a test failure) \u2014 ${result.reason}`);
37945
38216
  } else if (result.detail === "stale-head") {
37946
38217
  printLine(`pr checks-wait: failure (stale PR head, NOT a test failure) \u2014 ${result.reason}`);
38218
+ } else if (result.detail === "zero-runs") {
38219
+ printLine(`pr checks-wait: failure (zero workflow runs delivered, NOT pending checks) \u2014 ${result.reason}`);
37947
38220
  } else printLine(`pr checks-wait: ${result.status}${result.reason ? ` \u2014 ${result.reason}` : ""}${result.detail ? ` (${result.detail})` : ""}`);
37948
38221
  if (result.status === "failure" || result.status === "conflicting") process.exitCode = 1;
37949
38222
  if (result.status === "timeout" || result.status === "rate-limited") process.exitCode = PR_CHECKS_TIMEOUT_EXIT_CODE;
@@ -38014,6 +38287,8 @@ pr.command("land <number>").description("agent merge path (#1440): train probe \
38014
38287
  // #3388: `pr land` is the batch path — the one most likely to self-DOS the shared runner and
38015
38288
  // then read its own wall-clock kill as a broken diff.
38016
38289
  diagnoseFailure: () => waitLoopDiagnosis("pr land", prNumber, repo),
38290
+ // #5400: same zero-runs delivery probe as checks-wait — do not burn the land budget on silence.
38291
+ pollHeadWorkflowRunCount: () => waitLoopHeadWorkflowRunCount("pr land", prNumber, repo),
38017
38292
  baseBranch: "development",
38018
38293
  sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)),
38019
38294
  log: (message) => console.warn(message),
@@ -38136,6 +38411,7 @@ jsonParity(pr.command("merge <number>").description("merge a PR (squash by defau
38136
38411
  pollMergeable: () => pollRestPrMergeable(number, repo),
38137
38412
  pollRateLimit: () => waitLoopCorePool("pr merge --wait"),
38138
38413
  diagnoseFailure: () => waitLoopDiagnosis("pr merge --wait", number, repo),
38414
+ pollHeadWorkflowRunCount: () => waitLoopHeadWorkflowRunCount("pr merge --wait", number, repo),
38139
38415
  baseBranch,
38140
38416
  sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)),
38141
38417
  log: (message) => console.warn(message),
@@ -38255,11 +38531,27 @@ jsonParity(pr.command("merge <number>").description("merge a PR (squash by defau
38255
38531
  invalidateStatuslineBoardCache();
38256
38532
  const devDeploy = devDeployPlan.applicable && devDeployDeps && remoteNotAttemptedReason !== "pr-already-merged" ? await dispatchDevDeploy(repoForPostCleanup, devDeployDeps) : void 0;
38257
38533
  const devDeployManual = devDeployPlan.applicable ? devDeployPlan.manualPointer : void 0;
38534
+ let observedMethod;
38535
+ if (remoteNotAttemptedReason === "pr-already-merged" && repoForPostCleanup) {
38536
+ try {
38537
+ const mergeSha = (await defaultGitHubClient().rest("GET", `repos/${repoForPostCleanup}/pulls/${number}`)).merge_commit_sha;
38538
+ if (mergeSha) {
38539
+ const commit = await defaultGitHubClient().rest("GET", `repos/${repoForPostCleanup}/commits/${mergeSha}`);
38540
+ observedMethod = mergeMethodFromParentCount(Array.isArray(commit.parents) ? commit.parents.length : 0);
38541
+ }
38542
+ } catch {
38543
+ }
38544
+ }
38545
+ const methodField = reportedMergeMethod({
38546
+ requestedMethod: method.slice(2),
38547
+ alreadyMerged: remoteNotAttemptedReason === "pr-already-merged",
38548
+ observedMethod
38549
+ });
38258
38550
  console.log(JSON.stringify({
38259
38551
  mergeStatus: "merged",
38260
38552
  merged: number,
38261
38553
  branch: headRef,
38262
- method: method.slice(2),
38554
+ ...methodField ? { method: methodField } : {},
38263
38555
  remoteBranch,
38264
38556
  housekeeping,
38265
38557
  // `boardAdvance` keeps its published array shape; `boardAdvanceStatus` is the field a caller reads to
@@ -38297,11 +38589,12 @@ registerStageCommands(program2);
38297
38589
  var GH_TRAIN_TIMEOUT_MS = 3e4;
38298
38590
  var GH_RUN_WATCH_TIMEOUT_MS = 20 * 6e4;
38299
38591
  var NODE_PREPARE_TIMEOUT_MS = 10 * 6e4;
38592
+ var NODE_VERIFY_TIMEOUT_MS = 5 * 6e4;
38300
38593
  var NPM_TRAIN_TIMEOUT_MS = 6e4;
38301
38594
  function trainApplyDeps() {
38302
38595
  return {
38303
38596
  run: async (file, args) => {
38304
- const timeout = file === "node" && args[1] === "prepare" ? NODE_PREPARE_TIMEOUT_MS : file === "npm" ? NPM_TRAIN_TIMEOUT_MS : file !== "gh" ? GIT_TIMEOUT_MS : args[0] === "run" && args[1] === "watch" ? GH_RUN_WATCH_TIMEOUT_MS : GH_TRAIN_TIMEOUT_MS;
38597
+ const timeout = file === "node" && args[1] === "prepare" ? NODE_PREPARE_TIMEOUT_MS : file === "node" && args[1] === "verify" ? NODE_VERIFY_TIMEOUT_MS : file === "npm" ? NPM_TRAIN_TIMEOUT_MS : file !== "gh" ? GIT_TIMEOUT_MS : args[0] === "run" && args[1] === "watch" ? GH_RUN_WATCH_TIMEOUT_MS : GH_TRAIN_TIMEOUT_MS;
38305
38598
  try {
38306
38599
  return isWin2 && file === "npm" ? (await execFileP2("cmd.exe", ["/c", "npm", ...args], { timeout })).stdout : (await execFileP2(file, args, { timeout })).stdout;
38307
38600
  } catch (e) {
@@ -38640,7 +38933,8 @@ for (const commandName of ["rcand", "release"]) {
38640
38933
  applyTrainFollowUpExit(followUpStatus);
38641
38934
  return;
38642
38935
  } catch (e) {
38643
- return failGraceful(`${commandName}: ${e.message}`);
38936
+ process.exitCode = 1;
38937
+ return await failGraceful(`${commandName}: ${e.message}`);
38644
38938
  }
38645
38939
  }
38646
38940
  const repo = o.repo ?? await resolveRepo();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "4.0.15",
3
+ "version": "4.0.17",
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",