mandrel-platform 0.17.2 → 0.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 +220 -14
- package/config/commitlint.base.mjs +36 -0
- package/config/repo-settings.schema.json +78 -0
- package/package.json +2 -1
- package/scripts/apply-uptime-monitors.mjs +378 -0
- package/scripts/apply-uptime-monitors.test.mjs +372 -0
- package/scripts/check-repo-settings.mjs +363 -0
- package/scripts/check-repo-settings.test.mjs +320 -0
- package/scripts/check-required-contexts.mjs +247 -129
- package/scripts/check-required-contexts.test.mjs +137 -0
- package/scripts/check-ruleset.mjs +435 -0
- package/scripts/check-ruleset.test.mjs +439 -0
- package/scripts/check-wrangler-baseline.mjs +514 -0
- package/scripts/check-wrangler-baseline.test.mjs +454 -0
- package/scripts/platform-sync.mjs +533 -5
- package/scripts/platform-sync.test.mjs +477 -0
- package/templates/workflows/deploy-staging.yml +86 -0
- package/templates/workflows/uptime-apply.yml +54 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-ruleset.mjs
|
|
4
|
+
*
|
|
5
|
+
* Live branch-ruleset drift dashboard (Story #178).
|
|
6
|
+
*
|
|
7
|
+
* The platform ships `config/main-protection.schema.json` +
|
|
8
|
+
* `docs/runbooks/main-protection.json` (the decided main-branch protection
|
|
9
|
+
* contract: required status checks, no bypass actors, linear history,
|
|
10
|
+
* force-push/deletion blocked) plus a setup runbook — but nothing detects a
|
|
11
|
+
* LIVE ruleset drifting from that contract after initial setup. A silent
|
|
12
|
+
* ruleset edit (an actor added to `bypass_actors`, `strict` status checks
|
|
13
|
+
* turned off, force-pushes re-enabled) would go unnoticed today. The
|
|
14
|
+
* 2026-07-01 audit found exactly this risk class at the repo-settings layer
|
|
15
|
+
* (Story #171, `check-repo-settings.mjs`); this script is the same detector
|
|
16
|
+
* for the branch-ruleset layer.
|
|
17
|
+
*
|
|
18
|
+
* Mirrors the shape of `check-repo-settings.mjs` / `check-pin-drift.mjs`:
|
|
19
|
+
* data-driven consumer registry (reuses `scripts/pin-drift-consumers.json` —
|
|
20
|
+
* same fleet, no second registry to keep in sync), an injectable `runGh`
|
|
21
|
+
* seam for offline testing, pure exported classifier functions, `--json` /
|
|
22
|
+
* `--strict` flags, and `GITHUB_STEP_SUMMARY` integration.
|
|
23
|
+
*
|
|
24
|
+
* Reads each consumer's LIVE rulesets via the GitHub Rulesets API
|
|
25
|
+
* (`gh api repos/{owner}/{repo}/rulesets` for the list, then
|
|
26
|
+
* `gh api repos/{owner}/{repo}/rulesets/{id}` per ruleset for full rule
|
|
27
|
+
* detail — the list endpoint omits `rules`/`bypass_actors`) and diffs the
|
|
28
|
+
* ruleset targeting `refs/heads/<branch>` against
|
|
29
|
+
* `docs/runbooks/main-protection.json`:
|
|
30
|
+
*
|
|
31
|
+
* - `pull_request` rule present → PR required to merge.
|
|
32
|
+
* - `bypass_actors` empty → no bypass actor exempts the rule.
|
|
33
|
+
* - `required_status_checks` rule → contexts match `requiredStatusChecks`
|
|
34
|
+
* and `strict_required_status_checks_policy`
|
|
35
|
+
* is true (branch must be up to date).
|
|
36
|
+
* - `required_linear_history` rule → present iff `requireLinearHistory`.
|
|
37
|
+
* - `non_fast_forward` rule (force-push) → present iff `!allowForcePushes`.
|
|
38
|
+
* - `deletion` rule → present iff `!allowDeletions`.
|
|
39
|
+
*
|
|
40
|
+
* Non-blocking by design (standing decision #10 — same posture as
|
|
41
|
+
* `check-repo-settings.mjs` and `check-pin-drift.mjs`): the default exit
|
|
42
|
+
* code is 0 even when drift is found. `--strict` is an explicit opt-in for a
|
|
43
|
+
* one-off enforcement run; the scheduled dashboard invocation never passes
|
|
44
|
+
* it.
|
|
45
|
+
*
|
|
46
|
+
* Out of scope (see the Story): auto-fixing rulesets. This script reports
|
|
47
|
+
* drift and points at the setup runbook — it never mutates a live ruleset.
|
|
48
|
+
*
|
|
49
|
+
* Usage:
|
|
50
|
+
* node scripts/check-ruleset.mjs
|
|
51
|
+
* node scripts/check-ruleset.mjs --config scripts/pin-drift-consumers.json
|
|
52
|
+
* node scripts/check-ruleset.mjs --contract docs/runbooks/main-protection.json
|
|
53
|
+
* node scripts/check-ruleset.mjs --json # machine-readable envelope
|
|
54
|
+
* node scripts/check-ruleset.mjs --strict # exit 1 on any drift
|
|
55
|
+
*
|
|
56
|
+
* Exit codes:
|
|
57
|
+
* 0 — report emitted. Without --strict this is the default even when drift
|
|
58
|
+
* is present (report, don't block).
|
|
59
|
+
* 1 — with --strict: at least one consumer drifts from the contract.
|
|
60
|
+
* Without --strict: only on a fatal error (bad config, gh failure).
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
import { readFileSync, appendFileSync } from "node:fs";
|
|
64
|
+
import { resolve } from "node:path";
|
|
65
|
+
import { execFileSync } from "node:child_process";
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Arg parsing
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {string[]} argv
|
|
73
|
+
* @returns {{ config: string, contract: string, json: boolean, strict: boolean }}
|
|
74
|
+
*/
|
|
75
|
+
export function parseArgv(argv = []) {
|
|
76
|
+
let config = "scripts/pin-drift-consumers.json";
|
|
77
|
+
let contract = "docs/runbooks/main-protection.json";
|
|
78
|
+
let json = false;
|
|
79
|
+
let strict = false;
|
|
80
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
81
|
+
const a = argv[i];
|
|
82
|
+
if (a === "--config") {
|
|
83
|
+
const next = argv[i + 1];
|
|
84
|
+
if (next && !next.startsWith("--")) {
|
|
85
|
+
config = next;
|
|
86
|
+
i += 1;
|
|
87
|
+
}
|
|
88
|
+
} else if (a === "--contract") {
|
|
89
|
+
const next = argv[i + 1];
|
|
90
|
+
if (next && !next.startsWith("--")) {
|
|
91
|
+
contract = next;
|
|
92
|
+
i += 1;
|
|
93
|
+
}
|
|
94
|
+
} else if (a === "--json") {
|
|
95
|
+
json = true;
|
|
96
|
+
} else if (a === "--strict") {
|
|
97
|
+
strict = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { config, contract, json, strict };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Ruleset shape mapping
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Pick the ruleset (from a consumer's full `GET /rulesets/{id}` list) that
|
|
109
|
+
* targets the contract's protected branch — `refs/heads/<branch>` in the
|
|
110
|
+
* ruleset's `conditions.ref_name.include` list — and is `active`. Disabled
|
|
111
|
+
* ("evaluate") rulesets are ignored: they exist but do not enforce, so a
|
|
112
|
+
* contract check against them would be misleading.
|
|
113
|
+
*
|
|
114
|
+
* @param {Array<Record<string, unknown>>} rulesets Full ruleset objects (post-detail-fetch).
|
|
115
|
+
* @param {string} branch
|
|
116
|
+
* @returns {Record<string, unknown> | null}
|
|
117
|
+
*/
|
|
118
|
+
export function findBranchRuleset(rulesets, branch) {
|
|
119
|
+
const targetRef = `refs/heads/${branch}`;
|
|
120
|
+
const match = rulesets.find((rs) => {
|
|
121
|
+
if (rs.enforcement !== "active") return false;
|
|
122
|
+
const include = rs.conditions?.ref_name?.include ?? [];
|
|
123
|
+
return include.includes(targetRef) || include.includes("~DEFAULT_BRANCH");
|
|
124
|
+
});
|
|
125
|
+
return match ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Map a full ruleset object's `rules[]` array into the contract's field
|
|
130
|
+
* shape, so it can be diffed the same way `check-repo-settings.mjs` diffs
|
|
131
|
+
* camelCase settings fields against the baseline.
|
|
132
|
+
*
|
|
133
|
+
* @param {Record<string, unknown>} ruleset
|
|
134
|
+
* @returns {{
|
|
135
|
+
* pullRequestRequired: boolean,
|
|
136
|
+
* bypassActorsEmpty: boolean,
|
|
137
|
+
* requiredStatusChecks: string[],
|
|
138
|
+
* strictRequiredStatusChecksPolicy: boolean,
|
|
139
|
+
* requireLinearHistory: boolean,
|
|
140
|
+
* allowForcePushes: boolean,
|
|
141
|
+
* allowDeletions: boolean,
|
|
142
|
+
* }}
|
|
143
|
+
*/
|
|
144
|
+
export function mapRulesetToContract(ruleset) {
|
|
145
|
+
const rules = Array.isArray(ruleset.rules) ? ruleset.rules : [];
|
|
146
|
+
const byType = Object.fromEntries(rules.map((r) => [r.type, r]));
|
|
147
|
+
|
|
148
|
+
const statusCheckRule = byType.required_status_checks;
|
|
149
|
+
const statusChecks = (statusCheckRule?.parameters?.required_status_checks ?? []).map((c) => c.context);
|
|
150
|
+
|
|
151
|
+
const bypassActors = Array.isArray(ruleset.bypass_actors) ? ruleset.bypass_actors : [];
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
pullRequestRequired: Boolean(byType.pull_request),
|
|
155
|
+
bypassActorsEmpty: bypassActors.length === 0,
|
|
156
|
+
requiredStatusChecks: statusChecks,
|
|
157
|
+
strictRequiredStatusChecksPolicy: Boolean(statusCheckRule?.parameters?.strict_required_status_checks_policy),
|
|
158
|
+
// GitHub models "force pushes blocked" as the presence of the
|
|
159
|
+
// `non_fast_forward` rule, and "deletions blocked" as the presence of
|
|
160
|
+
// the `deletion` rule — both are "rule present == restriction active",
|
|
161
|
+
// the inverse of the contract's `allow*` booleans.
|
|
162
|
+
allowForcePushes: !byType.non_fast_forward,
|
|
163
|
+
allowDeletions: !byType.deletion,
|
|
164
|
+
requireLinearHistory: Boolean(byType.required_linear_history),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Diff a mapped live ruleset against the main-protection contract. Unknown
|
|
170
|
+
* contract keys (`$schema`, `branch`, `aggregatorJob`, `upstreamJobs`,
|
|
171
|
+
* `enforceAdmins`, `_note`) are ignored — this checker only asserts the
|
|
172
|
+
* dimensions a ruleset can actually encode.
|
|
173
|
+
*
|
|
174
|
+
* `requiredStatusChecks` is compared as a set (order-independent) since
|
|
175
|
+
* GitHub does not guarantee array ordering on the API response.
|
|
176
|
+
*
|
|
177
|
+
* @param {ReturnType<typeof mapRulesetToContract>} live
|
|
178
|
+
* @param {Record<string, unknown>} contract
|
|
179
|
+
* @returns {{ drifted: boolean, mismatches: Array<{ field: string, expected: unknown, actual: unknown }> }}
|
|
180
|
+
*/
|
|
181
|
+
export function diffRuleset(live, contract) {
|
|
182
|
+
const mismatches = [];
|
|
183
|
+
|
|
184
|
+
if (contract.requiredStatusChecks !== undefined) {
|
|
185
|
+
const expected = [...contract.requiredStatusChecks].sort();
|
|
186
|
+
const actual = [...(live.requiredStatusChecks ?? [])].sort();
|
|
187
|
+
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
|
|
188
|
+
mismatches.push({
|
|
189
|
+
field: "requiredStatusChecks",
|
|
190
|
+
expected: contract.requiredStatusChecks,
|
|
191
|
+
actual: live.requiredStatusChecks,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (live.pullRequestRequired !== true) {
|
|
197
|
+
mismatches.push({ field: "pullRequestRequired", expected: true, actual: live.pullRequestRequired });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (live.bypassActorsEmpty !== true) {
|
|
201
|
+
mismatches.push({ field: "bypassActorsEmpty", expected: true, actual: live.bypassActorsEmpty });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (live.strictRequiredStatusChecksPolicy !== true) {
|
|
205
|
+
mismatches.push({
|
|
206
|
+
field: "strictRequiredStatusChecksPolicy",
|
|
207
|
+
expected: true,
|
|
208
|
+
actual: live.strictRequiredStatusChecksPolicy,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const boolFields = [
|
|
213
|
+
["requireLinearHistory", contract.requireLinearHistory],
|
|
214
|
+
["allowForcePushes", contract.allowForcePushes],
|
|
215
|
+
["allowDeletions", contract.allowDeletions],
|
|
216
|
+
];
|
|
217
|
+
for (const [field, expected] of boolFields) {
|
|
218
|
+
if (expected === undefined) continue;
|
|
219
|
+
if (live[field] !== expected) {
|
|
220
|
+
mismatches.push({ field, expected, actual: live[field] });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { drifted: mismatches.length > 0, mismatches };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// GitHub access
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
function ghApiJson(apiPath, runGh) {
|
|
232
|
+
const raw = runGh(["api", apiPath, "-H", "Accept: application/vnd.github+json"]);
|
|
233
|
+
return JSON.parse(raw);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Default gh runner — shells out to the `gh` CLI. Same shape as
|
|
238
|
+
* check-repo-settings.mjs's defaultGhRunner so all three checkers share test
|
|
239
|
+
* doubles.
|
|
240
|
+
*
|
|
241
|
+
* @param {string[]} args
|
|
242
|
+
* @returns {string}
|
|
243
|
+
*/
|
|
244
|
+
export function defaultGhRunner(args) {
|
|
245
|
+
return execFileSync("gh", args, {
|
|
246
|
+
encoding: "utf-8",
|
|
247
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Fetch a consumer's rulesets: list, then hydrate each with the detail
|
|
253
|
+
* endpoint (the list response omits `rules`/`bypass_actors`), then pick the
|
|
254
|
+
* one targeting `branch`.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} repo "owner/repo".
|
|
257
|
+
* @param {string} branch
|
|
258
|
+
* @param {(args: string[]) => string} runGh
|
|
259
|
+
* @returns {Record<string, unknown> | null}
|
|
260
|
+
*/
|
|
261
|
+
export function fetchBranchRuleset(repo, branch, runGh) {
|
|
262
|
+
const list = ghApiJson(`repos/${repo}/rulesets`, runGh);
|
|
263
|
+
const detailed = (Array.isArray(list) ? list : []).map((rs) => ghApiJson(`repos/${repo}/rulesets/${rs.id}`, runGh));
|
|
264
|
+
return findBranchRuleset(detailed, branch);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// Orchestration
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Build the full drift report for the configured consumers against the
|
|
273
|
+
* given contract.
|
|
274
|
+
*
|
|
275
|
+
* @param {{ consumers: Array<{ name: string, repo: string, branch?: string }> }} config
|
|
276
|
+
* @param {Record<string, unknown>} contract
|
|
277
|
+
* @param {(args: string[]) => string} runGh
|
|
278
|
+
* @returns {{ contract: Record<string, unknown>, consumers: Array<object> }}
|
|
279
|
+
*/
|
|
280
|
+
export function buildReport(config, contract, runGh) {
|
|
281
|
+
const branch = contract.branch ?? "main";
|
|
282
|
+
const consumers = config.consumers.map((consumer) => {
|
|
283
|
+
const consumerBranch = consumer.branch ?? branch;
|
|
284
|
+
try {
|
|
285
|
+
const ruleset = fetchBranchRuleset(consumer.repo, consumerBranch, runGh);
|
|
286
|
+
if (!ruleset) {
|
|
287
|
+
return {
|
|
288
|
+
name: consumer.name,
|
|
289
|
+
repo: consumer.repo,
|
|
290
|
+
status: "missing",
|
|
291
|
+
error: `no active ruleset targets refs/heads/${consumerBranch}`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const live = mapRulesetToContract(ruleset);
|
|
295
|
+
const { drifted, mismatches } = diffRuleset(live, contract);
|
|
296
|
+
return {
|
|
297
|
+
name: consumer.name,
|
|
298
|
+
repo: consumer.repo,
|
|
299
|
+
status: drifted ? "drift" : "current",
|
|
300
|
+
rulesetId: ruleset.id,
|
|
301
|
+
rulesetName: ruleset.name,
|
|
302
|
+
live,
|
|
303
|
+
mismatches,
|
|
304
|
+
};
|
|
305
|
+
} catch (err) {
|
|
306
|
+
return {
|
|
307
|
+
name: consumer.name,
|
|
308
|
+
repo: consumer.repo,
|
|
309
|
+
status: "error",
|
|
310
|
+
error: err instanceof Error ? err.message : String(err),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
return { contract, consumers };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* @param {ReturnType<typeof buildReport>} report
|
|
319
|
+
* @returns {boolean}
|
|
320
|
+
*/
|
|
321
|
+
export function hasDrift(report) {
|
|
322
|
+
return report.consumers.some((c) => c.status === "drift" || c.status === "missing");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* @param {ReturnType<typeof buildReport>} report
|
|
327
|
+
* @returns {string}
|
|
328
|
+
*/
|
|
329
|
+
export function renderReport(report) {
|
|
330
|
+
const lines = [];
|
|
331
|
+
lines.push("## Branch-Ruleset Drift Dashboard");
|
|
332
|
+
lines.push("");
|
|
333
|
+
lines.push(
|
|
334
|
+
"Non-blocking by design (standing decision #10) — drift is reported here, never a hard gate on a consumer's `main`.",
|
|
335
|
+
);
|
|
336
|
+
lines.push("");
|
|
337
|
+
lines.push("| Consumer | Status | Detail |");
|
|
338
|
+
lines.push("| -------- | ------ | ------ |");
|
|
339
|
+
for (const c of report.consumers) {
|
|
340
|
+
if (c.status === "current") {
|
|
341
|
+
lines.push(`| ${c.name} | ✅ current | matches the main-protection contract |`);
|
|
342
|
+
} else if (c.status === "missing") {
|
|
343
|
+
lines.push(`| ${c.name} | ⚠️ missing | ${c.error} |`);
|
|
344
|
+
} else if (c.status === "error") {
|
|
345
|
+
lines.push(`| ${c.name} | ⚠️ error | ${c.error} |`);
|
|
346
|
+
} else {
|
|
347
|
+
const detail = c.mismatches
|
|
348
|
+
.map((m) => `${m.field}: expected \`${JSON.stringify(m.expected)}\`, got \`${JSON.stringify(m.actual)}\``)
|
|
349
|
+
.join("; ");
|
|
350
|
+
lines.push(`| ${c.name} | ❌ drift | ${detail} |`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return lines.join("\n");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// CLI entry
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param {{
|
|
362
|
+
* argv?: string[],
|
|
363
|
+
* cwd?: string,
|
|
364
|
+
* stdout?: { write: (s: string) => void },
|
|
365
|
+
* stderr?: { write: (s: string) => void },
|
|
366
|
+
* runGh?: (args: string[]) => string,
|
|
367
|
+
* summaryPath?: string | undefined,
|
|
368
|
+
* }} [opts]
|
|
369
|
+
* @returns {number} exit code
|
|
370
|
+
*/
|
|
371
|
+
export function runCli({
|
|
372
|
+
argv = process.argv.slice(2),
|
|
373
|
+
cwd = process.cwd(),
|
|
374
|
+
stdout = process.stdout,
|
|
375
|
+
stderr = process.stderr,
|
|
376
|
+
runGh = defaultGhRunner,
|
|
377
|
+
summaryPath = process.env.GITHUB_STEP_SUMMARY,
|
|
378
|
+
} = {}) {
|
|
379
|
+
const { config: configRel, contract: contractRel, json, strict } = parseArgv(argv);
|
|
380
|
+
const configPath = resolve(cwd, configRel);
|
|
381
|
+
const contractPath = resolve(cwd, contractRel);
|
|
382
|
+
|
|
383
|
+
let config;
|
|
384
|
+
let contract;
|
|
385
|
+
try {
|
|
386
|
+
config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
387
|
+
} catch (err) {
|
|
388
|
+
stderr.write(
|
|
389
|
+
`[ruleset] ❌ failed to read config ${configPath}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
390
|
+
);
|
|
391
|
+
return 1;
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
contract = JSON.parse(readFileSync(contractPath, "utf-8"));
|
|
395
|
+
} catch (err) {
|
|
396
|
+
stderr.write(
|
|
397
|
+
`[ruleset] ❌ failed to read contract ${contractPath}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
398
|
+
);
|
|
399
|
+
return 1;
|
|
400
|
+
}
|
|
401
|
+
if (!Array.isArray(config.consumers)) {
|
|
402
|
+
stderr.write(`[ruleset] ❌ config must define { consumers: [] }\n`);
|
|
403
|
+
return 1;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const report = buildReport(config, contract, runGh);
|
|
407
|
+
const drift = hasDrift(report);
|
|
408
|
+
|
|
409
|
+
if (json) {
|
|
410
|
+
stdout.write(`${JSON.stringify({ kind: "ruleset-report", drift, ...report }, null, 2)}\n`);
|
|
411
|
+
} else {
|
|
412
|
+
const text = renderReport(report);
|
|
413
|
+
stdout.write(`${text}\n`);
|
|
414
|
+
if (summaryPath) {
|
|
415
|
+
try {
|
|
416
|
+
appendFileSync(summaryPath, `${text}\n`);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
stderr.write(`[ruleset] ⚠ could not write job summary: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (strict && drift) {
|
|
424
|
+
stderr.write(`[ruleset] ❌ drift detected (--strict)\n`);
|
|
425
|
+
return 1;
|
|
426
|
+
}
|
|
427
|
+
return 0;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Direct-invocation guard (matches the repo's other scripts/*.mjs entry style).
|
|
431
|
+
const invokedDirectly =
|
|
432
|
+
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
|
433
|
+
if (invokedDirectly) {
|
|
434
|
+
process.exit(runCli());
|
|
435
|
+
}
|