@mutmutco/cli 4.4.17 → 4.5.1

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 +773 -145
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -757,7 +757,7 @@ var init_house_map = __esm({
757
757
  "use strict";
758
758
  HOUSE_ROOTS = ["oracle", "harbour", "devops", "vault", "learning"];
759
759
  COMPAT_SHIM_DEATH_WAVE = "Wave 3";
760
- FLAT_ALIAS_CUT_NOTE = `flat Wave 0 alias \u2014 removed in ${COMPAT_SHIM_DEATH_WAVE} (#4316, #4438); only the canonical house-prefixed path parses`;
760
+ FLAT_ALIAS_CUT_NOTE = `flat Wave 0 alias \u2014 no longer advertised since ${COMPAT_SHIM_DEATH_WAVE} (#4316, #4438); it still runs with a notice naming the canonical house-prefixed path (#7034)`;
761
761
  HOUSE_QUESTIONS = {
762
762
  oracle: "Is it live?",
763
763
  harbour: "Is it bounded?",
@@ -6245,6 +6245,44 @@ function collectPullRequestWorkflowContexts(workflows) {
6245
6245
  }
6246
6246
  return [...contexts].sort((a, b) => a.localeCompare(b));
6247
6247
  }
6248
+ function jobNeeds(job) {
6249
+ const lines2 = job.body.split(/\r?\n/).slice(1);
6250
+ const at = lines2.findIndex((line) => /^\s+needs:/.test(line));
6251
+ if (at < 0) return [];
6252
+ const indent = lines2[at].match(/^(\s*)/)?.[1].length ?? 0;
6253
+ const inline = lines2[at].replace(/^\s*needs:\s*/, "").replace(/#.*$/, "").trim();
6254
+ if (inline) return inline.replace(/^\[|\]$/g, "").split(",").map(unquote).filter(Boolean);
6255
+ const items = [];
6256
+ for (const line of lines2.slice(at + 1)) {
6257
+ if (!line.trim()) continue;
6258
+ if ((line.match(/^(\s*)/)?.[1].length ?? 0) <= indent) break;
6259
+ items.push(unquote(line.replace(/^\s*-\s*/, "")));
6260
+ }
6261
+ return items.filter(Boolean);
6262
+ }
6263
+ function unquote(s) {
6264
+ return s.replace(/#.*$/, "").trim().replace(/^['"]|['"]$/g, "");
6265
+ }
6266
+ function unblockingGateJobs(workflows, required) {
6267
+ const unblocking = /* @__PURE__ */ new Set();
6268
+ for (const wf of workflows) {
6269
+ if (!workflowTriggersPullRequest(wf.body)) continue;
6270
+ const jobs = parseWorkflowJobs(wf.body);
6271
+ const needs = new Map(jobs.map((job) => [job.id, jobNeeds(job)]));
6272
+ const covered = /* @__PURE__ */ new Set();
6273
+ const stack = jobs.map((job) => job.id).filter((id) => required.has(id));
6274
+ while (stack.length) {
6275
+ const id = stack.pop();
6276
+ if (covered.has(id)) continue;
6277
+ covered.add(id);
6278
+ stack.push(...needs.get(id) ?? []);
6279
+ }
6280
+ for (const job of jobs) {
6281
+ if (!covered.has(job.id) && jobReportsPullRequestCheck(job)) unblocking.add(job.id);
6282
+ }
6283
+ }
6284
+ return [...unblocking].sort((a, b) => a.localeCompare(b));
6285
+ }
6248
6286
  function pathFilteredPullRequestWorkflows(workflows) {
6249
6287
  const filtered = [];
6250
6288
  for (const wf of workflows) {
@@ -14979,6 +15017,26 @@ async function installAppFoldDeps(deps) {
14979
15017
  }
14980
15018
  await deps.run("npm", lockListing.trim() ? ["ci"] : ["install"]);
14981
15019
  }
15020
+ var SHARED_INSTALLER_PACKAGES = ["@mutmutco/installer-face", "@mutmutco/installer-launcher", "@mutmutco/installer-gate"];
15021
+ async function refreshSharedInstallerDeps(deps) {
15022
+ let declared;
15023
+ try {
15024
+ const lock = JSON.parse(await deps.run("git", ["show", "HEAD:package-lock.json"]));
15025
+ const present = Object.keys(lock.packages ?? {});
15026
+ declared = SHARED_INSTALLER_PACKAGES.filter((name) => present.some((key) => key === `node_modules/${name}` || key.endsWith(`/node_modules/${name}`)));
15027
+ } catch {
15028
+ return null;
15029
+ }
15030
+ if (declared.length === 0) return null;
15031
+ try {
15032
+ await deps.run("npm", ["update", ...declared, "--package-lock-only", "--no-audit", "--no-fund"]);
15033
+ } catch (e) {
15034
+ const why = e instanceof Error ? e.message.split("\n")[0] : String(e);
15035
+ deps.warn?.(`shared installer refresh skipped (${why}) \u2014 shipping the locked versions`);
15036
+ return `shared installer refresh skipped (${why}); the locked ${declared.join(", ")} still satisfy their ranges`;
15037
+ }
15038
+ return `shared installer deps refreshed to the newest their ranges allow (${declared.join(", ")})`;
15039
+ }
14982
15040
  async function restoreMainNpmPackIdentities(deps, version) {
14983
15041
  const bomPath = "distribution-bom.json";
14984
15042
  let mainListing;
@@ -15133,6 +15191,8 @@ async function foldReleaseVersion(deps, model, tag, foldPaths, sourceCommit = "H
15133
15191
  }
15134
15192
  } else {
15135
15193
  await installAppFoldDeps(deps);
15194
+ const refresh = await refreshSharedInstallerDeps(deps);
15195
+ if (refresh) foldNoteExtra = `; ${refresh}`;
15136
15196
  await deps.run("npm", ["version", version, "--no-git-tag-version", "--allow-same-version"]);
15137
15197
  const patch = Number(version.split(".")[2] ?? "0");
15138
15198
  if (model === "registry-publish" && Number.isFinite(patch) && patch > 0) {
@@ -15938,10 +15998,10 @@ var rollout_plan_default = {
15938
15998
  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)."
15939
15999
  },
15940
16000
  baseline: {
15941
- version: "4.4.17",
15942
- tag: "v4.4.17",
15943
- commit: "748489250635",
15944
- npm: "@mutmutco/cli@4.4.17"
16001
+ version: "4.5.1",
16002
+ tag: "v4.5.1",
16003
+ commit: "489af0245908",
16004
+ npm: "@mutmutco/cli@4.5.1"
15945
16005
  },
15946
16006
  exitCriterion: "fleet-n-of-n",
15947
16007
  hubOnlyShortcut: "forbidden",
@@ -15958,14 +16018,14 @@ var rollout_plan_default = {
15958
16018
  repo: "mutmutco/mmi-hub",
15959
16019
  role: "canary",
15960
16020
  schedule: "train",
15961
- v3Target: "v4.4.17"
16021
+ v3Target: "v4.5.1"
15962
16022
  }
15963
16023
  ],
15964
16024
  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.",
15965
16025
  rollback: {
15966
16026
  independent: true,
15967
- mechanism: "npm dist-tag latest -> 4.4.17 and redeploy the Hub Lambda from tag v4.4.17 (748489250635); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
15968
- v3Target: "v4.4.17 (@mutmutco/cli@4.4.17, tag commit 748489250635 \u2014 last known-good release carrying the repo-index v4-only contract)"
16027
+ mechanism: "npm dist-tag latest -> 4.5.1 and redeploy the Hub Lambda from tag v4.5.1 (489af0245908); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
16028
+ v3Target: "v4.5.1 (@mutmutco/cli@4.5.1, tag commit 489af0245908 \u2014 last known-good release carrying the repo-index v4-only contract)"
15969
16029
  }
15970
16030
  },
15971
16031
  {
@@ -18487,6 +18547,13 @@ async function awaitAlignmentLanded(deps, ctx, leg, base, opts = {}) {
18487
18547
  await sleep2(ALIGNMENT_LAND_POLL_MS);
18488
18548
  }
18489
18549
  }
18550
+ async function awaitAlignmentsLanded(deps, ctx, legs) {
18551
+ const [dev, rc] = await Promise.all([
18552
+ awaitAlignmentLanded(deps, ctx, legs.dev, "development"),
18553
+ legs.rc ? awaitAlignmentLanded(deps, ctx, legs.rc, "rc") : void 0
18554
+ ]);
18555
+ return { dev, ...rc ? { rc } : {} };
18556
+ }
18490
18557
  async function alignRcForward(deps, ctx, tag) {
18491
18558
  deps.warn?.("Release: Bringing the release candidate up to date");
18492
18559
  await runGitRemoteRead(deps, ["fetch", "origin", "main", "rc"]);
@@ -18734,6 +18801,60 @@ async function resolveRcResumeTag(deps, base, sha, probed) {
18734
18801
  return { tag: newest, note };
18735
18802
  }
18736
18803
 
18804
+ // ../infra/installer-families.mjs
18805
+ var INSTALLER_FAMILIES = ["jerv", "mmi"];
18806
+ var INSTALLER_FAMILY_HOSTS = {
18807
+ jerv: "get.jerv.ai",
18808
+ mmi: "get.mutmut.work"
18809
+ };
18810
+ var INSTALLER_LOGINS = ["github", "google"];
18811
+ var PRODUCT_RE = /^[a-z][a-z0-9-]{0,39}$/;
18812
+ function expectedInstallerLogin(family, division) {
18813
+ if (family === "jerv") return "github";
18814
+ return String(division ?? "").trim().toLowerCase() === "mmi" ? "github" : "google";
18815
+ }
18816
+ function installerVersionUrl(installer) {
18817
+ return `https://${INSTALLER_FAMILY_HOSTS[installer.family]}/${installer.product}/release/version`;
18818
+ }
18819
+ function installerDeclarationError(value, division) {
18820
+ if (value === void 0 || value === null) return null;
18821
+ if (typeof value !== "object" || Array.isArray(value)) {
18822
+ return "installer must be an object {product, family, login?}";
18823
+ }
18824
+ const record = (
18825
+ /** @type {Record<string, unknown>} */
18826
+ value
18827
+ );
18828
+ for (const key of Object.keys(record)) {
18829
+ if (!["product", "family", "login"].includes(key)) return `installer.${key} is not a field \u2014 expected product, family and optionally login`;
18830
+ }
18831
+ if (typeof record.product !== "string" || !PRODUCT_RE.test(record.product)) {
18832
+ return "installer.product must be a lowercase kebab name (e.g. mm-strategy) \u2014 it is the /<product> path on the family host";
18833
+ }
18834
+ if (!INSTALLER_FAMILIES.includes(
18835
+ /** @type {string} */
18836
+ record.family
18837
+ )) {
18838
+ return `installer.family must be one of: ${INSTALLER_FAMILIES.join(", ")} (jerv \u2192 ${INSTALLER_FAMILY_HOSTS.jerv}, mmi \u2192 ${INSTALLER_FAMILY_HOSTS.mmi})`;
18839
+ }
18840
+ if (record.login !== void 0 && !INSTALLER_LOGINS.includes(
18841
+ /** @type {string} */
18842
+ record.login
18843
+ )) {
18844
+ return `installer.login must be one of: ${INSTALLER_LOGINS.join(", ")}`;
18845
+ }
18846
+ const expected = expectedInstallerLogin(
18847
+ /** @type {'jerv' | 'mmi'} */
18848
+ record.family,
18849
+ division
18850
+ );
18851
+ if (record.login !== void 0 && record.login !== expected) {
18852
+ const who = record.family === "jerv" ? "Jerv products sign in with GitHub" : String(division ?? "").trim().toLowerCase() === "mmi" ? "MMI products sign in with GitHub" : "MM and other Mutatis Mutandis products sign in with Google";
18853
+ return `installer.login ${record.login} breaks the sign-in rule: ${who} (owner ruling 2026-09-23)`;
18854
+ }
18855
+ return null;
18856
+ }
18857
+
18737
18858
  // src/tenant-control-parse.ts
18738
18859
  var OUTPUT_BEGIN = "mmi-control-output-begin";
18739
18860
  var OUTPUT_END = "mmi-control-output-end";
@@ -18889,6 +19010,12 @@ async function observePublishIfRequired(deps, ctx, meta, model, stage, releaseSh
18889
19010
  deployStatus: aggregateWorkflowRuns(workflowRuns)
18890
19011
  };
18891
19012
  }
19013
+ async function joinDeployAndPublish(deploy, publish) {
19014
+ const [d, p] = await Promise.all([deploy, publish]);
19015
+ if (!p) return [d, p];
19016
+ const mark = (x) => x.workflowRuns ? { ...x, workflowRuns: x.workflowRuns.map((r) => ({ ...r, parallel: true })) } : x;
19017
+ return [mark(d), mark(p)];
19018
+ }
18892
19019
  function appendPublishDispatch(deploy, publish) {
18893
19020
  if (!publish) return deploy;
18894
19021
  return {
@@ -18910,7 +19037,7 @@ function mmiGetRedeployCommand(repo) {
18910
19037
  }
18911
19038
  function readLocalLauncherVersion(root = process.cwd()) {
18912
19039
  try {
18913
- const raw = (0, import_node_fs19.readFileSync)((0, import_node_path17.join)(root, "installer", "launcher", "package.json"), "utf8");
19040
+ const raw = (0, import_node_fs19.readFileSync)((0, import_node_path17.join)(root, "cli", "package.json"), "utf8");
18914
19041
  const version = JSON.parse(raw).version;
18915
19042
  return typeof version === "string" && version.trim() ? version.trim() : null;
18916
19043
  } catch {
@@ -18939,17 +19066,54 @@ async function fetchServedChannelVersion(url, fetchImpl = fetch) {
18939
19066
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
18940
19067
  }
18941
19068
  }
19069
+ async function findPriorGreenDispatch(deps, repo, workflow, sha, titleTokens) {
19070
+ if (!/^[0-9a-f]{40}$/i.test(sha)) return void 0;
19071
+ try {
19072
+ const rows = JSON.parse(await deps.run("gh", [
19073
+ "run",
19074
+ "list",
19075
+ "--repo",
19076
+ repo,
19077
+ "--workflow",
19078
+ workflow,
19079
+ "--commit",
19080
+ sha,
19081
+ "--event",
19082
+ "workflow_dispatch",
19083
+ "--limit",
19084
+ "20",
19085
+ "--json",
19086
+ "databaseId,url,status,conclusion,displayTitle,headSha"
19087
+ ]));
19088
+ const hit = rows.find((r) => typeof r.databaseId === "number" && r.headSha?.toLowerCase() === sha.toLowerCase() && r.status === "completed" && r.conclusion === "success" && titleTokens.every((t) => (r.displayTitle ?? "").split(/[^\w.-]+/).includes(t)));
19089
+ return hit ? { runId: hit.databaseId, ...hit.url ? { runUrl: hit.url } : {} } : void 0;
19090
+ } catch {
19091
+ return void 0;
19092
+ }
19093
+ }
19094
+ function planeFailureRow(workflow, evidence) {
19095
+ return { workflow, runUrlNote: evidence, conclusion: "failure" };
19096
+ }
18942
19097
  async function appendMmiGetRedeploy(deps, input, dispatch) {
18943
19098
  if (!isHubControlRepo(input.repo)) return dispatch;
18944
19099
  const remedy = mmiGetRedeployCommand(input.repo);
18945
- const withNote = (note, deployStatus) => ({
19100
+ const withNote = (note, deployStatus, row) => ({
18946
19101
  ...dispatch,
18947
19102
  note: `${dispatch.note}; ${note}`,
18948
- ...deployStatus ? { deployStatus } : {}
19103
+ ...deployStatus ? { deployStatus } : {},
19104
+ ...row ? { workflowRuns: [...dispatch.workflowRuns ?? [], row] } : {}
18949
19105
  });
18950
19106
  if (input.publishStatus !== "success") {
18951
19107
  return withNote(`mmi-get box NOT redeployed (publish did not succeed) \u2014 after the publish is green, run \`${remedy}\``);
18952
19108
  }
19109
+ const prior = input.releaseSha ? await findPriorGreenDispatch(deps, input.repo, "tenant-deploy.yml", input.releaseSha, [HUB_GET_SLUG, HUB_GET_STAGE]) : void 0;
19110
+ if (prior) {
19111
+ return {
19112
+ ...dispatch,
19113
+ note: `${dispatch.note}; mmi-get box already redeployed for this release (tenant-deploy.yml run ${prior.runId} at ${input.releaseSha.slice(0, 12)}) \u2014 not dispatched again`,
19114
+ workflowRuns: [...dispatch.workflowRuns ?? [], { workflow: "tenant-deploy.yml", runId: prior.runId, ...prior.runUrl ? { runUrl: prior.runUrl } : {}, conclusion: "success" }]
19115
+ };
19116
+ }
18953
19117
  const since = (deps.now ?? Date.now)();
18954
19118
  try {
18955
19119
  await deps.run("gh", [
@@ -18971,12 +19135,12 @@ async function appendMmiGetRedeploy(deps, input, dispatch) {
18971
19135
  ]);
18972
19136
  } catch (e) {
18973
19137
  const msg = e instanceof Error ? e.message : String(e);
18974
- return withNote(`tenant-deploy dispatch FAILED: ${msg}. The release itself landed, but the MMI-GET BOX WAS NOT REDEPLOYED \u2014 recover with \`${remedy}\``, "failure");
19138
+ return withNote(`tenant-deploy dispatch FAILED: ${msg}. The release itself landed, but the MMI-GET BOX WAS NOT REDEPLOYED \u2014 recover with \`${remedy}\``, "failure", planeFailureRow("tenant-deploy.yml", `dispatch refused: ${msg}`));
18975
19139
  }
18976
19140
  const correlated = await correlateTenantRun(deps, since, [HUB_GET_SLUG, HUB_GET_STAGE]);
18977
19141
  if (correlated.read === "failed" || correlated.read === "absent") {
18978
19142
  const why = correlated.read === "failed" ? `its run could not be READ: ${correlated.error}` : "no matching run appeared";
18979
- return withNote(`tenant-deploy dispatched (slug=${HUB_GET_SLUG}, stage=${HUB_GET_STAGE}) but ${why}. Check the Actions tab; if it never ran, recover with \`${remedy}\``, "failure");
19143
+ return withNote(`tenant-deploy dispatched (slug=${HUB_GET_SLUG}, stage=${HUB_GET_STAGE}) but ${why}. Check the Actions tab; if it never ran, recover with \`${remedy}\``, "failure", planeFailureRow("tenant-deploy.yml", `dispatched, but ${why}`));
18980
19144
  }
18981
19145
  const { runId, runUrl } = correlated;
18982
19146
  const runRow = { workflow: "tenant-deploy.yml", ...runId ? { runId } : {}, ...runUrl ? { runUrl } : {} };
@@ -19006,35 +19170,44 @@ async function appendServedChannelPublish(deps, input, dispatch) {
19006
19170
  if (!isHubControlRepo(input.repo)) return dispatch;
19007
19171
  const channelUrl = input.channelUrl ?? readServedChannelUrl();
19008
19172
  const remedy = getPublishDispatchCommand(input.repo);
19009
- const withNote = (note, deployStatus) => ({
19173
+ const withNote = (note, deployStatus, row) => ({
19010
19174
  ...dispatch,
19011
19175
  note: `${dispatch.note}; ${note}`,
19012
- ...deployStatus ? { deployStatus } : {}
19176
+ ...deployStatus ? { deployStatus } : {},
19177
+ ...row ? { workflowRuns: [...dispatch.workflowRuns ?? [], row] } : {}
19013
19178
  });
19014
19179
  if (input.publishStatus !== "success") {
19015
19180
  return withNote(`served channel NOT moved (publish did not succeed) \u2014 after the publish is green, run \`${remedy}\``);
19016
19181
  }
19017
- const since = (deps.now ?? Date.now)();
19018
- try {
19019
- await deps.run("gh", ["workflow", "run", "get-publish.yml", "--repo", input.repo, "--ref", "main", "-f", `product=${HUB_SERVED_PRODUCT}`]);
19020
- } catch (e) {
19021
- const msg = e instanceof Error ? e.message : String(e);
19022
- return withNote(`get-publish dispatch FAILED: ${msg}. The release itself landed, but the SERVED CHANNEL IS UNCHANGED \u2014 recover with \`${remedy}\``, "failure");
19023
- }
19024
- const correlated = await correlateGetPublishRun(deps, since, HUB_SERVED_PRODUCT);
19025
- if (correlated.read === "failed") {
19026
- return withNote(`get-publish dispatched but its run could not be READ: ${correlated.error}. Check the Actions tab; if it never ran, recover with \`${remedy}\``, "failure");
19027
- }
19028
- const { runId, runUrl } = correlated;
19029
- const runRow = { workflow: "get-publish.yml", ...runId ? { runId } : {}, ...runUrl ? { runUrl } : {} };
19030
- if (!input.watch) {
19031
- return {
19032
- ...dispatch,
19033
- note: `${dispatch.note}; dispatched get-publish.yml (product=${HUB_SERVED_PRODUCT}) \u2014 NOT watched, so the served channel is not yet proven moved`,
19034
- workflowRuns: [...dispatch.workflowRuns ?? [], { ...runRow, conclusion: "pending" }]
19035
- };
19182
+ const prior = input.releaseSha ? await findPriorGreenDispatch(deps, input.repo, "get-publish.yml", input.releaseSha, [HUB_SERVED_PRODUCT]) : void 0;
19183
+ let runRow;
19184
+ let runStatus;
19185
+ if (prior) {
19186
+ runRow = { workflow: "get-publish.yml", runId: prior.runId, ...prior.runUrl ? { runUrl: prior.runUrl } : {} };
19187
+ runStatus = "success";
19188
+ } else {
19189
+ const since = (deps.now ?? Date.now)();
19190
+ try {
19191
+ await deps.run("gh", ["workflow", "run", "get-publish.yml", "--repo", input.repo, "--ref", "main", "-f", `product=${HUB_SERVED_PRODUCT}`]);
19192
+ } catch (e) {
19193
+ const msg = e instanceof Error ? e.message : String(e);
19194
+ return withNote(`get-publish dispatch FAILED: ${msg}. The release itself landed, but the SERVED CHANNEL IS UNCHANGED \u2014 recover with \`${remedy}\``, "failure", planeFailureRow("get-publish.yml", `dispatch refused: ${msg}`));
19195
+ }
19196
+ const correlated = await correlateGetPublishRun(deps, since, HUB_SERVED_PRODUCT);
19197
+ if (correlated.read === "failed") {
19198
+ return withNote(`get-publish dispatched but its run could not be READ: ${correlated.error}. Check the Actions tab; if it never ran, recover with \`${remedy}\``, "failure", planeFailureRow("get-publish.yml", `dispatched, but its run could not be read`));
19199
+ }
19200
+ const { runId, runUrl } = correlated;
19201
+ runRow = { workflow: "get-publish.yml", ...runId ? { runId } : {}, ...runUrl ? { runUrl } : {} };
19202
+ if (!input.watch) {
19203
+ return {
19204
+ ...dispatch,
19205
+ note: `${dispatch.note}; dispatched get-publish.yml (product=${HUB_SERVED_PRODUCT}) \u2014 NOT watched, so the served channel is not yet proven moved`,
19206
+ workflowRuns: [...dispatch.workflowRuns ?? [], { ...runRow, conclusion: "pending" }]
19207
+ };
19208
+ }
19209
+ runStatus = await watchTenantRun(deps, runId, input.repo);
19036
19210
  }
19037
- const runStatus = await watchTenantRun(deps, runId, input.repo);
19038
19211
  if (runStatus !== "success") {
19039
19212
  return {
19040
19213
  ...dispatch,
@@ -19053,19 +19226,29 @@ async function appendServedChannelPublish(deps, input, dispatch) {
19053
19226
  };
19054
19227
  }
19055
19228
  const probe = await (deps.readServedChannelVersion ?? fetchServedChannelVersion)(channelUrl);
19229
+ const evidence = (conclusion, text) => [...runs, servedChannelRow(HUB_SERVED_PRODUCT, conclusion, text)];
19230
+ if (!probe.ok && staleServedProcess(probe)) {
19231
+ const redeploy = mmiGetRedeployCommand(input.repo);
19232
+ return {
19233
+ ...dispatch,
19234
+ note: `${dispatch.note}; get-publish.yml succeeded but ${channelUrl} answered HTTP ${probe.status} \u2014 STALE SERVED PROCESS: the files may be current, but the process serving them predates GET /release/version; recover with \`${redeploy}\``,
19235
+ workflowRuns: evidence("failure", `${channelUrl} answered HTTP ${probe.status} (stale served process)`),
19236
+ deployStatus: "failure"
19237
+ };
19238
+ }
19056
19239
  if (!probe.ok) {
19057
19240
  const why = probe.error ?? `HTTP ${probe.status ?? "unknown"}`;
19058
19241
  return {
19059
19242
  ...dispatch,
19060
19243
  note: `${dispatch.note}; get-publish.yml succeeded but the served channel version could NOT be read at ${channelUrl} (${why}) \u2014 the move is unverified`,
19061
- workflowRuns: runs
19244
+ workflowRuns: evidence("pending", `${channelUrl} unreadable (${why})`)
19062
19245
  };
19063
19246
  }
19064
19247
  if (expected && probe.version !== expected) {
19065
19248
  return {
19066
19249
  ...dispatch,
19067
19250
  note: `${dispatch.note}; get-publish.yml succeeded but the served channel still reports ${probe.version} (expected launcher ${expected}) \u2014 THE CHANNEL DID NOT MOVE; recover with \`${remedy}\``,
19068
- workflowRuns: runs,
19251
+ workflowRuns: evidence("failure", `${channelUrl} reports ${probe.version}, expected ${expected}`),
19069
19252
  deployStatus: "failure"
19070
19253
  };
19071
19254
  }
@@ -19073,15 +19256,106 @@ async function appendServedChannelPublish(deps, input, dispatch) {
19073
19256
  return {
19074
19257
  ...dispatch,
19075
19258
  note: `${dispatch.note}; get-publish.yml succeeded and ${channelUrl} reports ${probe.version}, but the local launcher version could not be read \u2014 nothing was compared, so the move is unverified`,
19076
- workflowRuns: runs
19259
+ workflowRuns: evidence("pending", `${channelUrl} reports ${probe.version}; nothing to compare against`)
19077
19260
  };
19078
19261
  }
19079
19262
  return {
19080
19263
  ...dispatch,
19081
19264
  note: `${dispatch.note}; served channel moved \u2014 ${channelUrl} now reports ${probe.version}`,
19082
- workflowRuns: runs
19265
+ workflowRuns: evidence("success", `${channelUrl} reports ${probe.version}`)
19266
+ };
19267
+ }
19268
+ function staleServedProcess(probe) {
19269
+ return probe.status === 401 || probe.status === 403 || probe.status === 404;
19270
+ }
19271
+ function servedChannelRow(product, conclusion, evidence) {
19272
+ return { workflow: `served-channel(${product})`, runUrlNote: evidence, conclusion };
19273
+ }
19274
+ var JERV_GATEWAY_REMEDY = "from the Jerv-Hub checkout, re-run the Gateway deploy on its current release tag: `bash scripts/jerv-gateway-release-deploy.sh --tag <jerv-hub tag>`";
19275
+ var SERVED_CHANNELS = {
19276
+ "mutmutco/mm-strategy": {
19277
+ product: "mm-strategy",
19278
+ url: "https://strategy.mutmut.work/release/version",
19279
+ mover: "the MM-Strategy container deploy (the running server's own version is the pointer)",
19280
+ remedy: "`mmi-cli devops runtime tenant redeploy mutmutco/MM-Strategy main`"
19281
+ },
19282
+ "mutmutco/jerv-hub": {
19283
+ product: "jerv",
19284
+ url: "https://get.jerv.ai/jerv/release/version",
19285
+ mover: "the Jerv Gateway deploy (`jerv-gate fetch-release --product jerv`)",
19286
+ remedy: JERV_GATEWAY_REMEDY
19287
+ },
19288
+ "mutmutco/jerv-jervcode": {
19289
+ product: "jervcode",
19290
+ url: "https://get.jerv.ai/jervcode/release/version",
19291
+ mover: "the Jerv Gateway deploy, which fetches JervCode's latest release \u2014 a JervCode release alone does not move it",
19292
+ remedy: JERV_GATEWAY_REMEDY
19293
+ }
19294
+ };
19295
+ function servedChannelFor(repo, installer) {
19296
+ const known = SERVED_CHANNELS[repo.toLowerCase()];
19297
+ if (!installer) return known;
19298
+ if (isHubControlRepo(repo)) return void 0;
19299
+ const url = installerVersionUrl(installer);
19300
+ return {
19301
+ product: installer.product,
19302
+ url,
19303
+ mover: known?.mover ?? `this repo's release (${installer.family === "jerv" ? "get.jerv.ai" : "get.mutmut.work"} serves /${installer.product})`,
19304
+ remedy: known?.remedy ?? `re-run the release's served-channel publish for ${installer.product} (\`mmi-cli devops release --resume\`)`
19083
19305
  };
19084
19306
  }
19307
+ var SERVED_CHANNEL_PROBE_ATTEMPTS = 3;
19308
+ var SERVED_CHANNEL_PROBE_BACKOFF_MS = 1e4;
19309
+ async function appendServedChannelProof(deps, input, dispatch) {
19310
+ const channel = servedChannelFor(input.repo, input.installer);
19311
+ if (!channel) return dispatch;
19312
+ const expected = input.tag.replace(/^v/, "");
19313
+ const withProof = (conclusion, evidence, note, failed = false) => ({
19314
+ ...dispatch,
19315
+ note: `${dispatch.note}; ${note}`,
19316
+ workflowRuns: [...dispatch.workflowRuns ?? [], servedChannelRow(channel.product, conclusion, evidence)],
19317
+ ...failed ? { deployStatus: "failure" } : {}
19318
+ });
19319
+ if (!input.shipped) {
19320
+ 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`);
19321
+ }
19322
+ const read = deps.readServedChannelVersion ?? fetchServedChannelVersion;
19323
+ let probe = await read(channel.url);
19324
+ for (let attempt = 1; attempt < SERVED_CHANNEL_PROBE_ATTEMPTS && probe.ok && probe.version !== expected; attempt++) {
19325
+ await (deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(SERVED_CHANNEL_PROBE_BACKOFF_MS * attempt);
19326
+ probe = await read(channel.url);
19327
+ }
19328
+ if (!probe.ok && staleServedProcess(probe)) {
19329
+ return withProof(
19330
+ "failure",
19331
+ `${channel.url} answered HTTP ${probe.status} (stale served process)`,
19332
+ `${channel.url} answered HTTP ${probe.status} \u2014 STALE SERVED PROCESS: the process serving ${channel.product} predates GET /release/version; it moves with ${channel.mover}; recover with ${channel.remedy}`,
19333
+ true
19334
+ );
19335
+ }
19336
+ if (!probe.ok) {
19337
+ const why = probe.error ?? `HTTP ${probe.status ?? "unknown"}`;
19338
+ return withProof("pending", `${channel.url} unreadable (${why})`, `served channel ${channel.url} could NOT be read (${why}) \u2014 the move is unverified`);
19339
+ }
19340
+ if (probe.version !== expected) {
19341
+ return withProof(
19342
+ "failure",
19343
+ `${channel.url} reports ${probe.version}, expected ${expected}`,
19344
+ `served channel ${channel.url} still reports ${probe.version} (expected ${expected}) \u2014 THE CHANNEL DID NOT MOVE: new installs get ${probe.version}; it moves with ${channel.mover}; recover with ${channel.remedy}`,
19345
+ true
19346
+ );
19347
+ }
19348
+ return withProof("success", `${channel.url} reports ${probe.version}`, `served channel moved \u2014 ${channel.url} reports ${probe.version}`);
19349
+ }
19350
+ async function appendServedChannelLegs(deps, input, dispatch) {
19351
+ const publishStatus = input.publish?.state === "complete" ? "success" : "failure";
19352
+ let out = await appendMmiGetRedeploy(deps, { repo: input.repo, publishStatus, watch: input.watch, releaseSha: input.releaseSha }, dispatch);
19353
+ out = await appendServedChannelPublish(deps, { repo: input.repo, publishStatus, watch: input.watch, releaseSha: input.releaseSha }, out);
19354
+ return appendServedChannelProof(deps, { repo: input.repo, tag: input.tag, shipped: shipLegsGreen(input.deploy, input.publish), installer: input.installer }, out);
19355
+ }
19356
+ function servedChannelRowsAfter(before, after) {
19357
+ return (after.workflowRuns ?? []).slice((before.workflowRuns ?? []).length);
19358
+ }
19085
19359
  var JERV_GATEWAY_RUN_URL_NOTE = "no run URL emitted \u2014 host health leg runs outside GitHub Actions";
19086
19360
  var JERV_GATEWAY_RELEASE_DEPLOY_SCRIPT = "scripts/jerv-gateway-release-deploy.sh";
19087
19361
  async function runTaggedJervGatewayLocalWrapper(deps, tag, tagSha) {
@@ -19141,15 +19415,15 @@ async function appendJervGatewayReleaseDeploy(deps, repo, tag, tagSha, dispatch)
19141
19415
  }
19142
19416
  }
19143
19417
  async function watchOwnWorkflowRuns(deps, repo, targets, since, headSha, enumerateSha = false) {
19144
- const workflowRuns = [];
19145
- for (const target of targets) {
19418
+ const parallel = targets.length > 1 ? { parallel: true } : {};
19419
+ const workflowRuns = await Promise.all(targets.map(async (target) => {
19146
19420
  try {
19147
19421
  const run = await correlateWorkflowRun(deps, { ...target, since, headSha, repo });
19148
- workflowRuns.push(await watchWorkflowRun(deps, target.workflow, run, repo));
19422
+ return { ...await watchWorkflowRun(deps, target.workflow, run, repo), ...parallel };
19149
19423
  } catch {
19150
- workflowRuns.push({ workflow: target.workflow, conclusion: "failure" });
19424
+ return { workflow: target.workflow, conclusion: "failure", ...parallel };
19151
19425
  }
19152
- }
19426
+ }));
19153
19427
  if (!enumerateSha) return workflowRuns;
19154
19428
  const seen = new Set(workflowRuns.map((r) => r.runId).filter((id) => typeof id === "number"));
19155
19429
  workflowRuns.push(...await discoverShaWorkflowRuns(deps, repo, headSha, seen));
@@ -19204,8 +19478,8 @@ async function vercelDeployDispatch(deps, ctx, ref, watch, headSha) {
19204
19478
  return { note: `${note}; ${detail}`, ...read?.logUrl ? { runUrl: read.logUrl } : {}, workflowRuns: [row(read, deployStatus)], deployStatus };
19205
19479
  }
19206
19480
  function shipLegsGreen(deploy, publish) {
19207
- const green = (s) => s === "complete" || s === "skipped";
19208
- return green(deploy?.state) && green(publish?.state);
19481
+ const green = (leg) => leg?.superseded !== true && (leg?.state === "complete" || leg?.state === "skipped");
19482
+ return green(deploy) && green(publish);
19209
19483
  }
19210
19484
  async function announceShippedRelease(deps, args, model, legs, deferred) {
19211
19485
  if (!deps.announce) return void 0;
@@ -20718,6 +20992,20 @@ var import_node_crypto5 = require("node:crypto");
20718
20992
  // src/file-lock.ts
20719
20993
  var import_promises2 = require("node:fs/promises");
20720
20994
  var import_node_path21 = require("node:path");
20995
+ function pidIsDead(pid) {
20996
+ if (!Number.isInteger(pid) || pid <= 0) return false;
20997
+ if (pid === process.pid) return false;
20998
+ try {
20999
+ process.kill(pid, 0);
21000
+ return false;
21001
+ } catch (e) {
21002
+ return e.code === "ESRCH";
21003
+ }
21004
+ }
21005
+ function lockTokenPid(token) {
21006
+ const m = /^(\d+)-/.exec(token);
21007
+ return m ? Number(m[1]) : void 0;
21008
+ }
20721
21009
  var sleep = (ms) => new Promise((resolve7) => setTimeout(resolve7, ms));
20722
21010
  var IMMEDIATE_RETRY_BUDGET = 3;
20723
21011
  var FileLockBusyError = class extends Error {
@@ -20733,9 +21021,24 @@ function resolveFileLockOpts(opts = {}) {
20733
21021
  staleMs: opts.staleMs ?? 3e4,
20734
21022
  retryMs: opts.retryMs ?? 50,
20735
21023
  maxWaitMs: opts.maxWaitMs ?? 5e3,
20736
- label: opts.label ?? "file lock"
21024
+ label: opts.label ?? "file lock",
21025
+ deadHolderSteal: opts.deadHolderSteal ?? false,
21026
+ holder: opts.holder ?? ""
20737
21027
  };
20738
21028
  }
21029
+ async function lockIsStealable(lockPath, opts) {
21030
+ const age = Date.now() - (await (0, import_promises2.stat)(lockPath)).mtimeMs;
21031
+ if (age > opts.staleMs) return true;
21032
+ if (!opts.deadHolderSteal) return false;
21033
+ let token;
21034
+ try {
21035
+ token = await (0, import_promises2.readFile)(lockPath, "utf8");
21036
+ } catch {
21037
+ return false;
21038
+ }
21039
+ const pid = lockTokenPid(token);
21040
+ return pid !== void 0 && pidIsDead(pid);
21041
+ }
20739
21042
  async function acquireFileLock(lockPath, opts, deadline) {
20740
21043
  let immediateRetries = 0;
20741
21044
  for (; ; ) {
@@ -20746,12 +21049,12 @@ async function acquireFileLock(lockPath, opts, deadline) {
20746
21049
  const code = e.code;
20747
21050
  if (code !== "EEXIST" && code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") throw e;
20748
21051
  const retryNow = async () => {
21052
+ if (++immediateRetries <= IMMEDIATE_RETRY_BUDGET) return;
20749
21053
  if (Date.now() >= deadline) throw new FileLockBusyError(opts.label, lockPath, opts.maxWaitMs);
20750
- if (++immediateRetries > IMMEDIATE_RETRY_BUDGET) await sleep(opts.retryMs);
21054
+ await sleep(opts.retryMs);
20751
21055
  };
20752
21056
  try {
20753
- const age = Date.now() - (await (0, import_promises2.stat)(lockPath)).mtimeMs;
20754
- if (age > opts.staleMs) {
21057
+ if (await lockIsStealable(lockPath, opts)) {
20755
21058
  try {
20756
21059
  await (0, import_promises2.unlink)(lockPath);
20757
21060
  await retryNow();
@@ -20777,7 +21080,8 @@ async function acquireFileLock(lockPath, opts, deadline) {
20777
21080
  await sleep(opts.retryMs);
20778
21081
  continue;
20779
21082
  }
20780
- const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
21083
+ const stamp = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
21084
+ const token = opts.holder ? `${stamp} ${opts.holder.replace(/\s+/g, " ").trim()}` : stamp;
20781
21085
  try {
20782
21086
  await handle.writeFile(token);
20783
21087
  } catch (e) {
@@ -20801,11 +21105,14 @@ async function releaseFileLock(lockPath, guard) {
20801
21105
  }
20802
21106
  }
20803
21107
  async function withFileLock(lockPath, opts, fn) {
21108
+ return withFencedFileLock(lockPath, opts, () => fn());
21109
+ }
21110
+ async function withFencedFileLock(lockPath, opts, fn) {
20804
21111
  const resolved = resolveFileLockOpts(opts);
20805
21112
  await (0, import_promises2.mkdir)((0, import_node_path21.dirname)(lockPath), { recursive: true }).catch(() => void 0);
20806
21113
  const guard = await acquireFileLock(lockPath, resolved, Date.now() + resolved.maxWaitMs);
20807
21114
  try {
20808
- return await fn();
21115
+ return await fn(() => fileLockHeldBy(lockPath, guard.token));
20809
21116
  } finally {
20810
21117
  await releaseFileLock(lockPath, guard);
20811
21118
  }
@@ -21043,8 +21350,114 @@ function atomicWriteJson(path2, ledger) {
21043
21350
  `, "utf8");
21044
21351
  (0, import_node_fs22.renameSync)(tmp, path2);
21045
21352
  }
21353
+ var LEDGER_LOCK_OPTS = { label: "release ledger", staleMs: 18e4, deadHolderSteal: true };
21354
+ async function withLedgerLock(path2, fn, opts = {}) {
21355
+ return withFencedFileLock(`${path2}.lock`, { ...LEDGER_LOCK_OPTS, ...opts.maxWaitMs != null ? { maxWaitMs: opts.maxWaitMs } : {} }, (stillHeld) => fn(async (write) => {
21356
+ if (!await stillHeld()) {
21357
+ throw new ReleaseLedgerError("lock-lost", `release ledger at ${path2}: this writer's lock was stolen while it worked \u2014 nothing was written over the successor (#7027)`);
21358
+ }
21359
+ return write();
21360
+ }));
21361
+ }
21362
+ function checkoutRootFrom(cwd) {
21363
+ let dir = (0, import_node_path22.resolve)(cwd);
21364
+ for (; ; ) {
21365
+ if (resolveGitDir(dir)) return dir;
21366
+ const parent = (0, import_node_path22.dirname)(dir);
21367
+ if (parent === dir) throw new ReleaseLedgerError("no-checkout", `train writer lock: no git checkout found above ${cwd}`);
21368
+ dir = parent;
21369
+ }
21370
+ }
21371
+ function trainWriterLockPath(cwd) {
21372
+ return (0, import_node_path22.join)((0, import_node_path22.dirname)(releaseLedgerPath(checkoutRootFrom(cwd))), "train.lock");
21373
+ }
21374
+ var TrainWriterBusyError = class extends Error {
21375
+ lockPath;
21376
+ holder;
21377
+ constructor(lockPath, holder, verb) {
21378
+ super(
21379
+ `${verb} refused before any mutation: another train is running on this checkout (${holder}). One train at a time writes the release ledger \u2014 wait for it to finish, or, if that process is gone, rerun: a dead holder's lock is reclaimed automatically (lock: ${lockPath}) (#7027)`
21380
+ );
21381
+ this.name = "TrainWriterBusyError";
21382
+ this.lockPath = lockPath;
21383
+ this.holder = holder;
21384
+ }
21385
+ };
21386
+ var TRAIN_WRITER_STALE_MS = 12 * 60 * 6e4;
21387
+ function describeTrainWriterToken(token) {
21388
+ const pid = lockTokenPid(token);
21389
+ const [stamp, ...rest] = token.trim().split(" ");
21390
+ const since = Number((stamp ?? "").split("-")[1]);
21391
+ const when = Number.isFinite(since) && since > 0 ? `, since ${new Date(since).toISOString()}` : "";
21392
+ const what = rest.join(" ").trim();
21393
+ return `${what || "a train command"}${pid ? `, pid ${pid}` : ""}${when}`;
21394
+ }
21395
+ function liveTrainWriterHolder(cwd) {
21396
+ let token;
21397
+ try {
21398
+ token = (0, import_node_fs22.readFileSync)(trainWriterLockPath(cwd), "utf8");
21399
+ } catch {
21400
+ return void 0;
21401
+ }
21402
+ const pid = lockTokenPid(token);
21403
+ if (pid === void 0 || pid === process.pid || pidIsDead(pid)) return void 0;
21404
+ return describeTrainWriterToken(token);
21405
+ }
21406
+ var heldTrainWriter;
21407
+ async function acquireTrainWriterLock(cwd, verb) {
21408
+ let lockPath;
21409
+ try {
21410
+ lockPath = trainWriterLockPath(cwd);
21411
+ } catch {
21412
+ return async () => void 0;
21413
+ }
21414
+ if (heldTrainWriter && heldTrainWriter.lockPath === lockPath) {
21415
+ heldTrainWriter.depth += 1;
21416
+ return async () => {
21417
+ if (heldTrainWriter) heldTrainWriter.depth -= 1;
21418
+ };
21419
+ }
21420
+ (0, import_node_fs22.mkdirSync)((0, import_node_path22.dirname)(lockPath), { recursive: true });
21421
+ const opts = resolveFileLockOpts({ label: "train writer", staleMs: TRAIN_WRITER_STALE_MS, deadHolderSteal: true, maxWaitMs: 0, holder: verb });
21422
+ let guard;
21423
+ try {
21424
+ guard = await acquireFileLock(lockPath, opts, Date.now());
21425
+ } catch (e) {
21426
+ if (!(e instanceof FileLockBusyError)) throw e;
21427
+ let token = "";
21428
+ try {
21429
+ token = (0, import_node_fs22.readFileSync)(lockPath, "utf8");
21430
+ } catch {
21431
+ token = "";
21432
+ }
21433
+ throw new TrainWriterBusyError(lockPath, token ? describeTrainWriterToken(token) : "holder unreadable", verb);
21434
+ }
21435
+ const held = {
21436
+ lockPath,
21437
+ depth: 1,
21438
+ release: async () => {
21439
+ heldTrainWriter = void 0;
21440
+ await releaseFileLock(lockPath, guard);
21441
+ }
21442
+ };
21443
+ heldTrainWriter = held;
21444
+ return async () => {
21445
+ held.depth -= 1;
21446
+ if (held.depth <= 0 && heldTrainWriter === held) await held.release();
21447
+ };
21448
+ }
21449
+ async function withTrainWriter(deps, verb, fn) {
21450
+ const seam = "trainWriter" in deps ? deps.trainWriter : void 0;
21451
+ if (!seam) return fn();
21452
+ const release = await seam(verb, process.cwd());
21453
+ try {
21454
+ return await fn();
21455
+ } finally {
21456
+ await release();
21457
+ }
21458
+ }
21046
21459
  async function withReleaseLedger(path2, expected, fn, opts = {}) {
21047
- return withFileLock(`${path2}.lock`, { label: "release ledger", maxWaitMs: opts.maxWaitMs ?? 5e3 }, async () => {
21460
+ return withLedgerLock(path2, async (fence) => {
21048
21461
  let current;
21049
21462
  let raw;
21050
21463
  try {
@@ -21059,9 +21472,9 @@ async function withReleaseLedger(path2, expected, fn, opts = {}) {
21059
21472
  if (expected) validateLedgerAnchors(current, expected);
21060
21473
  }
21061
21474
  const { ledger, value } = await fn(current);
21062
- if (ledger) atomicWriteJson(path2, ledger);
21475
+ if (ledger) await fence(() => atomicWriteJson(path2, ledger));
21063
21476
  return value;
21064
- });
21477
+ }, { maxWaitMs: opts.maxWaitMs ?? 5e3 });
21065
21478
  }
21066
21479
  function openReleaseLedgerWriter(cwd, opts = {}) {
21067
21480
  const now = opts.now ?? (() => /* @__PURE__ */ new Date());
@@ -21184,7 +21597,9 @@ var TRANSIENT_TRANSPORT_MARKERS = [
21184
21597
  /socket hang ?up/i,
21185
21598
  /remote end hung ?up unexpectedly/i,
21186
21599
  /early EOF/i,
21187
- /tls handshake timeout/i
21600
+ /tls handshake timeout/i,
21601
+ /kex_exchange_identification: connection closed by remote host/i,
21602
+ /connection closed by remote host/i
21188
21603
  ];
21189
21604
  function classifyTransientTransportFailure(evidence) {
21190
21605
  if (!evidence) return false;
@@ -21674,7 +22089,7 @@ async function reverifyPersistedRunPhases(deps, path2, anchors, input) {
21674
22089
  return result;
21675
22090
  }
21676
22091
  async function clearLedgerFile(path2, anchors, maxWaitMs) {
21677
- return withFileLock(`${path2}.lock`, { label: "release ledger", maxWaitMs }, async () => {
22092
+ return withLedgerLock(path2, async (fence) => {
21678
22093
  let raw;
21679
22094
  try {
21680
22095
  raw = (0, import_node_fs22.readFileSync)(path2, "utf8");
@@ -21693,9 +22108,9 @@ async function clearLedgerFile(path2, anchors, maxWaitMs) {
21693
22108
  } catch (e) {
21694
22109
  return { cleared: false, note: `release ledger at ${path2} belongs to ${ledger.tag} @ ${ledger.tagSha.slice(0, 12)} \u2014 not this release; left in place (${e instanceof Error ? e.message : String(e)})` };
21695
22110
  }
21696
- (0, import_node_fs22.unlinkSync)(path2);
22111
+ await fence(() => (0, import_node_fs22.unlinkSync)(path2));
21697
22112
  return { cleared: true, note: `cleared the release ledger at ${path2} for the live-proven aborted ${anchors.tag}` };
21698
- });
22113
+ }, { maxWaitMs });
21699
22114
  }
21700
22115
  async function clearReleaseLedger(cwd, anchors, opts = {}) {
21701
22116
  let path2;
@@ -21835,7 +22250,7 @@ async function alignmentLegClosedLive(deps, prior) {
21835
22250
  }
21836
22251
  async function healStaleAlignmentLeg(deps, cwd, expected) {
21837
22252
  const path2 = releaseLedgerPath(cwd);
21838
- return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
22253
+ return withLedgerLock(path2, async (fence) => {
21839
22254
  let prior;
21840
22255
  try {
21841
22256
  prior = parseReleaseLedger((0, import_node_fs22.readFileSync)(path2, "utf8"));
@@ -21851,16 +22266,80 @@ async function healStaleAlignmentLeg(deps, cwd, expected) {
21851
22266
  }
21852
22267
  const closed = await alignmentLegClosedLive(deps, prior);
21853
22268
  if (!closed) return void 0;
21854
- atomicWriteJson(path2, { ...prior, phases: { ...prior.phases, alignment: closed }, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
22269
+ await fence(() => atomicWriteJson(path2, { ...prior, phases: { ...prior.phases, alignment: closed }, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
21855
22270
  return prior.tag;
21856
22271
  });
21857
22272
  }
22273
+ async function healUnfoldedRunLegs(deps, cwd, model) {
22274
+ const path2 = releaseLedgerPath(cwd);
22275
+ let prior;
22276
+ try {
22277
+ prior = parseReleaseLedger((0, import_node_fs22.readFileSync)(path2, "utf8"));
22278
+ } catch {
22279
+ return void 0;
22280
+ }
22281
+ const legs = ["deploy", "publish"];
22282
+ const openLeg = (rec) => rec.state === "pending" && rec.superseded !== true;
22283
+ if (!legs.some((phase) => openLeg(prior.phases[phase]))) return void 0;
22284
+ const anchors = { repo: prior.repo, tag: prior.tag, tagSha: prior.tagSha };
22285
+ const folded = [];
22286
+ let current = prior;
22287
+ if (legs.some((phase) => openLeg(prior.phases[phase]) && prior.phases[phase].runId != null)) {
22288
+ let reverified;
22289
+ try {
22290
+ reverified = await reverifyPersistedRunPhases(deps, path2, anchors, { sha: prior.tagSha, repo: prior.repo });
22291
+ } catch {
22292
+ return void 0;
22293
+ }
22294
+ if (!reverified.ledger) return void 0;
22295
+ current = reverified.ledger;
22296
+ for (const phase of legs) {
22297
+ const before = prior.phases[phase];
22298
+ const after = current.phases[phase];
22299
+ if (openLeg(before) && before.runId != null && after.state !== "pending") folded.push({ phase, state: after.state, runId: after.runId });
22300
+ }
22301
+ }
22302
+ const targets = prodReleaseTargets(model);
22303
+ const runless = legs.filter((phase) => openLeg(current.phases[phase]) && current.phases[phase].runId == null);
22304
+ if (runless.length > 0 && targets.length > 0) {
22305
+ let rows;
22306
+ try {
22307
+ rows = await listOwnWorkflowRuns(deps, releaseRunRepoFor(model, prior.repo), targets, prior.tagSha);
22308
+ } catch {
22309
+ rows = [];
22310
+ }
22311
+ const inputs = phaseInputsFromRunRows(model, rows, prior.tagSha, { repo: prior.repo });
22312
+ const foldable = runless.filter((phase) => inputs[phase].runId != null && inputs[phase].state !== "pending");
22313
+ if (foldable.length > 0) {
22314
+ const written = await withReleaseLedger(path2, anchors, (ledger) => {
22315
+ if (!ledger) return { value: [] };
22316
+ const phases = { ...ledger.phases };
22317
+ const done = [];
22318
+ for (const phase of foldable) {
22319
+ const live = phases[phase];
22320
+ if (!openLeg(live) || live.runId != null) continue;
22321
+ const entry = compactEntry(inputs[phase]);
22322
+ phases[phase] = {
22323
+ ...entry,
22324
+ note: `${entry.note ?? `${phase} run on the release SHA`} \u2014 folded by train doctor --heal from the concluded run (#7003)`,
22325
+ retries: live.retries,
22326
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
22327
+ };
22328
+ done.push({ phase, state: entry.state, runId: entry.runId });
22329
+ }
22330
+ return done.length ? { ledger: { ...ledger, phases, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }, value: done } : { value: done };
22331
+ }).catch(() => []);
22332
+ folded.push(...written);
22333
+ }
22334
+ }
22335
+ return folded.length ? { tag: prior.tag, folded } : void 0;
22336
+ }
21858
22337
  function supersedeableDeployLegShape(rec) {
21859
22338
  return rec.state === "failed" || rec.state === "pending" && rec.runId == null;
21860
22339
  }
21861
22340
  async function supersedeContentCausedDeployLeg(deps, cwd) {
21862
22341
  const path2 = releaseLedgerPath(cwd);
21863
- return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
22342
+ return withLedgerLock(path2, async (fence) => {
21864
22343
  let prior;
21865
22344
  try {
21866
22345
  prior = parseReleaseLedger((0, import_node_fs22.readFileSync)(path2, "utf8"));
@@ -21901,14 +22380,14 @@ async function supersedeContentCausedDeployLeg(deps, cwd) {
21901
22380
  );
21902
22381
  }
21903
22382
  const note = `superseded: content-caused deploy failure on the immutable promoted ${prior.tag} \u2014 the cause fix rides the next patch train (master-approved closure, #6553)`;
21904
- atomicWriteJson(path2, {
22383
+ await fence(() => atomicWriteJson(path2, {
21905
22384
  ...prior,
21906
22385
  phases: {
21907
22386
  ...phases,
21908
22387
  deploy: { state: "skipped", sha: prior.tagSha, superseded: true, note, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
21909
22388
  },
21910
22389
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
21911
- });
22390
+ }));
21912
22391
  return {
21913
22392
  command: "release-supersede-deploy",
21914
22393
  repo: prior.repo,
@@ -21924,7 +22403,7 @@ function supersedeablePublishLegShape(rec) {
21924
22403
  function deferredGatewayDeployLegShape(rec) {
21925
22404
  return rec.state === "pending" && rec.runId == null && rec.workflow === "jerv-gateway";
21926
22405
  }
21927
- async function proveOriginPromotionOrArchived(deps, prior, path2, label, ref) {
22406
+ async function proveOriginPromotionOrArchived(deps, prior, path2, label, ref, fence) {
21928
22407
  let liveSha = "";
21929
22408
  try {
21930
22409
  liveSha = (await deps.run("git", ["ls-remote", "origin", `refs/tags/${prior.tag}`])).trim().split(/\s+/)[0] ?? "";
@@ -21963,12 +22442,12 @@ async function proveOriginPromotionOrArchived(deps, prior, path2, label, ref) {
21963
22442
  if ((0, import_node_fs22.existsSync)(archivePath)) {
21964
22443
  throw new Error(`${label}: archive already exists at ${archivePath} \u2014 preserving both ledger records; resolve the collision by hand; nothing was written (${ref})`);
21965
22444
  }
21966
- (0, import_node_fs22.renameSync)(path2, archivePath);
22445
+ await fence(() => (0, import_node_fs22.renameSync)(path2, archivePath));
21967
22446
  return "deleted";
21968
22447
  }
21969
22448
  async function supersedeContentCausedPublishLeg(deps, cwd) {
21970
22449
  const path2 = releaseLedgerPath(cwd);
21971
- return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
22450
+ return withLedgerLock(path2, async (fence) => {
21972
22451
  let prior;
21973
22452
  try {
21974
22453
  prior = parseReleaseLedger((0, import_node_fs22.readFileSync)(path2, "utf8"));
@@ -21999,7 +22478,7 @@ async function supersedeContentCausedPublishLeg(deps, cwd) {
21999
22478
  `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)`
22000
22479
  );
22001
22480
  }
22002
- if (await proveOriginPromotionOrArchived(deps, prior, path2, "release --supersede-publish", "#6638") === "deleted") {
22481
+ if (await proveOriginPromotionOrArchived(deps, prior, path2, "release --supersede-publish", "#6638", fence) === "deleted") {
22003
22482
  return {
22004
22483
  command: "release-supersede-publish",
22005
22484
  repo: prior.repo,
@@ -22028,14 +22507,14 @@ async function supersedeContentCausedPublishLeg(deps, cwd) {
22028
22507
  gatewayDeploy = { workflow: "jerv-gateway", state: "complete", note: dispatch.note };
22029
22508
  }
22030
22509
  const note = `superseded: content-caused publish failure on the immutable promoted ${prior.tag} \u2014 the cause fix rides the next patch train (master-approved closure, #6638)`;
22031
- atomicWriteJson(path2, {
22510
+ await fence(() => atomicWriteJson(path2, {
22032
22511
  ...prior,
22033
22512
  phases: {
22034
22513
  ...phases,
22035
22514
  publish: { state: "skipped", sha: prior.tagSha, superseded: true, note, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
22036
22515
  },
22037
22516
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
22038
- });
22517
+ }));
22039
22518
  return {
22040
22519
  command: "release-supersede-publish",
22041
22520
  repo: prior.repo,
@@ -22050,7 +22529,7 @@ async function supersedeContentCausedDeployAndPublishLegs(deps, cwd) {
22050
22529
  const label = "release --supersede-deploy --supersede-publish";
22051
22530
  const ref = "#6752";
22052
22531
  const path2 = releaseLedgerPath(cwd);
22053
- return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
22532
+ return withLedgerLock(path2, async (fence) => {
22054
22533
  let prior;
22055
22534
  try {
22056
22535
  prior = parseReleaseLedger((0, import_node_fs22.readFileSync)(path2, "utf8"));
@@ -22098,7 +22577,7 @@ async function supersedeContentCausedDeployAndPublishLegs(deps, cwd) {
22098
22577
  `${label}: ${prior.tag} has unresolved legs besides deploy and publish (${unresolved.map((p) => `${p}=${phases[p].state}`).join(", ")}) \u2014 the combined closure closes only the two content-caused legs of an otherwise-complete release; resolve those first, nothing was written (${ref})`
22099
22578
  );
22100
22579
  }
22101
- if (await proveOriginPromotionOrArchived(deps, prior, path2, label, ref) === "deleted") {
22580
+ if (await proveOriginPromotionOrArchived(deps, prior, path2, label, ref, fence) === "deleted") {
22102
22581
  return {
22103
22582
  command: "release-supersede-deploy-and-publish",
22104
22583
  repo: prior.repo,
@@ -22144,7 +22623,7 @@ async function supersedeContentCausedDeployAndPublishLegs(deps, cwd) {
22144
22623
  note: `superseded: content-caused publish failure on the immutable promoted ${prior.tag} \u2014 the cause fix rides the next patch train (master-approved closure, ${ref})`,
22145
22624
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
22146
22625
  };
22147
- atomicWriteJson(path2, { ...prior, phases, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
22626
+ await fence(() => atomicWriteJson(path2, { ...prior, phases, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
22148
22627
  return {
22149
22628
  command: "release-supersede-deploy-and-publish",
22150
22629
  repo: prior.repo,
@@ -22175,7 +22654,7 @@ async function beginReleaseLedger(deps, cwd, targetTag) {
22175
22654
  return { ok: true };
22176
22655
  }
22177
22656
  await reverifyPriorRunLegs(deps, path2, targetTag);
22178
- return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
22657
+ return withLedgerLock(path2, async (fence) => {
22179
22658
  let raw;
22180
22659
  try {
22181
22660
  raw = (0, import_node_fs22.readFileSync)(path2, "utf8");
@@ -22195,7 +22674,7 @@ async function beginReleaseLedger(deps, cwd, targetTag) {
22195
22674
  const closed = await alignmentLegClosedLive(deps, prior);
22196
22675
  if (closed) {
22197
22676
  prior = { ...prior, phases: { ...prior.phases, alignment: closed }, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
22198
- atomicWriteJson(path2, prior);
22677
+ await fence(() => atomicWriteJson(path2, prior));
22199
22678
  derived = deriveReleasePhaseStatus(prior);
22200
22679
  }
22201
22680
  }
@@ -22218,13 +22697,13 @@ async function beginReleaseLedger(deps, cwd, targetTag) {
22218
22697
  }
22219
22698
  const archivePath = (0, import_node_path22.join)((0, import_node_path22.dirname)(path2), `phases.archive-${prior.tag}.json`);
22220
22699
  try {
22221
- (0, import_node_fs22.unlinkSync)(archivePath);
22700
+ await fence(() => (0, import_node_fs22.unlinkSync)(archivePath));
22222
22701
  } catch (e) {
22223
22702
  if (e.code !== "ENOENT") {
22224
22703
  return { ok: false, error: `could not clear the stale archive at ${archivePath} (${e instanceof Error ? e.message : String(e)}) \u2014 the ledger for ${prior.tag} stays active (#5987)` };
22225
22704
  }
22226
22705
  }
22227
- (0, import_node_fs22.renameSync)(path2, archivePath);
22706
+ await fence(() => (0, import_node_fs22.renameSync)(path2, archivePath));
22228
22707
  return { ok: true, note: `archived the completed release ledger ${prior.tag} to ${archivePath} (live-proven at ${prior.tagSha.slice(0, 12)})` };
22229
22708
  });
22230
22709
  }
@@ -22249,7 +22728,7 @@ async function defaultRetryDeploy(deps, prior) {
22249
22728
  }
22250
22729
  async function retryHotfixDeployLegs(deps, cwd, opts = {}) {
22251
22730
  const path2 = releaseLedgerPath(cwd);
22252
- return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
22731
+ return withLedgerLock(path2, async (fence) => {
22253
22732
  let prior;
22254
22733
  try {
22255
22734
  prior = parseReleaseLedger((0, import_node_fs22.readFileSync)(path2, "utf8"));
@@ -22330,7 +22809,7 @@ async function retryHotfixDeployLegs(deps, cwd, opts = {}) {
22330
22809
  );
22331
22810
  }
22332
22811
  }
22333
- atomicWriteJson(path2, { ...prior, phases, updatedAt: now().toISOString() });
22812
+ await fence(() => atomicWriteJson(path2, { ...prior, phases, updatedAt: now().toISOString() }));
22334
22813
  const stillFailed = retried.filter((phase) => phases[phase].state === "failed");
22335
22814
  const status = stillFailed.length === 0 ? "success" : "failed";
22336
22815
  return {
@@ -22482,6 +22961,8 @@ var defaultDeps2 = {
22482
22961
  }
22483
22962
  },
22484
22963
  healAlignmentLeg: healStaleAlignmentLeg,
22964
+ healRunLegs: healUnfoldedRunLegs,
22965
+ trainWriterHolder: liveTrainWriterHolder,
22485
22966
  readSurfacesRaw: (cwd) => {
22486
22967
  try {
22487
22968
  return (0, import_node_fs23.readFileSync)((0, import_node_path23.join)(cwd, "surfaces.json"), "utf8");
@@ -22824,11 +23305,31 @@ async function runTrainDoctor(input) {
22824
23305
  break;
22825
23306
  }
22826
23307
  }
23308
+ const runningTrain = deps.trainWriterHolder(cwd);
23309
+ if (runningTrain) {
23310
+ add({ code: "train-running", severity: "blocker", source: "local", title: `another train is running on this checkout: ${runningTrain}`, remedy: "wait for it to finish, then rerun; if that process is gone, just rerun \u2014 a dead holder's lock is reclaimed automatically (#7027)" });
23311
+ }
23312
+ const healLedger = heal && !runningTrain;
22827
23313
  if (ledgerRaw !== void 0 && ledgerPath) {
22828
23314
  try {
22829
23315
  let ledger = parseReleaseLedger(ledgerRaw);
22830
23316
  let derived = deriveReleasePhaseStatus(ledger);
22831
- if (heal && derived.phaseStatus === "phases-pending" && derived.legs.every((l) => l.state !== "pending" || l.phase === "alignment")) {
23317
+ const deployModel = typeof meta?.deployModel === "string" && isDeployModel(meta.deployModel) ? meta.deployModel : void 0;
23318
+ if (healLedger && deployModel && derived.legs.some((l) => (l.phase === "deploy" || l.phase === "publish") && l.state === "pending")) {
23319
+ const run = await deps.healRunLegs(train, cwd, deployModel);
23320
+ if (run) {
23321
+ add({
23322
+ code: "ledger-unfolded",
23323
+ severity: "healed",
23324
+ source: "origin",
23325
+ title: `folded ${run.folded.map((f) => `${f.phase}=${f.state}${f.runId ? ` (run ${f.runId})` : ""}`).join(", ")} into the ${run.tag} ledger from the concluded run(s) on the tag SHA (#7003)`,
23326
+ remedy: "nothing \u2014 the leg now records what GitHub already concluded"
23327
+ });
23328
+ ledger = parseReleaseLedger(deps.readLedgerRaw(ledgerPath) ?? ledgerRaw);
23329
+ derived = deriveReleasePhaseStatus(ledger);
23330
+ }
23331
+ }
23332
+ if (healLedger && derived.phaseStatus === "phases-pending" && derived.legs.every((l) => l.state !== "pending" || l.phase === "alignment")) {
22832
23333
  const closedTag = await deps.healAlignmentLeg(train, cwd);
22833
23334
  if (closedTag) {
22834
23335
  add({ code: "ledger-alignment-stale", severity: "healed", source: "origin", title: `closed the alignment-pending leg of ${closedTag}: its alignment PR(s) read MERGED on a fresh origin read (#6061)`, remedy: "nothing \u2014 the ledger is phases-green" });
@@ -22866,7 +23367,7 @@ async function runTrainDoctor(input) {
22866
23367
  // #6429: each lane finishes its own run (#6068) — an rcand-lane ledger named as a release
22867
23368
  // ledger, with `release --resume` as its remedy, sends the operator to a verb that refuses it.
22868
23369
  title: `a prior ${ledger.lane ?? "release"} ledger for ${ledger.tag} is ${derived.phaseStatus} (${open2}) \u2014 a new train must not stack on it (#5987)`,
22869
- remedy: failed ? ledgerFailedRemedy(ledger) : `finish it first: ${ledgerLaneResumeCommand(ledger)} (alignment-only pending legs close themselves with \`train doctor --heal\` once the PR merges)${supersession}`
23370
+ 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}`
22870
23371
  });
22871
23372
  } else if (newestRemoteReleaseTag && newestRemoteReleaseTag !== ledger.tag) {
22872
23373
  add({
@@ -23622,6 +24123,8 @@ async function recoverPhaseWithMaybeTransientRerun(deps, ledger, anchors, phase,
23622
24123
  }
23623
24124
  async function recoverFailedDispatchPhase(deps, ledger, anchors, phase, dispatch, input, recover = recoverPhaseWithMaybeTransientRerun) {
23624
24125
  if (dispatch.deployStatus !== "failure") return dispatch;
24126
+ const persisted = await ledger?.load(anchors).catch(() => void 0);
24127
+ if (persisted?.phases[phase]?.superseded === true) return dispatch;
23625
24128
  let workflowRuns = dispatch.workflowRuns?.length ? [...dispatch.workflowRuns] : void 0;
23626
24129
  const notes = [];
23627
24130
  let fallbackRecovered = false;
@@ -23629,6 +24132,7 @@ async function recoverFailedDispatchPhase(deps, ledger, anchors, phase, dispatch
23629
24132
  if (failedRows.length > 0) {
23630
24133
  for (const failed of failedRows) {
23631
24134
  const rowPhase = failed.workflow.includes("publish") ? "publish" : phase;
24135
+ if (persisted?.phases[rowPhase]?.superseded === true) continue;
23632
24136
  const recovery = await recover(deps, ledger, anchors, rowPhase, {
23633
24137
  repo: input.repo,
23634
24138
  sha: input.sha,
@@ -23803,7 +24307,10 @@ ${recovery.note}`), recoveryInput);
23803
24307
  await verifyPublishedRelease(deps, ctx.repo, tag, "main", releaseSha);
23804
24308
  await recordPhase(ledger, deps, ledgerAnchors, "githubRelease", phaseEntry({ state: "complete", sha: releaseSha, runUrl: releaseUrl, note: `verified GitHub Release ${tag} against ${releaseSha.slice(0, 12)}` }), { landed: "the immutable tag, the green required-check wall, and the origin/main fast-forward" });
23805
24309
  let alignment = alignEarly ? await alignEarly() : void 0;
23806
- const deployDispatch0 = await dispatchDeploy(deps, ctx, "main", "main", deployModel, watch, autoRunSince, releaseSha, "report", meta.publishDir);
24310
+ const [deployDispatch0, publishDispatch0] = await joinDeployAndPublish(
24311
+ dispatchDeploy(deps, ctx, "main", "main", deployModel, watch, autoRunSince, releaseSha, "report", meta.publishDir),
24312
+ observePublishIfRequired(deps, ctx, meta, deployModel, "main", releaseSha, watch)
24313
+ );
23807
24314
  const deployRunRepo = releaseRunRepoFor(deployModel, ctx.repo);
23808
24315
  const failedReleaseRun = deployDispatch0.workflowRuns?.find((run) => run.conclusion === "failure");
23809
24316
  const dispatchFailurePhase = failedReleaseRun?.workflow === "publish.yml" || deployModel === "registry-publish" ? "publish" : "deploy";
@@ -23825,7 +24332,6 @@ ${recovery.note}`), recoveryInput);
23825
24332
  await recordPhase(ledger, deps, ledgerAnchors, "deploy", deployLeg, {
23826
24333
  landed: "the immutable tag, the green required-check wall, the origin/main fast-forward, and the verified GitHub Release"
23827
24334
  });
23828
- const publishDispatch0 = await observePublishIfRequired(deps, ctx, meta, deployModel, "main", releaseSha, watch);
23829
24335
  const publishDispatch = publishDispatch0 ? await recoverFailedDispatchPhase(
23830
24336
  deps,
23831
24337
  ledger,
@@ -23841,17 +24347,6 @@ ${recovery.note}`), recoveryInput);
23841
24347
  await recordPhase(ledger, deps, ledgerAnchors, "publish", publishLeg, {
23842
24348
  landed: "the immutable tag, the green required-check wall, origin/main, the verified GitHub Release, and the dispatched deploy path"
23843
24349
  });
23844
- let dispatch = appendPublishDispatch(deployDispatch, publishDispatch);
23845
- dispatch = await appendMmiGetRedeploy(
23846
- deps,
23847
- { repo: ctx.repo, publishStatus: publishLeg.state === "complete" ? "success" : "failure", watch },
23848
- dispatch
23849
- );
23850
- dispatch = await appendServedChannelPublish(
23851
- deps,
23852
- { repo: ctx.repo, publishStatus: publishLeg.state === "complete" ? "success" : "failure", watch },
23853
- dispatch
23854
- );
23855
24350
  const announcing = announceShippedRelease(
23856
24351
  deps,
23857
24352
  { repo: ctx.repo, tag, summaryFile: options.announceSummaryFile },
@@ -23859,12 +24354,13 @@ ${recovery.note}`), recoveryInput);
23859
24354
  { deploy: deployLeg, publish: publishLeg },
23860
24355
  releaseAnnounceDeferral(deployModel, options.announceSummaryFile)
23861
24356
  );
23862
- if (watch && alignment) {
23863
- alignment = {
23864
- dev: await awaitAlignmentLanded(deps, ctx, alignment.dev, "development"),
23865
- ...alignment.rc ? { rc: await awaitAlignmentLanded(deps, ctx, alignment.rc, "rc") } : {}
23866
- };
23867
- }
24357
+ const aligning = watch && alignment ? awaitAlignmentsLanded(deps, ctx, alignment) : Promise.resolve(alignment);
24358
+ const dispatch = await appendServedChannelLegs(
24359
+ deps,
24360
+ { repo: ctx.repo, tag, releaseSha, watch, deploy: deployLeg, publish: publishLeg, installer: meta.installer },
24361
+ appendPublishDispatch(deployDispatch, publishDispatch)
24362
+ );
24363
+ alignment = await aligning;
23868
24364
  const announceNote = await announcing;
23869
24365
  return { checks, releaseUrl, announceNote, dispatch, ledgerAnchors, ...alignment ? { alignment } : {} };
23870
24366
  }
@@ -23932,6 +24428,9 @@ async function probeAncestor(deps, ancestor, descendant, label) {
23932
24428
  }
23933
24429
  }
23934
24430
  async function runRcandResume(deps, options = {}) {
24431
+ return withTrainWriter(deps, "rcand --resume", () => runRcandResumeLocked(deps, options));
24432
+ }
24433
+ async function runRcandResumeLocked(deps, options) {
23935
24434
  const watch = options.watch ?? false;
23936
24435
  const ctx = await buildTrainApplyContext(deps);
23937
24436
  await requireCleanTree(deps);
@@ -24180,6 +24679,8 @@ Nothing was written. Inspect ${ledger.path} (or clear it only after proving the
24180
24679
  dispatch2.deployStatus = historicalDeploy;
24181
24680
  dispatch2.note = `historical ${deployModel} deploy/publish status: ${historicalDeploy}; ${companion.note}`;
24182
24681
  }
24682
+ if (persisted?.phases.deploy.superseded === true) phaseInputs.deploy = persisted.phases.deploy;
24683
+ if (persisted?.phases.publish.superseded === true) phaseInputs.publish = persisted.phases.publish;
24183
24684
  if (persisted) {
24184
24685
  if (persisted.phases.deploy.superseded !== true) {
24185
24686
  await recordPhase(ledger, deps, anchors, "deploy", phaseEntry(phaseInputs.deploy), { strict: true, landed: "the verified GitHub Release and origin/main at the immutable tag SHA" });
@@ -24190,8 +24691,15 @@ Nothing was written. Inspect ${ledger.path} (or clear it only after proving the
24190
24691
  await recordPhase(ledger, deps, anchors, "githubRelease", phaseEntry({ state: "complete", sha: tagSha, note: "re-verified live: the GitHub Release exists at the immutable tag SHA" }), { strict: true, landed: "the promotion (verified live)" });
24191
24692
  await recordPhase(ledger, deps, anchors, "promotion", phaseEntry({ state: "complete", sha: tagSha, note: "re-verified live: origin/main contains the immutable tag SHA" }), { strict: true, landed: "nothing beyond the already-public release" });
24192
24693
  }
24694
+ const shippedDispatch = await appendServedChannelLegs(
24695
+ deps,
24696
+ { repo: ctx.repo, tag, releaseSha: tagSha, watch, deploy: phaseInputs.deploy, publish: phaseInputs.publish, installer: meta.installer },
24697
+ dispatch2
24698
+ );
24699
+ Object.assign(dispatch2, shippedDispatch);
24193
24700
  if (isJervHubRepo(ctx.repo)) steps2.push(dispatch2.note);
24194
- const announceNote2 = persisted && !shippedBefore && persisted.phases.deploy.superseded !== true && (prodReleaseTargets(deployModel).length > 0 || companion !== null) ? await announceShippedRelease(
24701
+ const settledBySupersede = persisted?.phases.deploy.superseded === true || persisted?.phases.publish.superseded === true;
24702
+ const announceNote2 = persisted && !shippedBefore && !settledBySupersede && (prodReleaseTargets(deployModel).length > 0 || companion !== null) ? await announceShippedRelease(
24195
24703
  deps,
24196
24704
  { repo: ctx.repo, tag, summaryFile: options.announceSummaryFile },
24197
24705
  deployModel,
@@ -24281,7 +24789,10 @@ ${recovery.note}`), recoveryInput);
24281
24789
  await recordPhase(ledger, deps, anchors, "githubRelease", phaseEntry({ state: "complete", sha: tagSha, runUrl: releaseUrl, note: "verified the Release published against the tagged commit" }), { strict: ledgerMode === "strict", landed: "the immutable tag, the re-proven wall, and the origin/main push" });
24282
24790
  let devRollForward = await rollDevelopmentForward(deps, ctx, tag);
24283
24791
  let rcAlignment = !directTrack && branchHints.hasRcBranch ? await alignRcForward(deps, ctx, tag) : void 0;
24284
- const deployDispatch0 = await dispatchDeploy(deps, ctx, "main", "main", deployModel, watch, autoRunSince, tagSha, "report", meta.publishDir);
24792
+ const [deployDispatch0, publishDispatch0] = await joinDeployAndPublish(
24793
+ dispatchDeploy(deps, ctx, "main", "main", deployModel, watch, autoRunSince, tagSha, "report", meta.publishDir),
24794
+ observePublishIfRequired(deps, ctx, meta, deployModel, "main", tagSha, watch)
24795
+ );
24285
24796
  const recoveredDeployDispatch = await recoverFailedDispatchPhase(
24286
24797
  deps,
24287
24798
  ledger,
@@ -24297,12 +24808,13 @@ ${recovery.note}`), recoveryInput);
24297
24808
  );
24298
24809
  const deployDispatch = await appendJervGatewayReleaseDeploy(deps, ctx.repo, tag, tagSha, recoveredDeployDispatch);
24299
24810
  steps.push(`dispatched the ${deployModel} deploy path`);
24300
- const deployLeg = phaseEntry(deployPhaseInput(deployModel, deployDispatch, { releaseSha: tagSha }));
24301
- await recordPhase(ledger, deps, anchors, "deploy", deployLeg, {
24302
- strict: ledgerMode === "strict",
24303
- landed: "the immutable tag, the re-proven wall, the origin/main push, and the verified GitHub Release"
24304
- });
24305
- const publishDispatch0 = await observePublishIfRequired(deps, ctx, meta, deployModel, "main", tagSha, watch);
24811
+ const deployLeg = persisted?.phases.deploy.superseded === true ? persisted.phases.deploy : phaseEntry(deployPhaseInput(deployModel, deployDispatch, { releaseSha: tagSha }));
24812
+ if (persisted?.phases.deploy.superseded !== true) {
24813
+ await recordPhase(ledger, deps, anchors, "deploy", deployLeg, {
24814
+ strict: ledgerMode === "strict",
24815
+ landed: "the immutable tag, the re-proven wall, the origin/main push, and the verified GitHub Release"
24816
+ });
24817
+ }
24306
24818
  const publishDispatch = publishDispatch0 ? await recoverFailedDispatchPhase(
24307
24819
  deps,
24308
24820
  ledger,
@@ -24311,12 +24823,18 @@ ${recovery.note}`), recoveryInput);
24311
24823
  publishDispatch0,
24312
24824
  { repo: ctx.repo, sha: tagSha, watched: false, workflow: "publish.yml" }
24313
24825
  ) : null;
24314
- const publishLeg = phaseEntry(publishPhaseInput(deployModel, deployDispatch, publishDispatch, meta.publishRequired === true, { releaseSha: tagSha, repo: ctx.repo }));
24315
- await recordPhase(ledger, deps, anchors, "publish", publishLeg, {
24316
- strict: ledgerMode === "strict",
24317
- landed: "the immutable tag, the re-proven wall, origin/main, the verified GitHub Release, and the dispatched deploy path"
24318
- });
24319
- const dispatch = appendPublishDispatch(deployDispatch, publishDispatch);
24826
+ const publishLeg = persisted?.phases.publish.superseded === true ? persisted.phases.publish : phaseEntry(publishPhaseInput(deployModel, deployDispatch, publishDispatch, meta.publishRequired === true, { releaseSha: tagSha, repo: ctx.repo }));
24827
+ if (persisted?.phases.publish.superseded !== true) {
24828
+ await recordPhase(ledger, deps, anchors, "publish", publishLeg, {
24829
+ strict: ledgerMode === "strict",
24830
+ landed: "the immutable tag, the re-proven wall, origin/main, the verified GitHub Release, and the dispatched deploy path"
24831
+ });
24832
+ }
24833
+ const dispatch = await appendServedChannelLegs(
24834
+ deps,
24835
+ { repo: ctx.repo, tag, releaseSha: tagSha, watch, deploy: deployLeg, publish: publishLeg, installer: meta.installer },
24836
+ appendPublishDispatch(deployDispatch, publishDispatch)
24837
+ );
24320
24838
  const announcing = announceShippedRelease(
24321
24839
  deps,
24322
24840
  { repo: ctx.repo, tag, summaryFile: options.announceSummaryFile },
@@ -24325,8 +24843,9 @@ ${recovery.note}`), recoveryInput);
24325
24843
  releaseAnnounceDeferral(deployModel, options.announceSummaryFile)
24326
24844
  );
24327
24845
  if (watch) {
24328
- devRollForward = await awaitAlignmentLanded(deps, ctx, devRollForward, "development");
24329
- if (rcAlignment) rcAlignment = await awaitAlignmentLanded(deps, ctx, rcAlignment, "rc");
24846
+ const landed = await awaitAlignmentsLanded(deps, ctx, { dev: devRollForward, rc: rcAlignment });
24847
+ devRollForward = landed.dev;
24848
+ rcAlignment = landed.rc;
24330
24849
  }
24331
24850
  const announceNote = await announcing;
24332
24851
  steps.push(`development roll-forward: ${devRollForward.status}`);
@@ -24372,6 +24891,9 @@ async function withCheckoutRestoredAfterReleaseResume(deps, startBranch, operati
24372
24891
  return withCheckoutRestoredAfterRelease(deps, result, startBranch);
24373
24892
  }
24374
24893
  async function runReleaseResume(deps, options = {}) {
24894
+ return withTrainWriter(deps, "release --resume", () => runReleaseResumeLocked(deps, options));
24895
+ }
24896
+ async function runReleaseResumeLocked(deps, options) {
24375
24897
  const ctx = await buildTrainApplyContext(deps);
24376
24898
  await requireCleanTree(deps);
24377
24899
  await runGitRemoteRead(deps, ["fetch", "origin", "--tags"]);
@@ -24387,6 +24909,10 @@ async function runReleaseResume(deps, options = {}) {
24387
24909
  );
24388
24910
  }
24389
24911
  async function runReleaseAbort(deps, options = {}) {
24912
+ if (!options.approved) return runReleaseAbortLocked(deps, options);
24913
+ return withTrainWriter(deps, "release --abort", () => runReleaseAbortLocked(deps, options));
24914
+ }
24915
+ async function runReleaseAbortLocked(deps, options) {
24390
24916
  if (!options.approved) {
24391
24917
  throw new Error("release --abort requires --apply after explicit approval; nothing was written");
24392
24918
  }
@@ -24461,7 +24987,7 @@ async function runReleaseSupersedeDeploy(deps, options = {}) {
24461
24987
  throw new Error("release --supersede-deploy requires --apply after explicit master approval; nothing was written");
24462
24988
  }
24463
24989
  try {
24464
- return await supersedeContentCausedDeployLeg(deps, process.cwd());
24990
+ return await withTrainWriter(deps, "release --supersede-deploy", () => supersedeContentCausedDeployLeg(deps, process.cwd()));
24465
24991
  } catch (e) {
24466
24992
  const message2 = e instanceof Error ? e.message : String(e);
24467
24993
  throw new Error(message2.includes("nothing was written") || message2.includes("nothing to do") ? message2 : `${message2}; nothing was written`);
@@ -24472,7 +24998,7 @@ async function runReleaseSupersedePublish(deps, options = {}) {
24472
24998
  throw new Error("release --supersede-publish requires --apply after explicit master approval; nothing was written");
24473
24999
  }
24474
25000
  try {
24475
- return await supersedeContentCausedPublishLeg(deps, process.cwd());
25001
+ return await withTrainWriter(deps, "release --supersede-publish", () => supersedeContentCausedPublishLeg(deps, process.cwd()));
24476
25002
  } catch (e) {
24477
25003
  const message2 = e instanceof Error ? e.message : String(e);
24478
25004
  throw new Error(message2.includes("nothing was written") || message2.includes("nothing to do") ? message2 : `${message2}; nothing was written`);
@@ -24483,13 +25009,17 @@ async function runReleaseSupersedeDeployAndPublish(deps, options = {}) {
24483
25009
  throw new Error("release --supersede-deploy --supersede-publish requires --apply after explicit master approval; nothing was written");
24484
25010
  }
24485
25011
  try {
24486
- return await supersedeContentCausedDeployAndPublishLegs(deps, process.cwd());
25012
+ return await withTrainWriter(deps, "release --supersede-deploy --supersede-publish", () => supersedeContentCausedDeployAndPublishLegs(deps, process.cwd()));
24487
25013
  } catch (e) {
24488
25014
  const message2 = e instanceof Error ? e.message : String(e);
24489
25015
  throw new Error(message2.includes("nothing was written") || message2.includes("nothing to do") ? message2 : `${message2}; nothing was written`);
24490
25016
  }
24491
25017
  }
24492
25018
  async function runReleasePublishRetry(deps, runId, options = {}) {
25019
+ if (!options.approved) return runReleasePublishRetryLocked(deps, runId, options);
25020
+ return withTrainWriter(deps, "release --retry-publish", () => runReleasePublishRetryLocked(deps, runId, options));
25021
+ }
25022
+ async function runReleasePublishRetryLocked(deps, runId, options) {
24493
25023
  if (!options.approved) {
24494
25024
  throw new Error("release --retry-publish requires --apply after explicit approval; nothing was written");
24495
25025
  }
@@ -24934,6 +25464,9 @@ async function resolveHotfixVersion(deps, env = process.env) {
24934
25464
  return { tag: `v${version}`, version, baseTag };
24935
25465
  }
24936
25466
  async function runTrainApply(command, deps, options = {}) {
25467
+ return withTrainWriter(deps, `${command} --apply`, () => runTrainApplyLocked(command, deps, options));
25468
+ }
25469
+ async function runTrainApplyLocked(command, deps, options = {}) {
24937
25470
  const watch = options.watch ?? false;
24938
25471
  await runTrainDoctor({ lane: command === "rcand" ? "rcand" : "release", dev: command === "release" && options.dev === true, heal: true, train: deps, refuse: true });
24939
25472
  const ctx = await buildTrainApplyContext(deps);
@@ -24981,6 +25514,7 @@ var RULESET_TEMPLATE_SEED = "seed:mmi-product-required-checks.template.json";
24981
25514
  var RULESET_REFERENCE_MATCH_LABEL = "committed ruleset reference matches the live ruleset (#3816)";
24982
25515
  var GATE_GREEN_ON_BASE_LABEL = "gate is green on the default branch (#3819)";
24983
25516
  var REQUIRED_CONTEXTS_EMITTED_LABEL = "required check contexts match PR workflows";
25517
+ var UNBLOCKING_GATE_JOBS_LABEL = "every gate job can block a merge (#7012)";
24984
25518
  var TAG_ADDRESSABLE_CONTEXTS_LABEL = "rc/main required contexts are tag-addressable (#3880)";
24985
25519
  var RELEASE_BRANCH_REACHABLE_LABEL = "rc/main required contexts are reachable from a release-base head (#5193)";
24986
25520
  var RULESET_TRACK_SCOPE_LABEL = "product ruleset branch scope covers the release track (#6327)";
@@ -25413,6 +25947,17 @@ async function auditRepoCi(repo, deps) {
25413
25947
  remediation: missingEmitters.length ? `Fix the pull_request workflows or ${contextAuthority.source} before reconciling` : void 0
25414
25948
  });
25415
25949
  }
25950
+ if (hasRequiredChecks) {
25951
+ const gateFiles = new Set(gateWorkflowFiles(prWorkflowPaths));
25952
+ const gateWorkflows = (await getBranchWorkflows() ?? []).filter((wf) => gateFiles.has(wf.path.slice(wf.path.lastIndexOf("/") + 1)));
25953
+ const unblocking = unblockingGateJobs(gateWorkflows, new Set(liveContexts)).filter((job) => !AGENT_PR_BOOKKEEPING_CONTEXTS.has(job));
25954
+ checks.push({
25955
+ ok: unblocking.length === 0,
25956
+ label: UNBLOCKING_GATE_JOBS_LABEL,
25957
+ detail: unblocking.length ? `gate job(s) [${unblocking.join(", ")}] run on every PR but are neither required nor needed by a required job (required: [${liveContexts.join(", ")}]) \u2014 a red one still merges and turns ${baseBranch} red for every later PR` : void 0,
25958
+ remediation: unblocking.length ? `mmi-cli oracle org project set ${repo} --var requiredChecks=${JSON.stringify(sortedUnique([...liveContexts, ...unblocking]))} then mmi-cli devops ci reconcile --repo ${repo} --apply \u2014 or make a required job \`needs:\` them` : void 0
25959
+ });
25960
+ }
25416
25961
  const prOnly = contextAuthority.coversReleaseBranches ? contextAuthority.contexts.filter((context) => TRAIN_PR_ONLY_CONTEXTS.has(context)) : [];
25417
25962
  const tagBlind = contextAuthority.coversReleaseBranches ? contextsUnreachableOnTagPush(contextAuthority.contexts, await getBranchWorkflows() ?? [], TRAIN_PR_ONLY_CONTEXTS) : [];
25418
25963
  checks.push({
@@ -30348,7 +30893,7 @@ function registerDeployCommands(program3) {
30348
30893
  init_cli_shared();
30349
30894
 
30350
30895
  // src/project-set.ts
30351
- var UNSET_KEYS = ["oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "tenantTasks", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "releaseChannel", "releaseLanguage", "dsManifestPath", "consumesDesignSystem", "ci", "requiredChecks", "requiredCheckBranches", "ciExemptReason", "gate", "seedCanary"];
30896
+ var UNSET_KEYS = ["installer", "oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "tenantTasks", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "releaseChannel", "releaseLanguage", "dsManifestPath", "consumesDesignSystem", "ci", "requiredChecks", "requiredCheckBranches", "ciExemptReason", "gate", "seedCanary"];
30352
30897
  var UNSET_KEY_SET = new Set(UNSET_KEYS);
30353
30898
  var RUNTIME_SECRET_STAGES = ["dev", "rc", "main"];
30354
30899
  var SECRET_CONSUMERS = ["runtime", "build", "lambda", "actions", "agent", "box"];
@@ -30606,6 +31151,17 @@ function parseRuntimeVaultOnlyVar(raw) {
30606
31151
  if (raw === "false") return false;
30607
31152
  throw new Error("org project set: runtimeVaultOnly must be true or false");
30608
31153
  }
31154
+ function parseInstallerVar(raw) {
31155
+ let value;
31156
+ try {
31157
+ value = JSON.parse(raw);
31158
+ } catch {
31159
+ throw new Error('org project set: installer must be JSON, e.g. {"product":"mm-strategy","family":"mmi"}');
31160
+ }
31161
+ const error = installerDeclarationError(value, "mmi");
31162
+ if (error && !error.includes("breaks the sign-in rule")) throw new Error(`org project set: ${error}`);
31163
+ return value;
31164
+ }
30609
31165
  function parseSeedCanaryVar(raw) {
30610
31166
  if (raw === "true") return true;
30611
31167
  if (raw === "false") return false;
@@ -30776,7 +31332,8 @@ var SETTABLE_VAR_KEYS = [
30776
31332
  "ciExemptReason",
30777
31333
  "gate",
30778
31334
  "secrets",
30779
- "seedCanary"
31335
+ "seedCanary",
31336
+ "installer"
30780
31337
  ];
30781
31338
  var SETTABLE_VAR_KEY_SET = new Set(SETTABLE_VAR_KEYS);
30782
31339
  var SETTABLE_VAR_HINTS = {
@@ -30789,6 +31346,7 @@ var SETTABLE_VAR_HINTS = {
30789
31346
  consumesDesignSystem: '"fofu"',
30790
31347
  runtimeVaultOnly: "true|false",
30791
31348
  seedCanary: "true|false",
31349
+ installer: "JSON {product,family:jerv|mmi,login?:github|google} \u2014 the repo ships a one-line installer on get.jerv.ai or get.mutmut.work",
30792
31350
  repos: 'JSON array, e.g. ["mutmutco/mm-foo"]',
30793
31351
  oauth: "JSON {subdomains,domains,callbackPath,extraCallbackPaths}",
30794
31352
  requiredGcpApis: "comma-string",
@@ -30924,6 +31482,8 @@ function buildProjectSetPatch(input) {
30924
31482
  patch[key] = parseRuntimeVaultOnlyVar(raw);
30925
31483
  } else if (key === "seedCanary") {
30926
31484
  patch[key] = parseSeedCanaryVar(raw);
31485
+ } else if (key === "installer") {
31486
+ patch[key] = parseInstallerVar(raw);
30927
31487
  } else if (key === "consumesDesignSystem") {
30928
31488
  patch[key] = parseConsumesDesignSystemVar(raw);
30929
31489
  } else if (key === "publishDir") {
@@ -43998,6 +44558,32 @@ function formatHotfixBatchPreflightRefusal(failures, fromSpecs) {
43998
44558
  ${lines2.join("\n")}
43999
44559
  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`;
44000
44560
  }
44561
+ var CHERRY_PICKED_FROM = /\(cherry picked from commit ([0-9a-f]{7,40})\)/g;
44562
+ async function findHotfixPickGaps(deps, sources, tolerated) {
44563
+ if (sources.length === 0) return [];
44564
+ const namedShas = new Set(sources.map((s) => s.sha.toLowerCase()));
44565
+ const carried = /* @__PURE__ */ new Set();
44566
+ const mainOnly = await deps.run("git", ["log", "--no-merges", "--format=%B", "origin/development..origin/main"]);
44567
+ for (const m of mainOnly.matchAll(CHERRY_PICKED_FROM)) carried.add(m[1].toLowerCase());
44568
+ const gaps = [];
44569
+ const devOnly = (await deps.run("git", ["log", "--no-merges", "--reverse", "--format=%H%x09%s", "origin/main..origin/development"])).split("\n").map((s) => s.trim()).filter(Boolean);
44570
+ for (const line of devOnly) {
44571
+ const [sha, ...subjectParts] = line.split(" ");
44572
+ if (!sha) continue;
44573
+ const lower = sha.toLowerCase();
44574
+ if (namedShas.has(lower) || carried.has(lower)) continue;
44575
+ const paths = (await deps.run("git", ["show", "--no-color", "--name-only", "--pretty=format:", sha])).split("\n").map((s) => s.trim()).filter(Boolean);
44576
+ if (paths.length === 0 || paths.every((p) => pathIsTolerated(p, tolerated))) continue;
44577
+ gaps.push({ sha, subject: subjectParts.join(" ") });
44578
+ }
44579
+ return gaps;
44580
+ }
44581
+ function formatHotfixPickGapRefusal(gaps, tag, fromSpecs) {
44582
+ const lines2 = gaps.map((g) => ` - ${g.sha.slice(0, 7)} ${g.subject}`);
44583
+ return `hotfix start refused: ${tag} would omit ${gaps.length} merged development fix(es) not on origin/main:
44584
+ ${lines2.join("\n")}
44585
+ 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.`;
44586
+ }
44001
44587
  async function preflightHotfixBatchPicks(deps, sources, pickTolerated) {
44002
44588
  if (sources.length === 0) return [];
44003
44589
  const startBranch = clean3(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
@@ -44137,6 +44723,9 @@ async function hotfixReleaseExists(deps, ctx, tag) {
44137
44723
  }
44138
44724
  }
44139
44725
  async function runHotfixStart(deps, options, doctor = runTrainDoctor) {
44726
+ return withTrainWriter(deps, "hotfix start", () => runHotfixStartLocked(deps, options, doctor));
44727
+ }
44728
+ async function runHotfixStartLocked(deps, options, doctor) {
44140
44729
  await doctor({ lane: "hotfix", heal: true, train: deps, refuse: true });
44141
44730
  const ctx = await buildTrainApplyContext(deps);
44142
44731
  const status = await deps.run("git", ["status", "--porcelain"]);
@@ -44184,6 +44773,18 @@ async function runHotfixStart(deps, options, doctor = runTrainDoctor) {
44184
44773
  }
44185
44774
  const label = sources.map((s) => s.label).join(", ");
44186
44775
  const { foldPaths, pickTolerated } = await resolveHotfixPickTolerated(deps, deployModel);
44776
+ const ackedShas = (options.ack ?? []).map((a) => a.trim().toLowerCase()).filter(Boolean);
44777
+ const pickGaps = await findHotfixPickGaps(deps, sources, pickTolerated);
44778
+ const unacked = pickGaps.filter((g) => {
44779
+ const sha = g.sha.toLowerCase();
44780
+ return !ackedShas.some((a) => sha.startsWith(a) || a.startsWith(sha));
44781
+ });
44782
+ if (unacked.length > 0) {
44783
+ throw new Error(formatHotfixPickGapRefusal(unacked, tag, specs));
44784
+ }
44785
+ if (pickGaps.length > 0) {
44786
+ notes.push(`pick-completeness ack: excluded ${pickGaps.map((g) => `${g.sha.slice(0, 7)} ${g.subject}`).join("; ")} by --ack (#6994)`);
44787
+ }
44187
44788
  const remoteBranch = clean3(await deps.run("git", ["ls-remote", "origin", `refs/heads/${branch}`]));
44188
44789
  if (remoteBranch) {
44189
44790
  await deps.run("git", ["checkout", branch]);
@@ -44248,7 +44849,9 @@ async function runHotfixStart(deps, options, doctor = runTrainDoctor) {
44248
44849
  "--body",
44249
44850
  `Hotfix ${tag}: cherry-pick of ${label} onto origin/main${bumpNote}.
44250
44851
 
44251
- <!-- mmi-hotfix-carries: ${sources.map((s) => s.sha).join(",")} -->
44852
+ <!-- mmi-hotfix-carries: ${sources.map((s) => s.sha).join(",")} -->${pickGaps.length > 0 ? `
44853
+
44854
+ <!-- mmi-hotfix-acked-excluded: ${pickGaps.map((g) => g.sha).join(",")} (#6994 pick-completeness) -->` : ""}
44252
44855
 
44253
44856
  Merge this PR (human-initiated), then run \`mmi-cli devops hotfix release ${tag}\`.`
44254
44857
  ]));
@@ -44420,6 +45023,24 @@ async function appendHotfixGatewayRun(deps, repo, tag, sha, runs, note) {
44420
45023
  }
44421
45024
  return gateway.note;
44422
45025
  }
45026
+ async function appendHotfixServedChannel(deps, input, runs, note) {
45027
+ const before = hotfixDispatchFromRuns(runs, note);
45028
+ const after = await appendServedChannelLegs(
45029
+ deps,
45030
+ { repo: input.repo, tag: input.tag, releaseSha: input.sha, watch: true, deploy: input.deploy, publish: input.publish, installer: input.installer },
45031
+ before
45032
+ );
45033
+ for (const row of servedChannelRowsAfter(before, after)) {
45034
+ runs.push({
45035
+ workflow: row.workflow,
45036
+ ...row.runId != null ? { runId: row.runId } : {},
45037
+ ...row.runUrl ? { url: row.runUrl } : {},
45038
+ ...row.runUrlNote ? { runUrlNote: row.runUrlNote } : {},
45039
+ conclusion: row.conclusion
45040
+ });
45041
+ }
45042
+ return after.note;
45043
+ }
44423
45044
  function hotfixPhaseInputsFromRuns(deployModel, runs, note, publishDispatch, publishRequired, opts) {
44424
45045
  const dispatch = hotfixDispatchFromRuns(runs, note);
44425
45046
  return {
@@ -44542,6 +45163,9 @@ async function assertConsumerBomLineage(deps, mergedSha, tag) {
44542
45163
  }
44543
45164
  }
44544
45165
  async function runHotfixRelease(deps, versionInput, options = {}, doctor = runTrainDoctor) {
45166
+ return withTrainWriter(deps, `hotfix release ${versionInput}`, () => runHotfixReleaseLocked(deps, versionInput, options, doctor));
45167
+ }
45168
+ async function runHotfixReleaseLocked(deps, versionInput, options, doctor) {
44545
45169
  await doctor({ lane: "hotfix", heal: true, train: deps, refuse: true });
44546
45170
  const ctx = await buildTrainApplyContext(deps);
44547
45171
  const { tag, version } = normalizeHotfixVersion(versionInput);
@@ -44638,9 +45262,9 @@ ${decision.note}`);
44638
45262
  let deployNote;
44639
45263
  let tenantPublishDispatch = null;
44640
45264
  if (deployModel === "hub-serverless") {
44641
- for (const workflow of HOTFIX_RELEASE_WORKFLOWS) {
44642
- const watched = await watchReleaseRun(deps, ctx, workflow, mergedSha);
44643
- runs.push(await recoverHotfixReleaseRun(deps, ledger, anchors, ctx, deployModel, workflow === "publish.yml" ? "publish" : "deploy", watched));
45265
+ const watchedRuns = await Promise.all(HOTFIX_RELEASE_WORKFLOWS.map((workflow) => watchReleaseRun(deps, ctx, workflow, mergedSha)));
45266
+ for (const watched of watchedRuns) {
45267
+ runs.push(await recoverHotfixReleaseRun(deps, ledger, anchors, ctx, deployModel, watched.workflow === "publish.yml" ? "publish" : "deploy", watched));
44644
45268
  }
44645
45269
  deployNote = "watched release-triggered deploy.yml + publish.yml";
44646
45270
  } else if (deployModel === "registry-publish") {
@@ -44649,17 +45273,9 @@ ${decision.note}`);
44649
45273
  runs.push(run);
44650
45274
  deployNote = "watched this repo's own release-triggered publish.yml (#2428 \u2014 no central dispatch)";
44651
45275
  } else if (deployModel === "tenant-container" || deployModel === "solo-container") {
44652
- const deploy0 = await dispatchDeploy(
44653
- deps,
44654
- ctx,
44655
- "main",
44656
- "main",
44657
- deployModel,
44658
- true,
44659
- (deps.now ?? Date.now)(),
44660
- mergedSha,
44661
- "report",
44662
- meta.publishDir
45276
+ const [deploy0, publish0] = await joinDeployAndPublish(
45277
+ dispatchDeploy(deps, ctx, "main", "main", deployModel, true, (deps.now ?? Date.now)(), mergedSha, "report", meta.publishDir),
45278
+ observePublishIfRequired(deps, ctx, meta, deployModel, "main", mergedSha, true)
44663
45279
  );
44664
45280
  const deploy = await recoverFailedDispatchPhase(
44665
45281
  deps,
@@ -44669,7 +45285,6 @@ ${decision.note}`);
44669
45285
  deploy0,
44670
45286
  { repo: releaseRunRepoFor(deployModel, ctx.repo), sha: mergedSha, watched: true, workflow: "tenant-deploy.yml" }
44671
45287
  );
44672
- const publish0 = await observePublishIfRequired(deps, ctx, meta, deployModel, "main", mergedSha, true);
44673
45288
  const publish = publish0 ? await recoverFailedDispatchPhase(
44674
45289
  deps,
44675
45290
  ledger,
@@ -44697,6 +45312,7 @@ ${decision.note}`);
44697
45312
  );
44698
45313
  await recordHotfixPhase(ledger, anchors, "deploy", ledgerInputs.deploy, "the immutable tag, the green wall, origin/main, and the GitHub Release");
44699
45314
  await recordHotfixPhase(ledger, anchors, "publish", ledgerInputs.publish, "the immutable tag, the green wall, origin/main, the GitHub Release, and the deploy path");
45315
+ deployNote = await appendHotfixServedChannel(deps, { repo: ctx.repo, tag, sha: mergedSha, deploy: ledgerInputs.deploy, publish: ledgerInputs.publish, installer: meta.installer }, runs, deployNote);
44700
45316
  const announceNote = releaseExists ? void 0 : await announceShippedRelease(
44701
45317
  deps,
44702
45318
  { repo: ctx.repo, tag, summaryFile: options.announceSummaryFile },
@@ -44765,6 +45381,9 @@ ${decision.note}`);
44765
45381
  return { ...result, ...localSync ? { localSync } : {} };
44766
45382
  }
44767
45383
  async function runHotfixRetryDeploy(deps, versionInput) {
45384
+ return withTrainWriter(deps, `hotfix release ${versionInput} --retry-deploy`, () => runHotfixRetryDeployLocked(deps, versionInput));
45385
+ }
45386
+ async function runHotfixRetryDeployLocked(deps, versionInput) {
44768
45387
  const ctx = await buildTrainApplyContext(deps);
44769
45388
  const { tag } = normalizeHotfixVersion(versionInput);
44770
45389
  const status = await deps.run("git", ["status", "--porcelain"]);
@@ -45380,6 +45999,8 @@ async function announceSummaryRefusal(path2) {
45380
45999
  }
45381
46000
  function trainApplyDeps() {
45382
46001
  return {
46002
+ // #7027: every mutating train verb holds this checkout's train writer lock for its whole run.
46003
+ trainWriter: (verb, cwd) => acquireTrainWriterLock(cwd, `mmi-cli devops ${verb}`),
45383
46004
  run: async (file, args) => {
45384
46005
  const timeout = file === "node" && args[1] === "prepare" ? NODE_PREPARE_TIMEOUT_MS : file === "node" && args[1] === "verify" ? NODE_VERIFY_TIMEOUT_MS : file === "npm" && (args[0] === "version" || args[0] === "ci" || args[0] === "install") ? NPM_FOLD_TIMEOUT_MS : file === "npm" ? NPM_TRAIN_TIMEOUT_MS : file === "bash" && /jerv-gateway-release-deploy\.sh$/.test(args[0] ?? "") ? JERV_GATEWAY_DEPLOY_TIMEOUT_MS : file !== "gh" ? GIT_TIMEOUT_MS : args[0] === "run" && args[1] === "watch" ? GH_RUN_WATCH_TIMEOUT_MS : GH_TRAIN_TIMEOUT_MS;
45385
46006
  try {
@@ -45491,7 +46112,7 @@ function trainApplyDeps() {
45491
46112
  }
45492
46113
  function formatWorkflowRun(r) {
45493
46114
  const evidenced = workflowRunWithEvidence(r);
45494
- return `${evidenced.workflow} ${evidenced.runUrl ?? evidenced.runUrlNote} ${evidenced.conclusion.toUpperCase()}`;
46115
+ return `${evidenced.workflow} ${evidenced.runUrl ?? evidenced.runUrlNote} ${evidenced.conclusion.toUpperCase()}${r.parallel ? " (parallel)" : ""}`;
45495
46116
  }
45496
46117
  function renderDeployLine(d) {
45497
46118
  const parts = [d.dispatch];
@@ -46136,7 +46757,7 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
46136
46757
  const steps = trainPlan("hotfix");
46137
46758
  console.log(o.json ? JSON.stringify({ command: "hotfix", steps }, null, 2) : renderSteps("mmi-cli devops hotfix: dry-run plan", steps));
46138
46759
  });
46139
- 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));
46760
+ 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("--ack <shas>", "comma-separated dev shas a human verified are NOT needed on main, overriding the pick-completeness guard for exactly those fixes \u2014 recorded in the PR body, never silent (#6994)").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, ack: o.ack ? o.ack.split(",").map((s) => s.trim()).filter(Boolean) : void 0 }), o, renderHotfixStart));
46140
46761
  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("--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) => {
46141
46762
  if (o.retryDeploy) {
46142
46763
  if (o.announceSummaryFile || o.carries) {
@@ -47595,10 +48216,9 @@ function resolveHouseShim(argv) {
47595
48216
  const flatPath = flatTokens.join(" ");
47596
48217
  const flatHouse = houseForPath(flatPath);
47597
48218
  if (!flatHouse || flatHouse === "core") return;
47598
- refuseHouseShim(
47599
- flatPath,
47600
- `mmi-cli: the flat '${flatPath}' alias was removed in ${COMPAT_SHIM_DEATH_WAVE} (#4316) \u2014 ${recoveryPointerFor(flatPath)}`
47601
- );
48219
+ process.stderr.write(`mmi-cli: '${flatPath}' is the old flat form \u2014 ran it as \`mmi-cli ${canonicalPathFor(registeredPathPrefix(flatPath))}\` (use that spelling)
48220
+ `);
48221
+ return;
47602
48222
  }
47603
48223
  const remainder = argv.slice(3);
47604
48224
  const first = remainder[0];
@@ -47620,6 +48240,14 @@ function resolveHouseShim(argv) {
47620
48240
  if (actual && isDeclaredHouseAlias(houseToken, lookupPath)) {
47621
48241
  process.stderr.write(
47622
48242
  `mmi-cli: '${lookupPath}' lives in house '${actual}', not '${houseToken}' \u2014 routed to \`mmi-cli ${canonicalPathFor(lookupPath)}\`
48243
+ `
48244
+ );
48245
+ argv.splice(2, 1);
48246
+ return;
48247
+ }
48248
+ if (actual && actual !== "core" && registeredPathPrefix(lookupPath) === lookupPath) {
48249
+ process.stderr.write(
48250
+ `mmi-cli: '${lookupPath}' lives in house '${actual}', not '${houseToken}' \u2014 ran it as \`mmi-cli ${canonicalPathFor(lookupPath)}\`
47623
48251
  `
47624
48252
  );
47625
48253
  argv.splice(2, 1);
@@ -47648,8 +48276,8 @@ function printHouseRootHelp(house) {
47648
48276
  }
47649
48277
  lines2.push(
47650
48278
  "",
47651
- `Canonical: \`mmi-cli ${house} <command> \u2026\`. The flat \`mmi-cli <command> \u2026\` compatibility shims`,
47652
- `were removed in ${COMPAT_SHIM_DEATH_WAVE} (#4316, #4438) \u2014 only the canonical house-prefixed form parses.`
48279
+ `Canonical: \`mmi-cli ${house} <command> \u2026\`. The old flat \`mmi-cli <command> \u2026\` form still runs`,
48280
+ "with a notice naming the canonical spelling (#7034), so an out-of-date plugin keeps working."
47653
48281
  );
47654
48282
  consoleIo.log(lines2.join("\n"));
47655
48283
  }