mandrel-platform 0.13.0 → 0.14.2

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.
@@ -0,0 +1,748 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * platform-repair.mjs — scheduled platform-sync repair-PR loop (Story #113).
4
+ *
5
+ * Closes the detect→repair gap. `check-pin-drift.mjs` (Story #67/#107) *detects*
6
+ * cross-consumer pin drift — split pins, release lag, npm lag, and npm/`uses:`
7
+ * surface skew — and renders a dashboard, but drift was only ever *seen*, never
8
+ * *fixed*: `platform-sync.mjs` (MP-14) repairs a consumer but is run by hand.
9
+ * This script is the standing job that joins the two: it reads the detector's
10
+ * verdict and, for every consumer carrying **repairable** drift, clones the
11
+ * consumer, runs `platform-sync` to repair it, and opens (or updates) a single
12
+ * idempotent **repair PR** against that consumer.
13
+ *
14
+ * DESIGN DECISION (settled, Story #113 / roadmap §4.2): auto-open repair PRs
15
+ * rather than escalate pin-drift to a hard gate. A hard gate can red-line a
16
+ * consumer's `main` for drift caused by a *fresh platform release* (the
17
+ * consumer's Renovate hold has simply not fired yet — not the consumer's
18
+ * fault), whereas a repair PR is self-healing and keeps the signal advisory.
19
+ * `pin-drift.yml` is unchanged and stays advisory.
20
+ *
21
+ * REPAIRABLE vs. NOT:
22
+ * - REPAIRABLE → split pin, `uses:` lagging, npm lagging, surface skew. These
23
+ * are exactly the states `platform-sync --ref <latest>` rewrites: it pins
24
+ * every first-party `uses:` to the latest release SHA, reconciles the npm
25
+ * dep is out of scope for the sync (npm is bumped by Renovate), but the
26
+ * workflow-pin + extends + runbook surfaces are repaired in one pass.
27
+ * - NOT REPAIRABLE / SKIPPED → `holding` (inside the Renovate
28
+ * `minimumReleaseAge` window — repairing now races Renovate and would be
29
+ * reverted), `error` (the detector could not read the repo), `unknown`
30
+ * (floating tags / unresolved SHA — no deterministic target), and
31
+ * `current` (nothing to do). Skips are reported, never PR'd.
32
+ *
33
+ * IDEMPOTENCY: one repair PR per consumer, keyed off a stable head branch
34
+ * (`mandrel-platform/pin-repair`). A re-run finds the existing open PR by head
35
+ * branch and force-updates the branch + refreshes the PR body instead of
36
+ * opening a duplicate. When the repaired tree is byte-identical to the existing
37
+ * repair branch, nothing is pushed and the PR is left untouched.
38
+ *
39
+ * CROSS-REPO AUTH (least-privilege): opening a PR on a consumer needs write to
40
+ * that consumer, which the platform's own `GITHUB_TOKEN` does NOT grant. The
41
+ * scheduled workflow injects a fine-grained PAT / GitHub App token
42
+ * (`PIN_REPAIR_TOKEN`) scoped to **Contents: write + Pull requests: write** on
43
+ * the consumer repos ONLY. See docs/runbooks/pin-drift-dashboard.md § "Repair
44
+ * loop token". When the token is absent the run completes read-only: it reports
45
+ * the repairs it *would* open and exits 0 (advisory), never failing the job.
46
+ *
47
+ * Usage:
48
+ * node scripts/platform-repair.mjs # repair all drifting consumers
49
+ * node scripts/platform-repair.mjs --dry-run # plan only; no clone, no push, no PR
50
+ * node scripts/platform-repair.mjs --config <path> # alternate consumer registry
51
+ * node scripts/platform-repair.mjs --ref <release-ref> # pin target (default: latest release tag)
52
+ * node scripts/platform-repair.mjs --json # machine-readable envelope
53
+ * node scripts/platform-repair.mjs --dashboard-run-url <url> # link in the PR body
54
+ *
55
+ * Exit codes:
56
+ * 0 — report emitted (advisory by default, even when repairs were opened or
57
+ * would-be-opened). This job self-heals; it does not gate.
58
+ * 1 — only on a fatal error (bad config, gh/git failure during a real
59
+ * mutation the operator must see).
60
+ *
61
+ * GitHub Actions: when GITHUB_STEP_SUMMARY is set, the human-readable report is
62
+ * appended there so it renders on the job summary page.
63
+ */
64
+
65
+ import { execFileSync } from "node:child_process";
66
+ import { appendFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
67
+ import { tmpdir } from "node:os";
68
+ import { dirname, join, resolve } from "node:path";
69
+ import { fileURLToPath } from "node:url";
70
+
71
+ import { buildReport, defaultGhRunner, isFullSha } from "./check-pin-drift.mjs";
72
+
73
+ const __dirname = dirname(fileURLToPath(import.meta.url));
74
+
75
+ // The stable head branch every repair PR is opened from. Keying idempotency off
76
+ // a fixed branch name (rather than a generated one) is what guarantees a re-run
77
+ // updates the existing PR instead of opening a duplicate.
78
+ export const REPAIR_BRANCH = "mandrel-platform/pin-repair";
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Arg parsing
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /**
85
+ * @param {string[]} argv
86
+ * @returns {{
87
+ * config: string,
88
+ * ref: string | null,
89
+ * dryRun: boolean,
90
+ * json: boolean,
91
+ * dashboardRunUrl: string | null,
92
+ * }}
93
+ */
94
+ export function parseArgv(argv = []) {
95
+ let config = "scripts/pin-drift-consumers.json";
96
+ let ref = null;
97
+ let dryRun = false;
98
+ let json = false;
99
+ let dashboardRunUrl = null;
100
+ for (let i = 0; i < argv.length; i += 1) {
101
+ const a = argv[i];
102
+ if (a === "--config" && argv[i + 1] && !argv[i + 1].startsWith("--")) {
103
+ config = argv[++i];
104
+ } else if (a === "--ref" && argv[i + 1] && !argv[i + 1].startsWith("--")) {
105
+ ref = argv[++i];
106
+ } else if (a === "--dashboard-run-url" && argv[i + 1]) {
107
+ dashboardRunUrl = argv[++i];
108
+ } else if (a === "--dry-run") {
109
+ dryRun = true;
110
+ } else if (a === "--json") {
111
+ json = true;
112
+ }
113
+ }
114
+ return { config, ref, dryRun, json, dashboardRunUrl };
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Pure helpers — repairability classification + PR-body rendering
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /**
122
+ * Decide whether a per-consumer detector result is REPAIRABLE by the
123
+ * platform-sync loop. Repairable = the consumer has real drift the sync can
124
+ * deterministically fix by pinning to the latest release SHA: a split pin, a
125
+ * lagging `uses:` pin, a lagging npm dep, or a surface skew. NOT repairable:
126
+ *
127
+ * - `holding` → inside the Renovate minimumReleaseAge window; repairing now
128
+ * races Renovate and would be reverted. Defer.
129
+ * - `error` → the detector could not read the repo; nothing to repair.
130
+ * - no drift → `drift` is false (current / no-pins / npm-absent-only).
131
+ * - unknown → the consumer pins ONLY floating tags / unresolved SHAs, so
132
+ * there is no deterministic SHA target to rewrite to. Surface
133
+ * it as a skip, not a silent no-op.
134
+ *
135
+ * @param {{
136
+ * error?: string,
137
+ * drift?: boolean,
138
+ * holding?: boolean,
139
+ * verdict: { lagState: string, splitPinned?: boolean, pinnedSha?: string | null },
140
+ * npm?: { npmState?: string },
141
+ * surfaceSkew?: boolean,
142
+ * }} result
143
+ * @returns {{ repairable: boolean, reason: string }}
144
+ */
145
+ export function classifyRepairability(result) {
146
+ if (result.error) {
147
+ return { repairable: false, reason: "error" };
148
+ }
149
+ if (result.holding === true) {
150
+ return { repairable: false, reason: "holding" };
151
+ }
152
+ if (result.drift !== true) {
153
+ return { repairable: false, reason: "no-drift" };
154
+ }
155
+ const v = result.verdict || {};
156
+ // A consumer pinning ONLY floating refs (no resolvable SHA) and NOT split has
157
+ // no deterministic pin to rewrite — `unknown` lag with no split. Split pins
158
+ // are always repairable (sync collapses them to the single latest SHA).
159
+ if (
160
+ !v.splitPinned &&
161
+ v.lagState === "unknown" &&
162
+ (result.npm?.npmState ?? "absent") !== "lagging" &&
163
+ result.surfaceSkew !== true
164
+ ) {
165
+ return { repairable: false, reason: "unknown-ref" };
166
+ }
167
+ return { repairable: true, reason: "drift" };
168
+ }
169
+
170
+ /**
171
+ * Human-readable one-line drift descriptor for a consumer, for the PR body /
172
+ * report. Names the specific drift classes the detector found.
173
+ *
174
+ * @param {{
175
+ * verdict: { lagState: string, splitPinned?: boolean, distinctRefs?: string[], pinnedSha?: string | null },
176
+ * npm?: { npmState?: string, version?: string | null },
177
+ * surfaceSkew?: boolean,
178
+ * }} result
179
+ * @returns {string[]} one descriptor per detected drift class.
180
+ */
181
+ export function describeDrift(result) {
182
+ const out = [];
183
+ const v = result.verdict || {};
184
+ if (v.splitPinned) {
185
+ const n = (v.distinctRefs || []).length;
186
+ out.push(`**Split pin** — ${n} distinct platform refs across workflow chains.`);
187
+ } else if (v.lagState === "lagging") {
188
+ const short = v.pinnedSha ? v.pinnedSha.slice(0, 7) : "?";
189
+ out.push(`**Release lag** — workflow \`uses:\` pins \`${short}\`, behind the latest release.`);
190
+ }
191
+ if (result.surfaceSkew === true) {
192
+ out.push(
193
+ `**Surface skew** — the npm \`mandrel-platform\` dependency (\`${result.npm?.version ?? "?"}\`) and the workflow \`uses:\` pins are on different releases.`,
194
+ );
195
+ } else if (result.npm?.npmState === "lagging") {
196
+ out.push(`**npm lag** — \`mandrel-platform@${result.npm.version}\` is behind the latest release.`);
197
+ }
198
+ return out;
199
+ }
200
+
201
+ /**
202
+ * Render the repair-PR body for one consumer. Explains what drifted (Acceptance
203
+ * criterion 3) and links the pin-drift dashboard run that detected it. The body
204
+ * is deterministic for a given (drift, ref, dashboard URL) so re-running the
205
+ * loop on an unchanged drift state produces an identical body (idempotent
206
+ * update is a no-op diff).
207
+ *
208
+ * @param {{
209
+ * name: string,
210
+ * repo: string,
211
+ * result: object,
212
+ * ref: string,
213
+ * targetSha: string | null,
214
+ * dashboardRunUrl: string | null,
215
+ * }} args
216
+ * @returns {string}
217
+ */
218
+ export function renderRepairPrBody({ name, repo, result, ref, targetSha, dashboardRunUrl }) {
219
+ const drift = describeDrift(result);
220
+ const out = [];
221
+ out.push("## 🔧 Automated mandrel-platform pin-drift repair");
222
+ out.push("");
223
+ out.push(
224
+ `The cross-consumer **pin-drift dashboard** detected that \`${name}\` (\`${repo}\`) has drifted ` +
225
+ `from the latest mandrel-platform release. This PR was opened automatically by the ` +
226
+ `\`platform-sync\` repair loop to bring it back in sync.`,
227
+ );
228
+ out.push("");
229
+ out.push("### What drifted");
230
+ out.push("");
231
+ if (drift.length === 0) {
232
+ out.push("- (drift detail unavailable)");
233
+ } else {
234
+ for (const d of drift) out.push(`- ${d}`);
235
+ }
236
+ out.push("");
237
+ out.push("### What this PR does");
238
+ out.push("");
239
+ const shaLabel = targetSha ? ` (\`${targetSha.slice(0, 7)}\`)` : "";
240
+ out.push(
241
+ `Runs \`platform-sync --ref ${ref}\`${shaLabel}: rewrites every first-party ` +
242
+ "`uses:` pin to the single latest release SHA, materializes any missing runbook " +
243
+ "reference stubs, and reconciles the Renovate / tsconfig `extends` chains to the " +
244
+ "shared SSOT.",
245
+ );
246
+ out.push("");
247
+ out.push("### Detection source");
248
+ out.push("");
249
+ if (dashboardRunUrl) {
250
+ out.push(`- Pin-drift dashboard run: ${dashboardRunUrl}`);
251
+ } else {
252
+ out.push(
253
+ "- Pin-drift dashboard (`.github/workflows/pin-drift.yml` in `dsj1984/mandrel-platform`).",
254
+ );
255
+ }
256
+ out.push("");
257
+ out.push("---");
258
+ out.push("");
259
+ out.push(
260
+ "> This PR still goes through this repo's required CI before it can merge — the " +
261
+ "repair loop opens it, it does **not** auto-merge it. `pin-drift.yml` remains " +
262
+ "advisory; it does not gate `main`.",
263
+ );
264
+ out.push("");
265
+ return out.join("\n");
266
+ }
267
+
268
+ const PR_TITLE = "chore: repair mandrel-platform pin drift";
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Git / gh / sync seams (injectable for tests)
272
+ // ---------------------------------------------------------------------------
273
+
274
+ /**
275
+ * Default git runner. Shells out to `git` in `cwd`.
276
+ * @param {string[]} args
277
+ * @param {{ cwd?: string }} [opts]
278
+ * @returns {string}
279
+ */
280
+ export function defaultGitRunner(args, { cwd } = {}) {
281
+ return execFileSync("git", args, {
282
+ cwd,
283
+ encoding: "utf-8",
284
+ maxBuffer: 32 * 1024 * 1024,
285
+ stdio: ["ignore", "pipe", "pipe"],
286
+ });
287
+ }
288
+
289
+ /**
290
+ * Default platform-sync runner. Invokes platform-sync.mjs against a checked-out
291
+ * consumer dir and parses its `--json` envelope.
292
+ * @param {{ consumer: string, ref: string, sha?: string | null, templates?: string | null }} opts
293
+ * @returns {object} the platform-sync result envelope.
294
+ */
295
+ export function defaultSyncRunner({ consumer, ref, sha, templates }) {
296
+ const cli = join(__dirname, "platform-sync.mjs");
297
+ const args = [cli, "--ref", ref, "--consumer", consumer, "--json"];
298
+ if (sha) args.push("--sha", sha);
299
+ if (templates) args.push("--templates", templates);
300
+ const raw = execFileSync("node", args, { encoding: "utf-8", maxBuffer: 32 * 1024 * 1024 });
301
+ return JSON.parse(raw);
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // Per-consumer repair
306
+ // ---------------------------------------------------------------------------
307
+
308
+ /**
309
+ * Find an existing open repair PR on a consumer (head = REPAIR_BRANCH). Returns
310
+ * the PR number or null. Idempotency hinges on this probe.
311
+ *
312
+ * @param {string} repo "owner/name".
313
+ * @param {(args: string[]) => string} runGh
314
+ * @returns {number | null}
315
+ */
316
+ export function findOpenRepairPr(repo, runGh) {
317
+ let out;
318
+ try {
319
+ out = runGh([
320
+ "pr",
321
+ "list",
322
+ "--repo",
323
+ repo,
324
+ "--head",
325
+ REPAIR_BRANCH,
326
+ "--state",
327
+ "open",
328
+ "--json",
329
+ "number",
330
+ ]);
331
+ } catch {
332
+ return null;
333
+ }
334
+ let arr;
335
+ try {
336
+ arr = JSON.parse(out);
337
+ } catch {
338
+ return null;
339
+ }
340
+ if (Array.isArray(arr) && arr.length > 0 && Number.isInteger(arr[0].number)) {
341
+ return arr[0].number;
342
+ }
343
+ return null;
344
+ }
345
+
346
+ /**
347
+ * Repair one drifting consumer end-to-end: clone, run platform-sync, and open
348
+ * or update the idempotent repair PR. Pure orchestration over injected seams,
349
+ * so the whole flow is unit-testable offline.
350
+ *
351
+ * @param {{
352
+ * consumer: { name: string, repo: string, branch?: string },
353
+ * result: object,
354
+ * ref: string,
355
+ * targetSha: string | null,
356
+ * dashboardRunUrl: string | null,
357
+ * dryRun: boolean,
358
+ * token: string | null,
359
+ * templates: string | null,
360
+ * runGh: (args: string[]) => string,
361
+ * runGit: (args: string[], opts?: { cwd?: string }) => string,
362
+ * runSync: (opts: object) => object,
363
+ * workRoot: string,
364
+ * }} args
365
+ * @returns {{ name: string, repo: string, action: string, prNumber: number | null, changed: boolean, detail?: string }}
366
+ */
367
+ export function repairConsumer({
368
+ consumer,
369
+ result,
370
+ ref,
371
+ targetSha,
372
+ dashboardRunUrl,
373
+ dryRun,
374
+ token,
375
+ templates,
376
+ runGh,
377
+ runGit,
378
+ runSync,
379
+ workRoot,
380
+ }) {
381
+ const { name, repo } = consumer;
382
+ const body = renderRepairPrBody({ name, repo, result, ref, targetSha, dashboardRunUrl });
383
+
384
+ if (dryRun) {
385
+ return { name, repo, action: "planned", prNumber: null, changed: true };
386
+ }
387
+ if (!token) {
388
+ // No write token: report the repair we WOULD open, exit clean (advisory).
389
+ return {
390
+ name,
391
+ repo,
392
+ action: "skipped-no-token",
393
+ prNumber: null,
394
+ changed: false,
395
+ detail: "PIN_REPAIR_TOKEN absent — repair planned but not opened (run is read-only).",
396
+ };
397
+ }
398
+
399
+ const checkoutDir = join(workRoot, name);
400
+ const authedUrl = `https://x-access-token:${token}@github.com/${repo}.git`;
401
+
402
+ // 1. Shallow-clone the consumer's default branch (or pinned branch).
403
+ const cloneArgs = ["clone", "--depth", "1"];
404
+ if (consumer.branch) cloneArgs.push("--branch", consumer.branch);
405
+ cloneArgs.push(authedUrl, checkoutDir);
406
+ runGit(cloneArgs);
407
+
408
+ // 2. Run platform-sync against the checkout.
409
+ const sync = runSync({ consumer: checkoutDir, ref, sha: targetSha, templates });
410
+ if (!sync.changed) {
411
+ // Detector said drift, but the sync is a no-op (e.g. floating-tag-only pin
412
+ // the sync does not rewrite). Nothing to PR.
413
+ return { name, repo, action: "noop", prNumber: null, changed: false };
414
+ }
415
+
416
+ // 3. Stage + commit on the stable repair branch.
417
+ runGit(["checkout", "-B", REPAIR_BRANCH], { cwd: checkoutDir });
418
+ runGit(["add", "-A"], { cwd: checkoutDir });
419
+ // No-op guard: if the tree matches the existing remote repair branch there is
420
+ // nothing to commit. `git commit` exits non-zero on an empty index; treat
421
+ // that as "already in sync on the repair branch".
422
+ let committed = true;
423
+ try {
424
+ runGit(
425
+ [
426
+ "-c",
427
+ "user.name=mandrel-platform[bot]",
428
+ "-c",
429
+ "user.email=mandrel-platform-bot@users.noreply.github.com",
430
+ "commit",
431
+ "-m",
432
+ `${PR_TITLE} (${ref})`,
433
+ ],
434
+ { cwd: checkoutDir },
435
+ );
436
+ } catch {
437
+ committed = false;
438
+ }
439
+ if (!committed) {
440
+ return { name, repo, action: "noop", prNumber: null, changed: false };
441
+ }
442
+
443
+ // 4. Force-push the repair branch (idempotent: overwrites a stale repair
444
+ // branch from a prior run with the current repair state).
445
+ runGit(["push", "--force", authedUrl, `HEAD:${REPAIR_BRANCH}`], { cwd: checkoutDir });
446
+
447
+ // 5. Open or update the PR.
448
+ const existing = findOpenRepairPr(repo, runGh);
449
+ if (existing !== null) {
450
+ runGh(["pr", "edit", String(existing), "--repo", repo, "--body", body, "--title", PR_TITLE]);
451
+ return { name, repo, action: "updated", prNumber: existing, changed: true };
452
+ }
453
+ const base = consumer.branch || defaultBranchOf(repo, runGh);
454
+ const createOut = runGh([
455
+ "pr",
456
+ "create",
457
+ "--repo",
458
+ repo,
459
+ "--head",
460
+ REPAIR_BRANCH,
461
+ "--base",
462
+ base,
463
+ "--title",
464
+ PR_TITLE,
465
+ "--body",
466
+ body,
467
+ ]);
468
+ const prNumber = parsePrNumberFromUrl(createOut);
469
+ return { name, repo, action: "opened", prNumber, changed: true };
470
+ }
471
+
472
+ /**
473
+ * Resolve a consumer's default branch via `gh`. Falls back to "main".
474
+ * @param {string} repo
475
+ * @param {(args: string[]) => string} runGh
476
+ * @returns {string}
477
+ */
478
+ export function defaultBranchOf(repo, runGh) {
479
+ try {
480
+ const out = runGh(["repo", "view", repo, "--json", "defaultBranchRef", "-q", ".defaultBranchRef.name"]);
481
+ const name = out.trim();
482
+ return name || "main";
483
+ } catch {
484
+ return "main";
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Extract the PR number from a `gh pr create` stdout (it prints the PR URL).
490
+ * @param {string} out
491
+ * @returns {number | null}
492
+ */
493
+ export function parsePrNumberFromUrl(out) {
494
+ const m = /\/pull\/(\d+)\s*$/.exec((out || "").trim());
495
+ return m ? Number(m[1]) : null;
496
+ }
497
+
498
+ // ---------------------------------------------------------------------------
499
+ // Report rendering
500
+ // ---------------------------------------------------------------------------
501
+
502
+ const ACTION_LABEL = {
503
+ opened: "🟢 PR opened",
504
+ updated: "🔄 PR updated",
505
+ planned: "📋 would open (dry-run)",
506
+ "skipped-no-token": "🔑 no token — would open",
507
+ noop: "➖ sync no-op",
508
+ holding: "⏳ holding (deferred)",
509
+ error: "⚠️ detector error",
510
+ "no-drift": "✅ no drift",
511
+ "unknown-ref": "❔ floating ref — manual",
512
+ };
513
+
514
+ /**
515
+ * Render the human-readable repair report.
516
+ * @param {{ ref: string, targetSha: string | null, dryRun: boolean, hasToken: boolean, rows: Array<object> }} report
517
+ * @returns {string}
518
+ */
519
+ export function renderRepairReport({ ref, targetSha, dryRun, hasToken, rows }) {
520
+ const out = [];
521
+ out.push("## platform-sync repair loop");
522
+ out.push("");
523
+ const shaLabel = targetSha ? ` (\`${targetSha.slice(0, 7)}\`)` : "";
524
+ out.push(`Target ref: \`${ref}\`${shaLabel}`);
525
+ out.push(`Mode: ${dryRun ? "dry-run (no mutations)" : hasToken ? "live" : "read-only (no PIN_REPAIR_TOKEN)"}`);
526
+ out.push("");
527
+ out.push("| Consumer | Repo | Outcome | PR |");
528
+ out.push("| -------- | ---- | ------- | -- |");
529
+ for (const r of rows) {
530
+ const label = ACTION_LABEL[r.action] ?? r.action;
531
+ const pr = r.prNumber ? `#${r.prNumber}` : "—";
532
+ out.push(`| \`${r.name}\` | \`${r.repo}\` | ${label} | ${pr} |`);
533
+ }
534
+ out.push("");
535
+ const repaired = rows.filter((r) => r.action === "opened" || r.action === "updated");
536
+ const wouldRepair = rows.filter(
537
+ (r) => r.action === "planned" || r.action === "skipped-no-token",
538
+ );
539
+ if (repaired.length > 0) {
540
+ out.push(`### Repaired (${repaired.length})`);
541
+ out.push("");
542
+ for (const r of repaired) {
543
+ out.push(`- \`${r.name}\` — ${ACTION_LABEL[r.action]}${r.prNumber ? ` (#${r.prNumber})` : ""}`);
544
+ }
545
+ out.push("");
546
+ }
547
+ if (wouldRepair.length > 0) {
548
+ out.push(`### Would repair (${wouldRepair.length})`);
549
+ out.push("");
550
+ for (const r of wouldRepair) {
551
+ out.push(`- \`${r.name}\`${r.detail ? ` — ${r.detail}` : ""}`);
552
+ }
553
+ out.push("");
554
+ }
555
+ if (repaired.length === 0 && wouldRepair.length === 0) {
556
+ out.push("### ✅ No repairable drift — every consumer is in sync (or holding).");
557
+ out.push("");
558
+ }
559
+ return out.join("\n");
560
+ }
561
+
562
+ // ---------------------------------------------------------------------------
563
+ // Orchestration
564
+ // ---------------------------------------------------------------------------
565
+
566
+ /**
567
+ * Build the repair plan + execute it.
568
+ *
569
+ * @param {{
570
+ * config: object,
571
+ * ref: string | null,
572
+ * dryRun: boolean,
573
+ * dashboardRunUrl: string | null,
574
+ * token: string | null,
575
+ * templates: string | null,
576
+ * runGh?: (args: string[]) => string,
577
+ * runGit?: (args: string[], opts?: { cwd?: string }) => string,
578
+ * runSync?: (opts: object) => object,
579
+ * workRoot?: string,
580
+ * nowMs?: number,
581
+ * }} opts
582
+ * @returns {{ ref: string, targetSha: string | null, dryRun: boolean, hasToken: boolean, rows: Array<object> }}
583
+ */
584
+ export function runRepair({
585
+ config,
586
+ ref,
587
+ dryRun,
588
+ dashboardRunUrl,
589
+ token,
590
+ templates,
591
+ runGh = defaultGhRunner,
592
+ runGit = defaultGitRunner,
593
+ runSync = defaultSyncRunner,
594
+ workRoot,
595
+ nowMs = Date.now(),
596
+ }) {
597
+ // Reuse the detector to classify every consumer (single SSOT for drift).
598
+ const report = buildReport(config, runGh, nowMs);
599
+ const latestTag = report.latestRelease?.tag ?? null;
600
+ const targetSha = report.latestRelease?.sha ?? null;
601
+ // The pin target is the latest release tag (so the `# <ref>` annotation reads
602
+ // as a release), pinned by its resolved SHA. An explicit --ref overrides.
603
+ const effectiveRef = ref || latestTag || "main";
604
+
605
+ const rows = [];
606
+ // Only allocate a temp workdir when we'll actually clone.
607
+ const needWork = !dryRun && token;
608
+ const tmpRoot = needWork
609
+ ? workRoot || mkdtempSync(join(tmpdir(), "platform-repair-"))
610
+ : null;
611
+ try {
612
+ for (const r of report.results) {
613
+ const consumer = { name: r.name, repo: r.repo, branch: r.branch !== "?" ? r.branch : undefined };
614
+ const { repairable, reason } = classifyRepairability(r);
615
+ if (!repairable) {
616
+ rows.push({ name: r.name, repo: r.repo, action: reason, prNumber: null, changed: false });
617
+ continue;
618
+ }
619
+ const row = repairConsumer({
620
+ consumer,
621
+ result: r,
622
+ ref: effectiveRef,
623
+ targetSha: isFullSha(targetSha || "") ? targetSha : null,
624
+ dashboardRunUrl,
625
+ dryRun,
626
+ token,
627
+ templates,
628
+ runGh,
629
+ runGit,
630
+ runSync,
631
+ workRoot: tmpRoot,
632
+ });
633
+ rows.push(row);
634
+ }
635
+ } finally {
636
+ if (tmpRoot && !workRoot) {
637
+ try {
638
+ rmSync(tmpRoot, { recursive: true, force: true });
639
+ } catch {
640
+ // best-effort cleanup
641
+ }
642
+ }
643
+ }
644
+
645
+ return { ref: effectiveRef, targetSha, dryRun, hasToken: Boolean(token), rows };
646
+ }
647
+
648
+ // ---------------------------------------------------------------------------
649
+ // CLI entry
650
+ // ---------------------------------------------------------------------------
651
+
652
+ /**
653
+ * @param {{
654
+ * argv?: string[],
655
+ * cwd?: string,
656
+ * stdout?: { write: (s: string) => void },
657
+ * stderr?: { write: (s: string) => void },
658
+ * env?: Record<string, string | undefined>,
659
+ * runGh?: (args: string[]) => string,
660
+ * runGit?: (args: string[], opts?: { cwd?: string }) => string,
661
+ * runSync?: (opts: object) => object,
662
+ * summaryPath?: string | undefined,
663
+ * nowMs?: number,
664
+ * }} [opts]
665
+ * @returns {number} exit code
666
+ */
667
+ export function runCli({
668
+ argv = process.argv.slice(2),
669
+ cwd = process.cwd(),
670
+ stdout = process.stdout,
671
+ stderr = process.stderr,
672
+ env = process.env,
673
+ runGh = defaultGhRunner,
674
+ runGit = defaultGitRunner,
675
+ runSync = defaultSyncRunner,
676
+ summaryPath = process.env.GITHUB_STEP_SUMMARY,
677
+ nowMs = Date.now(),
678
+ } = {}) {
679
+ const { config: configRel, ref, dryRun, json, dashboardRunUrl } = parseArgv(argv);
680
+ const configPath = resolve(cwd, configRel);
681
+
682
+ let config;
683
+ try {
684
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
685
+ } catch (err) {
686
+ stderr.write(
687
+ `[platform-repair] ❌ failed to read config ${configPath}: ${err instanceof Error ? err.message : String(err)}\n`,
688
+ );
689
+ return 1;
690
+ }
691
+ if (!config.platformRepo || !Array.isArray(config.consumers)) {
692
+ stderr.write("[platform-repair] ❌ config must define { platformRepo, consumers: [] }\n");
693
+ return 1;
694
+ }
695
+
696
+ const token = env.PIN_REPAIR_TOKEN || null;
697
+ // The dashboard run URL defaults to the live Actions run when invoked in CI.
698
+ const runUrl =
699
+ dashboardRunUrl ||
700
+ (env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID
701
+ ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`
702
+ : null);
703
+
704
+ let report;
705
+ try {
706
+ report = runRepair({
707
+ config,
708
+ ref,
709
+ dryRun,
710
+ dashboardRunUrl: runUrl,
711
+ token,
712
+ templates: null,
713
+ runGh,
714
+ runGit,
715
+ runSync,
716
+ nowMs,
717
+ });
718
+ } catch (err) {
719
+ stderr.write(
720
+ `[platform-repair] ❌ ${err instanceof Error ? err.message : String(err)}\n`,
721
+ );
722
+ return 1;
723
+ }
724
+
725
+ if (json) {
726
+ stdout.write(`${JSON.stringify({ kind: "platform-repair-report", ...report }, null, 2)}\n`);
727
+ } else {
728
+ const text = renderRepairReport(report);
729
+ stdout.write(`${text}\n`);
730
+ if (summaryPath) {
731
+ try {
732
+ appendFileSync(summaryPath, `${text}\n`);
733
+ } catch (err) {
734
+ stderr.write(
735
+ `[platform-repair] ⚠ could not write job summary: ${err instanceof Error ? err.message : String(err)}\n`,
736
+ );
737
+ }
738
+ }
739
+ }
740
+ return 0;
741
+ }
742
+
743
+ // Direct-invocation guard (matches the repo's other scripts/*.mjs entry style).
744
+ const invokedDirectly =
745
+ process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
746
+ if (invokedDirectly) {
747
+ process.exit(runCli());
748
+ }