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.
- package/README.md +203 -7
- package/config/dependency-cruiser.base.json +121 -0
- package/config/edge-security/allowlist.mjs +138 -0
- package/config/edge-security/cors-astro.mjs +131 -0
- package/config/edge-security/cors-hono.mjs +111 -0
- package/config/edge-security/index.mjs +34 -0
- package/config/edge-security/rate-limit.mjs +219 -0
- package/config/edge-security/security-headers.mjs +148 -0
- package/config/knip.base.json +7 -0
- package/config/lighthouse.base.json +27 -0
- package/config/size-limit.base.json +6 -0
- package/config/stryker.base.json +15 -0
- package/package.json +23 -2
- package/scripts/check-action-pins.mjs +344 -0
- package/scripts/check-action-pins.test.mjs +240 -0
- package/scripts/check-coverage-threshold.mjs +300 -0
- package/scripts/check-coverage-threshold.test.mjs +350 -0
- package/scripts/check-destructive-migration.mjs +313 -0
- package/scripts/check-destructive-migration.test.mjs +183 -0
- package/scripts/check-pin-drift.mjs +361 -17
- package/scripts/check-pin-drift.test.mjs +344 -1
- package/scripts/edge-security.test.mjs +300 -0
- package/scripts/pin-drift-consumers.json +2 -0
- package/scripts/platform-repair.mjs +748 -0
- package/scripts/platform-repair.test.mjs +458 -0
- package/scripts/platform-sync.mjs +0 -0
- package/scripts/platform-sync.test.mjs +0 -0
|
@@ -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
|
+
}
|