@mutmutco/cli 2.16.0 → 2.18.0
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.
- package/README.md +1 -1
- package/dist/index.cjs +1281 -162
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -3525,7 +3525,7 @@ function parseHookInput(stdin) {
|
|
|
3525
3525
|
}
|
|
3526
3526
|
|
|
3527
3527
|
// src/index.ts
|
|
3528
|
-
var
|
|
3528
|
+
var import_node_child_process7 = require("node:child_process");
|
|
3529
3529
|
var import_node_util6 = require("node:util");
|
|
3530
3530
|
var import_node_path8 = require("node:path");
|
|
3531
3531
|
var import_node_os3 = require("node:os");
|
|
@@ -5106,6 +5106,42 @@ function ghError(e) {
|
|
|
5106
5106
|
}
|
|
5107
5107
|
|
|
5108
5108
|
// src/gc.ts
|
|
5109
|
+
var WORKTREE_LOCK_RE = /EPERM|EBUSY|EACCES|ENOTEMPTY|permission denied|access is denied|used by another process|resource busy|directory not empty/i;
|
|
5110
|
+
function isWorktreeLockError(error) {
|
|
5111
|
+
return WORKTREE_LOCK_RE.test(error instanceof Error ? error.message : String(error));
|
|
5112
|
+
}
|
|
5113
|
+
var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
5114
|
+
async function removeWorktreeWithRecovery(wtPath, deps) {
|
|
5115
|
+
const maxAttempts = deps.maxAttempts ?? 3;
|
|
5116
|
+
const backoff = deps.backoffMs ?? [250, 1e3];
|
|
5117
|
+
let attempts = 0;
|
|
5118
|
+
let lastError;
|
|
5119
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
5120
|
+
attempts++;
|
|
5121
|
+
try {
|
|
5122
|
+
await deps.git(["worktree", "remove", "--force", wtPath]);
|
|
5123
|
+
return { status: "removed", attempts, recovery: i > 0 ? "retry" : void 0 };
|
|
5124
|
+
} catch (e) {
|
|
5125
|
+
lastError = e;
|
|
5126
|
+
const retriesLeft = i < maxAttempts - 1;
|
|
5127
|
+
if (retriesLeft && isWorktreeLockError(e)) {
|
|
5128
|
+
await deps.sleep(backoff[Math.min(i, backoff.length - 1)]);
|
|
5129
|
+
continue;
|
|
5130
|
+
}
|
|
5131
|
+
break;
|
|
5132
|
+
}
|
|
5133
|
+
}
|
|
5134
|
+
if (deps.removeWorktreeDir) {
|
|
5135
|
+
try {
|
|
5136
|
+
await deps.removeWorktreeDir(wtPath);
|
|
5137
|
+
await deps.git(["worktree", "prune"]).catch(() => "");
|
|
5138
|
+
return { status: "removed", attempts, recovery: "fallback" };
|
|
5139
|
+
} catch (fallbackError) {
|
|
5140
|
+
lastError = fallbackError;
|
|
5141
|
+
}
|
|
5142
|
+
}
|
|
5143
|
+
return { status: "failed", attempts, error: errorMessage(lastError) };
|
|
5144
|
+
}
|
|
5109
5145
|
function buildRemoteBranchCleanupReport(branch, input) {
|
|
5110
5146
|
if (!input.attempted) return { name: branch, status: "not-attempted", reason: input.reason };
|
|
5111
5147
|
if (input.existsAfter === true) return { name: branch, status: "failed", reason: "still-present-after-delete" };
|
|
@@ -5113,6 +5149,55 @@ function buildRemoteBranchCleanupReport(branch, input) {
|
|
|
5113
5149
|
if (input.existsAfter === false) return { name: branch, status: "deleted" };
|
|
5114
5150
|
return { name: branch, status: "not-attempted", reason: input.reason ?? "remote-check-unavailable" };
|
|
5115
5151
|
}
|
|
5152
|
+
async function buildPrMergeRemoteBranchCleanupReport(branch, deps, input) {
|
|
5153
|
+
const existsAfter = input.attempted ? await deps.exists(branch, { prune: true }) : void 0;
|
|
5154
|
+
return buildRemoteBranchCleanupReport(branch, {
|
|
5155
|
+
attempted: input.attempted,
|
|
5156
|
+
existedBefore: input.existedBefore,
|
|
5157
|
+
existsAfter,
|
|
5158
|
+
reason: input.reason
|
|
5159
|
+
});
|
|
5160
|
+
}
|
|
5161
|
+
function summarizePrMergeCleanupStatus(input) {
|
|
5162
|
+
if (input.remoteBranch.status === "failed") return "warnings";
|
|
5163
|
+
if (input.localBranch.status === "failed") return "warnings";
|
|
5164
|
+
if (input.localBranch.reason === "worktree-removal-failed") return "warnings";
|
|
5165
|
+
if (input.worktree?.status === "failed") return "warnings";
|
|
5166
|
+
return "clean";
|
|
5167
|
+
}
|
|
5168
|
+
function buildPrMergeResultPayload(input) {
|
|
5169
|
+
return {
|
|
5170
|
+
mergeStatus: "merged",
|
|
5171
|
+
merged: input.number,
|
|
5172
|
+
branch: input.branch,
|
|
5173
|
+
method: input.method,
|
|
5174
|
+
cleanupStatus: summarizePrMergeCleanupStatus({
|
|
5175
|
+
remoteBranch: input.remoteBranch,
|
|
5176
|
+
localBranch: input.localCleanup.localBranch,
|
|
5177
|
+
worktree: input.localCleanup.worktree
|
|
5178
|
+
}),
|
|
5179
|
+
remoteBranch: input.remoteBranch,
|
|
5180
|
+
localBranch: input.localCleanup.localBranch,
|
|
5181
|
+
worktree: input.localCleanup.worktree
|
|
5182
|
+
};
|
|
5183
|
+
}
|
|
5184
|
+
function buildPrMergeArgs(input) {
|
|
5185
|
+
const args = ["pr", "merge", input.number, ...input.repoArgs, input.method, "--delete-branch"];
|
|
5186
|
+
if (input.auto) args.push("--auto");
|
|
5187
|
+
return args;
|
|
5188
|
+
}
|
|
5189
|
+
function basePolicyBlocksImmediateMerge(message) {
|
|
5190
|
+
return /base branch policy prohibits|protected branch|required status check|merge queue/i.test(message);
|
|
5191
|
+
}
|
|
5192
|
+
async function checkRemoteBranchExists(branch, deps, options = {}) {
|
|
5193
|
+
if (!branch) return void 0;
|
|
5194
|
+
try {
|
|
5195
|
+
if (options.prune) await deps.execGit(["fetch", "origin", "--prune"]).catch(() => void 0);
|
|
5196
|
+
return (await deps.execGit(["ls-remote", "--heads", "origin", branch])).trim().length > 0;
|
|
5197
|
+
} catch {
|
|
5198
|
+
return void 0;
|
|
5199
|
+
}
|
|
5200
|
+
}
|
|
5116
5201
|
var DEFAULT_PROTECTED = /* @__PURE__ */ new Set(["development", "main", "master", "rc"]);
|
|
5117
5202
|
function groupedPrs(prs) {
|
|
5118
5203
|
const out = /* @__PURE__ */ new Map();
|
|
@@ -5314,7 +5399,7 @@ function branchMissingFromList(branch, stdout) {
|
|
|
5314
5399
|
return !names.includes(branch);
|
|
5315
5400
|
}
|
|
5316
5401
|
function shellQuote(value) {
|
|
5317
|
-
return `
|
|
5402
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
5318
5403
|
}
|
|
5319
5404
|
function safeWorktreeRemoveCommand(safeCwd, targetPath) {
|
|
5320
5405
|
const prefix = safeCwd ? `git -C ${shellQuote(safeCwd)}` : "git";
|
|
@@ -5340,7 +5425,11 @@ async function cleanupPrMergeLocalBranch(branch, options) {
|
|
|
5340
5425
|
const wtPath = selectPrMergeCleanupWorktree(branch, beforeWorktrees, afterWorktrees, options.startingPath);
|
|
5341
5426
|
const safeCwd = selectSafeWorktreeCwd([...afterWorktrees, ...beforeWorktrees], wtPath);
|
|
5342
5427
|
const git = (args) => safeCwd ? options.execGit(["-C", safeCwd, ...args]) : options.execGit(args);
|
|
5343
|
-
|
|
5428
|
+
const mainWorktreePath = beforeWorktrees[0]?.path ?? afterWorktrees[0]?.path;
|
|
5429
|
+
const mainWorktreeTarget = Boolean(wtPath && mainWorktreePath && samePath(wtPath, mainWorktreePath));
|
|
5430
|
+
if (wtPath && mainWorktreeTarget) {
|
|
5431
|
+
report.worktree = { path: wtPath, status: "not-attempted", reason: "main-worktree" };
|
|
5432
|
+
} else if (wtPath) {
|
|
5344
5433
|
let stageTeardown;
|
|
5345
5434
|
if (options.teardownWorktreeStage) {
|
|
5346
5435
|
try {
|
|
@@ -5349,14 +5438,18 @@ async function cleanupPrMergeLocalBranch(branch, options) {
|
|
|
5349
5438
|
stageTeardown = { status: "failed", error: errorMessage(e) };
|
|
5350
5439
|
}
|
|
5351
5440
|
}
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
|
|
5441
|
+
const outcome = await removeWorktreeWithRecovery(wtPath, {
|
|
5442
|
+
git,
|
|
5443
|
+
sleep: options.sleep ?? defaultSleep,
|
|
5444
|
+
removeWorktreeDir: options.removeWorktreeDir
|
|
5445
|
+
});
|
|
5446
|
+
if (outcome.status === "removed") {
|
|
5447
|
+
report.worktree = { path: wtPath, status: "removed", stageTeardown, recovery: outcome.recovery };
|
|
5448
|
+
} else {
|
|
5356
5449
|
report.worktree = {
|
|
5357
5450
|
path: wtPath,
|
|
5358
5451
|
status: "failed",
|
|
5359
|
-
error:
|
|
5452
|
+
error: outcome.error,
|
|
5360
5453
|
safeCleanupCommand: safeWorktreeRemoveCommand(safeCwd, wtPath),
|
|
5361
5454
|
stageTeardown
|
|
5362
5455
|
};
|
|
@@ -5408,6 +5501,43 @@ function formatGcPlan(plan2, apply) {
|
|
|
5408
5501
|
}
|
|
5409
5502
|
|
|
5410
5503
|
// src/command-plans.ts
|
|
5504
|
+
function parseTrainTag(tag) {
|
|
5505
|
+
const m = /^v(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/.exec(tag);
|
|
5506
|
+
return m ? { major: +m[1], minor: +m[2], patch: +m[3], rc: m[4] ? +m[4] : null } : null;
|
|
5507
|
+
}
|
|
5508
|
+
function cmpTrainRelease(a, b) {
|
|
5509
|
+
return a.major - b.major || a.minor - b.minor || a.patch - b.patch;
|
|
5510
|
+
}
|
|
5511
|
+
function trainPlanTargetsFromTags(tags, explicitRaw) {
|
|
5512
|
+
const all = tags.map(parseTrainTag).filter((v) => Boolean(v));
|
|
5513
|
+
const release = all.find((v) => v.rc === null) ?? { major: 0, minor: 0, patch: 0, rc: null };
|
|
5514
|
+
const targets = {};
|
|
5515
|
+
let explicit = null;
|
|
5516
|
+
if (explicitRaw) {
|
|
5517
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(String(explicitRaw).trim());
|
|
5518
|
+
if (!m) {
|
|
5519
|
+
targets.plannedReleaseError = `MMI_RELEASE_VERSION must be X.Y.Z (got ${explicitRaw})`;
|
|
5520
|
+
} else {
|
|
5521
|
+
explicit = { major: +m[1], minor: +m[2], patch: +m[3], rc: null };
|
|
5522
|
+
if (cmpTrainRelease(explicit, release) <= 0) {
|
|
5523
|
+
targets.plannedReleaseError = `MMI_RELEASE_VERSION ${explicitRaw} must be ahead of the latest release v${release.major}.${release.minor}.${release.patch}`;
|
|
5524
|
+
}
|
|
5525
|
+
}
|
|
5526
|
+
}
|
|
5527
|
+
if (!targets.plannedReleaseError) {
|
|
5528
|
+
const planned = explicit ?? { major: release.major, minor: release.minor + 1, patch: 0, rc: null };
|
|
5529
|
+
targets.plannedRelease = `v${planned.major}.${planned.minor}.${planned.patch}`;
|
|
5530
|
+
}
|
|
5531
|
+
let rcs = all.filter((v) => v.rc !== null);
|
|
5532
|
+
if (explicit) rcs = rcs.filter((v) => v.major === explicit.major && v.minor === explicit.minor && v.patch === explicit.patch);
|
|
5533
|
+
rcs.sort((a, b) => cmpTrainRelease(b, a) || (b.rc ?? 0) - (a.rc ?? 0));
|
|
5534
|
+
if (rcs[0]) {
|
|
5535
|
+
targets.existingRcRelease = `v${rcs[0].major}.${rcs[0].minor}.${rcs[0].patch}`;
|
|
5536
|
+
} else {
|
|
5537
|
+
targets.existingRcReleaseError = explicit ? `no rc tag for ${explicit.major}.${explicit.minor}.${explicit.patch} to release` : "no rc tag to release";
|
|
5538
|
+
}
|
|
5539
|
+
return targets;
|
|
5540
|
+
}
|
|
5411
5541
|
function stagePlan(stage2 = {}, stops = true) {
|
|
5412
5542
|
return [
|
|
5413
5543
|
...stops ? [{ label: "force-kill previous local stage", command: "mmi-cli stage stop --apply" }] : [],
|
|
@@ -5436,30 +5566,60 @@ function stageLivePlan() {
|
|
|
5436
5566
|
{ label: "remote rc/live environments move through the gated promotion train", command: "mmi-cli rcand && mmi-cli release && mmi-cli hotfix", gated: true }
|
|
5437
5567
|
];
|
|
5438
5568
|
}
|
|
5439
|
-
function
|
|
5569
|
+
function rcandVersionStep(targets) {
|
|
5570
|
+
const planned = targets.plannedRelease ? `plannedRelease=${targets.plannedRelease}` : "plannedRelease=(unresolved)";
|
|
5571
|
+
const existing = targets.existingRcRelease ? `existingRcRelease=${targets.existingRcRelease}` : "existingRcRelease=(none resolved yet)";
|
|
5572
|
+
return {
|
|
5573
|
+
label: `resolve train version targets (${planned}; ${existing})`,
|
|
5574
|
+
command: "plannedRelease: node scripts/next-version.mjs cycle; existing rc release only: node scripts/next-version.mjs release",
|
|
5575
|
+
gated: true
|
|
5576
|
+
};
|
|
5577
|
+
}
|
|
5578
|
+
function trainPlan(command, options = {}) {
|
|
5579
|
+
const isHub = options.repo?.toLowerCase() === "mutmutco/mmi-hub";
|
|
5580
|
+
const isDirect = options.releaseTrack === "direct" || options.releaseTrack === void 0 && isHub;
|
|
5440
5581
|
if (command === "rcand") {
|
|
5582
|
+
if (isDirect) {
|
|
5583
|
+
return [
|
|
5584
|
+
{ label: "direct-track repos skip rc; use /release from development instead", command: "mmi-cli release --apply", gated: true }
|
|
5585
|
+
];
|
|
5586
|
+
}
|
|
5441
5587
|
return [
|
|
5442
5588
|
{ label: "verify operator is a master-admin org owner", command: "gh api orgs/<owner>/memberships/<login> --jq .role", gated: true },
|
|
5443
5589
|
{ label: "verify current branch is development", gated: true },
|
|
5590
|
+
rcandVersionStep(options),
|
|
5444
5591
|
{ label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
|
|
5445
5592
|
{ label: "preflight required rc secret names", command: "mmi-cli secrets preflight --stage rc --repo <owner/repo>", gated: true },
|
|
5446
|
-
{ label: "for Hub distribution changes, verify the release bump is already landed before touching rc", command: "node scripts/release-distribution.mjs verify <release-tag> --skip-npm-view", gated: true },
|
|
5593
|
+
{ label: "for Hub distribution changes, verify the release bump is already landed before touching rc", command: "node scripts/release-distribution.mjs verify <release-tag> --skip-npm-view; if missing: mmi-cli train prep --apply", gated: true },
|
|
5447
5594
|
{ label: "merge development to rc", gated: true },
|
|
5448
|
-
{ label: "trigger the deploy path for this repo model, returning
|
|
5595
|
+
{ label: "trigger the deploy path for this repo model, returning Hub Actions run id/url data (and, with --watch, its outcome)", command: "tenant-container: gh workflow run tenant-deploy.yml ... then gh run list/watch; hub-serverless: no manual dispatch, deploy.yml auto-fires on rc push, correlate/watch that run", gated: true },
|
|
5449
5596
|
{ label: "after a failed deploy, retry the existing rc ref (no re-tag/merge)", command: "mmi-cli tenant redeploy <owner/repo> rc --watch", gated: true }
|
|
5450
5597
|
];
|
|
5451
5598
|
}
|
|
5452
5599
|
if (command === "release") {
|
|
5600
|
+
if (isDirect) {
|
|
5601
|
+
return [
|
|
5602
|
+
{ label: "verify operator is a master-admin org owner", command: "gh api orgs/<owner>/memberships/<login> --jq .role", gated: true },
|
|
5603
|
+
{ label: "verify current branch is development", gated: true },
|
|
5604
|
+
{ label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
|
|
5605
|
+
{ label: "preflight required main secret names", command: "mmi-cli secrets preflight --stage main --repo <owner/repo>", gated: true },
|
|
5606
|
+
{ label: "direct-track release skips rc; verify any distribution bump is already landed on development", command: "node scripts/release-distribution.mjs verify <release-tag> --skip-npm-view", gated: true },
|
|
5607
|
+
{ label: "merge development to main", gated: true },
|
|
5608
|
+
{ label: "tag release and publish GitHub Release", gated: true },
|
|
5609
|
+
{ label: "trigger the repo deploy path from the release event", command: "hub-serverless: deploy.yml + publish.yml auto-fire on the release; other models deploy via their own workflow", gated: true },
|
|
5610
|
+
{ label: "roll development forward", gated: true }
|
|
5611
|
+
];
|
|
5612
|
+
}
|
|
5453
5613
|
return [
|
|
5454
5614
|
{ label: "verify operator is a master-admin org owner", command: "gh api orgs/<owner>/memberships/<login> --jq .role", gated: true },
|
|
5455
5615
|
{ label: "verify current branch is rc", gated: true },
|
|
5456
5616
|
{ label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
|
|
5457
5617
|
{ label: "preflight required main secret names", command: "mmi-cli secrets preflight --stage main --repo <owner/repo>", gated: true },
|
|
5458
|
-
{ label: "verify every main-only hotfix commit is covered by the rc candidate", command: "
|
|
5618
|
+
{ label: "verify every main-only hotfix commit is covered by the rc candidate (the guard runs automatically inside the apply step below; --ack <sha> overrides a verified, trailer-less port)", command: "mmi-cli release --apply [--ack <sha>]", gated: true },
|
|
5459
5619
|
{ label: "merge rc to main", gated: true },
|
|
5460
|
-
{ label: "for Hub distribution changes, verify the promoted SHA carries the release bump", command: "node scripts/release-distribution.mjs verify <release-tag> --skip-npm-view", gated: true },
|
|
5620
|
+
{ label: "for Hub distribution changes, verify the promoted SHA carries the release bump", command: "node scripts/release-distribution.mjs verify <release-tag> --skip-npm-view; if missing: mmi-cli train prep --apply", gated: true },
|
|
5461
5621
|
{ label: "tag release and publish GitHub Release", gated: true },
|
|
5462
|
-
{ label: "trigger the deploy path for this repo model, returning
|
|
5622
|
+
{ label: "trigger the deploy path for this repo model, returning Hub Actions run id/url data (and, with --watch, its outcome)", command: "tenant-container: gh workflow run tenant-deploy.yml ... then gh run list/watch; hub-serverless: no manual dispatch, deploy.yml + publish.yml auto-fire on the release, correlate/watch those runs", gated: true },
|
|
5463
5623
|
{ label: "roll development forward", gated: true }
|
|
5464
5624
|
];
|
|
5465
5625
|
}
|
|
@@ -5488,10 +5648,13 @@ function bootstrapPlan(repo, repoClass) {
|
|
|
5488
5648
|
function shellFor(platform = process.platform) {
|
|
5489
5649
|
return platform === "win32" ? "powershell" : "bash";
|
|
5490
5650
|
}
|
|
5651
|
+
function isCentralContainerModel(model) {
|
|
5652
|
+
return model === "tenant-container" || model === "solo-container";
|
|
5653
|
+
}
|
|
5491
5654
|
function deriveStageGap(inputs) {
|
|
5492
5655
|
const missing = [];
|
|
5493
|
-
if (inputs.deployModel
|
|
5494
|
-
return `local stage default applies to
|
|
5656
|
+
if (!isCentralContainerModel(inputs.deployModel)) {
|
|
5657
|
+
return `local stage default applies to central-container repos only (tenant-container/solo-container; registry deployModel = ${inputs.deployModel ?? "unset"})`;
|
|
5495
5658
|
}
|
|
5496
5659
|
if (!inputs.hasCompose) missing.push("docker-compose.yml");
|
|
5497
5660
|
if (!inputs.hasEnvExample) missing.push(".env.example");
|
|
@@ -5546,9 +5709,9 @@ function stalePosixFields(config, shell2) {
|
|
|
5546
5709
|
}
|
|
5547
5710
|
function sanitizeLocalStage(local, stale) {
|
|
5548
5711
|
if (!stale.length) return local;
|
|
5549
|
-
const
|
|
5550
|
-
for (const field of stale) delete
|
|
5551
|
-
return
|
|
5712
|
+
const clean4 = { ...local };
|
|
5713
|
+
for (const field of stale) delete clean4[field];
|
|
5714
|
+
return clean4;
|
|
5552
5715
|
}
|
|
5553
5716
|
function staleNote(staleFields, outcome) {
|
|
5554
5717
|
const list = staleFields.join(", ");
|
|
@@ -6284,16 +6447,21 @@ async function runStage(config = {}, opts = {}) {
|
|
|
6284
6447
|
}
|
|
6285
6448
|
|
|
6286
6449
|
// src/project-model.ts
|
|
6287
|
-
var PROJECT_TYPES = ["web-app", "hub-service", "content", "desktop-game", "non-deployable"];
|
|
6288
|
-
var DEPLOY_MODELS = ["hub-serverless", "serverless", "tenant-container", "content", "none"];
|
|
6450
|
+
var PROJECT_TYPES = ["web-app", "hub-service", "content", "desktop-game", "non-deployable", "cli-tool", "worker"];
|
|
6451
|
+
var DEPLOY_MODELS = ["hub-serverless", "serverless", "tenant-container", "solo-container", "registry-publish", "content", "none"];
|
|
6452
|
+
var RELEASE_TRACKS = ["full", "direct", "trunk"];
|
|
6289
6453
|
var PROJECT_TYPE_SET = new Set(PROJECT_TYPES);
|
|
6290
6454
|
var DEPLOY_MODEL_SET = new Set(DEPLOY_MODELS);
|
|
6455
|
+
var RELEASE_TRACK_SET = new Set(RELEASE_TRACKS);
|
|
6291
6456
|
function isProjectType(value) {
|
|
6292
6457
|
return Boolean(value && PROJECT_TYPE_SET.has(value));
|
|
6293
6458
|
}
|
|
6294
6459
|
function isDeployModel(value) {
|
|
6295
6460
|
return Boolean(value && DEPLOY_MODEL_SET.has(value));
|
|
6296
6461
|
}
|
|
6462
|
+
function isReleaseTrack(value) {
|
|
6463
|
+
return Boolean(value && RELEASE_TRACK_SET.has(value));
|
|
6464
|
+
}
|
|
6297
6465
|
function repoIsHub(repo) {
|
|
6298
6466
|
return repo.toLowerCase().endsWith("/mmi-hub") || repo.toLowerCase() === "mmi-hub";
|
|
6299
6467
|
}
|
|
@@ -6302,6 +6470,8 @@ function resolveProjectTypeConfident(meta, repo) {
|
|
|
6302
6470
|
if (isProjectType(rawType)) return rawType;
|
|
6303
6471
|
if (meta?.class === "content" || meta?.deployModel === "content") return "content";
|
|
6304
6472
|
if (meta?.deployModel === "hub-serverless" || repoIsHub(repo)) return "hub-service";
|
|
6473
|
+
if (meta?.deployModel === "registry-publish") return "cli-tool";
|
|
6474
|
+
if (meta?.deployModel === "solo-container") return "worker";
|
|
6305
6475
|
if (meta?.deployModel === "none") return "non-deployable";
|
|
6306
6476
|
return void 0;
|
|
6307
6477
|
}
|
|
@@ -6315,10 +6485,22 @@ function resolveDeployModel(meta, repo) {
|
|
|
6315
6485
|
if (projectType === "content" || meta?.class === "content") return "content";
|
|
6316
6486
|
if (projectType === "hub-service" || repoIsHub(repo)) return "hub-serverless";
|
|
6317
6487
|
if (projectType === "desktop-game" || projectType === "non-deployable") return "none";
|
|
6488
|
+
if (projectType === "cli-tool") return "registry-publish";
|
|
6318
6489
|
return "tenant-container";
|
|
6319
6490
|
}
|
|
6320
6491
|
function projectTypeClearsWebProfile(projectType, deployModel) {
|
|
6321
|
-
return projectType === "content" || projectType === "desktop-game" || projectType === "non-deployable" || deployModel === "content" || deployModel === "none";
|
|
6492
|
+
return projectType === "content" || projectType === "desktop-game" || projectType === "non-deployable" || projectType === "cli-tool" || deployModel === "content" || deployModel === "none" || deployModel === "registry-publish";
|
|
6493
|
+
}
|
|
6494
|
+
function resolveReleaseTrack(meta) {
|
|
6495
|
+
const raw = typeof meta?.releaseTrack === "string" ? meta.releaseTrack : void 0;
|
|
6496
|
+
if (isReleaseTrack(raw)) return raw;
|
|
6497
|
+
if (meta?.class === "content" || meta?.deployModel === "content") return "trunk";
|
|
6498
|
+
return "full";
|
|
6499
|
+
}
|
|
6500
|
+
function branchesForTrack(track) {
|
|
6501
|
+
if (track === "trunk") return ["main"];
|
|
6502
|
+
if (track === "direct") return ["development", "main"];
|
|
6503
|
+
return ["development", "rc", "main"];
|
|
6322
6504
|
}
|
|
6323
6505
|
|
|
6324
6506
|
// src/train-apply.ts
|
|
@@ -6338,6 +6520,30 @@ async function verifyHubDistributionVersion(deps, model, releaseTag) {
|
|
|
6338
6520
|
if (model !== "hub-serverless") return;
|
|
6339
6521
|
await deps.run("node", ["scripts/release-distribution.mjs", "verify", releaseTag, "--skip-npm-view"]);
|
|
6340
6522
|
}
|
|
6523
|
+
async function verifyPublishedRelease(deps, repo, tag, expectedTarget, expectedSha) {
|
|
6524
|
+
const out = await deps.run("gh", ["release", "view", tag, "--repo", repo, "--json", "tagName,targetCommitish"]);
|
|
6525
|
+
let release;
|
|
6526
|
+
try {
|
|
6527
|
+
release = JSON.parse(out);
|
|
6528
|
+
} catch {
|
|
6529
|
+
throw new Error(`Release ${tag} metadata was not valid JSON`);
|
|
6530
|
+
}
|
|
6531
|
+
if (release.tagName !== tag) {
|
|
6532
|
+
throw new Error(`Release metadata tag is ${String(release.tagName || "(missing)")}, expected ${tag}`);
|
|
6533
|
+
}
|
|
6534
|
+
if (release.targetCommitish !== expectedTarget) {
|
|
6535
|
+
throw new Error(`Release ${tag} targetCommitish is ${String(release.targetCommitish || "(missing)")}, expected ${expectedTarget}`);
|
|
6536
|
+
}
|
|
6537
|
+
const tagSha = requireValue(clean(await deps.run("git", ["rev-parse", `${tag}^{commit}`])), "release tag sha");
|
|
6538
|
+
if (tagSha !== expectedSha) {
|
|
6539
|
+
throw new Error(`Release ${tag} tag points at ${tagSha}, expected ${expectedSha}`);
|
|
6540
|
+
}
|
|
6541
|
+
const branchOut = clean(await deps.run("git", ["ls-remote", "origin", `refs/heads/${expectedTarget}`]));
|
|
6542
|
+
const branchSha = requireValue(branchOut.split(/\s+/)[0] ?? "", `origin/${expectedTarget} sha`);
|
|
6543
|
+
if (branchSha !== expectedSha) {
|
|
6544
|
+
throw new Error(`origin/${expectedTarget} points at ${branchSha}, expected ${expectedSha}`);
|
|
6545
|
+
}
|
|
6546
|
+
}
|
|
6341
6547
|
var ORG_SPINE_FILES = [
|
|
6342
6548
|
"AGENTS.md",
|
|
6343
6549
|
"CLAUDE.md",
|
|
@@ -6414,6 +6620,16 @@ async function requireBranch(deps, branch) {
|
|
|
6414
6620
|
if (current !== branch) throw new Error(`must run from ${branch}, currently on ${current || "(unknown)"}`);
|
|
6415
6621
|
}
|
|
6416
6622
|
var HUB_REPO2 = "mutmutco/MMI-Hub";
|
|
6623
|
+
function isHubControlRepo(repo) {
|
|
6624
|
+
return repo.toLowerCase() === HUB_REPO2.toLowerCase();
|
|
6625
|
+
}
|
|
6626
|
+
async function loadProjectMeta(deps, ctx) {
|
|
6627
|
+
try {
|
|
6628
|
+
return JSON.parse(await deps.runSelf(["project", "get", ctx.repo]));
|
|
6629
|
+
} catch {
|
|
6630
|
+
return null;
|
|
6631
|
+
}
|
|
6632
|
+
}
|
|
6417
6633
|
var CORRELATE_ATTEMPTS = 5;
|
|
6418
6634
|
var CORRELATE_DELAY_MS = 1500;
|
|
6419
6635
|
var CORRELATE_SKEW_SLACK_MS = 1e4;
|
|
@@ -6423,7 +6639,7 @@ var TRAIN_PROTECTION_CONTEXTS_JQ = "[.contexts[]]";
|
|
|
6423
6639
|
var TRAIN_RULES_CONTEXTS_JQ = '[.[]|select(.type=="required_status_checks")|.parameters.required_status_checks[].context]';
|
|
6424
6640
|
var TRAIN_CHECK_ATTEMPTS = 40;
|
|
6425
6641
|
var TRAIN_CHECK_DELAY_MS = 15e3;
|
|
6426
|
-
async function
|
|
6642
|
+
async function correlateDispatchedRun(deps, workflow, since) {
|
|
6427
6643
|
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
6428
6644
|
const threshold = since - CORRELATE_SKEW_SLACK_MS;
|
|
6429
6645
|
for (let attempt = 0; attempt < CORRELATE_ATTEMPTS; attempt++) {
|
|
@@ -6436,7 +6652,7 @@ async function correlateTenantRun(deps, since) {
|
|
|
6436
6652
|
"--repo",
|
|
6437
6653
|
HUB_REPO2,
|
|
6438
6654
|
"--workflow",
|
|
6439
|
-
|
|
6655
|
+
workflow,
|
|
6440
6656
|
"--limit",
|
|
6441
6657
|
"10",
|
|
6442
6658
|
"--json",
|
|
@@ -6451,6 +6667,48 @@ async function correlateTenantRun(deps, since) {
|
|
|
6451
6667
|
}
|
|
6452
6668
|
return {};
|
|
6453
6669
|
}
|
|
6670
|
+
function correlateTenantRun(deps, since) {
|
|
6671
|
+
return correlateDispatchedRun(deps, "tenant-deploy.yml", since);
|
|
6672
|
+
}
|
|
6673
|
+
function correlatePublishRun(deps, since) {
|
|
6674
|
+
return correlateDispatchedRun(deps, "tenant-publish.yml", since);
|
|
6675
|
+
}
|
|
6676
|
+
async function correlateWorkflowRun(deps, args) {
|
|
6677
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
6678
|
+
const threshold = args.since - CORRELATE_SKEW_SLACK_MS;
|
|
6679
|
+
let lastError;
|
|
6680
|
+
let parsedAnyResponse = false;
|
|
6681
|
+
for (let attempt = 0; attempt < CORRELATE_ATTEMPTS; attempt++) {
|
|
6682
|
+
if (attempt > 0) await sleep(CORRELATE_DELAY_MS);
|
|
6683
|
+
const listArgs = [
|
|
6684
|
+
"run",
|
|
6685
|
+
"list",
|
|
6686
|
+
"--repo",
|
|
6687
|
+
HUB_REPO2,
|
|
6688
|
+
"--workflow",
|
|
6689
|
+
args.workflow,
|
|
6690
|
+
"--event",
|
|
6691
|
+
args.event,
|
|
6692
|
+
...args.branch ? ["--branch", args.branch] : [],
|
|
6693
|
+
"--limit",
|
|
6694
|
+
"10",
|
|
6695
|
+
"--json",
|
|
6696
|
+
"databaseId,url,event,createdAt,status,conclusion,headSha"
|
|
6697
|
+
];
|
|
6698
|
+
let rows;
|
|
6699
|
+
try {
|
|
6700
|
+
rows = JSON.parse(await deps.run("gh", listArgs));
|
|
6701
|
+
parsedAnyResponse = true;
|
|
6702
|
+
} catch {
|
|
6703
|
+
lastError = new Error(`could not list ${args.workflow} runs`);
|
|
6704
|
+
continue;
|
|
6705
|
+
}
|
|
6706
|
+
const match = rows.filter((r) => r.event === args.event && r.headSha === args.headSha && typeof r.databaseId === "number").map((r) => ({ row: r, created: Date.parse(r.createdAt ?? "") })).filter((c) => Number.isFinite(c.created) && c.created >= threshold).sort((a, b) => b.created - a.created)[0];
|
|
6707
|
+
if (match) return { runId: match.row.databaseId, runUrl: match.row.url };
|
|
6708
|
+
}
|
|
6709
|
+
if (!parsedAnyResponse && lastError) throw lastError;
|
|
6710
|
+
return {};
|
|
6711
|
+
}
|
|
6454
6712
|
async function watchTenantRun(deps, runId) {
|
|
6455
6713
|
if (runId == null) return "pending";
|
|
6456
6714
|
try {
|
|
@@ -6460,6 +6718,16 @@ async function watchTenantRun(deps, runId) {
|
|
|
6460
6718
|
return "failure";
|
|
6461
6719
|
}
|
|
6462
6720
|
}
|
|
6721
|
+
async function watchWorkflowRun(deps, workflow, run) {
|
|
6722
|
+
if (run.runId == null) return { workflow, conclusion: "pending" };
|
|
6723
|
+
const conclusion = await watchTenantRun(deps, run.runId);
|
|
6724
|
+
return { workflow, runId: run.runId, runUrl: run.runUrl, conclusion };
|
|
6725
|
+
}
|
|
6726
|
+
function aggregateWorkflowRuns(runs) {
|
|
6727
|
+
if (runs.some((r) => r.conclusion === "failure")) return "failure";
|
|
6728
|
+
if (runs.some((r) => r.conclusion === "pending")) return "pending";
|
|
6729
|
+
return "success";
|
|
6730
|
+
}
|
|
6463
6731
|
function isNotFoundError(e) {
|
|
6464
6732
|
const msg = `${e.message ?? e} ${String(e.stderr ?? "")}`;
|
|
6465
6733
|
return /HTTP 404|Not Found|\(404\)/i.test(msg);
|
|
@@ -6578,13 +6846,20 @@ async function resolveRcResumeTag(deps, base, sha) {
|
|
|
6578
6846
|
const note = leftovers.length > 0 ? `resuming existing RC tag ${newest} already on ${sha.slice(0, 7)} (newest of ${sorted.length}); harmless leftover tag(s) on the same SHA: ${leftovers.join(", ")}` : `resuming existing RC tag ${newest} already on ${sha.slice(0, 7)} instead of minting a fresh rc`;
|
|
6579
6847
|
return { tag: newest, note };
|
|
6580
6848
|
}
|
|
6581
|
-
async function dispatchDeploy(deps, ctx, stage2, ref, model, watch) {
|
|
6582
|
-
if (model === "tenant-container") {
|
|
6849
|
+
async function dispatchDeploy(deps, ctx, stage2, ref, model, watch, autoRunSince, autoRunHeadSha) {
|
|
6850
|
+
if (model === "tenant-container" || model === "solo-container") {
|
|
6851
|
+
const since = (deps.now ?? Date.now)();
|
|
6852
|
+
await deps.dispatchTenantDeploy({ repo: ctx.repo, slug: ctx.slug, ref, stage: stage2 });
|
|
6853
|
+
const { runId, runUrl } = await correlateTenantRun(deps, since);
|
|
6854
|
+
const deployStatus = watch ? await watchTenantRun(deps, runId) : "pending";
|
|
6855
|
+
return { note: `dispatched tenant-deploy.yml (slug=${ctx.slug}, ref=${ref}, stage=${stage2})`, runId, runUrl, deployStatus };
|
|
6856
|
+
}
|
|
6857
|
+
if (model === "registry-publish") {
|
|
6583
6858
|
const since = (deps.now ?? Date.now)();
|
|
6584
6859
|
await deps.run("gh", [
|
|
6585
6860
|
"workflow",
|
|
6586
6861
|
"run",
|
|
6587
|
-
"tenant-
|
|
6862
|
+
"tenant-publish.yml",
|
|
6588
6863
|
"--repo",
|
|
6589
6864
|
HUB_REPO2,
|
|
6590
6865
|
"-f",
|
|
@@ -6596,25 +6871,40 @@ async function dispatchDeploy(deps, ctx, stage2, ref, model, watch) {
|
|
|
6596
6871
|
"-f",
|
|
6597
6872
|
`stage=${stage2}`
|
|
6598
6873
|
]);
|
|
6599
|
-
const { runId, runUrl } = await
|
|
6874
|
+
const { runId, runUrl } = await correlatePublishRun(deps, since);
|
|
6600
6875
|
const deployStatus = watch ? await watchTenantRun(deps, runId) : "pending";
|
|
6601
|
-
return { note: `dispatched tenant-
|
|
6876
|
+
return { note: `dispatched tenant-publish.yml (slug=${ctx.slug}, ref=${ref}, stage=${stage2})`, runId, runUrl, deployStatus };
|
|
6602
6877
|
}
|
|
6603
6878
|
if (model === "hub-serverless") {
|
|
6879
|
+
const note = ref === "rc" ? "no manual dispatch: deploy.yml auto-fires on the rc push (rc stage)" : "no manual dispatch: deploy.yml + publish.yml auto-fire on the published Release (prod)";
|
|
6880
|
+
if (!watch) return { note, deployStatus: "pending" };
|
|
6881
|
+
if (!autoRunHeadSha) return { note, deployStatus: "pending" };
|
|
6882
|
+
const since = autoRunSince ?? (deps.now ?? Date.now)();
|
|
6883
|
+
const targets = ref === "rc" ? [{ workflow: "deploy.yml", event: "push", branch: "rc" }] : [
|
|
6884
|
+
{ workflow: "deploy.yml", event: "release" },
|
|
6885
|
+
{ workflow: "publish.yml", event: "release" }
|
|
6886
|
+
];
|
|
6887
|
+
const workflowRuns = [];
|
|
6888
|
+
for (const target of targets) {
|
|
6889
|
+
try {
|
|
6890
|
+
const run = await correlateWorkflowRun(deps, { ...target, since, headSha: autoRunHeadSha });
|
|
6891
|
+
workflowRuns.push(await watchWorkflowRun(deps, target.workflow, run));
|
|
6892
|
+
} catch {
|
|
6893
|
+
workflowRuns.push({ workflow: target.workflow, conclusion: "failure" });
|
|
6894
|
+
}
|
|
6895
|
+
}
|
|
6896
|
+
const primary = workflowRuns[0];
|
|
6604
6897
|
return {
|
|
6605
|
-
note
|
|
6606
|
-
|
|
6898
|
+
note,
|
|
6899
|
+
runId: primary?.runId,
|
|
6900
|
+
runUrl: primary?.runUrl,
|
|
6901
|
+
workflowRuns,
|
|
6902
|
+
deployStatus: aggregateWorkflowRuns(workflowRuns)
|
|
6607
6903
|
};
|
|
6608
6904
|
}
|
|
6609
6905
|
return { note: `no manual dispatch: ${model} repo deploys via its own push-triggered workflow`, deployStatus: "pending" };
|
|
6610
6906
|
}
|
|
6611
|
-
async function preflight(deps, ctx, stage2) {
|
|
6612
|
-
let meta = null;
|
|
6613
|
-
try {
|
|
6614
|
-
meta = JSON.parse(await deps.runSelf(["project", "get", ctx.repo]));
|
|
6615
|
-
} catch {
|
|
6616
|
-
meta = null;
|
|
6617
|
-
}
|
|
6907
|
+
async function preflight(deps, ctx, stage2, meta) {
|
|
6618
6908
|
const model = resolveDeployModel2(meta, ctx.repo);
|
|
6619
6909
|
if (model === "content") {
|
|
6620
6910
|
throw new Error(`${ctx.repo} is a content repo (deployModel=content) \u2014 the release train does not apply (trunk-based; PR to main)`);
|
|
@@ -6630,14 +6920,19 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6630
6920
|
const ctx = await buildTrainApplyContext(deps);
|
|
6631
6921
|
await requireCleanTree(deps);
|
|
6632
6922
|
await deps.run("git", ["fetch", "origin"]);
|
|
6923
|
+
const meta = await loadProjectMeta(deps, ctx);
|
|
6924
|
+
const directTrack = isHubControlRepo(ctx.repo) || resolveReleaseTrack(meta) === "direct";
|
|
6633
6925
|
if (command === "rcand") {
|
|
6634
6926
|
await requireBranch(deps, "development");
|
|
6927
|
+
if (directTrack) {
|
|
6928
|
+
throw new Error("direct-track repos release straight to main (no rc); run `mmi-cli release --apply` from development instead of rcand");
|
|
6929
|
+
}
|
|
6635
6930
|
await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
|
|
6636
6931
|
ensurePositiveCount(
|
|
6637
6932
|
await deps.run("git", ["rev-list", "--count", "origin/rc..origin/development"]),
|
|
6638
6933
|
"nothing to promote: origin/development is not ahead of origin/rc"
|
|
6639
6934
|
);
|
|
6640
|
-
const deployModel2 = await preflight(deps, ctx, "rc");
|
|
6935
|
+
const deployModel2 = await preflight(deps, ctx, "rc", meta);
|
|
6641
6936
|
const releaseBase = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "cycle"])), "release base");
|
|
6642
6937
|
await verifyHubDistributionVersion(deps, deployModel2, releaseBase);
|
|
6643
6938
|
await deps.run("git", ["checkout", "rc"]);
|
|
@@ -6650,16 +6945,74 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6650
6945
|
await ensureTagPushed(deps, tag2, rcSha);
|
|
6651
6946
|
const requiredChecks2 = await discoverRequiredCheckContexts(deps, ctx, "rc");
|
|
6652
6947
|
const checks2 = await waitForRequiredTrainChecks(deps, ctx, rcSha, requiredChecks2);
|
|
6948
|
+
const autoRunSince2 = (deps.now ?? Date.now)();
|
|
6653
6949
|
await deps.run("git", ["push", "origin", "rc"]);
|
|
6654
|
-
const d2 = await dispatchDeploy(deps, ctx, "rc", "rc", deployModel2, watch);
|
|
6655
|
-
return { ...ctx, command, stage: "rc", ref: "rc", tag: tag2, deployModel: deployModel2, promoted: true, checks: checks2, resumeNote, dispatch: d2.note, runId: d2.runId, runUrl: d2.runUrl, deployStatus: d2.deployStatus };
|
|
6950
|
+
const d2 = await dispatchDeploy(deps, ctx, "rc", "rc", deployModel2, watch, autoRunSince2, rcSha);
|
|
6951
|
+
return { ...ctx, command, stage: "rc", ref: "rc", tag: tag2, deployModel: deployModel2, promoted: true, checks: checks2, resumeNote, dispatch: d2.note, runId: d2.runId, runUrl: d2.runUrl, workflowRuns: d2.workflowRuns, deployStatus: d2.deployStatus };
|
|
6952
|
+
}
|
|
6953
|
+
if (directTrack) {
|
|
6954
|
+
await requireBranch(deps, "development");
|
|
6955
|
+
await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
|
|
6956
|
+
ensurePositiveCount(
|
|
6957
|
+
await deps.run("git", ["rev-list", "--count", "origin/main..origin/development"]),
|
|
6958
|
+
"nothing to release: origin/development is not ahead of origin/main"
|
|
6959
|
+
);
|
|
6960
|
+
const deployModel2 = await preflight(deps, ctx, "main", meta);
|
|
6961
|
+
const tag2 = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "cycle"])), "release tag");
|
|
6962
|
+
await verifyHubDistributionVersion(deps, deployModel2, tag2);
|
|
6963
|
+
const predicted2 = await predictMergeConflicts(deps, "origin/main", "origin/development");
|
|
6964
|
+
const predictedNonSpine2 = predicted2.filter((f) => !isSpinePath(f));
|
|
6965
|
+
if (predictedNonSpine2.length > 0) {
|
|
6966
|
+
throw new Error(
|
|
6967
|
+
`development -> main merge would conflict on non-spine path(s): ${predictedNonSpine2.join(", ")} \u2014 no merge was started. The train is misaligned: reconcile main and development via an approved alignment PR, then rerun release.`
|
|
6968
|
+
);
|
|
6969
|
+
}
|
|
6970
|
+
await deps.run("git", ["checkout", "main"]);
|
|
6971
|
+
await deps.run("git", ["pull", "--ff-only", "origin", "main"]);
|
|
6972
|
+
if (predicted2.length === 0) {
|
|
6973
|
+
await deps.run("git", ["merge", "development", "--no-edit"]);
|
|
6974
|
+
} else {
|
|
6975
|
+
await mergeWithSpineResolution(deps, "development", "development -> main", "theirs");
|
|
6976
|
+
}
|
|
6977
|
+
const releaseSha2 = requireValue(clean(await deps.run("git", ["rev-parse", "main"])), "release sha");
|
|
6978
|
+
await ensureTagPushed(deps, tag2, releaseSha2);
|
|
6979
|
+
const requiredChecks2 = await discoverRequiredCheckContexts(deps, ctx, "main");
|
|
6980
|
+
const checks2 = await waitForRequiredTrainChecks(deps, ctx, releaseSha2, requiredChecks2);
|
|
6981
|
+
await deps.run("git", ["push", "origin", "main"]);
|
|
6982
|
+
const releaseUrl2 = clean(await deps.run("gh", ["release", "create", tag2, "--generate-notes", "--latest", "--repo", ctx.repo])) || void 0;
|
|
6983
|
+
const announceNote2 = deps.announce ? (await deps.announce({ repo: ctx.repo, tag: tag2, summaryFile: options.announceSummaryFile })).note : void 0;
|
|
6984
|
+
const autoRunSince2 = (deps.now ?? Date.now)();
|
|
6985
|
+
const d2 = await dispatchDeploy(deps, ctx, "main", "main", deployModel2, watch, autoRunSince2, releaseSha2);
|
|
6986
|
+
await deps.run("git", ["checkout", "development"]);
|
|
6987
|
+
await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
|
|
6988
|
+
await deps.run("git", ["merge", "main", "--no-edit"]);
|
|
6989
|
+
await deps.run("git", ["push", "origin", "development"]);
|
|
6990
|
+
return {
|
|
6991
|
+
...ctx,
|
|
6992
|
+
command,
|
|
6993
|
+
stage: "main",
|
|
6994
|
+
ref: "main",
|
|
6995
|
+
tag: tag2,
|
|
6996
|
+
deployModel: deployModel2,
|
|
6997
|
+
promoted: true,
|
|
6998
|
+
checks: checks2,
|
|
6999
|
+
dispatch: d2.note,
|
|
7000
|
+
runId: d2.runId,
|
|
7001
|
+
runUrl: d2.runUrl,
|
|
7002
|
+
workflowRuns: d2.workflowRuns,
|
|
7003
|
+
deployStatus: d2.deployStatus,
|
|
7004
|
+
rcRetirement: "not-applicable",
|
|
7005
|
+
rcRetirementNote: "direct-track release skips rc; no rc runtime to retire",
|
|
7006
|
+
announceNote: announceNote2,
|
|
7007
|
+
release: { tag: tag2, url: releaseUrl2, targetSha: releaseSha2 }
|
|
7008
|
+
};
|
|
6656
7009
|
}
|
|
6657
7010
|
await requireBranch(deps, "rc");
|
|
6658
7011
|
ensurePositiveCount(
|
|
6659
7012
|
await deps.run("git", ["rev-list", "--count", "origin/main..origin/rc"]),
|
|
6660
7013
|
"nothing to release: origin/rc is not ahead of origin/main"
|
|
6661
7014
|
);
|
|
6662
|
-
const deployModel = await preflight(deps, ctx, "main");
|
|
7015
|
+
const deployModel = await preflight(deps, ctx, "main", meta);
|
|
6663
7016
|
const predicted = await predictMergeConflicts(deps, "origin/main", "origin/rc");
|
|
6664
7017
|
const predictedNonSpine = predicted.filter((f) => !isSpinePath(f));
|
|
6665
7018
|
if (predictedNonSpine.length > 0) {
|
|
@@ -6667,6 +7020,13 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6667
7020
|
`rc -> main merge would conflict on non-spine path(s): ${predictedNonSpine.join(", ")} \u2014 no merge was started. The train is misaligned: reconcile main and rc via an approved alignment PR (do not hand-resolve on main), then rerun release.`
|
|
6668
7021
|
);
|
|
6669
7022
|
}
|
|
7023
|
+
const coverage = deps.hotfixCoverage({ mainRef: "origin/main", rcRef: "origin/rc", ack: options.ack ?? [] });
|
|
7024
|
+
if (!coverage.ok) {
|
|
7025
|
+
const list = coverage.uncovered.map((c) => `${c.sha.slice(0, 8)} ${c.subject}`).join("; ");
|
|
7026
|
+
throw new Error(
|
|
7027
|
+
`hotfix-coverage: ${coverage.uncovered.length} main-only commit(s) not proven in origin/rc \u2014 the candidate would revert a prod hotfix: ${list}. Re-cut /rcand from development, or have the authorized human verify the content is in the candidate and rerun release with --ack <sha>[,<sha>\u2026].`
|
|
7028
|
+
);
|
|
7029
|
+
}
|
|
6670
7030
|
const releasedRcSha = clean(await deps.run("git", ["rev-parse", "origin/rc"]));
|
|
6671
7031
|
await deps.run("git", ["checkout", "main"]);
|
|
6672
7032
|
await deps.run("git", ["pull", "--ff-only", "origin", "main"]);
|
|
@@ -6682,9 +7042,11 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6682
7042
|
const requiredChecks = await discoverRequiredCheckContexts(deps, ctx, "main");
|
|
6683
7043
|
const checks = await waitForRequiredTrainChecks(deps, ctx, releaseSha, requiredChecks);
|
|
6684
7044
|
await deps.run("git", ["push", "origin", "main"]);
|
|
6685
|
-
const
|
|
7045
|
+
const autoRunSince = (deps.now ?? Date.now)();
|
|
7046
|
+
const releaseUrl = clean(await deps.run("gh", ["release", "create", tag, "--target", "main", "--generate-notes", "--latest", "--repo", ctx.repo])) || void 0;
|
|
7047
|
+
await verifyPublishedRelease(deps, ctx.repo, tag, "main", releaseSha);
|
|
6686
7048
|
const announceNote = deps.announce ? (await deps.announce({ repo: ctx.repo, tag, summaryFile: options.announceSummaryFile })).note : void 0;
|
|
6687
|
-
const d = await dispatchDeploy(deps, ctx, "main", "main", deployModel, watch);
|
|
7049
|
+
const d = await dispatchDeploy(deps, ctx, "main", "main", deployModel, watch, autoRunSince, releaseSha);
|
|
6688
7050
|
const retirement = await retireRcRuntime(deps, ctx, deployModel, d.deployStatus, releasedRcSha);
|
|
6689
7051
|
await deps.run("git", ["checkout", "development"]);
|
|
6690
7052
|
await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
|
|
@@ -6703,6 +7065,7 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6703
7065
|
dispatch: d.note,
|
|
6704
7066
|
runId: d.runId,
|
|
6705
7067
|
runUrl: d.runUrl,
|
|
7068
|
+
workflowRuns: d.workflowRuns,
|
|
6706
7069
|
deployStatus: d.deployStatus,
|
|
6707
7070
|
rcRetirement: retirement.status,
|
|
6708
7071
|
rcRetirementNote: retirement.note,
|
|
@@ -6743,6 +7106,49 @@ function retireCategoryFrom(text) {
|
|
|
6743
7106
|
return void 0;
|
|
6744
7107
|
}
|
|
6745
7108
|
}
|
|
7109
|
+
var TRANSPORT_REASONS = /* @__PURE__ */ new Set(["invalid-instance", "invalid-document", "throttled", "timeout", "other"]);
|
|
7110
|
+
function retireReasonFrom(text) {
|
|
7111
|
+
try {
|
|
7112
|
+
const r = JSON.parse(text).reason;
|
|
7113
|
+
return typeof r === "string" && TRANSPORT_REASONS.has(r) ? r : void 0;
|
|
7114
|
+
} catch {
|
|
7115
|
+
return void 0;
|
|
7116
|
+
}
|
|
7117
|
+
}
|
|
7118
|
+
function isRetryableTransport(reason) {
|
|
7119
|
+
return reason === void 0 || reason === "throttled" || reason === "timeout" || reason === "other";
|
|
7120
|
+
}
|
|
7121
|
+
var RETIRE_MAX_ATTEMPTS = 3;
|
|
7122
|
+
var RETIRE_BACKOFF_MS = 1500;
|
|
7123
|
+
async function attemptRetire(deps, ctx) {
|
|
7124
|
+
try {
|
|
7125
|
+
const out = await deps.runSelf(["tenant", "control", ctx.repo, "rc", "retire"]);
|
|
7126
|
+
let commandId = "";
|
|
7127
|
+
let category = retireCategoryFrom(out);
|
|
7128
|
+
try {
|
|
7129
|
+
commandId = String(JSON.parse(out).commandId ?? "");
|
|
7130
|
+
} catch {
|
|
7131
|
+
}
|
|
7132
|
+
if (category === "retired-edge-pending") {
|
|
7133
|
+
return { result: {
|
|
7134
|
+
status: "retired",
|
|
7135
|
+
category,
|
|
7136
|
+
note: `rc runtime retired; edge vhost reconcile pending (tenant control retire${commandId ? `, command ${commandId}` : ""}) \u2014 registry coords kept`
|
|
7137
|
+
} };
|
|
7138
|
+
}
|
|
7139
|
+
category = category ?? "retired";
|
|
7140
|
+
return { result: {
|
|
7141
|
+
status: "retired",
|
|
7142
|
+
category,
|
|
7143
|
+
note: `rc runtime retired (tenant control retire${commandId ? `, command ${commandId}` : ""}) \u2014 registry coords kept; /rcand or tenant redeploy recreates rc next cycle`
|
|
7144
|
+
} };
|
|
7145
|
+
} catch (e) {
|
|
7146
|
+
const err = e;
|
|
7147
|
+
const category = retireCategoryFrom(err.stdout ?? "") ?? "transport-failed";
|
|
7148
|
+
const reason = retireReasonFrom(err.stdout ?? "");
|
|
7149
|
+
return { result: { status: "failed", category, note: `rc retirement failed (the release itself succeeded): ${err.message}` }, reason, message: err.message };
|
|
7150
|
+
}
|
|
7151
|
+
}
|
|
6746
7152
|
async function retireRcRuntime(deps, ctx, model, deployStatus, releasedRcSha) {
|
|
6747
7153
|
if (model !== "tenant-container") {
|
|
6748
7154
|
return { status: "not-applicable", note: `${model} has no co-resident rc runtime to retire` };
|
|
@@ -6765,30 +7171,22 @@ async function retireRcRuntime(deps, ctx, model, deployStatus, releasedRcSha) {
|
|
|
6765
7171
|
note: `origin/rc moved past the released candidate (${releasedRcSha.slice(0, 7)} -> ${rcNow.slice(0, 7)}) \u2014 a new candidate is in flight; rc runtime left untouched`
|
|
6766
7172
|
};
|
|
6767
7173
|
}
|
|
6768
|
-
const
|
|
6769
|
-
let
|
|
6770
|
-
let
|
|
6771
|
-
|
|
6772
|
-
|
|
6773
|
-
|
|
6774
|
-
|
|
6775
|
-
|
|
6776
|
-
|
|
6777
|
-
|
|
6778
|
-
|
|
6779
|
-
|
|
6780
|
-
|
|
6781
|
-
}
|
|
6782
|
-
category = category ?? "retired";
|
|
6783
|
-
return {
|
|
6784
|
-
status: "retired",
|
|
6785
|
-
category,
|
|
6786
|
-
note: `rc runtime retired (tenant control retire${commandId ? `, command ${commandId}` : ""}) \u2014 registry coords kept; /rcand or tenant redeploy recreates rc next cycle`
|
|
6787
|
-
};
|
|
7174
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
7175
|
+
let last;
|
|
7176
|
+
for (let attempt = 1; attempt <= RETIRE_MAX_ATTEMPTS; attempt++) {
|
|
7177
|
+
last = await attemptRetire(deps, ctx);
|
|
7178
|
+
if (last.result.status === "retired") return last.result;
|
|
7179
|
+
const retryable = last.result.category === "transport-failed" && isRetryableTransport(last.reason);
|
|
7180
|
+
if (!retryable || attempt === RETIRE_MAX_ATTEMPTS) break;
|
|
7181
|
+
await sleep(RETIRE_BACKOFF_MS * attempt);
|
|
7182
|
+
}
|
|
7183
|
+
const f = last;
|
|
7184
|
+
const reasonSuffix = f.reason ? ` [reason: ${f.reason}]` : "";
|
|
7185
|
+
const note = `rc retirement failed (the release itself succeeded)${reasonSuffix}: ${f.message ?? f.result.note}. The rc runtime may be orphaned on the box \u2014 retire it with: mmi-cli tenant control ${ctx.repo} rc retire (or sweep all: mmi-cli tenant sweep-rc --retire --yes)`;
|
|
7186
|
+
return { status: "failed", category: f.result.category, note };
|
|
6788
7187
|
} catch (e) {
|
|
6789
7188
|
const err = e;
|
|
6790
|
-
|
|
6791
|
-
return { status: "failed", category, note: `rc retirement failed (the release itself succeeded): ${err.message}` };
|
|
7189
|
+
return { status: "failed", category: "transport-failed", note: `rc retirement failed (the release itself succeeded): ${err.message}` };
|
|
6792
7190
|
}
|
|
6793
7191
|
}
|
|
6794
7192
|
async function runTenantRedeploy(deps, options) {
|
|
@@ -6810,18 +7208,182 @@ async function runTenantRedeploy(deps, options) {
|
|
|
6810
7208
|
meta = null;
|
|
6811
7209
|
}
|
|
6812
7210
|
const deployModel = resolveDeployModel2(meta, repo);
|
|
6813
|
-
if (deployModel !== "tenant-container") {
|
|
6814
|
-
throw new Error(`${repo} is ${deployModel}, not tenant-container \u2014 there is no central tenant-deploy run to retry (its deploy fires from its own workflow)`);
|
|
7211
|
+
if (deployModel !== "tenant-container" && deployModel !== "solo-container") {
|
|
7212
|
+
throw new Error(`${repo} is ${deployModel}, not a central-container model (tenant-container/solo-container) \u2014 there is no central tenant-deploy run to retry (its deploy fires from its own workflow)`);
|
|
6815
7213
|
}
|
|
6816
7214
|
const d = await dispatchDeploy(deps, ctx, stage2, ref, deployModel, watch);
|
|
6817
|
-
return { ...ctx, command: "tenant-redeploy", stage: stage2, ref, deployModel, dispatch: d.note, runId: d.runId, runUrl: d.runUrl, deployStatus: d.deployStatus };
|
|
7215
|
+
return { ...ctx, command: "tenant-redeploy", stage: stage2, ref, deployModel, dispatch: d.note, runId: d.runId, runUrl: d.runUrl, workflowRuns: d.workflowRuns, deployStatus: d.deployStatus };
|
|
7216
|
+
}
|
|
7217
|
+
|
|
7218
|
+
// src/hotfix-coverage.ts
|
|
7219
|
+
var import_node_child_process6 = require("node:child_process");
|
|
7220
|
+
var CHERRY_TRAILER = /\(cherry picked from commit ([0-9a-f]{7,40})\)/g;
|
|
7221
|
+
function checkHotfixCoverage(options = {}) {
|
|
7222
|
+
const { cwd = process.cwd(), mainRef = "origin/main", rcRef = "origin/rc", manifestPaths = [] } = options;
|
|
7223
|
+
const ack = (options.ack ?? []).filter(Boolean);
|
|
7224
|
+
const git = options.git ?? ((args, opts) => (0, import_node_child_process6.execFileSync)("git", args, { cwd, encoding: "utf8", input: opts?.input, stdio: ["pipe", "pipe", "pipe"] }));
|
|
7225
|
+
const revList = (range) => {
|
|
7226
|
+
const out = git(["rev-list", "--no-merges", range]).trim();
|
|
7227
|
+
return out ? out.split("\n") : [];
|
|
7228
|
+
};
|
|
7229
|
+
const isAncestor = (sha, ref) => {
|
|
7230
|
+
try {
|
|
7231
|
+
git(["merge-base", "--is-ancestor", sha, ref]);
|
|
7232
|
+
return true;
|
|
7233
|
+
} catch {
|
|
7234
|
+
return false;
|
|
7235
|
+
}
|
|
7236
|
+
};
|
|
7237
|
+
const patchId = (sha) => {
|
|
7238
|
+
const diff = git(["show", "--no-color", "--pretty=format:", sha]);
|
|
7239
|
+
if (!diff.trim()) return null;
|
|
7240
|
+
const out = git(["patch-id", "--stable"], { input: diff }).trim();
|
|
7241
|
+
return out ? out.split(" ")[0] : null;
|
|
7242
|
+
};
|
|
7243
|
+
const changedPaths = (sha) => {
|
|
7244
|
+
const out = git(["show", "--no-color", "--name-only", "--pretty=format:", sha]).trim();
|
|
7245
|
+
return out ? out.split("\n") : [];
|
|
7246
|
+
};
|
|
7247
|
+
const cherrySources = (sha) => {
|
|
7248
|
+
const message = git(["log", "-1", "--format=%B", sha]);
|
|
7249
|
+
return [...message.matchAll(CHERRY_TRAILER)].map((m) => m[1]);
|
|
7250
|
+
};
|
|
7251
|
+
const mainOnly = revList(`${rcRef}..${mainRef}`);
|
|
7252
|
+
const rcSidePatchIds = /* @__PURE__ */ new Map();
|
|
7253
|
+
let rcSideBuilt = false;
|
|
7254
|
+
const buildRcSide = () => {
|
|
7255
|
+
if (rcSideBuilt) return;
|
|
7256
|
+
rcSideBuilt = true;
|
|
7257
|
+
for (const sha of revList(`${mainRef}..${rcRef}`)) {
|
|
7258
|
+
const id = patchId(sha);
|
|
7259
|
+
if (id) rcSidePatchIds.set(id, sha);
|
|
7260
|
+
}
|
|
7261
|
+
};
|
|
7262
|
+
const commits = mainOnly.map((sha) => {
|
|
7263
|
+
const subject = git(["log", "-1", "--format=%s", sha]).trim();
|
|
7264
|
+
const paths = changedPaths(sha);
|
|
7265
|
+
if (paths.length > 0 && paths.every((p) => manifestPaths.includes(p))) {
|
|
7266
|
+
return { sha, subject, coverage: "exempt-distribution" };
|
|
7267
|
+
}
|
|
7268
|
+
const sources = cherrySources(sha);
|
|
7269
|
+
if (sources.length > 0) {
|
|
7270
|
+
if (sources.every((s) => isAncestor(s, rcRef))) {
|
|
7271
|
+
return { sha, subject, coverage: "trailer", sources };
|
|
7272
|
+
}
|
|
7273
|
+
if (ack.some((a) => sha.startsWith(a))) return { sha, subject, coverage: "acked", sources };
|
|
7274
|
+
return { sha, subject, coverage: "uncovered", sources };
|
|
7275
|
+
}
|
|
7276
|
+
const id = patchId(sha);
|
|
7277
|
+
if (id) {
|
|
7278
|
+
buildRcSide();
|
|
7279
|
+
const matchedRcSha = rcSidePatchIds.get(id);
|
|
7280
|
+
if (matchedRcSha) {
|
|
7281
|
+
return { sha, subject, coverage: "patch-id", matchedRcSha };
|
|
7282
|
+
}
|
|
7283
|
+
}
|
|
7284
|
+
if (ack.some((a) => sha.startsWith(a))) return { sha, subject, coverage: "acked" };
|
|
7285
|
+
return { sha, subject, coverage: "uncovered" };
|
|
7286
|
+
});
|
|
7287
|
+
const uncovered = commits.filter((c) => c.coverage === "uncovered");
|
|
7288
|
+
return { ok: uncovered.length === 0, mainRef, rcRef, commits, uncovered };
|
|
7289
|
+
}
|
|
7290
|
+
|
|
7291
|
+
// src/train-prep.ts
|
|
7292
|
+
function clean2(text) {
|
|
7293
|
+
return text.trim();
|
|
7294
|
+
}
|
|
7295
|
+
function normalizeVersion2(target) {
|
|
7296
|
+
const version = target.replace(/^v/, "");
|
|
7297
|
+
if (!/^\d+\.\d+\.\d+$/.test(version)) {
|
|
7298
|
+
throw new Error(`invalid train prep target ${target || "(empty)"}`);
|
|
7299
|
+
}
|
|
7300
|
+
return version;
|
|
7301
|
+
}
|
|
7302
|
+
function parseChangedFiles(out) {
|
|
7303
|
+
return out.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
7304
|
+
}
|
|
7305
|
+
async function runTrainPrep(deps, options = {}) {
|
|
7306
|
+
const target = clean2(await deps.run("node", ["scripts/next-version.mjs", "cycle"]));
|
|
7307
|
+
const version = normalizeVersion2(target);
|
|
7308
|
+
if (!options.apply) {
|
|
7309
|
+
return { target, version, applied: false, command: "mmi-cli train prep --apply", stagedFiles: [] };
|
|
7310
|
+
}
|
|
7311
|
+
await deps.run("node", ["scripts/release-distribution.mjs", "prepare", target]);
|
|
7312
|
+
const stagedFiles = parseChangedFiles(await deps.run("node", ["scripts/release-distribution.mjs", "changed-files"]));
|
|
7313
|
+
if (stagedFiles.length === 0) throw new Error("no locked distribution files returned by release-distribution changed-files");
|
|
7314
|
+
await deps.run("git", ["add", "--", ...stagedFiles]);
|
|
7315
|
+
await deps.run("node", ["scripts/release-distribution.mjs", "verify", target, "--skip-npm-view"]);
|
|
7316
|
+
return { target, version, applied: true, command: "mmi-cli pr create --base development --head <current-branch>", stagedFiles };
|
|
7317
|
+
}
|
|
7318
|
+
|
|
7319
|
+
// src/tenant-sweep.ts
|
|
7320
|
+
function isRcBearingTenant(p) {
|
|
7321
|
+
if ((p.deployModel ?? "") !== "tenant-container") return false;
|
|
7322
|
+
return (p.releaseTrack ?? "full") === "full";
|
|
7323
|
+
}
|
|
7324
|
+
function pickRepo(p) {
|
|
7325
|
+
const repos = p.repos ?? [];
|
|
7326
|
+
if (repos.length === 0) return null;
|
|
7327
|
+
const bySlug = repos.find((r) => r.split("/")[1]?.toLowerCase() === (p.slug ?? "").toLowerCase());
|
|
7328
|
+
return bySlug ?? repos[0];
|
|
7329
|
+
}
|
|
7330
|
+
async function sweepRcOrphans(deps, opts) {
|
|
7331
|
+
const projects = await deps.listProjects();
|
|
7332
|
+
if (!projects) throw new Error("project list unavailable \u2014 Hub unreachable or this repo is not bootstrapped");
|
|
7333
|
+
const targets = projects.filter(isRcBearingTenant);
|
|
7334
|
+
const stages = [];
|
|
7335
|
+
for (const p of targets) {
|
|
7336
|
+
const repo = pickRepo(p);
|
|
7337
|
+
if (!repo) continue;
|
|
7338
|
+
const slug = p.slug ?? "";
|
|
7339
|
+
let serviceState;
|
|
7340
|
+
try {
|
|
7341
|
+
serviceState = (await deps.status(repo)).serviceState || "unknown";
|
|
7342
|
+
} catch (e) {
|
|
7343
|
+
stages.push({ repo, slug, serviceState: "error", orphanCandidate: false, detail: e.message });
|
|
7344
|
+
continue;
|
|
7345
|
+
}
|
|
7346
|
+
const orphanCandidate = serviceState === "running";
|
|
7347
|
+
const stage2 = { repo, slug, serviceState, orphanCandidate };
|
|
7348
|
+
if (orphanCandidate && opts.retire) {
|
|
7349
|
+
try {
|
|
7350
|
+
const r = await deps.retire(repo);
|
|
7351
|
+
stage2.retired = r.ok ? "retired" : "failed";
|
|
7352
|
+
stage2.category = r.category;
|
|
7353
|
+
stage2.reason = r.reason;
|
|
7354
|
+
} catch (e) {
|
|
7355
|
+
stage2.retired = "failed";
|
|
7356
|
+
stage2.detail = e.message;
|
|
7357
|
+
}
|
|
7358
|
+
}
|
|
7359
|
+
stages.push(stage2);
|
|
7360
|
+
}
|
|
7361
|
+
return {
|
|
7362
|
+
scanned: stages.length,
|
|
7363
|
+
running: stages.filter((s) => s.orphanCandidate && s.retired !== "retired").length,
|
|
7364
|
+
stages,
|
|
7365
|
+
retireAttempted: !!opts.retire
|
|
7366
|
+
};
|
|
7367
|
+
}
|
|
7368
|
+
function renderSweep(r) {
|
|
7369
|
+
const lines = [`tenant sweep-rc: scanned ${r.scanned} tenant-container(s); ${r.running} rc runtime(s) running`];
|
|
7370
|
+
for (const s of r.stages) {
|
|
7371
|
+
let line = ` ${s.repo} rc: ${s.orphanCandidate ? "RUNNING" : s.serviceState}`;
|
|
7372
|
+
if (s.retired) line += ` -> ${s.retired}${s.category ? ` (${s.category}${s.reason ? `/${s.reason}` : ""})` : ""}`;
|
|
7373
|
+
if (s.detail) line += ` \u2014 ${s.detail}`;
|
|
7374
|
+
lines.push(line);
|
|
7375
|
+
}
|
|
7376
|
+
if (r.running > 0 && !r.retireAttempted) {
|
|
7377
|
+
lines.push("Retire an orphan with: mmi-cli tenant control <owner/repo> rc retire (or sweep all running: mmi-cli tenant sweep-rc --retire --yes)");
|
|
7378
|
+
}
|
|
7379
|
+
return lines.join("\n");
|
|
6818
7380
|
}
|
|
6819
7381
|
|
|
6820
7382
|
// src/hotfix-apply.ts
|
|
6821
7383
|
var HOTFIX_RELEASE_WORKFLOWS = ["deploy.yml", "publish.yml"];
|
|
6822
7384
|
var HOTFIX_RUN_FIND_ATTEMPTS = 10;
|
|
6823
7385
|
var HOTFIX_RUN_FIND_DELAY_MS = 15e3;
|
|
6824
|
-
function
|
|
7386
|
+
function clean3(out) {
|
|
6825
7387
|
return out.trim();
|
|
6826
7388
|
}
|
|
6827
7389
|
function sleeper(deps) {
|
|
@@ -6875,7 +7437,7 @@ async function resolveHotfixSource(deps, ctx, from) {
|
|
|
6875
7437
|
if (!sha2) throw new Error(`PR #${num} has no merge commit recorded \u2014 name the commit SHA explicitly`);
|
|
6876
7438
|
return { sha: sha2, label: `PR #${num} (${sha2.slice(0, 7)})` };
|
|
6877
7439
|
}
|
|
6878
|
-
const sha =
|
|
7440
|
+
const sha = clean3(await deps.run("git", ["rev-parse", "--verify", `${from}^{commit}`]));
|
|
6879
7441
|
if (!sha) throw new Error(`could not resolve commit ${from}`);
|
|
6880
7442
|
return { sha, label: sha.slice(0, 7) };
|
|
6881
7443
|
}
|
|
@@ -6902,7 +7464,7 @@ async function runHotfixStart(deps, options) {
|
|
|
6902
7464
|
};
|
|
6903
7465
|
}
|
|
6904
7466
|
const { sha, label } = await resolveHotfixSource(deps, ctx, options.from);
|
|
6905
|
-
const remoteBranch =
|
|
7467
|
+
const remoteBranch = clean3(await deps.run("git", ["ls-remote", "origin", `refs/heads/${branch}`]));
|
|
6906
7468
|
if (remoteBranch) {
|
|
6907
7469
|
await deps.run("git", ["checkout", branch]);
|
|
6908
7470
|
await deps.run("git", ["pull", "--ff-only", "origin", branch]);
|
|
@@ -6928,7 +7490,7 @@ async function runHotfixStart(deps, options) {
|
|
|
6928
7490
|
}
|
|
6929
7491
|
await deps.run("git", ["push", "-u", "origin", branch]);
|
|
6930
7492
|
}
|
|
6931
|
-
const prUrl =
|
|
7493
|
+
const prUrl = clean3(await deps.run("gh", [
|
|
6932
7494
|
"pr",
|
|
6933
7495
|
"create",
|
|
6934
7496
|
"--repo",
|
|
@@ -7011,7 +7573,7 @@ async function runHotfixRelease(deps, versionInput, options = {}) {
|
|
|
7011
7573
|
if (releaseExists) {
|
|
7012
7574
|
releaseNote = `Release ${tag} already exists \u2014 resumed without recreating`;
|
|
7013
7575
|
} else {
|
|
7014
|
-
const tagCommit =
|
|
7576
|
+
const tagCommit = clean3(await deps.run("git", ["rev-parse", `${tag}^{commit}`]));
|
|
7015
7577
|
await deps.run("gh", ["release", "create", tag, "--repo", ctx.repo, "--target", tagCommit, "--generate-notes", "--latest"]);
|
|
7016
7578
|
releaseNote = `Release ${tag} created (target ${tagCommit.slice(0, 7)})`;
|
|
7017
7579
|
if (deps.announce) {
|
|
@@ -7022,7 +7584,7 @@ async function runHotfixRelease(deps, versionInput, options = {}) {
|
|
|
7022
7584
|
for (const workflow of HOTFIX_RELEASE_WORKFLOWS) {
|
|
7023
7585
|
runs.push(await watchReleaseRun(deps, ctx, workflow, mergedSha));
|
|
7024
7586
|
}
|
|
7025
|
-
const previousRef =
|
|
7587
|
+
const previousRef = clean3(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
|
|
7026
7588
|
let verifyNote;
|
|
7027
7589
|
const publishSucceeded = runs.find((r) => r.workflow === "publish.yml")?.conclusion === "success";
|
|
7028
7590
|
try {
|
|
@@ -7090,9 +7652,9 @@ async function runHotfixStatus(deps, versionInput) {
|
|
|
7090
7652
|
return { ...ctx, command: "hotfix-status", ...facts, ...deriveHotfixState(facts) };
|
|
7091
7653
|
}
|
|
7092
7654
|
async function gatherHotfixFacts(deps, ctx, tag, version) {
|
|
7093
|
-
const branchExists = Boolean(
|
|
7655
|
+
const branchExists = Boolean(clean3(await deps.run("git", ["ls-remote", "origin", `refs/heads/${hotfixBranch(tag)}`])));
|
|
7094
7656
|
const pr2 = await findHotfixPr(deps, ctx, tag);
|
|
7095
|
-
const remoteTag =
|
|
7657
|
+
const remoteTag = clean3(await deps.run("git", ["ls-remote", "origin", `refs/tags/${tag}`]));
|
|
7096
7658
|
const tagPushed = Boolean(remoteTag);
|
|
7097
7659
|
const tagSha = remoteTag.split(/\s+/)[0] || "";
|
|
7098
7660
|
let releaseExists = false;
|
|
@@ -7126,7 +7688,7 @@ async function gatherHotfixFacts(deps, ctx, tag, version) {
|
|
|
7126
7688
|
}
|
|
7127
7689
|
}
|
|
7128
7690
|
}
|
|
7129
|
-
const npmVersion = await deps.run("npm", ["view", "@mutmutco/cli", "version", "--silent"]).then(
|
|
7691
|
+
const npmVersion = await deps.run("npm", ["view", "@mutmutco/cli", "version", "--silent"]).then(clean3, () => "unknown");
|
|
7130
7692
|
const devVersion = await deps.run("git", ["show", "origin/development:cli/package.json"]).then(
|
|
7131
7693
|
(out) => {
|
|
7132
7694
|
try {
|
|
@@ -7305,6 +7867,18 @@ function ensurePortRange(repo, path2) {
|
|
|
7305
7867
|
function portCursorSeed(registry2) {
|
|
7306
7868
|
return nextPortBlock(registry2)[0];
|
|
7307
7869
|
}
|
|
7870
|
+
function metaPortRange(meta) {
|
|
7871
|
+
const r = meta?.portRange;
|
|
7872
|
+
if (r && typeof r.start === "number" && typeof r.end === "number") return [r.start, r.end];
|
|
7873
|
+
return null;
|
|
7874
|
+
}
|
|
7875
|
+
function decidePortRange(input) {
|
|
7876
|
+
if (!input.metaReadOk) {
|
|
7877
|
+
return { action: "fail", reason: "could not verify the existing port block (Hub registry read failed) \u2014 retry; NOT allocating (a re-allocation on an unverified read would advance the cursor and hand out a duplicate block)" };
|
|
7878
|
+
}
|
|
7879
|
+
if (input.metaPortRange) return { action: "return", range: input.metaPortRange };
|
|
7880
|
+
return { action: "allocate" };
|
|
7881
|
+
}
|
|
7308
7882
|
function existingPortRange(repo, registry2) {
|
|
7309
7883
|
return registry2[repo] ?? null;
|
|
7310
7884
|
}
|
|
@@ -7584,7 +8158,8 @@ var requiredProjectWorkflows = [
|
|
|
7584
8158
|
];
|
|
7585
8159
|
var requiredOrgRulesetTypes = ["pull_request", "non_fast_forward", "deletion"];
|
|
7586
8160
|
var requiredHubStatusChecks = ["cli", "infra", "docs"];
|
|
7587
|
-
function expectedBranches(repoClass) {
|
|
8161
|
+
function expectedBranches(repoClass, releaseTrack) {
|
|
8162
|
+
if (isReleaseTrack(releaseTrack)) return branchesForTrack(releaseTrack);
|
|
7588
8163
|
return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
|
|
7589
8164
|
}
|
|
7590
8165
|
function gcpProjectForSlug(slug) {
|
|
@@ -7677,9 +8252,9 @@ function localRegistryCheck(deps, path2, predicate) {
|
|
|
7677
8252
|
if (text == null) return null;
|
|
7678
8253
|
return predicate(safeJson2(text, null));
|
|
7679
8254
|
}
|
|
7680
|
-
async function verifyBootstrap(repo, repoClass, deps) {
|
|
7681
|
-
const branchesWanted = expectedBranches(repoClass);
|
|
7682
|
-
const baseBranch = repoClass === "content" ? "main" : "development";
|
|
8255
|
+
async function verifyBootstrap(repo, repoClass, deps, releaseTrack) {
|
|
8256
|
+
const branchesWanted = expectedBranches(repoClass, releaseTrack);
|
|
8257
|
+
const baseBranch = releaseTrack === "trunk" || repoClass === "content" ? "main" : "development";
|
|
7683
8258
|
const checks = [];
|
|
7684
8259
|
const repoInfo = await restJson2(deps, `repos/${repo}`, {});
|
|
7685
8260
|
checks.push({ ok: Boolean(repoInfo.default_branch), label: "repo exists" });
|
|
@@ -7981,6 +8556,15 @@ function parseOwnerRepo(repo) {
|
|
|
7981
8556
|
if (owner.includes("\\") || name.includes("\\")) throw new Error("repo must be owner/repo");
|
|
7982
8557
|
return { owner, name, slug: name.toLowerCase(), fullName: `${owner}/${name}` };
|
|
7983
8558
|
}
|
|
8559
|
+
var DEFAULT_INSTALL_CMD = "npm ci";
|
|
8560
|
+
function withDerivedRepoVars(vars, parsed, cls) {
|
|
8561
|
+
const out = { ...vars };
|
|
8562
|
+
out.REPO_NAME ??= parsed.name;
|
|
8563
|
+
out.REPO_SLUG ??= parsed.slug;
|
|
8564
|
+
out.CLASS ??= cls;
|
|
8565
|
+
out.INSTALL_CMD ??= DEFAULT_INSTALL_CMD;
|
|
8566
|
+
return out;
|
|
8567
|
+
}
|
|
7984
8568
|
function planSeedAction(seed, exists) {
|
|
7985
8569
|
if (seed.source === "fanout") {
|
|
7986
8570
|
return { target: seed.target, action: "skip", ownership: "fanout", reason: "delivered by the fanout pipeline" };
|
|
@@ -7993,6 +8577,18 @@ function planSeedAction(seed, exists) {
|
|
|
7993
8577
|
}
|
|
7994
8578
|
return exists ? { target: seed.target, action: "update", ownership: "org", reason: "org-owned, refresh to current" } : { target: seed.target, action: "create", ownership: "org", reason: "org-owned, missing" };
|
|
7995
8579
|
}
|
|
8580
|
+
function reconcileSeedAction(action, content, isManagedBlock) {
|
|
8581
|
+
if (action.action === "skip") return action;
|
|
8582
|
+
if (content == null) {
|
|
8583
|
+
return { ...action, action: "skip", reason: "no resolvable content" };
|
|
8584
|
+
}
|
|
8585
|
+
if (isManagedBlock) return action;
|
|
8586
|
+
const unfilled = missingPlaceholders(content);
|
|
8587
|
+
if (unfilled.length) {
|
|
8588
|
+
return { ...action, action: "skip", reason: `unfilled: ${unfilled.join(", ")} \u2014 pass --var` };
|
|
8589
|
+
}
|
|
8590
|
+
return action;
|
|
8591
|
+
}
|
|
7996
8592
|
function renderSeedPlan(actions) {
|
|
7997
8593
|
const lines = ["bootstrap apply \u2014 seed plan (dry-run; no mutations):"];
|
|
7998
8594
|
for (const a of actions) {
|
|
@@ -8002,6 +8598,20 @@ function renderSeedPlan(actions) {
|
|
|
8002
8598
|
lines.push(` \u2014 ${order.map((k) => `${actions.filter((a) => a.action === k).length} ${k}`).join(", ")}`);
|
|
8003
8599
|
return lines.join("\n");
|
|
8004
8600
|
}
|
|
8601
|
+
var GITHUB_DEFAULT_LABELS = [
|
|
8602
|
+
"documentation",
|
|
8603
|
+
"duplicate",
|
|
8604
|
+
"enhancement",
|
|
8605
|
+
"good first issue",
|
|
8606
|
+
"help wanted",
|
|
8607
|
+
"invalid",
|
|
8608
|
+
"question",
|
|
8609
|
+
"wontfix"
|
|
8610
|
+
];
|
|
8611
|
+
function labelsToPrune(orgLabelNames) {
|
|
8612
|
+
const org = new Set(orgLabelNames);
|
|
8613
|
+
return GITHUB_DEFAULT_LABELS.filter((name) => !org.has(name));
|
|
8614
|
+
}
|
|
8005
8615
|
function resolveSeedContent(seed, vars, readFile2) {
|
|
8006
8616
|
if (seed.source === "self") return readFile2(seed.target);
|
|
8007
8617
|
if (seed.source.startsWith("seed:")) {
|
|
@@ -8014,10 +8624,13 @@ function buildRegisterPayload(repo, cls, vars, options = {}) {
|
|
|
8014
8624
|
const parsedRepo = parseOwnerRepo(repo);
|
|
8015
8625
|
const slug = parsedRepo.slug;
|
|
8016
8626
|
if (options.projectType && !isProjectType(options.projectType)) {
|
|
8017
|
-
throw new Error(
|
|
8627
|
+
throw new Error(`projectType must be one of: ${PROJECT_TYPES.join(", ")}`);
|
|
8018
8628
|
}
|
|
8019
8629
|
if (options.deployModel && !isDeployModel(options.deployModel)) {
|
|
8020
|
-
throw new Error(
|
|
8630
|
+
throw new Error(`deployModel must be one of: ${DEPLOY_MODELS.join(", ")}`);
|
|
8631
|
+
}
|
|
8632
|
+
if (options.releaseTrack && !isReleaseTrack(options.releaseTrack)) {
|
|
8633
|
+
throw new Error("releaseTrack must be full, direct, or trunk");
|
|
8021
8634
|
}
|
|
8022
8635
|
const shape = {
|
|
8023
8636
|
class: cls,
|
|
@@ -8027,7 +8640,7 @@ function buildRegisterPayload(repo, cls, vars, options = {}) {
|
|
|
8027
8640
|
const projectType = resolveProjectTypeConfident(shape, parsedRepo.fullName);
|
|
8028
8641
|
if (!projectType) {
|
|
8029
8642
|
throw new Error(
|
|
8030
|
-
`Project type for ${parsedRepo.fullName} is unset and not derivable \u2014 pass --project-type
|
|
8643
|
+
`Project type for ${parsedRepo.fullName} is unset and not derivable \u2014 pass --project-type <${PROJECT_TYPES.join("|")}> and --deploy-model <${DEPLOY_MODELS.join("|")}> (prevents defaulting a non-web repo to tenant-container).`
|
|
8031
8644
|
);
|
|
8032
8645
|
}
|
|
8033
8646
|
const deployModel = resolveDeployModel({ ...shape, projectType }, parsedRepo.fullName);
|
|
@@ -8059,6 +8672,8 @@ function buildRegisterPayload(repo, cls, vars, options = {}) {
|
|
|
8059
8672
|
class: cls,
|
|
8060
8673
|
projectType,
|
|
8061
8674
|
deployModel,
|
|
8675
|
+
// #917: only emit when explicitly direct/trunk; absent → registry resolves to `full` (no clobber).
|
|
8676
|
+
releaseTrack: isReleaseTrack(options.releaseTrack) ? options.releaseTrack : void 0,
|
|
8062
8677
|
// Board coords (from GraphQL at bootstrap, passed as --var by the skill).
|
|
8063
8678
|
projectOwner: vars.PROJECT_OWNER || void 0,
|
|
8064
8679
|
projectNumber: num(vars.PROJECT_NUMBER),
|
|
@@ -8074,6 +8689,83 @@ function buildRegisterPayload(repo, cls, vars, options = {}) {
|
|
|
8074
8689
|
for (const k of Object.keys(payload)) if (payload[k] === void 0) delete payload[k];
|
|
8075
8690
|
return payload;
|
|
8076
8691
|
}
|
|
8692
|
+
var BOARD_FIELD_VAR_MAP = {
|
|
8693
|
+
Status: {
|
|
8694
|
+
fieldIdVar: "STATUS_FIELD_ID",
|
|
8695
|
+
options: { Todo: "STATUS_TODO", "In Progress": "STATUS_IN_PROGRESS", "In Review": "STATUS_IN_REVIEW", Done: "STATUS_DONE" }
|
|
8696
|
+
},
|
|
8697
|
+
Priority: {
|
|
8698
|
+
fieldIdVar: "PRIORITY_FIELD_ID",
|
|
8699
|
+
options: { Urgent: "PRIORITY_URGENT", High: "PRIORITY_HIGH", Medium: "PRIORITY_MEDIUM", Low: "PRIORITY_LOW" }
|
|
8700
|
+
}
|
|
8701
|
+
};
|
|
8702
|
+
function extractBoardFieldVars(fieldsJson) {
|
|
8703
|
+
const out = {};
|
|
8704
|
+
const nodes = Array.isArray(fieldsJson) ? fieldsJson : fieldsJson?.data?.node?.fields?.nodes ?? fieldsJson?.node?.fields?.nodes;
|
|
8705
|
+
if (!Array.isArray(nodes)) return out;
|
|
8706
|
+
for (const node of nodes) {
|
|
8707
|
+
const field = node;
|
|
8708
|
+
const name = typeof field.name === "string" ? field.name : void 0;
|
|
8709
|
+
const map = name ? BOARD_FIELD_VAR_MAP[name] : void 0;
|
|
8710
|
+
if (!map || typeof field.id !== "string" || !field.id) continue;
|
|
8711
|
+
out[map.fieldIdVar] = field.id;
|
|
8712
|
+
const options = Array.isArray(field.options) ? field.options : [];
|
|
8713
|
+
for (const opt of options) {
|
|
8714
|
+
const o = opt;
|
|
8715
|
+
const varName = typeof o.name === "string" ? map.options[o.name] : void 0;
|
|
8716
|
+
if (varName && typeof o.id === "string" && o.id) out[varName] = o.id;
|
|
8717
|
+
}
|
|
8718
|
+
}
|
|
8719
|
+
return out;
|
|
8720
|
+
}
|
|
8721
|
+
function boardFieldsQueryArgs(projectId) {
|
|
8722
|
+
const query = "query($id: ID!) { node(id: $id) { ... on ProjectV2 { fields(first: 50) { nodes { ... on ProjectV2SingleSelectField { id name options { id name } } } } } } }";
|
|
8723
|
+
return ["api", "graphql", "-f", `query=${query}`, "-f", `id=${projectId}`];
|
|
8724
|
+
}
|
|
8725
|
+
function serializeRegistry(obj) {
|
|
8726
|
+
return `${JSON.stringify(obj, null, 2)}
|
|
8727
|
+
`;
|
|
8728
|
+
}
|
|
8729
|
+
function planFanoutRegistration(fanoutTargetsRaw, projectsRaw, entry) {
|
|
8730
|
+
const fanout = JSON.parse(fanoutTargetsRaw);
|
|
8731
|
+
const projects = JSON.parse(projectsRaw);
|
|
8732
|
+
const fanoutRepos = Array.isArray(fanout.repos) ? fanout.repos : [];
|
|
8733
|
+
const projectEntries = Array.isArray(projects.projects) ? projects.projects : [];
|
|
8734
|
+
const name = entry.name ?? entry.repo;
|
|
8735
|
+
const canonName = entry.repo.toLowerCase();
|
|
8736
|
+
const inFanout = fanoutRepos.some((r) => typeof r.repo === "string" && r.repo.toLowerCase() === canonName);
|
|
8737
|
+
const inProjects = projectEntries.some(
|
|
8738
|
+
(p) => p.slug === entry.slug || Array.isArray(p.repos) && p.repos.some((full) => String(full).split("/").pop()?.toLowerCase() === canonName)
|
|
8739
|
+
);
|
|
8740
|
+
if (inFanout || inProjects) {
|
|
8741
|
+
return { changed: false, fanoutTargets: fanoutTargetsRaw, projects: projectsRaw };
|
|
8742
|
+
}
|
|
8743
|
+
const projectEntry = {
|
|
8744
|
+
name,
|
|
8745
|
+
slug: entry.slug,
|
|
8746
|
+
projectId: entry.projectId,
|
|
8747
|
+
wikiRepo: entry.wikiRepo,
|
|
8748
|
+
repos: [`mutmutco/${entry.repo}`]
|
|
8749
|
+
};
|
|
8750
|
+
if (entry.cls === "content") projectEntry.branch = "main";
|
|
8751
|
+
for (const k of Object.keys(projectEntry)) if (projectEntry[k] === void 0) delete projectEntry[k];
|
|
8752
|
+
const nextProjects = { ...projects, projects: [...projectEntries, projectEntry] };
|
|
8753
|
+
const fanoutEntry = { repo: entry.repo, branch: entry.branch, class: entry.cls };
|
|
8754
|
+
const nextFanout = { ...fanout, repos: [...fanoutRepos, fanoutEntry] };
|
|
8755
|
+
return {
|
|
8756
|
+
changed: true,
|
|
8757
|
+
fanoutTargets: serializeRegistry(nextFanout),
|
|
8758
|
+
projects: serializeRegistry(nextProjects)
|
|
8759
|
+
};
|
|
8760
|
+
}
|
|
8761
|
+
function decideFanoutPrAction(openPrs) {
|
|
8762
|
+
const list = Array.isArray(openPrs) ? openPrs : [];
|
|
8763
|
+
for (const pr2 of list) {
|
|
8764
|
+
const url = pr2?.url;
|
|
8765
|
+
if (typeof url === "string" && url.trim()) return { action: "reuse", url: url.trim() };
|
|
8766
|
+
}
|
|
8767
|
+
return { action: "create" };
|
|
8768
|
+
}
|
|
8077
8769
|
function contentPutArgs(repo, path2, content, branch, sha) {
|
|
8078
8770
|
const args = [
|
|
8079
8771
|
"api",
|
|
@@ -8237,6 +8929,33 @@ async function tenantControl(payload, deps) {
|
|
|
8237
8929
|
const timeoutMs = payload.wait ? WAITED_TENANT_CONTROL_TIMEOUT_MS : void 0;
|
|
8238
8930
|
return postJson("/tenant-control", payload, deps, "POST", { noRetry, timeoutMs });
|
|
8239
8931
|
}
|
|
8932
|
+
async function tenantDeploy(payload, deps) {
|
|
8933
|
+
return postJson("/tenant-deploy", payload, deps, "POST", { noRetry: true });
|
|
8934
|
+
}
|
|
8935
|
+
|
|
8936
|
+
// src/tenant-verify-secrets.ts
|
|
8937
|
+
function tenantControlWait(action) {
|
|
8938
|
+
return action === "status" || action === "retire" || action === "verify-secrets";
|
|
8939
|
+
}
|
|
8940
|
+
function renderVerifySecrets(body) {
|
|
8941
|
+
const secrets2 = body?.secrets ?? [];
|
|
8942
|
+
const counts = {
|
|
8943
|
+
match: secrets2.filter((s) => s.status === "match").length,
|
|
8944
|
+
mismatch: secrets2.filter((s) => s.status === "mismatch").length,
|
|
8945
|
+
missing: secrets2.filter((s) => s.status === "missing").length
|
|
8946
|
+
};
|
|
8947
|
+
const lines = secrets2.map((s) => `${s.key}: ${s.status}`);
|
|
8948
|
+
lines.push(`verify-secrets: ${counts.match} match, ${counts.mismatch} mismatch, ${counts.missing} missing`);
|
|
8949
|
+
const ssmStatus = body?.ssmStatus ?? "pending";
|
|
8950
|
+
if (ssmStatus !== "Success") {
|
|
8951
|
+
return { lines, failure: `verify-secrets did not complete (ssm status ${ssmStatus})${body?.commandId ? ` \u2014 command ${body.commandId}` : ""}` };
|
|
8952
|
+
}
|
|
8953
|
+
const bad = counts.mismatch + counts.missing;
|
|
8954
|
+
if (bad > 0) {
|
|
8955
|
+
return { lines, failure: `${bad} of ${secrets2.length} required secret(s) not matching the vault (${counts.mismatch} mismatch, ${counts.missing} missing)` };
|
|
8956
|
+
}
|
|
8957
|
+
return { lines, failure: null };
|
|
8958
|
+
}
|
|
8240
8959
|
|
|
8241
8960
|
// src/project-readiness.ts
|
|
8242
8961
|
function dnsErrorToResolution(code) {
|
|
@@ -8263,11 +8982,17 @@ function declaresNoPublicEdge(meta) {
|
|
|
8263
8982
|
const ed = meta?.edgeDomains;
|
|
8264
8983
|
return Boolean(ed && typeof ed === "object" && !Array.isArray(ed) && Object.keys(ed).length === 0);
|
|
8265
8984
|
}
|
|
8985
|
+
function declaresWorker(meta) {
|
|
8986
|
+
return meta?.projectType === "worker";
|
|
8987
|
+
}
|
|
8988
|
+
function isCentralContainerModel2(model) {
|
|
8989
|
+
return model === "tenant-container" || model === "solo-container";
|
|
8990
|
+
}
|
|
8266
8991
|
function isNoEdgeTenantWorker(meta, model) {
|
|
8267
|
-
return model
|
|
8992
|
+
return isCentralContainerModel2(model) && (declaresNoPublicEdge(meta) || declaresWorker(meta));
|
|
8268
8993
|
}
|
|
8269
8994
|
function projectRequiresDeployCoords(model, stage2, meta) {
|
|
8270
|
-
if (model
|
|
8995
|
+
if (!isCentralContainerModel2(model)) return false;
|
|
8271
8996
|
if (stage2 && isNoEdgeTenantWorker(meta, model)) return stage2 === "main";
|
|
8272
8997
|
return true;
|
|
8273
8998
|
}
|
|
@@ -8300,7 +9025,7 @@ function attestedLine(att) {
|
|
|
8300
9025
|
var CONTRACT_UNDECLARED_LINE = "No runtime secrets declared \u2014 declare requiredRuntimeSecrets (a per-stage name map) in the registry META, or attest the app needs none with an explicit empty stage map ({ dev: [], rc: [], main: [] }).";
|
|
8301
9026
|
function appGapsFor(meta, model, slug, projectType) {
|
|
8302
9027
|
const attested = appAttestationOf(meta);
|
|
8303
|
-
const isTenantWeb = !(projectType === "content" || model === "content") && projectType !== "desktop-game" && !(projectType === "non-deployable" || model === "none") && model !== "hub-serverless" && model !== "serverless";
|
|
9028
|
+
const isTenantWeb = !(projectType === "content" || model === "content") && projectType !== "desktop-game" && projectType !== "cli-tool" && !(projectType === "non-deployable" || model === "none") && model !== "hub-serverless" && model !== "serverless" && model !== "registry-publish";
|
|
8304
9029
|
const contractUndeclared = isTenantWeb && Boolean(meta) && !hasRuntimeSecretContract(meta?.requiredRuntimeSecrets);
|
|
8305
9030
|
if (attested) return contractUndeclared ? [attestedLine(attested), CONTRACT_UNDECLARED_LINE] : [attestedLine(attested)];
|
|
8306
9031
|
if (projectType === "content" || model === "content") return ["Content/KB repo: keep app-owned work to docs/content changes; release train does not apply."];
|
|
@@ -8329,13 +9054,20 @@ function appGapsFor(meta, model, slug, projectType) {
|
|
|
8329
9054
|
"Keep app-owned README.md and architecture.md aligned with v2 central deploy/secrets reality."
|
|
8330
9055
|
];
|
|
8331
9056
|
}
|
|
9057
|
+
if (projectType === "cli-tool" || model === "registry-publish") {
|
|
9058
|
+
return [
|
|
9059
|
+
"Distributable CLI/plugin: ship a publishable package.json (name, version, bin/exports) \u2014 release publishes to npm (plugin-marketplace publish is future work when a plugin tenant lands), not a box deploy; no Hub deploy coords, edge, or OAuth.",
|
|
9060
|
+
"Keep the published version in lockstep with the release tag; make the publish idempotent (skip when the version is already on the registry).",
|
|
9061
|
+
"Keep app-owned README.md and architecture.md aligned with the registry-publish release path."
|
|
9062
|
+
];
|
|
9063
|
+
}
|
|
8332
9064
|
const gaps = [
|
|
8333
9065
|
"Ensure the compose file carries env_file: .env and the app reads plain environment variables \u2014 the box injects coords + declared runtime secrets into the release .env; the app never self-loads SSM.",
|
|
8334
9066
|
"Make app config fail clearly for missing required env in prod/rc instead of relying on hidden defaults.",
|
|
8335
9067
|
"Keep app-owned README.md and architecture.md aligned with v2 central deploy/secrets reality."
|
|
8336
9068
|
];
|
|
8337
9069
|
if (isNoEdgeTenantWorker(meta, model)) {
|
|
8338
|
-
gaps.unshift("No public edge
|
|
9070
|
+
gaps.unshift("No public edge (worker/outbound-only tenant \u2014 `projectType: worker` or `edgeDomains: {}`): skip Cloudflare edge auto-heal, OAuth defaults, and dev/rc remote deploy coords unless META explicitly adds them.");
|
|
8339
9071
|
}
|
|
8340
9072
|
if (contractUndeclared) {
|
|
8341
9073
|
gaps.unshift(CONTRACT_UNDECLARED_LINE);
|
|
@@ -8401,7 +9133,7 @@ function buildV2HealPatch(repoOrSlug, meta) {
|
|
|
8401
9133
|
}
|
|
8402
9134
|
if (!meta?.vaultPath) patch.vaultPath = `/mmi-future/${slug}`;
|
|
8403
9135
|
if (!meta?.kbPointer) patch.kbPointer = `kb/projects/${slug}.md`;
|
|
8404
|
-
if (confidentType && !meta?.edgeDomains && model === "tenant-container") {
|
|
9136
|
+
if (confidentType && !meta?.edgeDomains && model === "tenant-container" && projectType !== "worker") {
|
|
8405
9137
|
patch.edgeDomains = {
|
|
8406
9138
|
dev: `dev.${sub}.mutatismutandis.co`,
|
|
8407
9139
|
rc: `rc.${sub}.mutatismutandis.co`,
|
|
@@ -8424,7 +9156,7 @@ function buildV2HealPatch(repoOrSlug, meta) {
|
|
|
8424
9156
|
patch.requiredRuntimeSecrets = next;
|
|
8425
9157
|
}
|
|
8426
9158
|
}
|
|
8427
|
-
const appOwnedGaps = confidentType ? appGapsFor(meta, model, slug, confidentType) : [`Project type is unset and not derivable \u2014 classify with \`mmi-cli project set ${repo} --project-type <web-app|hub-service|content|desktop-game|non-deployable> --deploy-model <tenant-container|hub-serverless|serverless|content|none>\` before heal completes the v2 fields (prevents defaulting a non-web repo to tenant-container).`];
|
|
9159
|
+
const appOwnedGaps = confidentType ? appGapsFor(meta, model, slug, confidentType) : [`Project type is unset and not derivable \u2014 classify with \`mmi-cli project set ${repo} --project-type <web-app|hub-service|content|desktop-game|non-deployable|cli-tool|worker> --deploy-model <tenant-container|solo-container|hub-serverless|serverless|registry-publish|content|none>\` before heal completes the v2 fields (prevents defaulting a non-web repo to tenant-container).`];
|
|
8428
9160
|
return { slug, patch, appOwnedGaps };
|
|
8429
9161
|
}
|
|
8430
9162
|
async function runV2Heal(repoOrSlug, opts, deps) {
|
|
@@ -8623,6 +9355,158 @@ function parseEdgeDomainsVar(raw) {
|
|
|
8623
9355
|
}
|
|
8624
9356
|
return out;
|
|
8625
9357
|
}
|
|
9358
|
+
var PRIORITY_OPTION_NAMES = ["Urgent", "High", "Medium", "Low"];
|
|
9359
|
+
function parsePriorityOptionsVar(raw) {
|
|
9360
|
+
let parsed;
|
|
9361
|
+
try {
|
|
9362
|
+
parsed = JSON.parse(raw);
|
|
9363
|
+
} catch {
|
|
9364
|
+
throw new Error('project set: priorityOptions must be JSON, e.g. {"Urgent":"<id>","High":"<id>","Medium":"<id>","Low":"<id>"}');
|
|
9365
|
+
}
|
|
9366
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
9367
|
+
throw new Error("project set: priorityOptions must be a {Urgent,High,Medium,Low} map of option-id strings");
|
|
9368
|
+
}
|
|
9369
|
+
const map = parsed;
|
|
9370
|
+
const out = {};
|
|
9371
|
+
for (const [name, id] of Object.entries(map)) {
|
|
9372
|
+
if (!PRIORITY_OPTION_NAMES.includes(name)) {
|
|
9373
|
+
throw new Error(`project set: priorityOptions "${name}" \u2014 expected only ${PRIORITY_OPTION_NAMES.join("/")}`);
|
|
9374
|
+
}
|
|
9375
|
+
if (typeof id !== "string" || !id.trim()) {
|
|
9376
|
+
throw new Error(`project set: priorityOptions.${name} must be a non-empty option-id string`);
|
|
9377
|
+
}
|
|
9378
|
+
out[name] = id.trim();
|
|
9379
|
+
}
|
|
9380
|
+
return out;
|
|
9381
|
+
}
|
|
9382
|
+
function parsePortRangeVar(raw) {
|
|
9383
|
+
let parsed;
|
|
9384
|
+
try {
|
|
9385
|
+
parsed = JSON.parse(raw);
|
|
9386
|
+
} catch {
|
|
9387
|
+
throw new Error('project set: portRange must be JSON, e.g. {"start":3700,"end":3710} or [3700,3710]');
|
|
9388
|
+
}
|
|
9389
|
+
let start;
|
|
9390
|
+
let end;
|
|
9391
|
+
if (Array.isArray(parsed) && parsed.length === 2) {
|
|
9392
|
+
[start, end] = parsed;
|
|
9393
|
+
} else if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
9394
|
+
({ start, end } = parsed);
|
|
9395
|
+
} else {
|
|
9396
|
+
throw new Error("project set: portRange must be {start,end} or a [start,end] tuple");
|
|
9397
|
+
}
|
|
9398
|
+
if (typeof start !== "number" || typeof end !== "number" || !Number.isFinite(start) || !Number.isFinite(end)) {
|
|
9399
|
+
throw new Error("project set: portRange start/end must be finite numbers");
|
|
9400
|
+
}
|
|
9401
|
+
if (start > end) throw new Error("project set: portRange start must be <= end");
|
|
9402
|
+
return { start, end };
|
|
9403
|
+
}
|
|
9404
|
+
function parseStatusOptionsVar(raw) {
|
|
9405
|
+
let parsed;
|
|
9406
|
+
try {
|
|
9407
|
+
parsed = JSON.parse(raw);
|
|
9408
|
+
} catch {
|
|
9409
|
+
throw new Error('project set: statusOptions must be JSON, e.g. {"Todo":"<id>","In Progress":"<id>","In Review":"<id>","Done":"<id>"}');
|
|
9410
|
+
}
|
|
9411
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
9412
|
+
throw new Error("project set: statusOptions must be a map of status name to option-id string");
|
|
9413
|
+
}
|
|
9414
|
+
const map = parsed;
|
|
9415
|
+
const out = {};
|
|
9416
|
+
for (const [name, id] of Object.entries(map)) {
|
|
9417
|
+
if (typeof id !== "string" || !id.trim()) {
|
|
9418
|
+
throw new Error(`project set: statusOptions.${name} must be a non-empty option-id string`);
|
|
9419
|
+
}
|
|
9420
|
+
out[name] = id.trim();
|
|
9421
|
+
}
|
|
9422
|
+
return out;
|
|
9423
|
+
}
|
|
9424
|
+
function parseOauthVar(raw) {
|
|
9425
|
+
let parsed;
|
|
9426
|
+
try {
|
|
9427
|
+
parsed = JSON.parse(raw);
|
|
9428
|
+
} catch {
|
|
9429
|
+
throw new Error('project set: oauth must be JSON, e.g. {"subdomains":["app"],"domains":["example.co"],"callbackPath":"/auth/callback"}');
|
|
9430
|
+
}
|
|
9431
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
9432
|
+
throw new Error("project set: oauth must be a {subdomains,domains,callbackPath} object");
|
|
9433
|
+
}
|
|
9434
|
+
const map = parsed;
|
|
9435
|
+
const out = {};
|
|
9436
|
+
for (const [key, value] of Object.entries(map)) {
|
|
9437
|
+
if (key === "subdomains" || key === "domains") {
|
|
9438
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string" || !v.trim())) {
|
|
9439
|
+
throw new Error(`project set: oauth.${key} must be an array of non-empty strings`);
|
|
9440
|
+
}
|
|
9441
|
+
out[key] = value.map((v) => v.trim());
|
|
9442
|
+
} else if (key === "callbackPath") {
|
|
9443
|
+
if (typeof value !== "string" || !value.trim()) throw new Error("project set: oauth.callbackPath must be a non-empty string");
|
|
9444
|
+
out.callbackPath = value.trim();
|
|
9445
|
+
} else {
|
|
9446
|
+
throw new Error(`project set: oauth key "${key}" \u2014 expected only subdomains/domains/callbackPath`);
|
|
9447
|
+
}
|
|
9448
|
+
}
|
|
9449
|
+
return out;
|
|
9450
|
+
}
|
|
9451
|
+
function parseReposVar(raw) {
|
|
9452
|
+
let parsed;
|
|
9453
|
+
try {
|
|
9454
|
+
parsed = JSON.parse(raw);
|
|
9455
|
+
} catch {
|
|
9456
|
+
throw new Error('project set: repos must be a JSON array, e.g. ["mutmutco/mm-foo"]');
|
|
9457
|
+
}
|
|
9458
|
+
if (!Array.isArray(parsed) || parsed.length === 0 || parsed.some((r) => typeof r !== "string" || !r.trim())) {
|
|
9459
|
+
throw new Error("project set: repos must be a non-empty array of owner/name strings");
|
|
9460
|
+
}
|
|
9461
|
+
return parsed.map((r) => r.trim());
|
|
9462
|
+
}
|
|
9463
|
+
function parsePublishRequiredVar(raw) {
|
|
9464
|
+
if (raw === "true") return true;
|
|
9465
|
+
if (raw === "false") return false;
|
|
9466
|
+
throw new Error("project set: publishRequired must be true or false");
|
|
9467
|
+
}
|
|
9468
|
+
var SETTABLE_VAR_KEYS = [
|
|
9469
|
+
"name",
|
|
9470
|
+
"division",
|
|
9471
|
+
"projectId",
|
|
9472
|
+
"projectOwner",
|
|
9473
|
+
"projectNumber",
|
|
9474
|
+
"branch",
|
|
9475
|
+
"wikiRepo",
|
|
9476
|
+
"vaultPath",
|
|
9477
|
+
"kbPointer",
|
|
9478
|
+
"repos",
|
|
9479
|
+
"oauth",
|
|
9480
|
+
"publishRequired",
|
|
9481
|
+
"requiredGcpApis",
|
|
9482
|
+
"requiredRuntimeSecrets",
|
|
9483
|
+
"edgeDomains",
|
|
9484
|
+
"statusFieldId",
|
|
9485
|
+
"statusOptions",
|
|
9486
|
+
"priorityFieldId",
|
|
9487
|
+
"priorityOptions",
|
|
9488
|
+
"portRange"
|
|
9489
|
+
];
|
|
9490
|
+
var SETTABLE_VAR_KEY_SET = new Set(SETTABLE_VAR_KEYS);
|
|
9491
|
+
var SETTABLE_VAR_HINTS = {
|
|
9492
|
+
projectNumber: "numeric",
|
|
9493
|
+
publishRequired: "true|false",
|
|
9494
|
+
repos: 'JSON array, e.g. ["mutmutco/mm-foo"]',
|
|
9495
|
+
oauth: "JSON {subdomains,domains,callbackPath}",
|
|
9496
|
+
requiredGcpApis: "comma-string",
|
|
9497
|
+
requiredRuntimeSecrets: 'JSON stage map, e.g. {"dev":["KEY"],"rc":["KEY"],"main":["KEY"]}',
|
|
9498
|
+
edgeDomains: "JSON {dev,rc,main} domain map",
|
|
9499
|
+
statusOptions: "JSON name\u2192id map",
|
|
9500
|
+
priorityOptions: "JSON {Urgent,High,Medium,Low}\u2192id map",
|
|
9501
|
+
portRange: "JSON {start,end} or [start,end]"
|
|
9502
|
+
};
|
|
9503
|
+
function settableVarHelp() {
|
|
9504
|
+
const keys = SETTABLE_VAR_KEYS.map((k) => {
|
|
9505
|
+
const hint = SETTABLE_VAR_HINTS[k];
|
|
9506
|
+
return hint ? `${k} (${hint})` : k;
|
|
9507
|
+
});
|
|
9508
|
+
return `META field to set (repeatable): ${keys.join(", ")}`;
|
|
9509
|
+
}
|
|
8626
9510
|
function buildProjectSetPatch(input) {
|
|
8627
9511
|
const patch = {};
|
|
8628
9512
|
if (input.class) {
|
|
@@ -8643,22 +9527,42 @@ function buildProjectSetPatch(input) {
|
|
|
8643
9527
|
}
|
|
8644
9528
|
patch.deployModel = input.deployModel;
|
|
8645
9529
|
}
|
|
9530
|
+
if (input.releaseTrack) {
|
|
9531
|
+
if (!isReleaseTrack(input.releaseTrack)) {
|
|
9532
|
+
throw new Error(`project set: --release-track must be one of: ${RELEASE_TRACKS.join(", ")}`);
|
|
9533
|
+
}
|
|
9534
|
+
patch.releaseTrack = input.releaseTrack;
|
|
9535
|
+
}
|
|
8646
9536
|
for (const value of input.vars) {
|
|
8647
9537
|
const eq = value.indexOf("=");
|
|
8648
|
-
if (eq
|
|
8649
|
-
|
|
8650
|
-
|
|
8651
|
-
|
|
8652
|
-
|
|
8653
|
-
|
|
8654
|
-
|
|
8655
|
-
|
|
8656
|
-
|
|
8657
|
-
|
|
8658
|
-
|
|
8659
|
-
|
|
8660
|
-
|
|
8661
|
-
|
|
9538
|
+
if (eq <= 0) continue;
|
|
9539
|
+
const key = value.slice(0, eq);
|
|
9540
|
+
const raw = value.slice(eq + 1);
|
|
9541
|
+
if (!SETTABLE_VAR_KEY_SET.has(key)) {
|
|
9542
|
+
throw new Error(`project set: --var KEY "${key}" is not settable \u2014 allowed keys: ${SETTABLE_VAR_KEYS.join(", ")}`);
|
|
9543
|
+
}
|
|
9544
|
+
if (key === "projectNumber") {
|
|
9545
|
+
const n = Number(raw);
|
|
9546
|
+
if (!Number.isFinite(n)) throw new Error("project set: projectNumber must be numeric");
|
|
9547
|
+
patch[key] = n;
|
|
9548
|
+
} else if (key === "requiredRuntimeSecrets") {
|
|
9549
|
+
patch[key] = parseRuntimeSecretsVar(raw);
|
|
9550
|
+
} else if (key === "edgeDomains") {
|
|
9551
|
+
patch[key] = parseEdgeDomainsVar(raw);
|
|
9552
|
+
} else if (key === "priorityOptions") {
|
|
9553
|
+
patch[key] = parsePriorityOptionsVar(raw);
|
|
9554
|
+
} else if (key === "statusOptions") {
|
|
9555
|
+
patch[key] = parseStatusOptionsVar(raw);
|
|
9556
|
+
} else if (key === "portRange") {
|
|
9557
|
+
patch[key] = parsePortRangeVar(raw);
|
|
9558
|
+
} else if (key === "oauth") {
|
|
9559
|
+
patch[key] = parseOauthVar(raw);
|
|
9560
|
+
} else if (key === "repos") {
|
|
9561
|
+
patch[key] = parseReposVar(raw);
|
|
9562
|
+
} else if (key === "publishRequired") {
|
|
9563
|
+
patch[key] = parsePublishRequiredVar(raw);
|
|
9564
|
+
} else {
|
|
9565
|
+
patch[key] = raw;
|
|
8662
9566
|
}
|
|
8663
9567
|
}
|
|
8664
9568
|
for (const key of input.unsets) {
|
|
@@ -8672,7 +9576,7 @@ function buildProjectSetPatch(input) {
|
|
|
8672
9576
|
patch.edgeDomains = null;
|
|
8673
9577
|
}
|
|
8674
9578
|
if (Object.keys(patch).length === 0) {
|
|
8675
|
-
throw new Error("project set: nothing to set - pass --class, --project-type, --deploy-model, --var KEY=VALUE, --unset KEY, and/or --clear-web-profile");
|
|
9579
|
+
throw new Error("project set: nothing to set - pass --class, --project-type, --deploy-model, --release-track, --var KEY=VALUE, --unset KEY, and/or --clear-web-profile");
|
|
8676
9580
|
}
|
|
8677
9581
|
return patch;
|
|
8678
9582
|
}
|
|
@@ -8697,8 +9601,8 @@ function resolveKbSource(rawBase) {
|
|
|
8697
9601
|
return { owner: m[1], repo: m[2], ref: m[3] };
|
|
8698
9602
|
}
|
|
8699
9603
|
function buildKbGetArgs(src, path2) {
|
|
8700
|
-
const
|
|
8701
|
-
return ["api", `repos/${src.owner}/${src.repo}/contents/${
|
|
9604
|
+
const clean4 = path2.replace(/^\/+/, "");
|
|
9605
|
+
return ["api", `repos/${src.owner}/${src.repo}/contents/${clean4}?ref=${src.ref}`, "-H", "Accept: application/vnd.github.raw"];
|
|
8702
9606
|
}
|
|
8703
9607
|
function buildKbTreeArgs(src) {
|
|
8704
9608
|
return ["api", `repos/${src.owner}/${src.repo}/git/trees/${src.ref}?recursive=1`];
|
|
@@ -9507,7 +10411,7 @@ function authorizeBodyHasMismatch(body) {
|
|
|
9507
10411
|
}
|
|
9508
10412
|
|
|
9509
10413
|
// src/index.ts
|
|
9510
|
-
var rawExecFileP3 = (0, import_node_util6.promisify)(
|
|
10414
|
+
var rawExecFileP3 = (0, import_node_util6.promisify)(import_node_child_process7.execFile);
|
|
9511
10415
|
var DEFAULT_EXEC_TIMEOUT_MS = 1e4;
|
|
9512
10416
|
var execFileP4 = (file, args, options = {}) => (
|
|
9513
10417
|
// encoding 'utf8' guarantees string stdout/stderr at runtime; the cast pins the type because
|
|
@@ -9936,7 +10840,7 @@ saga.command("head-update").option("--run", "detached worker: fetch state, run t
|
|
|
9936
10840
|
if (!headGateDue(tsPath)) return;
|
|
9937
10841
|
markHeadRun(tsPath);
|
|
9938
10842
|
try {
|
|
9939
|
-
(0,
|
|
10843
|
+
(0, import_node_child_process7.spawn)(process.execPath, [process.argv[1], "saga", "head-update", "--run"], {
|
|
9940
10844
|
detached: true,
|
|
9941
10845
|
stdio: "ignore",
|
|
9942
10846
|
windowsHide: true
|
|
@@ -10156,7 +11060,7 @@ function scheduleRelatedDiscovery(o) {
|
|
|
10156
11060
|
try {
|
|
10157
11061
|
const args = ["issue", "discover-related", "--number", String(o.number), "--title", o.title, "--body", o.body];
|
|
10158
11062
|
if (o.repo) args.push("--repo", o.repo);
|
|
10159
|
-
(0,
|
|
11063
|
+
(0, import_node_child_process7.spawn)(process.execPath, [process.argv[1], ...args], {
|
|
10160
11064
|
detached: true,
|
|
10161
11065
|
stdio: "ignore",
|
|
10162
11066
|
windowsHide: true,
|
|
@@ -10212,7 +11116,7 @@ function openInEditor(path2) {
|
|
|
10212
11116
|
return;
|
|
10213
11117
|
}
|
|
10214
11118
|
try {
|
|
10215
|
-
(0,
|
|
11119
|
+
(0, import_node_child_process7.spawn)(editor, [path2], { stdio: "inherit" });
|
|
10216
11120
|
} catch {
|
|
10217
11121
|
console.log(`open ${path2} manually`);
|
|
10218
11122
|
}
|
|
@@ -10371,15 +11275,22 @@ function reportWrite(label, res) {
|
|
|
10371
11275
|
fail(`${label}: HTTP ${res.status}${detail ? ` \u2014 ${detail}` : ""}`);
|
|
10372
11276
|
}
|
|
10373
11277
|
var tenant = program2.command("tenant").description("tenant runtime control through Hub authority");
|
|
10374
|
-
tenant.command("control <owner/repo> <stage> <action>").description("run bounded service control (status/start/stop/restart, plus rc-only retire) for a tenant; project-admin dev/rc, master main").option("--json", "machine-readable output").action(async (repo, stage2, action) => {
|
|
11278
|
+
tenant.command("control <owner/repo> <stage> <action>").description("run bounded service control (status/start/stop/restart, plus rc-only retire and read-only verify-secrets) for a tenant; project-admin dev/rc, master main").option("--json", "machine-readable output").action(async (repo, stage2, action, o) => {
|
|
10375
11279
|
const cfg = await loadConfig();
|
|
10376
|
-
const wait = action
|
|
11280
|
+
const wait = tenantControlWait(action);
|
|
10377
11281
|
const res = await tenantControl({ repo, stage: stage2, action, wait }, registryClientDeps(cfg));
|
|
10378
11282
|
const body = res.body;
|
|
10379
11283
|
if (!res.ok && body?.category) {
|
|
10380
11284
|
console.log(JSON.stringify(body));
|
|
10381
11285
|
return fail(`tenant control ${stage2} ${action}: ${body.category}`);
|
|
10382
11286
|
}
|
|
11287
|
+
if (res.ok && action === "verify-secrets") {
|
|
11288
|
+
const { lines, failure } = renderVerifySecrets(res.body);
|
|
11289
|
+
if (o.json) console.log(JSON.stringify(res.body));
|
|
11290
|
+
else lines.forEach((l) => console.log(l));
|
|
11291
|
+
if (failure) return fail(`tenant control ${stage2} verify-secrets: ${failure}`);
|
|
11292
|
+
return;
|
|
11293
|
+
}
|
|
10383
11294
|
reportWrite("tenant control", res);
|
|
10384
11295
|
});
|
|
10385
11296
|
tenant.command("redeploy <owner/repo> <stage>").description("re-dispatch the central tenant-deploy.yml for an already-promoted ref (no re-tag/merge); train-authority gated").option("--ref <ref>", "ref to deploy (defaults to the stage branch rc/main \u2014 the promoted ref)").option("--watch", "block on the dispatched run and report its outcome (gh run watch --exit-status)").option("--json", "machine-readable output").action(async (repo, stage2, o) => {
|
|
@@ -10391,6 +11302,31 @@ tenant.command("redeploy <owner/repo> <stage>").description("re-dispatch the cen
|
|
|
10391
11302
|
return fail(`tenant redeploy: ${e.message}`);
|
|
10392
11303
|
}
|
|
10393
11304
|
});
|
|
11305
|
+
tenant.command("sweep-rc").description("discover (and optionally retire) running rc tenant runtimes across tenant-containers \u2014 orphan cleanup after a failed post-release retire (#942)").option("--retire", "retire every running rc runtime found (requires --yes) \u2014 WARNING: tears down a legitimately-staged rc too").option("--yes", "confirm the destructive --retire").option("--json", "machine-readable output").action(async (o) => {
|
|
11306
|
+
if (o.retire && !o.yes) {
|
|
11307
|
+
return fail("tenant sweep-rc --retire is destructive (it tears down EVERY running rc, including one legitimately staged between /rcand and /release) \u2014 re-run with --yes to confirm");
|
|
11308
|
+
}
|
|
11309
|
+
const cfg = await loadConfig();
|
|
11310
|
+
const cdeps = registryClientDeps(cfg);
|
|
11311
|
+
try {
|
|
11312
|
+
const result = await sweepRcOrphans({
|
|
11313
|
+
listProjects: () => fetchProjectsList(cdeps),
|
|
11314
|
+
status: async (repo) => {
|
|
11315
|
+
const res = await tenantControl({ repo, stage: "rc", action: "status", wait: true }, cdeps);
|
|
11316
|
+
const b = res.body;
|
|
11317
|
+
return { serviceState: b?.serviceState ?? "unknown" };
|
|
11318
|
+
},
|
|
11319
|
+
retire: async (repo) => {
|
|
11320
|
+
const res = await tenantControl({ repo, stage: "rc", action: "retire", wait: true }, cdeps);
|
|
11321
|
+
const b = res.body;
|
|
11322
|
+
return { ok: res.ok, category: b?.category, reason: b?.reason };
|
|
11323
|
+
}
|
|
11324
|
+
}, { retire: !!o.retire });
|
|
11325
|
+
return printLine(o.json ? JSON.stringify(result) : renderSweep(result));
|
|
11326
|
+
} catch (e) {
|
|
11327
|
+
return fail(`tenant sweep-rc: ${e.message}`);
|
|
11328
|
+
}
|
|
11329
|
+
});
|
|
10394
11330
|
async function resolveDnsBounded(host, timeoutMs = 3e3) {
|
|
10395
11331
|
const { lookup } = await import("node:dns/promises");
|
|
10396
11332
|
const probe = lookup(host).then(() => true).catch((e) => dnsErrorToResolution(e?.code));
|
|
@@ -10559,7 +11495,7 @@ project.command("attest [owner/repo]").description("attest this repo's app-owned
|
|
|
10559
11495
|
const res = await attestAppGaps(slugOf(target), repo, registryClientDeps(cfg));
|
|
10560
11496
|
reportWrite("project attest", res);
|
|
10561
11497
|
});
|
|
10562
|
-
project.command("set [owner/repo]").description("MASTER-ONLY: upsert a project META (idempotent merge; defaults to the current repo; no clobber of unspecified fields)").option("--class <class>", "deployable | content").option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (v2 capability shape)`).option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path; none means no Hub deploy registration)`).option("--
|
|
11498
|
+
project.command("set [owner/repo]").description("MASTER-ONLY: upsert a project META (idempotent merge; defaults to the current repo; no clobber of unspecified fields)").option("--class <class>", "deployable | content").option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (v2 capability shape)`).option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path; none means no Hub deploy registration)`).option("--release-track <track>", `${RELEASE_TRACKS.join(" | ")} (branch topology; direct skips rc)`).option("--var <KEY=VALUE...>", settableVarHelp()).option("--unset <KEY...>", "META field to remove (repeatable): oauth, requiredRuntimeSecrets, edgeDomains, requiredGcpApis, publishRequired").option("--clear-web-profile", "remove web-only registry fields (oauth, edgeDomains) for non-web/content projects").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
|
|
10563
11499
|
const cfg = await loadConfig();
|
|
10564
11500
|
let target;
|
|
10565
11501
|
try {
|
|
@@ -10574,6 +11510,7 @@ project.command("set [owner/repo]").description("MASTER-ONLY: upsert a project M
|
|
|
10574
11510
|
class: o.class,
|
|
10575
11511
|
projectType: o.projectType,
|
|
10576
11512
|
deployModel: o.deployModel,
|
|
11513
|
+
releaseTrack: o.releaseTrack,
|
|
10577
11514
|
vars: rawValues("--var"),
|
|
10578
11515
|
unsets: rawValues("--unset"),
|
|
10579
11516
|
clearWebProfile: Boolean(o.clearWebProfile)
|
|
@@ -10832,14 +11769,10 @@ pr.command("create").description("create a PR and print {number,url} JSON").requ
|
|
|
10832
11769
|
const created = await ghCreate(buildPrArgs({ title: o.title, body, base: o.base, head: o.head, repo: o.repo }));
|
|
10833
11770
|
console.log(JSON.stringify(created));
|
|
10834
11771
|
});
|
|
10835
|
-
async function remoteBranchExists(branch) {
|
|
10836
|
-
|
|
10837
|
-
|
|
10838
|
-
|
|
10839
|
-
return stdout.trim().length > 0;
|
|
10840
|
-
} catch {
|
|
10841
|
-
return void 0;
|
|
10842
|
-
}
|
|
11772
|
+
async function remoteBranchExists(branch, options = {}) {
|
|
11773
|
+
return checkRemoteBranchExists(branch, {
|
|
11774
|
+
execGit: async (args) => (await execFileP4("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
|
|
11775
|
+
}, options);
|
|
10843
11776
|
}
|
|
10844
11777
|
var COMPOSE_TIMEOUT_MS = 12e4;
|
|
10845
11778
|
function teardownWorktreeStage(worktreePath) {
|
|
@@ -10855,7 +11788,7 @@ function teardownWorktreeStage(worktreePath) {
|
|
|
10855
11788
|
}
|
|
10856
11789
|
});
|
|
10857
11790
|
}
|
|
10858
|
-
pr.command("merge <number>").description("merge a PR (squash by default) and clean up its branch + worktree \u2014 no leftover local branch").option("--squash", "squash merge (default)").option("--merge", "create a merge commit").option("--rebase", "rebase merge").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action(async (number, o) => {
|
|
11791
|
+
pr.command("merge <number>").description("merge a PR (squash by default) and clean up its branch + worktree \u2014 no leftover local branch").option("--squash", "squash merge (default)").option("--merge", "create a merge commit").option("--rebase", "rebase merge").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--auto", "enable auto-merge \u2014 merge once the base-branch policy is satisfied (use for policy-gated repos)").action(async (number, o) => {
|
|
10859
11792
|
const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
|
|
10860
11793
|
const repoArgs = o.repo ? ["--repo", o.repo] : [];
|
|
10861
11794
|
const headRef = (await execFileP4("gh", ["pr", "view", number, ...repoArgs, "--json", "headRefName", "--jq", ".headRefName"], { timeout: GC_GH_TIMEOUT_MS })).stdout.trim();
|
|
@@ -10866,7 +11799,7 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
|
|
|
10866
11799
|
const remoteBefore = repoArgs.length ? void 0 : await remoteBranchExists(headRef);
|
|
10867
11800
|
let remoteDeleteAttempted = false;
|
|
10868
11801
|
let remoteNotAttemptedReason = repoArgs.length ? "repo-option" : void 0;
|
|
10869
|
-
await execFileP4("gh",
|
|
11802
|
+
await execFileP4("gh", buildPrMergeArgs({ number, repoArgs, method, auto: o.auto }), { timeout: GH_MUTATION_TIMEOUT_MS }).catch((e) => {
|
|
10870
11803
|
const message = String(e.message || "");
|
|
10871
11804
|
if (/already been merged/i.test(message)) {
|
|
10872
11805
|
remoteNotAttemptedReason = "pr-already-merged";
|
|
@@ -10874,14 +11807,27 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
|
|
|
10874
11807
|
}
|
|
10875
11808
|
const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
|
|
10876
11809
|
if (note) throw new Error(`gh pr merge ${number}: ${note}`);
|
|
11810
|
+
if (!o.auto && basePolicyBlocksImmediateMerge(message)) {
|
|
11811
|
+
throw new Error(`gh pr merge ${number}: the base-branch policy blocks an immediate merge \u2014 re-run with --auto to merge once required checks pass.`);
|
|
11812
|
+
}
|
|
10877
11813
|
if (!/used by worktree|cannot delete branch/i.test(message)) throw e;
|
|
10878
11814
|
});
|
|
11815
|
+
if (o.auto) {
|
|
11816
|
+
const state = (await execFileP4("gh", ["pr", "view", number, ...repoArgs, "--json", "state", "--jq", ".state"], { timeout: GC_GH_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
|
|
11817
|
+
if (state !== "MERGED") {
|
|
11818
|
+
console.log(JSON.stringify({ mergeStatus: "auto-merge-enqueued", pr: number, branch: headRef, state: state || "unknown" }));
|
|
11819
|
+
return;
|
|
11820
|
+
}
|
|
11821
|
+
}
|
|
10879
11822
|
if (!remoteNotAttemptedReason) remoteDeleteAttempted = true;
|
|
10880
|
-
const
|
|
10881
|
-
|
|
11823
|
+
const remoteBranch = repoArgs.length ? buildRemoteBranchCleanupReport(headRef, {
|
|
11824
|
+
attempted: false,
|
|
11825
|
+
reason: remoteNotAttemptedReason
|
|
11826
|
+
}) : await buildPrMergeRemoteBranchCleanupReport(headRef, {
|
|
11827
|
+
exists: remoteBranchExists
|
|
11828
|
+
}, {
|
|
10882
11829
|
attempted: remoteDeleteAttempted,
|
|
10883
11830
|
existedBefore: remoteBefore,
|
|
10884
|
-
existsAfter: remoteAfter,
|
|
10885
11831
|
reason: remoteNotAttemptedReason
|
|
10886
11832
|
});
|
|
10887
11833
|
const localCleanup = repoArgs.length ? {
|
|
@@ -10892,16 +11838,19 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
|
|
|
10892
11838
|
beforeWorktrees,
|
|
10893
11839
|
startingPath,
|
|
10894
11840
|
execGit: async (args) => (await execFileP4("git", args, { timeout: GIT_TIMEOUT_MS })).stdout,
|
|
10895
|
-
teardownWorktreeStage
|
|
11841
|
+
teardownWorktreeStage,
|
|
11842
|
+
// Hardened fallback when retried `git worktree remove` still hits a Windows file lock (#967).
|
|
11843
|
+
// `fs.rm` recursive removes a junction as a link (it does not traverse into the target) and its
|
|
11844
|
+
// own maxRetries/retryDelay rides out a handle that an indexer/antivirus releases a moment later.
|
|
11845
|
+
removeWorktreeDir: async (worktreePath) => (0, import_promises2.rm)(worktreePath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
|
10896
11846
|
});
|
|
10897
|
-
console.log(JSON.stringify({
|
|
10898
|
-
|
|
11847
|
+
console.log(JSON.stringify(buildPrMergeResultPayload({
|
|
11848
|
+
number,
|
|
10899
11849
|
branch: headRef,
|
|
10900
11850
|
method: method.slice(2),
|
|
10901
11851
|
remoteBranch,
|
|
10902
|
-
|
|
10903
|
-
|
|
10904
|
-
}));
|
|
11852
|
+
localCleanup
|
|
11853
|
+
})));
|
|
10905
11854
|
});
|
|
10906
11855
|
async function runBoardRead(o) {
|
|
10907
11856
|
try {
|
|
@@ -11072,6 +12021,19 @@ function reportedStageUrl(res, result) {
|
|
|
11072
12021
|
return result.port != null ? stageUrlForPort(result.port) : res.derived.url;
|
|
11073
12022
|
}
|
|
11074
12023
|
program2.command("port-range <repo>").description("assign (idempotently) + print the repo's local stage port block via the atomic ORG#config.portCursor allocator (committed-file fallback)").option("--json", "machine-readable output").action(async (repo, o) => {
|
|
12024
|
+
const cfg = await loadConfig();
|
|
12025
|
+
const reg = registryClientDeps(cfg);
|
|
12026
|
+
const slug = slugOf(repo);
|
|
12027
|
+
const read = await fetchProjectBySlugChecked(slug, reg);
|
|
12028
|
+
const decision = decidePortRange({ metaReadOk: read.ok, metaPortRange: read.ok ? metaPortRange(read.project) : null });
|
|
12029
|
+
if (decision.action === "fail") {
|
|
12030
|
+
return fail(`port-range: ${decision.reason}${read.ok ? "" : ` (${read.error})`}`);
|
|
12031
|
+
}
|
|
12032
|
+
if (decision.action === "return") {
|
|
12033
|
+
const [start2, end2] = decision.range;
|
|
12034
|
+
printLine(o.json ? JSON.stringify({ repo, portRange: [start2, end2], source: "meta" }) : `${repo}: stage.portRange [${start2}, ${end2}]`);
|
|
12035
|
+
return;
|
|
12036
|
+
}
|
|
11075
12037
|
const path2 = (0, import_node_path8.join)(process.cwd(), "infra", "port-ranges.json");
|
|
11076
12038
|
const allocate = async (seed) => {
|
|
11077
12039
|
const { stdout } = await execFileP4("node", [(0, import_node_path8.join)(process.cwd(), "infra", "port-ddb.mjs"), String(seed)], { timeout: 15e3 });
|
|
@@ -11079,8 +12041,16 @@ program2.command("port-range <repo>").description("assign (idempotently) + print
|
|
|
11079
12041
|
if (!Array.isArray(parsed.range) || parsed.range.length !== 2) throw new Error("port-ddb: no range in output");
|
|
11080
12042
|
return parsed.range;
|
|
11081
12043
|
};
|
|
11082
|
-
const { range: [start, end] } = await ensurePortRangeAtomic(repo, path2, allocate);
|
|
11083
|
-
|
|
12044
|
+
const { range: [start, end], source } = await ensurePortRangeAtomic(repo, path2, allocate);
|
|
12045
|
+
const write = await upsertProject(slug, { portRange: { start, end } }, reg);
|
|
12046
|
+
if (!write.ok && source === "ddb") {
|
|
12047
|
+
return fail(`port-range: block [${start}, ${end}] was allocated (cursor advanced) but NOT recorded in the registry META (${write.error ?? `HTTP ${write.status}`}) \u2014 fix auth/connectivity and retry so the block is persisted; do not re-run blind`);
|
|
12048
|
+
}
|
|
12049
|
+
if (o.json) {
|
|
12050
|
+
printLine(JSON.stringify({ repo, portRange: [start, end], source: "allocated", persisted: write.ok, ...write.ok ? {} : { persistError: write.error ?? `HTTP ${write.status}` } }));
|
|
12051
|
+
} else {
|
|
12052
|
+
printLine(`${repo}: stage.portRange [${start}, ${end}]${write.ok ? "" : ` (META not persisted: ${write.error ?? `HTTP ${write.status}`})`}`);
|
|
12053
|
+
}
|
|
11084
12054
|
});
|
|
11085
12055
|
var stage = program2.command("stage").description("plan or run the repo local stage environment").option("--json", "machine-readable output").option("--apply", "run the full local stage: stop previous, build, start, health-check").option("--timeout-ms <ms>", "bounded build/health timeout", "60000").action(async (o) => {
|
|
11086
12056
|
const res = await resolveStage();
|
|
@@ -11204,6 +12174,19 @@ function trainApplyDeps() {
|
|
|
11204
12174
|
const verdict = await fetchTrainAuthority(repo, registryClientDeps(await loadConfig()));
|
|
11205
12175
|
return verdict.ok ? { ok: true, role: verdict.authority.role, train: verdict.authority.train } : verdict;
|
|
11206
12176
|
},
|
|
12177
|
+
// Hub-App-authority dispatch of the central tenant deploy (#953) — the Hub fires the
|
|
12178
|
+
// workflow_dispatch with its App token, so the caller needs no MMI-Hub Actions write.
|
|
12179
|
+
dispatchTenantDeploy: async ({ repo, stage: stage2, ref }) => {
|
|
12180
|
+
const res = await tenantDeploy({ repo, stage: stage2, ref }, registryClientDeps(await loadConfig()));
|
|
12181
|
+
if (!res.ok) {
|
|
12182
|
+
const detail = res.body?.error ?? res.error ?? `HTTP ${res.status}`;
|
|
12183
|
+
throw new Error(`tenant deploy dispatch failed: ${detail}`);
|
|
12184
|
+
}
|
|
12185
|
+
},
|
|
12186
|
+
// Hotfix-coverage guard (#958): runs against the local clone via real git. manifestPaths is empty —
|
|
12187
|
+
// product repos carry no main-only distribution bump (that exemption is Hub-distribution-specific, and
|
|
12188
|
+
// the Hub is direct-track, so it never reaches this guard).
|
|
12189
|
+
hotfixCoverage: (input) => checkHotfixCoverage({ ...input, manifestPaths: [] }),
|
|
11207
12190
|
// Slack release announcement (#883): Hub-only + best-effort inside announceRelease itself.
|
|
11208
12191
|
announce: (args) => announceRelease({
|
|
11209
12192
|
run: async (file, cmdArgs) => (await execFileP4(file, cmdArgs, { timeout: GH_TRAIN_TIMEOUT_MS })).stdout,
|
|
@@ -11218,9 +12201,22 @@ function trainApplyDeps() {
|
|
|
11218
12201
|
}
|
|
11219
12202
|
};
|
|
11220
12203
|
}
|
|
12204
|
+
function trainPrepDeps() {
|
|
12205
|
+
return {
|
|
12206
|
+
run: async (file, args) => {
|
|
12207
|
+
const timeout = file === "node" && args[1] === "prepare" ? NODE_PREPARE_TIMEOUT_MS : GIT_TIMEOUT_MS;
|
|
12208
|
+
return (await execFileP4(file, args, { timeout })).stdout;
|
|
12209
|
+
}
|
|
12210
|
+
};
|
|
12211
|
+
}
|
|
12212
|
+
function formatWorkflowRun(r) {
|
|
12213
|
+
const ref = r.runUrl ?? (r.runId != null ? String(r.runId) : "unresolved");
|
|
12214
|
+
return `${r.workflow} ${ref} ${r.conclusion.toUpperCase()}`;
|
|
12215
|
+
}
|
|
11221
12216
|
function renderDeployLine(d) {
|
|
11222
12217
|
const parts = [d.dispatch];
|
|
11223
|
-
if (d.
|
|
12218
|
+
if (d.workflowRuns?.length) parts.push(`runs: ${d.workflowRuns.map(formatWorkflowRun).join(", ")}`);
|
|
12219
|
+
else if (d.runUrl) parts.push(`run ${d.runUrl}`);
|
|
11224
12220
|
if (d.deployStatus === "success") parts.push("deploy: SUCCEEDED");
|
|
11225
12221
|
else if (d.deployStatus === "failure") parts.push("deploy: FAILED (promotion stands; retry the deploy, do not re-tag)");
|
|
11226
12222
|
else if (d.runId != null) parts.push(`deploy: dispatched (watch: gh run watch ${d.runId} --repo mutmutco/MMI-Hub --exit-status)`);
|
|
@@ -11235,23 +12231,60 @@ function renderTrainApply(commandName, r) {
|
|
|
11235
12231
|
function renderTenantRedeploy(r) {
|
|
11236
12232
|
return `mmi-cli tenant redeploy: ${r.repo} ${r.stage} (ref=${r.ref}) [${r.deployModel}]; ${renderDeployLine(r)}`;
|
|
11237
12233
|
}
|
|
12234
|
+
async function resolveRcandPlanTargets() {
|
|
12235
|
+
try {
|
|
12236
|
+
const tags = (await execFileP4("git", ["tag", "--list", "v*", "--sort=-v:refname"], { timeout: 1e4 })).stdout.trim().split("\n").filter(Boolean);
|
|
12237
|
+
return trainPlanTargetsFromTags(tags, process.env.MMI_RELEASE_VERSION);
|
|
12238
|
+
} catch (e) {
|
|
12239
|
+
return { plannedReleaseError: e.message, existingRcReleaseError: e.message };
|
|
12240
|
+
}
|
|
12241
|
+
}
|
|
12242
|
+
function renderTrainPrep(r) {
|
|
12243
|
+
if (!r.applied) {
|
|
12244
|
+
return `mmi-cli train prep: target ${r.target}; run ${r.command} to prepare and stage locked distribution files`;
|
|
12245
|
+
}
|
|
12246
|
+
return [
|
|
12247
|
+
`mmi-cli train prep: prepared ${r.target}`,
|
|
12248
|
+
` - staged locked files: ${r.stagedFiles.join(", ")}`,
|
|
12249
|
+
" - stopped before promotion; no push, merge, tag, release, or deploy was run",
|
|
12250
|
+
` - next: ${r.command}`
|
|
12251
|
+
].join("\n");
|
|
12252
|
+
}
|
|
12253
|
+
var trainCmd = program2.command("train").description("release train helpers that stop before promotion");
|
|
12254
|
+
trainCmd.command("prep").description("prepare and stage the Hub distribution bump for the next release train").option("--json", "machine-readable output").option("--apply", "run release-distribution prepare, exact-file stage, verify, then stop").action(async (o) => {
|
|
12255
|
+
try {
|
|
12256
|
+
await requireFreshTrainCli("train prep");
|
|
12257
|
+
const result = await runTrainPrep(trainPrepDeps(), { apply: o.apply });
|
|
12258
|
+
printLine(o.json ? JSON.stringify(result, null, 2) : renderTrainPrep(result));
|
|
12259
|
+
} catch (e) {
|
|
12260
|
+
fail(`train prep: ${e.message}`);
|
|
12261
|
+
}
|
|
12262
|
+
});
|
|
11238
12263
|
for (const commandName of ["rcand", "release"]) {
|
|
11239
|
-
program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--watch", "block on the
|
|
12264
|
+
program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--watch", "block on the deploy/publish workflow runs and report their outcomes").option("--apply", "execute the guarded master-only train after explicit approval").option("--announce-summary-file <path>", "release only: agent-curated summary lines for the Hub Slack announcement (#883)").option("--ack <shas>", "release only: comma-separated dev shas a human verified are in the candidate, overriding the hotfix-coverage guard for a conflicted port whose -x trailer was lost (#958)").action(async (o) => {
|
|
11240
12265
|
try {
|
|
11241
12266
|
await requireFreshTrainCli(commandName);
|
|
11242
12267
|
} catch (e) {
|
|
11243
12268
|
return fail(`${commandName}: ${e.message}`);
|
|
11244
12269
|
}
|
|
12270
|
+
if (o.ack && commandName !== "release") {
|
|
12271
|
+
return fail("--ack applies only to release: it overrides the rc -> main hotfix-coverage guard, which rcand does not run");
|
|
12272
|
+
}
|
|
11245
12273
|
if (o.apply) {
|
|
11246
12274
|
try {
|
|
11247
|
-
const
|
|
12275
|
+
const ack = (o.ack ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
12276
|
+
const result = await runTrainApply(commandName, trainApplyDeps(), { watch: o.watch, announceSummaryFile: o.announceSummaryFile, ack });
|
|
11248
12277
|
return printLine(o.json ? JSON.stringify(result, null, 2) : renderTrainApply(commandName, result));
|
|
11249
12278
|
} catch (e) {
|
|
11250
12279
|
return fail(`${commandName}: ${e.message}`);
|
|
11251
12280
|
}
|
|
11252
12281
|
}
|
|
11253
|
-
const
|
|
11254
|
-
|
|
12282
|
+
const repo = await resolveRepo();
|
|
12283
|
+
const targets = commandName === "rcand" ? await resolveRcandPlanTargets() : void 0;
|
|
12284
|
+
const steps = trainPlan(commandName, { ...targets ?? {}, repo });
|
|
12285
|
+
console.log(
|
|
12286
|
+
o.json ? JSON.stringify({ command: commandName, ...targets ?? {}, repo, steps }, null, 2) : renderSteps(`mmi-cli ${commandName}: dry-run plan`, steps)
|
|
12287
|
+
);
|
|
11255
12288
|
});
|
|
11256
12289
|
}
|
|
11257
12290
|
function renderHotfixStart(r) {
|
|
@@ -11339,15 +12372,16 @@ bootstrap.command("verify <repo>").description("audit whether an existing repo i
|
|
|
11339
12372
|
return null;
|
|
11340
12373
|
}
|
|
11341
12374
|
}
|
|
11342
|
-
});
|
|
12375
|
+
}, resolveReleaseTrack(meta));
|
|
11343
12376
|
console.log(o.json ? JSON.stringify(report, null, 2) : renderBootstrapVerifyReport(report));
|
|
11344
12377
|
if (!report.ok) process.exitCode = 1;
|
|
11345
12378
|
});
|
|
11346
|
-
bootstrap.command("apply <repo>").description("idempotent seed apply from skills/bootstrap/seeds/manifest.json; dry-run unless --execute (live, master-gated)").option("--class <class>", "deployable | content", "deployable").option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (v2 capability shape)`).option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path)`).option("--execute", "LIVE apply via gh (master-gated) \u2014 stamps seed files + labels into the repo").option("--var <KEY=VALUE...>", "placeholder values for repo-owned templates (repeatable)").option("--json", "machine-readable output").action(async (repo) => {
|
|
12379
|
+
bootstrap.command("apply <repo>").description("idempotent seed apply from skills/bootstrap/seeds/manifest.json; dry-run unless --execute (live, master-gated)").option("--class <class>", "deployable | content", "deployable").option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (v2 capability shape)`).option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path)`).option("--release-track <track>", `${RELEASE_TRACKS.join(" | ")} (branch topology; direct skips rc)`).option("--execute", "LIVE apply via gh (master-gated) \u2014 stamps seed files + labels into the repo").option("--var <KEY=VALUE...>", "placeholder values for repo-owned templates (repeatable)").option("--json", "machine-readable output").action(async (repo) => {
|
|
11347
12380
|
const o = {
|
|
11348
12381
|
class: rawValue("--class", "deployable"),
|
|
11349
12382
|
projectType: rawValue("--project-type", ""),
|
|
11350
12383
|
deployModel: rawValue("--deploy-model", ""),
|
|
12384
|
+
releaseTrack: rawValue("--release-track", ""),
|
|
11351
12385
|
execute: rawFlag("--execute"),
|
|
11352
12386
|
json: rawFlag("--json")
|
|
11353
12387
|
};
|
|
@@ -11366,10 +12400,19 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
|
|
|
11366
12400
|
const gh = async (args) => execFileP4("gh", args, { timeout: 2e4 });
|
|
11367
12401
|
const readFile2 = (p) => (0, import_node_fs7.existsSync)(p) ? (0, import_node_fs7.readFileSync)(p, "utf8") : null;
|
|
11368
12402
|
const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
|
|
11369
|
-
const
|
|
12403
|
+
const rawVars = {};
|
|
11370
12404
|
for (const value of rawValues("--var")) {
|
|
11371
12405
|
const eq = value.indexOf("=");
|
|
11372
|
-
if (eq > 0)
|
|
12406
|
+
if (eq > 0) rawVars[value.slice(0, eq)] = value.slice(eq + 1);
|
|
12407
|
+
}
|
|
12408
|
+
const vars = withDerivedRepoVars(rawVars, parsedRepo, o.class);
|
|
12409
|
+
if (vars.PROJECT_ID) {
|
|
12410
|
+
try {
|
|
12411
|
+
const r = await gh(boardFieldsQueryArgs(vars.PROJECT_ID));
|
|
12412
|
+
const boardVars = extractBoardFieldVars(JSON.parse(r.stdout));
|
|
12413
|
+
for (const [k, v] of Object.entries(boardVars)) if (vars[k] == null) vars[k] = v;
|
|
12414
|
+
} catch {
|
|
12415
|
+
}
|
|
11373
12416
|
}
|
|
11374
12417
|
const actions = [];
|
|
11375
12418
|
const applied = [];
|
|
@@ -11395,22 +12438,12 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
|
|
|
11395
12438
|
exists = false;
|
|
11396
12439
|
}
|
|
11397
12440
|
}
|
|
11398
|
-
const
|
|
12441
|
+
const planned = planSeedAction(resolved, exists);
|
|
12442
|
+
const isBlock = resolved.source === "managed-block";
|
|
12443
|
+
const content = planned.action === "create" || planned.action === "update" ? isBlock ? upsertManagedGitignoreBlock(remoteContent).content : resolveSeedContent(resolved, vars, readFile2) : null;
|
|
12444
|
+
const action = reconcileSeedAction(planned, content, isBlock);
|
|
11399
12445
|
actions.push(action);
|
|
11400
12446
|
if (o.execute && (action.action === "create" || action.action === "update")) {
|
|
11401
|
-
const isBlock = resolved.source === "managed-block";
|
|
11402
|
-
const content = isBlock ? upsertManagedGitignoreBlock(remoteContent).content : resolveSeedContent(resolved, vars, readFile2);
|
|
11403
|
-
if (content == null) {
|
|
11404
|
-
applied.push(`skip ${resolved.target} (no resolvable content)`);
|
|
11405
|
-
continue;
|
|
11406
|
-
}
|
|
11407
|
-
if (!isBlock) {
|
|
11408
|
-
const missing = missingPlaceholders(content);
|
|
11409
|
-
if (missing.length) {
|
|
11410
|
-
applied.push(`skip ${resolved.target} (unfilled: ${missing.join(", ")} \u2014 pass --var)`);
|
|
11411
|
-
continue;
|
|
11412
|
-
}
|
|
11413
|
-
}
|
|
11414
12447
|
await gh(contentPutArgs(repo, resolved.target, content, baseBranch, action.action === "update" ? sha : void 0));
|
|
11415
12448
|
applied.push(`${action.action} ${resolved.target}`);
|
|
11416
12449
|
}
|
|
@@ -11424,13 +12457,23 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
|
|
|
11424
12457
|
applied.push(`label ${l.name} (failed)`);
|
|
11425
12458
|
}
|
|
11426
12459
|
}
|
|
12460
|
+
for (const name of labelsToPrune(manifest.labels.map((l) => l.name))) {
|
|
12461
|
+
try {
|
|
12462
|
+
await gh(["label", "delete", name, "-R", repo, "--yes"]);
|
|
12463
|
+
applied.push(`label ${name} (pruned)`);
|
|
12464
|
+
} catch (e) {
|
|
12465
|
+
if (/not found/i.test(e.message ?? "")) continue;
|
|
12466
|
+
applied.push(`label ${name} (prune failed)`);
|
|
12467
|
+
}
|
|
12468
|
+
}
|
|
11427
12469
|
}
|
|
11428
12470
|
const ddbWrites = [];
|
|
11429
12471
|
let registerPayload;
|
|
11430
12472
|
try {
|
|
11431
12473
|
registerPayload = buildRegisterPayload(repo, o.class, vars, {
|
|
11432
12474
|
projectType: o.projectType || void 0,
|
|
11433
|
-
deployModel: o.deployModel || void 0
|
|
12475
|
+
deployModel: o.deployModel || void 0,
|
|
12476
|
+
releaseTrack: o.releaseTrack || void 0
|
|
11434
12477
|
});
|
|
11435
12478
|
} catch (e) {
|
|
11436
12479
|
return fail(`bootstrap apply: ${e.message}`);
|
|
@@ -11446,7 +12489,83 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
|
|
|
11446
12489
|
applied.push(`ddb register ${registerPayload.slug} (failed: ${why})`);
|
|
11447
12490
|
}
|
|
11448
12491
|
}
|
|
11449
|
-
|
|
12492
|
+
let fanoutPrUrl;
|
|
12493
|
+
if (o.execute) {
|
|
12494
|
+
const fanoutEntry = {
|
|
12495
|
+
repo: parsedRepo.name,
|
|
12496
|
+
slug,
|
|
12497
|
+
projectId: vars.PROJECT_ID || void 0,
|
|
12498
|
+
wikiRepo: vars.WIKI_REPO || parsedRepo.fullName,
|
|
12499
|
+
branch: baseBranch,
|
|
12500
|
+
cls: o.class,
|
|
12501
|
+
name: vars.NAME || parsedRepo.name
|
|
12502
|
+
};
|
|
12503
|
+
const readHubFile = async (path2) => {
|
|
12504
|
+
const r = await gh(["api", `repos/${HUB_REPO}/contents/${enc(path2)}?ref=development`]);
|
|
12505
|
+
const parsed = JSON.parse(r.stdout);
|
|
12506
|
+
if (parsed.encoding !== "base64" || typeof parsed.content !== "string" || !parsed.sha) {
|
|
12507
|
+
throw new Error(`could not read ${HUB_REPO}/${path2}`);
|
|
12508
|
+
}
|
|
12509
|
+
return { content: Buffer.from(parsed.content, "base64").toString("utf8"), sha: parsed.sha };
|
|
12510
|
+
};
|
|
12511
|
+
try {
|
|
12512
|
+
const fanoutFile = await readHubFile(".github/fanout-targets.json");
|
|
12513
|
+
const projectsFile = await readHubFile("projects.json");
|
|
12514
|
+
const plan2 = planFanoutRegistration(fanoutFile.content, projectsFile.content, fanoutEntry);
|
|
12515
|
+
if (!plan2.changed) {
|
|
12516
|
+
applied.push(`fanout: already registered (${parsedRepo.name})`);
|
|
12517
|
+
} else {
|
|
12518
|
+
const branchName = `bootstrap-register-fanout-${slug}`;
|
|
12519
|
+
const headSha = (await gh(["api", `repos/${HUB_REPO}/git/ref/heads/development`, "--jq", ".object.sha"])).stdout.trim();
|
|
12520
|
+
try {
|
|
12521
|
+
await gh(["api", "-X", "POST", `repos/${HUB_REPO}/git/refs`, "-f", `ref=refs/heads/${branchName}`, "-f", `sha=${headSha}`]);
|
|
12522
|
+
} catch (e) {
|
|
12523
|
+
if (!/Reference already exists|already exists/i.test(String(e.message ?? ""))) throw e;
|
|
12524
|
+
}
|
|
12525
|
+
const branchFileSha = async (path2) => {
|
|
12526
|
+
try {
|
|
12527
|
+
const r = await gh(["api", `repos/${HUB_REPO}/contents/${enc(path2)}?ref=${branchName}`, "--jq", ".sha"]);
|
|
12528
|
+
return r.stdout.trim() || void 0;
|
|
12529
|
+
} catch (e) {
|
|
12530
|
+
if (/404|Not Found/i.test(String(e.message ?? ""))) return void 0;
|
|
12531
|
+
throw e;
|
|
12532
|
+
}
|
|
12533
|
+
};
|
|
12534
|
+
const fanoutBranchSha = await branchFileSha(".github/fanout-targets.json");
|
|
12535
|
+
const projectsBranchSha = await branchFileSha("projects.json");
|
|
12536
|
+
await gh(contentPutArgs(HUB_REPO, ".github/fanout-targets.json", plan2.fanoutTargets, branchName, fanoutBranchSha));
|
|
12537
|
+
await gh(contentPutArgs(HUB_REPO, "projects.json", plan2.projects, branchName, projectsBranchSha));
|
|
12538
|
+
const openPrs = await gh(["pr", "list", "--repo", HUB_REPO, "--head", branchName, "--base", "development", "--state", "open", "--json", "number,url"]);
|
|
12539
|
+
const prDecision = decideFanoutPrAction(JSON.parse(openPrs.stdout || "[]"));
|
|
12540
|
+
if (prDecision.action === "reuse") {
|
|
12541
|
+
fanoutPrUrl = prDecision.url;
|
|
12542
|
+
} else {
|
|
12543
|
+
const created = await ghCreate([
|
|
12544
|
+
"pr",
|
|
12545
|
+
"create",
|
|
12546
|
+
"--repo",
|
|
12547
|
+
HUB_REPO,
|
|
12548
|
+
"--base",
|
|
12549
|
+
"development",
|
|
12550
|
+
"--head",
|
|
12551
|
+
branchName,
|
|
12552
|
+
"--title",
|
|
12553
|
+
`bootstrap: register ${parsedRepo.name} for org-spine fanout`,
|
|
12554
|
+
"--body",
|
|
12555
|
+
`Auto-opened by \`mmi-cli bootstrap apply --execute ${repo}\` (#933): adds ${parsedRepo.name} to projects.json + .github/fanout-targets.json so the spine fans out to it.`
|
|
12556
|
+
]);
|
|
12557
|
+
fanoutPrUrl = created.url;
|
|
12558
|
+
}
|
|
12559
|
+
await gh(["pr", "merge", fanoutPrUrl, "--repo", HUB_REPO, "--auto", "--squash"]).catch((e) => {
|
|
12560
|
+
if (!/already/i.test(String(e.message ?? ""))) throw e;
|
|
12561
|
+
});
|
|
12562
|
+
applied.push(`fanout: PR ${fanoutPrUrl} (auto-merge enabled)`);
|
|
12563
|
+
}
|
|
12564
|
+
} catch (e) {
|
|
12565
|
+
return fail(`bootstrap apply: fanout registration failed: ${e.message}`);
|
|
12566
|
+
}
|
|
12567
|
+
}
|
|
12568
|
+
if (o.json) console.log(JSON.stringify({ repo, class: o.class, execute: o.execute, actions, applied, ddbWrites, fanoutPrUrl }, null, 2));
|
|
11450
12569
|
else {
|
|
11451
12570
|
console.log(renderSeedPlan(actions));
|
|
11452
12571
|
if (o.execute) console.log(`
|
|
@@ -11863,7 +12982,7 @@ program2.command("session-start").description("run the SessionStart verbs (rules
|
|
|
11863
12982
|
} catch (e) {
|
|
11864
12983
|
console.error(`[mmi-hook] saga session failed: ${e.message}`);
|
|
11865
12984
|
}
|
|
11866
|
-
spawnDetachedSelf(["docs", "sync", "--quiet"], { spawn:
|
|
12985
|
+
spawnDetachedSelf(["docs", "sync", "--quiet"], { spawn: import_node_child_process7.spawn, execPath: process.execPath, scriptPath: process.argv[1] });
|
|
11867
12986
|
const { parallel, sequential } = buildSessionStartPlan({
|
|
11868
12987
|
rulesSync: (io) => runRulesSync({ quiet: true }, io),
|
|
11869
12988
|
sagaShow: (io) => runSagaShow({ quiet: true }, io),
|