mandrel-platform 0.20.1 → 0.24.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/package.json +1 -1
- package/scripts/check-ci-required-aggregator.test.mjs +227 -0
- package/scripts/check-coverage-threshold.test.mjs +56 -51
- package/scripts/check-destructive-migration.mjs +68 -6
- package/scripts/check-destructive-migration.test.mjs +146 -0
- package/scripts/check-runner-health.mjs +469 -0
- package/scripts/check-runner-health.test.mjs +389 -0
- package/scripts/deploy-boot-smoke.mjs +364 -0
- package/scripts/deploy-boot-smoke.test.mjs +381 -0
- package/scripts/deploy-worker-secrets.mjs +190 -0
- package/scripts/deploy-worker-secrets.test.mjs +136 -0
- package/scripts/platform-sync.test.mjs +36 -9
- package/scripts/runner-fleet-consumers.json +20 -0
- package/templates/runbooks/README.md +13 -0
- package/templates/runbooks/runner-fleet-health.md +150 -0
- package/templates/runbooks/runner-provisioning.md +187 -0
- package/templates/runner/.env.example +45 -0
- package/templates/runner/job-cleanup.sh +97 -0
- package/templates/workflows/deploy-staging-run.yml +77 -0
- package/templates/workflows/deploy-staging.yml +67 -62
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-runner-health.mjs
|
|
4
|
+
*
|
|
5
|
+
* Scheduled runner-fleet health monitor for mandrel-platform (Story #258).
|
|
6
|
+
*
|
|
7
|
+
* All nine self-hosted runners across the fleet (domio, athportal, swarm-os)
|
|
8
|
+
* are co-resident on ONE operator Mac (2026-07-03 runner audit, repo-ops
|
|
9
|
+
* matrix §1a). If that host sleeps, reboots for an OS update, fills its disk,
|
|
10
|
+
* or a launchd service dies, every consumer's CI and deploy-trigger jobs
|
|
11
|
+
* silently queue ("waiting for a runner") with no alert — nothing watches the
|
|
12
|
+
* fleet today. This script is the standing check that closes that gap.
|
|
13
|
+
*
|
|
14
|
+
* Modeled on `check-pin-drift.mjs` (same GitHub-hosted, config-driven,
|
|
15
|
+
* `GITHUB_STEP_SUMMARY`-rendering shape via the `scripts/lib/gh-json.mjs`
|
|
16
|
+
* seam) with two differences: it must run FREQUENTLY (not weekly) and it must
|
|
17
|
+
* ALERT (not just render a dashboard), because the whole point is catching an
|
|
18
|
+
* offline fleet fast.
|
|
19
|
+
*
|
|
20
|
+
* For each repo in `scripts/runner-fleet-consumers.json` it:
|
|
21
|
+
* 1. Calls `GET /repos/{owner}/{repo}/actions/runners` and flags any runner
|
|
22
|
+
* whose `status !== "online"`.
|
|
23
|
+
* 2. Flags a count shortfall: fewer online runners matching the expected
|
|
24
|
+
* `labels` set than `expectedCount`.
|
|
25
|
+
* 3. Flags queue-staleness: a `queued`/`waiting` workflow run older than
|
|
26
|
+
* `staleQueuedMinutes` with no online runner matching its labels
|
|
27
|
+
* (a wedged fleet accepts jobs into the queue but never claims them).
|
|
28
|
+
* 4. Renders a per-repo dashboard to `GITHUB_STEP_SUMMARY`.
|
|
29
|
+
*
|
|
30
|
+
* Alerting (no external dependency, on-ethos default): the CLI exits
|
|
31
|
+
* non-zero when any repo is unhealthy, so GitHub's native failed-workflow
|
|
32
|
+
* notification fires. That is the only alert channel — no tracking issues
|
|
33
|
+
* are filed.
|
|
34
|
+
*
|
|
35
|
+
* GitHub access is via the `gh` CLI (`gh api`), through the same injectable
|
|
36
|
+
* `runGh` seam `check-pin-drift.mjs` uses (`scripts/lib/gh-json.mjs`), so the
|
|
37
|
+
* whole pipeline is exercised offline in tests with canned responses.
|
|
38
|
+
*
|
|
39
|
+
* Usage:
|
|
40
|
+
* node scripts/check-runner-health.mjs
|
|
41
|
+
* node scripts/check-runner-health.mjs --config scripts/runner-fleet-consumers.json
|
|
42
|
+
* node scripts/check-runner-health.mjs --json # machine-readable envelope
|
|
43
|
+
*
|
|
44
|
+
* Exit codes:
|
|
45
|
+
* 0 — every repo healthy.
|
|
46
|
+
* 1 — at least one repo unhealthy (offline runner, count shortfall, stale
|
|
47
|
+
* queued run, or a fetch error), OR a fatal error (bad config).
|
|
48
|
+
*
|
|
49
|
+
* GitHub Actions: when GITHUB_STEP_SUMMARY is set, the human-readable report
|
|
50
|
+
* is also appended there so it renders on the job summary page.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { readFileSync, appendFileSync } from "node:fs";
|
|
54
|
+
import { resolve } from "node:path";
|
|
55
|
+
|
|
56
|
+
import { defaultGhRunner, ghApiJson, isNotFound } from "./lib/gh-json.mjs";
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Arg parsing
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string[]} argv
|
|
64
|
+
* @returns {{ config: string, json: boolean }}
|
|
65
|
+
*/
|
|
66
|
+
export function parseArgv(argv = []) {
|
|
67
|
+
let config = "scripts/runner-fleet-consumers.json";
|
|
68
|
+
let json = false;
|
|
69
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
70
|
+
const a = argv[i];
|
|
71
|
+
if (a === "--config") {
|
|
72
|
+
const next = argv[i + 1];
|
|
73
|
+
if (next && !next.startsWith("--")) {
|
|
74
|
+
config = next;
|
|
75
|
+
i += 1;
|
|
76
|
+
}
|
|
77
|
+
} else if (a === "--json") {
|
|
78
|
+
json = true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { config, json };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Pure helpers (exported for unit-style probing without GitHub access)
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Does a runner's label set satisfy the expected label roster? A runner
|
|
90
|
+
* "matches" when every expected label is present among the runner's labels
|
|
91
|
+
* (order-independent, extra labels on the runner are fine).
|
|
92
|
+
*
|
|
93
|
+
* @param {string[]} runnerLabels
|
|
94
|
+
* @param {string[]} expectedLabels
|
|
95
|
+
* @returns {boolean}
|
|
96
|
+
*/
|
|
97
|
+
export function runnerMatchesLabels(runnerLabels, expectedLabels) {
|
|
98
|
+
const have = new Set((runnerLabels || []).map((l) => l.toLowerCase()));
|
|
99
|
+
return (expectedLabels || []).every((l) => have.has(l.toLowerCase()));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Classify one repo's live runner list against its expected roster.
|
|
104
|
+
*
|
|
105
|
+
* @param {Array<{ id: number, name: string, status: string, busy?: boolean, labels: Array<{ name: string }> }>} runners
|
|
106
|
+
* @param {{ expectedCount: number, labels: string[] }} expected
|
|
107
|
+
* @returns {{
|
|
108
|
+
* total: number,
|
|
109
|
+
* online: number,
|
|
110
|
+
* offline: Array<{ id: number, name: string, status: string }>,
|
|
111
|
+
* matchingOnline: number,
|
|
112
|
+
* shortfall: number,
|
|
113
|
+
* hasShortfall: boolean,
|
|
114
|
+
* hasOffline: boolean,
|
|
115
|
+
* }}
|
|
116
|
+
*/
|
|
117
|
+
export function classifyRunners(runners, expected) {
|
|
118
|
+
const list = Array.isArray(runners) ? runners : [];
|
|
119
|
+
const offline = list
|
|
120
|
+
.filter((r) => r.status !== "online")
|
|
121
|
+
.map((r) => ({ id: r.id, name: r.name, status: r.status }));
|
|
122
|
+
const online = list.filter((r) => r.status === "online");
|
|
123
|
+
const matchingOnline = online.filter((r) =>
|
|
124
|
+
runnerMatchesLabels(
|
|
125
|
+
(r.labels || []).map((l) => (typeof l === "string" ? l : l.name)),
|
|
126
|
+
expected.labels,
|
|
127
|
+
),
|
|
128
|
+
).length;
|
|
129
|
+
const expectedCount =
|
|
130
|
+
typeof expected.expectedCount === "number" ? expected.expectedCount : 0;
|
|
131
|
+
const shortfall = Math.max(0, expectedCount - matchingOnline);
|
|
132
|
+
return {
|
|
133
|
+
total: list.length,
|
|
134
|
+
online: online.length,
|
|
135
|
+
offline,
|
|
136
|
+
matchingOnline,
|
|
137
|
+
shortfall,
|
|
138
|
+
hasShortfall: shortfall > 0,
|
|
139
|
+
hasOffline: offline.length > 0,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Is a workflow run "stuck in queue"? True when its status is `queued` or
|
|
145
|
+
* `waiting`, it is older than `staleMinutes`, and no online runner in
|
|
146
|
+
* `onlineLabelSets` matches every one of its `labels` (i.e. nothing could ever
|
|
147
|
+
* pick it up).
|
|
148
|
+
*
|
|
149
|
+
* @param {{ status: string, created_at: string, labels?: string[] }} run A workflow run summary (labels resolved from its job requirements when available).
|
|
150
|
+
* @param {string[][]} onlineLabelSets Each online runner's label list.
|
|
151
|
+
* @param {number} staleMinutes
|
|
152
|
+
* @param {number} [nowMs] Injectable current epoch ms (for tests).
|
|
153
|
+
* @returns {boolean}
|
|
154
|
+
*/
|
|
155
|
+
export function isStaleQueuedRun(run, onlineLabelSets, staleMinutes, nowMs = Date.now()) {
|
|
156
|
+
if (run.status !== "queued" && run.status !== "waiting") return false;
|
|
157
|
+
const createdMs = Date.parse(run.created_at);
|
|
158
|
+
if (Number.isNaN(createdMs)) return false;
|
|
159
|
+
const ageMinutes = (nowMs - createdMs) / 60000;
|
|
160
|
+
if (ageMinutes < staleMinutes) return false;
|
|
161
|
+
const runLabels = Array.isArray(run.labels) ? run.labels : [];
|
|
162
|
+
if (runLabels.length === 0) {
|
|
163
|
+
// No label info to match against — can't assert "nothing could pick this
|
|
164
|
+
// up" without over-claiming, so don't flag it as stale from labels alone.
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
const anyOnlineMatches = onlineLabelSets.some((labels) =>
|
|
168
|
+
runnerMatchesLabels(labels, runLabels),
|
|
169
|
+
);
|
|
170
|
+
return !anyOnlineMatches;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Combine the three health signals into a single per-repo health verdict.
|
|
175
|
+
*
|
|
176
|
+
* @param {ReturnType<typeof classifyRunners>} runnerVerdict
|
|
177
|
+
* @param {Array<object>} staleRuns
|
|
178
|
+
* @returns {boolean} true when the repo is healthy (no offline, no shortfall, no stale queued runs).
|
|
179
|
+
*/
|
|
180
|
+
export function isRepoHealthy(runnerVerdict, staleRuns) {
|
|
181
|
+
return (
|
|
182
|
+
!runnerVerdict.hasOffline &&
|
|
183
|
+
!runnerVerdict.hasShortfall &&
|
|
184
|
+
(!Array.isArray(staleRuns) || staleRuns.length === 0)
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Render the human-readable dashboard report.
|
|
190
|
+
*
|
|
191
|
+
* @param {{
|
|
192
|
+
* results: Array<{
|
|
193
|
+
* name: string,
|
|
194
|
+
* repo: string,
|
|
195
|
+
* error?: string,
|
|
196
|
+
* verdict?: ReturnType<typeof classifyRunners>,
|
|
197
|
+
* staleRuns?: Array<{ id: number, html_url?: string, created_at: string }>,
|
|
198
|
+
* healthy?: boolean,
|
|
199
|
+
* }>,
|
|
200
|
+
* }} report
|
|
201
|
+
* @returns {string}
|
|
202
|
+
*/
|
|
203
|
+
export function renderReport(report) {
|
|
204
|
+
const out = [];
|
|
205
|
+
out.push("## Runner-fleet health dashboard");
|
|
206
|
+
out.push("");
|
|
207
|
+
out.push(
|
|
208
|
+
"All self-hosted runners are co-resident on one operator Mac — a wedged " +
|
|
209
|
+
"host silently stalls every listed repo's CI with no other alert.",
|
|
210
|
+
);
|
|
211
|
+
out.push("");
|
|
212
|
+
out.push("| Repo | Online / Expected | Offline | Stale queued | Status |");
|
|
213
|
+
out.push("| ---- | ------------------ | ------- | ------------- | ------ |");
|
|
214
|
+
|
|
215
|
+
const problemLines = [];
|
|
216
|
+
for (const r of report.results) {
|
|
217
|
+
if (r.error) {
|
|
218
|
+
out.push(`| \`${r.name}\` | — | — | — | ⚠️ error |`);
|
|
219
|
+
problemLines.push(`- \`${r.name}\` (${r.repo}): error — ${r.error}`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const v = r.verdict;
|
|
223
|
+
const stale = Array.isArray(r.staleRuns) ? r.staleRuns : [];
|
|
224
|
+
const healthy = r.healthy === true;
|
|
225
|
+
const status = healthy ? "✅ healthy" : "❌ degraded";
|
|
226
|
+
out.push(
|
|
227
|
+
`| \`${r.name}\` | ${v.matchingOnline}/${v.matchingOnline + v.shortfall} | ${v.offline.length} | ${stale.length} | ${status} |`,
|
|
228
|
+
);
|
|
229
|
+
if (!healthy) {
|
|
230
|
+
if (v.hasOffline) {
|
|
231
|
+
const names = v.offline.map((o) => `\`${o.name}\` (${o.status})`).join(", ");
|
|
232
|
+
problemLines.push(
|
|
233
|
+
`- \`${r.name}\` (${r.repo}): OFFLINE runner(s) — ${names}.`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (v.hasShortfall) {
|
|
237
|
+
problemLines.push(
|
|
238
|
+
`- \`${r.name}\` (${r.repo}): SHORTFALL — ${v.matchingOnline} online runner(s) matching labels, expected at least ${v.matchingOnline + v.shortfall}.`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (stale.length > 0) {
|
|
242
|
+
const runs = stale
|
|
243
|
+
.map((run) => (run.html_url ? `[#${run.id}](${run.html_url})` : `#${run.id}`))
|
|
244
|
+
.join(", ");
|
|
245
|
+
problemLines.push(
|
|
246
|
+
`- \`${r.name}\` (${r.repo}): STALE QUEUED RUN(S) — ${runs} queued with no matching online runner.`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
out.push("");
|
|
253
|
+
if (problemLines.length > 0) {
|
|
254
|
+
out.push("### Degraded");
|
|
255
|
+
out.push("");
|
|
256
|
+
out.push(...problemLines);
|
|
257
|
+
out.push("");
|
|
258
|
+
out.push(
|
|
259
|
+
"**Operator response:** wake/reboot the Mac, check disk space, and " +
|
|
260
|
+
"restart the launchd runner services. See " +
|
|
261
|
+
"`templates/runbooks/runner-fleet-health.md`.",
|
|
262
|
+
);
|
|
263
|
+
} else {
|
|
264
|
+
out.push("### ✅ Fleet healthy");
|
|
265
|
+
out.push("");
|
|
266
|
+
out.push("Every configured repo has its expected online runner count and no stale queued runs.");
|
|
267
|
+
}
|
|
268
|
+
out.push("");
|
|
269
|
+
return out.join("\n");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// GitHub access — runners + workflow runs.
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Fetch a repo's live self-hosted runner list.
|
|
278
|
+
*
|
|
279
|
+
* @param {string} repo "owner/name".
|
|
280
|
+
* @param {(args: string[]) => string} runGh
|
|
281
|
+
* @returns {Array<{ id: number, name: string, status: string, busy?: boolean, labels: Array<{ name: string }> }>}
|
|
282
|
+
*/
|
|
283
|
+
export function fetchRunners(repo, runGh) {
|
|
284
|
+
let obj;
|
|
285
|
+
try {
|
|
286
|
+
obj = ghApiJson(`repos/${repo}/actions/runners?per_page=100`, runGh);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
if (isNotFound(err)) return [];
|
|
289
|
+
throw err;
|
|
290
|
+
}
|
|
291
|
+
return Array.isArray(obj?.runners) ? obj.runners : [];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Fetch a repo's currently queued/waiting workflow runs (for stale-queue
|
|
296
|
+
* detection). Returns [] on a 404 (repo unreadable, or genuinely no runs);
|
|
297
|
+
* any other error propagates so the caller records an `error` row.
|
|
298
|
+
*
|
|
299
|
+
* @param {string} repo
|
|
300
|
+
* @param {(args: string[]) => string} runGh
|
|
301
|
+
* @returns {Array<{ id: number, status: string, created_at: string, html_url?: string, labels?: string[] }>}
|
|
302
|
+
*/
|
|
303
|
+
export function fetchQueuedRuns(repo, runGh) {
|
|
304
|
+
let obj;
|
|
305
|
+
try {
|
|
306
|
+
obj = ghApiJson(
|
|
307
|
+
`repos/${repo}/actions/runs?status=queued&per_page=50`,
|
|
308
|
+
runGh,
|
|
309
|
+
);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
if (isNotFound(err)) return [];
|
|
312
|
+
throw err;
|
|
313
|
+
}
|
|
314
|
+
const queued = Array.isArray(obj?.workflow_runs) ? obj.workflow_runs : [];
|
|
315
|
+
let waitingObj;
|
|
316
|
+
try {
|
|
317
|
+
waitingObj = ghApiJson(
|
|
318
|
+
`repos/${repo}/actions/runs?status=waiting&per_page=50`,
|
|
319
|
+
runGh,
|
|
320
|
+
);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
if (isNotFound(err)) return queued;
|
|
323
|
+
throw err;
|
|
324
|
+
}
|
|
325
|
+
const waiting = Array.isArray(waitingObj?.workflow_runs)
|
|
326
|
+
? waitingObj.workflow_runs
|
|
327
|
+
: [];
|
|
328
|
+
return [...queued, ...waiting];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// Orchestration
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Build the full health report for the configured repos.
|
|
337
|
+
*
|
|
338
|
+
* @param {{
|
|
339
|
+
* repos: Array<{ name: string, repo: string, expectedCount: number, labels: string[], staleQueuedMinutes?: number }>,
|
|
340
|
+
* defaultStaleQueuedMinutes?: number,
|
|
341
|
+
* }} config
|
|
342
|
+
* @param {(args: string[]) => string} runGh
|
|
343
|
+
* @param {number} [nowMs]
|
|
344
|
+
* @returns {{ results: Array<object> }}
|
|
345
|
+
*/
|
|
346
|
+
export function buildReport(config, runGh, nowMs = Date.now()) {
|
|
347
|
+
const defaultStale =
|
|
348
|
+
typeof config.defaultStaleQueuedMinutes === "number"
|
|
349
|
+
? config.defaultStaleQueuedMinutes
|
|
350
|
+
: 20;
|
|
351
|
+
const results = [];
|
|
352
|
+
for (const entry of config.repos) {
|
|
353
|
+
try {
|
|
354
|
+
const runners = fetchRunners(entry.repo, runGh);
|
|
355
|
+
const verdict = classifyRunners(runners, entry);
|
|
356
|
+
const onlineLabelSets = runners
|
|
357
|
+
.filter((r) => r.status === "online")
|
|
358
|
+
.map((r) => (r.labels || []).map((l) => (typeof l === "string" ? l : l.name)));
|
|
359
|
+
const staleMinutes =
|
|
360
|
+
typeof entry.staleQueuedMinutes === "number"
|
|
361
|
+
? entry.staleQueuedMinutes
|
|
362
|
+
: defaultStale;
|
|
363
|
+
const queuedRuns = fetchQueuedRuns(entry.repo, runGh);
|
|
364
|
+
const staleRuns = queuedRuns.filter((run) =>
|
|
365
|
+
isStaleQueuedRun(run, onlineLabelSets, staleMinutes, nowMs),
|
|
366
|
+
);
|
|
367
|
+
results.push({
|
|
368
|
+
name: entry.name,
|
|
369
|
+
repo: entry.repo,
|
|
370
|
+
verdict,
|
|
371
|
+
staleRuns,
|
|
372
|
+
healthy: isRepoHealthy(verdict, staleRuns),
|
|
373
|
+
});
|
|
374
|
+
} catch (err) {
|
|
375
|
+
results.push({
|
|
376
|
+
name: entry.name,
|
|
377
|
+
repo: entry.repo,
|
|
378
|
+
error: err instanceof Error ? err.message : String(err),
|
|
379
|
+
healthy: false,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return { results };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* @param {{ results: Array<{ error?: string, healthy?: boolean }> }} report
|
|
388
|
+
* @returns {boolean} true when any repo is unhealthy or errored.
|
|
389
|
+
*/
|
|
390
|
+
export function hasUnhealthy(report) {
|
|
391
|
+
return report.results.some((r) => r.error || r.healthy === false);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// CLI entry
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* @param {{
|
|
400
|
+
* argv?: string[],
|
|
401
|
+
* cwd?: string,
|
|
402
|
+
* stdout?: { write: (s: string) => void },
|
|
403
|
+
* stderr?: { write: (s: string) => void },
|
|
404
|
+
* runGh?: (args: string[]) => string,
|
|
405
|
+
* summaryPath?: string | undefined,
|
|
406
|
+
* nowMs?: number,
|
|
407
|
+
* }} [opts]
|
|
408
|
+
* @returns {number} exit code
|
|
409
|
+
*/
|
|
410
|
+
export function runCli({
|
|
411
|
+
argv = process.argv.slice(2),
|
|
412
|
+
cwd = process.cwd(),
|
|
413
|
+
stdout = process.stdout,
|
|
414
|
+
stderr = process.stderr,
|
|
415
|
+
runGh = defaultGhRunner,
|
|
416
|
+
summaryPath = process.env.GITHUB_STEP_SUMMARY,
|
|
417
|
+
nowMs = Date.now(),
|
|
418
|
+
} = {}) {
|
|
419
|
+
const { config: configRel, json } = parseArgv(argv);
|
|
420
|
+
const configPath = resolve(cwd, configRel);
|
|
421
|
+
|
|
422
|
+
let config;
|
|
423
|
+
try {
|
|
424
|
+
config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
425
|
+
} catch (err) {
|
|
426
|
+
stderr.write(
|
|
427
|
+
`[runner-health] ❌ failed to read config ${configPath}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
428
|
+
);
|
|
429
|
+
return 1;
|
|
430
|
+
}
|
|
431
|
+
if (!Array.isArray(config.repos)) {
|
|
432
|
+
stderr.write(`[runner-health] ❌ config must define { repos: [] }\n`);
|
|
433
|
+
return 1;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const report = buildReport(config, runGh, nowMs);
|
|
437
|
+
const unhealthy = hasUnhealthy(report);
|
|
438
|
+
|
|
439
|
+
if (json) {
|
|
440
|
+
stdout.write(
|
|
441
|
+
`${JSON.stringify({ kind: "runner-fleet-health-report", unhealthy, ...report }, null, 2)}\n`,
|
|
442
|
+
);
|
|
443
|
+
} else {
|
|
444
|
+
const text = renderReport(report);
|
|
445
|
+
stdout.write(`${text}\n`);
|
|
446
|
+
if (summaryPath) {
|
|
447
|
+
try {
|
|
448
|
+
appendFileSync(summaryPath, `${text}\n`);
|
|
449
|
+
} catch (err) {
|
|
450
|
+
stderr.write(
|
|
451
|
+
`[runner-health] ⚠ could not write job summary: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (unhealthy) {
|
|
458
|
+
stderr.write(`[runner-health] ❌ fleet degraded\n`);
|
|
459
|
+
return 1;
|
|
460
|
+
}
|
|
461
|
+
return 0;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Direct-invocation guard (matches the repo's other scripts/*.mjs entry style).
|
|
465
|
+
const invokedDirectly =
|
|
466
|
+
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
|
467
|
+
if (invokedDirectly) {
|
|
468
|
+
process.exit(runCli());
|
|
469
|
+
}
|