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,363 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-repo-settings.mjs
|
|
4
|
+
*
|
|
5
|
+
* GitHub-side repo-settings drift dashboard (Story #171).
|
|
6
|
+
*
|
|
7
|
+
* The 2026-07-01 settings-level audit (repo-ops consumers matrix §3a, roadmap
|
|
8
|
+
* §2.1) found real divergence in GitHub repo settings the platform shipped no
|
|
9
|
+
* contract for: default Actions workflow token permissions, merge-method
|
|
10
|
+
* allow-list, squash-commit source, auto-merge, and whether Actions can
|
|
11
|
+
* approve pull requests. `config/repo-settings.schema.json` /
|
|
12
|
+
* `docs/runbooks/repo-settings.json` encode the decided fleet baseline; this
|
|
13
|
+
* script reads each consumer's LIVE settings over the GitHub API
|
|
14
|
+
* (`gh api repos/{owner}/{repo}`) and reports drift against that baseline.
|
|
15
|
+
*
|
|
16
|
+
* Mirrors the shape of `check-pin-drift.mjs`: data-driven consumer registry
|
|
17
|
+
* (reuses `scripts/pin-drift-consumers.json` — same fleet, no second registry
|
|
18
|
+
* to keep in sync), an injectable `runGh` seam for offline testing, pure
|
|
19
|
+
* exported classifier functions, `--json` / `--strict` flags, and
|
|
20
|
+
* `GITHUB_STEP_SUMMARY` integration.
|
|
21
|
+
*
|
|
22
|
+
* Non-blocking by design (standing decision #10 — drift is repaired via
|
|
23
|
+
* auto-repair PRs / a dashboard, never a hard gate): the default exit code is
|
|
24
|
+
* 0 even when drift is found. `--strict` is an explicit opt-in for a one-off
|
|
25
|
+
* enforcement run; the scheduled dashboard invocation never passes it.
|
|
26
|
+
*
|
|
27
|
+
* Usage:
|
|
28
|
+
* node scripts/check-repo-settings.mjs
|
|
29
|
+
* node scripts/check-repo-settings.mjs --config scripts/pin-drift-consumers.json
|
|
30
|
+
* node scripts/check-repo-settings.mjs --baseline docs/runbooks/repo-settings.json
|
|
31
|
+
* node scripts/check-repo-settings.mjs --json # machine-readable envelope
|
|
32
|
+
* node scripts/check-repo-settings.mjs --strict # exit 1 on any drift
|
|
33
|
+
*
|
|
34
|
+
* Exit codes:
|
|
35
|
+
* 0 — report emitted. Without --strict this is the default even when drift
|
|
36
|
+
* is present (report, don't block).
|
|
37
|
+
* 1 — with --strict: at least one consumer drifts from the baseline.
|
|
38
|
+
* Without --strict: only on a fatal error (bad config, gh failure).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { readFileSync, appendFileSync } from "node:fs";
|
|
42
|
+
import { resolve } from "node:path";
|
|
43
|
+
import { execFileSync } from "node:child_process";
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Arg parsing
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string[]} argv
|
|
51
|
+
* @returns {{ config: string, baseline: string, json: boolean, strict: boolean }}
|
|
52
|
+
*/
|
|
53
|
+
export function parseArgv(argv = []) {
|
|
54
|
+
let config = "scripts/pin-drift-consumers.json";
|
|
55
|
+
let baseline = "docs/runbooks/repo-settings.json";
|
|
56
|
+
let json = false;
|
|
57
|
+
let strict = false;
|
|
58
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
59
|
+
const a = argv[i];
|
|
60
|
+
if (a === "--config") {
|
|
61
|
+
const next = argv[i + 1];
|
|
62
|
+
if (next && !next.startsWith("--")) {
|
|
63
|
+
config = next;
|
|
64
|
+
i += 1;
|
|
65
|
+
}
|
|
66
|
+
} else if (a === "--baseline") {
|
|
67
|
+
const next = argv[i + 1];
|
|
68
|
+
if (next && !next.startsWith("--")) {
|
|
69
|
+
baseline = next;
|
|
70
|
+
i += 1;
|
|
71
|
+
}
|
|
72
|
+
} else if (a === "--json") {
|
|
73
|
+
json = true;
|
|
74
|
+
} else if (a === "--strict") {
|
|
75
|
+
strict = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { config, baseline, json, strict };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Baseline dimensions
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The baseline keys this checker understands, mapped to how to read the
|
|
87
|
+
* equivalent field off `gh api repos/{owner}/{repo}` (repo settings) or
|
|
88
|
+
* `gh api repos/{owner}/{repo}/actions/permissions/workflow` (Actions
|
|
89
|
+
* workflow-permissions, a separate endpoint GitHub does not fold into the
|
|
90
|
+
* main repo payload).
|
|
91
|
+
*/
|
|
92
|
+
const REPO_FIELDS = [
|
|
93
|
+
"allowSquashMerge",
|
|
94
|
+
"allowMergeCommit",
|
|
95
|
+
"allowRebaseMerge",
|
|
96
|
+
"squashMergeCommitTitle",
|
|
97
|
+
"squashMergeCommitMessage",
|
|
98
|
+
"deleteBranchOnMerge",
|
|
99
|
+
"allowAutoMerge",
|
|
100
|
+
];
|
|
101
|
+
const ACTIONS_FIELDS = ["actionsDefaultWorkflowPermissions", "actionsCanApprovePullRequestReviews"];
|
|
102
|
+
|
|
103
|
+
const REPO_FIELD_TO_API_KEY = {
|
|
104
|
+
allowSquashMerge: "allow_squash_merge",
|
|
105
|
+
allowMergeCommit: "allow_merge_commit",
|
|
106
|
+
allowRebaseMerge: "allow_rebase_merge",
|
|
107
|
+
squashMergeCommitTitle: "squash_merge_commit_title",
|
|
108
|
+
squashMergeCommitMessage: "squash_merge_commit_message",
|
|
109
|
+
deleteBranchOnMerge: "delete_branch_on_merge",
|
|
110
|
+
allowAutoMerge: "allow_auto_merge",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const ACTIONS_FIELD_TO_API_KEY = {
|
|
114
|
+
actionsDefaultWorkflowPermissions: "default_workflow_permissions",
|
|
115
|
+
actionsCanApprovePullRequestReviews: "can_approve_pull_request_reviews",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Map a live `gh api repos/{owner}/{repo}` payload to the baseline's field
|
|
120
|
+
* shape (camelCase, only the dimensions this contract governs).
|
|
121
|
+
*
|
|
122
|
+
* @param {Record<string, unknown>} repoPayload
|
|
123
|
+
* @returns {Record<string, unknown>}
|
|
124
|
+
*/
|
|
125
|
+
export function mapRepoSettings(repoPayload) {
|
|
126
|
+
const out = {};
|
|
127
|
+
for (const field of REPO_FIELDS) {
|
|
128
|
+
out[field] = repoPayload[REPO_FIELD_TO_API_KEY[field]];
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Map a live `gh api repos/{owner}/{repo}/actions/permissions/workflow`
|
|
135
|
+
* payload to the baseline's field shape.
|
|
136
|
+
*
|
|
137
|
+
* @param {Record<string, unknown>} actionsPayload
|
|
138
|
+
* @returns {Record<string, unknown>}
|
|
139
|
+
*/
|
|
140
|
+
export function mapActionsSettings(actionsPayload) {
|
|
141
|
+
const out = {};
|
|
142
|
+
for (const field of ACTIONS_FIELDS) {
|
|
143
|
+
out[field] = actionsPayload[ACTIONS_FIELD_TO_API_KEY[field]];
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Diff a consumer's live settings against the baseline for every dimension
|
|
150
|
+
* the baseline declares (unknown/extra baseline keys are ignored so the
|
|
151
|
+
* schema can grow without breaking this classifier).
|
|
152
|
+
*
|
|
153
|
+
* @param {Record<string, unknown>} live
|
|
154
|
+
* @param {Record<string, unknown>} baseline
|
|
155
|
+
* @returns {{ drifted: boolean, mismatches: Array<{ field: string, expected: unknown, actual: unknown }> }}
|
|
156
|
+
*/
|
|
157
|
+
export function diffSettings(live, baseline) {
|
|
158
|
+
const mismatches = [];
|
|
159
|
+
for (const field of [...REPO_FIELDS, ...ACTIONS_FIELDS]) {
|
|
160
|
+
if (!(field in baseline)) continue;
|
|
161
|
+
const expected = baseline[field];
|
|
162
|
+
const actual = live[field];
|
|
163
|
+
if (actual !== expected) {
|
|
164
|
+
mismatches.push({ field, expected, actual });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { drifted: mismatches.length > 0, mismatches };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// GitHub access
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Run `gh api <path>` and parse the JSON response.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} apiPath
|
|
178
|
+
* @param {(args: string[]) => string} runGh
|
|
179
|
+
* @returns {unknown}
|
|
180
|
+
*/
|
|
181
|
+
function ghApiJson(apiPath, runGh) {
|
|
182
|
+
const raw = runGh(["api", apiPath, "-H", "Accept: application/vnd.github+json"]);
|
|
183
|
+
return JSON.parse(raw);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Default gh runner — shells out to the `gh` CLI. Same shape as
|
|
188
|
+
* check-pin-drift.mjs's defaultGhRunner so both scripts share test doubles.
|
|
189
|
+
*
|
|
190
|
+
* @param {string[]} args
|
|
191
|
+
* @returns {string}
|
|
192
|
+
*/
|
|
193
|
+
export function defaultGhRunner(args) {
|
|
194
|
+
return execFileSync("gh", args, {
|
|
195
|
+
encoding: "utf-8",
|
|
196
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Fetch one consumer's live settings across both endpoints and map them to
|
|
202
|
+
* the baseline's field shape.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} repo "owner/repo".
|
|
205
|
+
* @param {(args: string[]) => string} runGh
|
|
206
|
+
* @returns {Record<string, unknown>}
|
|
207
|
+
*/
|
|
208
|
+
export function fetchConsumerSettings(repo, runGh) {
|
|
209
|
+
const repoPayload = ghApiJson(`repos/${repo}`, runGh);
|
|
210
|
+
const actionsPayload = ghApiJson(`repos/${repo}/actions/permissions/workflow`, runGh);
|
|
211
|
+
return { ...mapRepoSettings(repoPayload), ...mapActionsSettings(actionsPayload) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Orchestration
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Build the full drift report for the configured consumers against the
|
|
220
|
+
* given baseline.
|
|
221
|
+
*
|
|
222
|
+
* @param {{ consumers: Array<{ name: string, repo: string, branch?: string }> }} config
|
|
223
|
+
* @param {Record<string, unknown>} baseline
|
|
224
|
+
* @param {(args: string[]) => string} runGh
|
|
225
|
+
* @returns {{ baseline: Record<string, unknown>, consumers: Array<object> }}
|
|
226
|
+
*/
|
|
227
|
+
export function buildReport(config, baseline, runGh) {
|
|
228
|
+
const consumers = config.consumers.map((consumer) => {
|
|
229
|
+
try {
|
|
230
|
+
const live = fetchConsumerSettings(consumer.repo, runGh);
|
|
231
|
+
const { drifted, mismatches } = diffSettings(live, baseline);
|
|
232
|
+
return { name: consumer.name, repo: consumer.repo, status: drifted ? "drift" : "current", live, mismatches };
|
|
233
|
+
} catch (err) {
|
|
234
|
+
return {
|
|
235
|
+
name: consumer.name,
|
|
236
|
+
repo: consumer.repo,
|
|
237
|
+
status: "error",
|
|
238
|
+
error: err instanceof Error ? err.message : String(err),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
return { baseline, consumers };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {ReturnType<typeof buildReport>} report
|
|
247
|
+
* @returns {boolean}
|
|
248
|
+
*/
|
|
249
|
+
export function hasDrift(report) {
|
|
250
|
+
return report.consumers.some((c) => c.status === "drift");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @param {ReturnType<typeof buildReport>} report
|
|
255
|
+
* @returns {string}
|
|
256
|
+
*/
|
|
257
|
+
export function renderReport(report) {
|
|
258
|
+
const lines = [];
|
|
259
|
+
lines.push("## Repo-Settings Baseline Dashboard");
|
|
260
|
+
lines.push("");
|
|
261
|
+
lines.push(
|
|
262
|
+
"Non-blocking by design (standing decision #10) — drift is reported here, never a hard gate on a consumer's `main`.",
|
|
263
|
+
);
|
|
264
|
+
lines.push("");
|
|
265
|
+
lines.push("| Consumer | Status | Detail |");
|
|
266
|
+
lines.push("| -------- | ------ | ------ |");
|
|
267
|
+
for (const c of report.consumers) {
|
|
268
|
+
if (c.status === "current") {
|
|
269
|
+
lines.push(`| ${c.name} | ✅ current | matches baseline |`);
|
|
270
|
+
} else if (c.status === "error") {
|
|
271
|
+
lines.push(`| ${c.name} | ⚠️ error | ${c.error} |`);
|
|
272
|
+
} else {
|
|
273
|
+
const detail = c.mismatches
|
|
274
|
+
.map((m) => `${m.field}: expected \`${m.expected}\`, got \`${m.actual}\``)
|
|
275
|
+
.join("; ");
|
|
276
|
+
lines.push(`| ${c.name} | ❌ drift | ${detail} |`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return lines.join("\n");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// CLI entry
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @param {{
|
|
288
|
+
* argv?: string[],
|
|
289
|
+
* cwd?: string,
|
|
290
|
+
* stdout?: { write: (s: string) => void },
|
|
291
|
+
* stderr?: { write: (s: string) => void },
|
|
292
|
+
* runGh?: (args: string[]) => string,
|
|
293
|
+
* summaryPath?: string | undefined,
|
|
294
|
+
* }} [opts]
|
|
295
|
+
* @returns {number} exit code
|
|
296
|
+
*/
|
|
297
|
+
export function runCli({
|
|
298
|
+
argv = process.argv.slice(2),
|
|
299
|
+
cwd = process.cwd(),
|
|
300
|
+
stdout = process.stdout,
|
|
301
|
+
stderr = process.stderr,
|
|
302
|
+
runGh = defaultGhRunner,
|
|
303
|
+
summaryPath = process.env.GITHUB_STEP_SUMMARY,
|
|
304
|
+
} = {}) {
|
|
305
|
+
const { config: configRel, baseline: baselineRel, json, strict } = parseArgv(argv);
|
|
306
|
+
const configPath = resolve(cwd, configRel);
|
|
307
|
+
const baselinePath = resolve(cwd, baselineRel);
|
|
308
|
+
|
|
309
|
+
let config;
|
|
310
|
+
let baseline;
|
|
311
|
+
try {
|
|
312
|
+
config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
313
|
+
} catch (err) {
|
|
314
|
+
stderr.write(
|
|
315
|
+
`[repo-settings] ❌ failed to read config ${configPath}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
316
|
+
);
|
|
317
|
+
return 1;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
baseline = JSON.parse(readFileSync(baselinePath, "utf-8"));
|
|
321
|
+
} catch (err) {
|
|
322
|
+
stderr.write(
|
|
323
|
+
`[repo-settings] ❌ failed to read baseline ${baselinePath}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
324
|
+
);
|
|
325
|
+
return 1;
|
|
326
|
+
}
|
|
327
|
+
if (!Array.isArray(config.consumers)) {
|
|
328
|
+
stderr.write(`[repo-settings] ❌ config must define { consumers: [] }\n`);
|
|
329
|
+
return 1;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const report = buildReport(config, baseline, runGh);
|
|
333
|
+
const drift = hasDrift(report);
|
|
334
|
+
|
|
335
|
+
if (json) {
|
|
336
|
+
stdout.write(`${JSON.stringify({ kind: "repo-settings-report", drift, ...report }, null, 2)}\n`);
|
|
337
|
+
} else {
|
|
338
|
+
const text = renderReport(report);
|
|
339
|
+
stdout.write(`${text}\n`);
|
|
340
|
+
if (summaryPath) {
|
|
341
|
+
try {
|
|
342
|
+
appendFileSync(summaryPath, `${text}\n`);
|
|
343
|
+
} catch (err) {
|
|
344
|
+
stderr.write(
|
|
345
|
+
`[repo-settings] ⚠ could not write job summary: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (strict && drift) {
|
|
352
|
+
stderr.write(`[repo-settings] ❌ drift detected (--strict)\n`);
|
|
353
|
+
return 1;
|
|
354
|
+
}
|
|
355
|
+
return 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Direct-invocation guard (matches the repo's other scripts/*.mjs entry style).
|
|
359
|
+
const invokedDirectly =
|
|
360
|
+
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
|
361
|
+
if (invokedDirectly) {
|
|
362
|
+
process.exit(runCli());
|
|
363
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-repo-settings.test.mjs — node:test suite for the Story #171
|
|
4
|
+
* repo-settings drift dashboard.
|
|
5
|
+
*
|
|
6
|
+
* Exercises the pure classifiers (mapRepoSettings/mapActionsSettings,
|
|
7
|
+
* diffSettings) directly, and the full buildReport/runCli pipeline with an
|
|
8
|
+
* injected `runGh` seam (offline — no real GitHub calls).
|
|
9
|
+
*
|
|
10
|
+
* Run: node scripts/check-repo-settings.test.mjs (or `node --test scripts/`)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { afterEach, beforeEach, test } from "node:test";
|
|
18
|
+
import {
|
|
19
|
+
mapRepoSettings,
|
|
20
|
+
mapActionsSettings,
|
|
21
|
+
diffSettings,
|
|
22
|
+
buildReport,
|
|
23
|
+
hasDrift,
|
|
24
|
+
renderReport,
|
|
25
|
+
runCli,
|
|
26
|
+
parseArgv,
|
|
27
|
+
} from "./check-repo-settings.mjs";
|
|
28
|
+
|
|
29
|
+
const BASELINE = {
|
|
30
|
+
allowSquashMerge: true,
|
|
31
|
+
allowMergeCommit: false,
|
|
32
|
+
allowRebaseMerge: false,
|
|
33
|
+
squashMergeCommitTitle: "PR_TITLE",
|
|
34
|
+
squashMergeCommitMessage: "PR_BODY",
|
|
35
|
+
deleteBranchOnMerge: true,
|
|
36
|
+
allowAutoMerge: true,
|
|
37
|
+
actionsDefaultWorkflowPermissions: "read",
|
|
38
|
+
actionsCanApprovePullRequestReviews: false,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
test("mapRepoSettings maps GitHub's snake_case repo payload to camelCase baseline fields", () => {
|
|
42
|
+
const payload = {
|
|
43
|
+
allow_squash_merge: true,
|
|
44
|
+
allow_merge_commit: false,
|
|
45
|
+
allow_rebase_merge: false,
|
|
46
|
+
squash_merge_commit_title: "PR_TITLE",
|
|
47
|
+
squash_merge_commit_message: "PR_BODY",
|
|
48
|
+
delete_branch_on_merge: true,
|
|
49
|
+
allow_auto_merge: true,
|
|
50
|
+
// extra fields the repo payload carries that the schema does not govern
|
|
51
|
+
full_name: "owner/repo",
|
|
52
|
+
};
|
|
53
|
+
const mapped = mapRepoSettings(payload);
|
|
54
|
+
assert.deepEqual(mapped, {
|
|
55
|
+
allowSquashMerge: true,
|
|
56
|
+
allowMergeCommit: false,
|
|
57
|
+
allowRebaseMerge: false,
|
|
58
|
+
squashMergeCommitTitle: "PR_TITLE",
|
|
59
|
+
squashMergeCommitMessage: "PR_BODY",
|
|
60
|
+
deleteBranchOnMerge: true,
|
|
61
|
+
allowAutoMerge: true,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("mapActionsSettings maps the Actions workflow-permissions payload", () => {
|
|
66
|
+
const payload = { default_workflow_permissions: "write", can_approve_pull_request_reviews: true };
|
|
67
|
+
assert.deepEqual(mapActionsSettings(payload), {
|
|
68
|
+
actionsDefaultWorkflowPermissions: "write",
|
|
69
|
+
actionsCanApprovePullRequestReviews: true,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("diffSettings reports no mismatches when live matches baseline", () => {
|
|
74
|
+
const { drifted, mismatches } = diffSettings({ ...BASELINE }, BASELINE);
|
|
75
|
+
assert.equal(drifted, false);
|
|
76
|
+
assert.deepEqual(mismatches, []);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("diffSettings flags every drifted field (domio-shaped: write token perms)", () => {
|
|
80
|
+
const live = { ...BASELINE, actionsDefaultWorkflowPermissions: "write" };
|
|
81
|
+
const { drifted, mismatches } = diffSettings(live, BASELINE);
|
|
82
|
+
assert.equal(drifted, true);
|
|
83
|
+
assert.deepEqual(mismatches, [
|
|
84
|
+
{ field: "actionsDefaultWorkflowPermissions", expected: "read", actual: "write" },
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("diffSettings flags multiple mismatches (athportal-shaped: merge methods + squash source)", () => {
|
|
89
|
+
const live = {
|
|
90
|
+
...BASELINE,
|
|
91
|
+
allowMergeCommit: true,
|
|
92
|
+
allowRebaseMerge: true,
|
|
93
|
+
squashMergeCommitMessage: "COMMIT_MESSAGES",
|
|
94
|
+
};
|
|
95
|
+
const { mismatches } = diffSettings(live, BASELINE);
|
|
96
|
+
const fields = mismatches.map((m) => m.field).sort();
|
|
97
|
+
assert.deepEqual(fields, ["allowMergeCommit", "allowRebaseMerge", "squashMergeCommitMessage"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("diffSettings ignores baseline keys that are not in the field list (schema _note, $schema)", () => {
|
|
101
|
+
const baselineWithExtras = { ...BASELINE, $schema: "../../config/repo-settings.schema.json", _note: "..." };
|
|
102
|
+
const { drifted } = diffSettings({ ...BASELINE }, baselineWithExtras);
|
|
103
|
+
assert.equal(drifted, false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
function fakeRunGh(responses) {
|
|
107
|
+
return (args) => {
|
|
108
|
+
const path = args[1]; // ["api", "<path>", ...]
|
|
109
|
+
if (!(path in responses)) {
|
|
110
|
+
throw new Error(`fakeRunGh: no stub for ${path}`);
|
|
111
|
+
}
|
|
112
|
+
return JSON.stringify(responses[path]);
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
test("buildReport: consumer matching the baseline reports 'current'", () => {
|
|
117
|
+
const config = { consumers: [{ name: "domio", repo: "dsj1984/domio" }] };
|
|
118
|
+
const runGh = fakeRunGh({
|
|
119
|
+
"repos/dsj1984/domio": {
|
|
120
|
+
allow_squash_merge: true,
|
|
121
|
+
allow_merge_commit: false,
|
|
122
|
+
allow_rebase_merge: false,
|
|
123
|
+
squash_merge_commit_title: "PR_TITLE",
|
|
124
|
+
squash_merge_commit_message: "PR_BODY",
|
|
125
|
+
delete_branch_on_merge: true,
|
|
126
|
+
allow_auto_merge: true,
|
|
127
|
+
},
|
|
128
|
+
"repos/dsj1984/domio/actions/permissions/workflow": {
|
|
129
|
+
default_workflow_permissions: "write",
|
|
130
|
+
can_approve_pull_request_reviews: false,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
const report = buildReport(config, BASELINE, runGh);
|
|
134
|
+
assert.equal(report.consumers[0].status, "drift");
|
|
135
|
+
assert.deepEqual(report.consumers[0].mismatches, [
|
|
136
|
+
{ field: "actionsDefaultWorkflowPermissions", expected: "read", actual: "write" },
|
|
137
|
+
]);
|
|
138
|
+
assert.equal(hasDrift(report), true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("buildReport: consumer fully matching baseline reports 'current' and no drift", () => {
|
|
142
|
+
const config = { consumers: [{ name: "swarm-os", repo: "Beestera/swarm-os" }] };
|
|
143
|
+
const runGh = fakeRunGh({
|
|
144
|
+
"repos/Beestera/swarm-os": {
|
|
145
|
+
allow_squash_merge: true,
|
|
146
|
+
allow_merge_commit: false,
|
|
147
|
+
allow_rebase_merge: false,
|
|
148
|
+
squash_merge_commit_title: "PR_TITLE",
|
|
149
|
+
squash_merge_commit_message: "PR_BODY",
|
|
150
|
+
delete_branch_on_merge: true,
|
|
151
|
+
allow_auto_merge: true,
|
|
152
|
+
},
|
|
153
|
+
"repos/Beestera/swarm-os/actions/permissions/workflow": {
|
|
154
|
+
default_workflow_permissions: "read",
|
|
155
|
+
can_approve_pull_request_reviews: false,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
const report = buildReport(config, BASELINE, runGh);
|
|
159
|
+
assert.equal(report.consumers[0].status, "current");
|
|
160
|
+
assert.equal(hasDrift(report), false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("buildReport: a gh failure for one consumer surfaces as 'error', not a thrown exception", () => {
|
|
164
|
+
const config = { consumers: [{ name: "broken", repo: "owner/broken" }] };
|
|
165
|
+
const runGh = () => {
|
|
166
|
+
throw new Error("gh: repository not found");
|
|
167
|
+
};
|
|
168
|
+
const report = buildReport(config, BASELINE, runGh);
|
|
169
|
+
assert.equal(report.consumers[0].status, "error");
|
|
170
|
+
assert.match(report.consumers[0].error, /not found/);
|
|
171
|
+
assert.equal(hasDrift(report), false, "an error is not itself drift");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("renderReport renders the non-blocking framing and per-consumer rows", () => {
|
|
175
|
+
const report = {
|
|
176
|
+
baseline: BASELINE,
|
|
177
|
+
consumers: [
|
|
178
|
+
{ name: "domio", repo: "dsj1984/domio", status: "current" },
|
|
179
|
+
{
|
|
180
|
+
name: "athportal",
|
|
181
|
+
repo: "dsj1984/athportal",
|
|
182
|
+
status: "drift",
|
|
183
|
+
mismatches: [{ field: "allowMergeCommit", expected: false, actual: true }],
|
|
184
|
+
},
|
|
185
|
+
{ name: "broken", repo: "owner/broken", status: "error", error: "boom" },
|
|
186
|
+
],
|
|
187
|
+
};
|
|
188
|
+
const text = renderReport(report);
|
|
189
|
+
assert.match(text, /never a hard gate/);
|
|
190
|
+
assert.match(text, /domio.*✅ current/);
|
|
191
|
+
assert.match(text, /athportal.*❌ drift/);
|
|
192
|
+
assert.match(text, /allowMergeCommit/);
|
|
193
|
+
assert.match(text, /broken.*⚠️ error/);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("parseArgv reads --config/--baseline/--json/--strict", () => {
|
|
197
|
+
const parsed = parseArgv(["--config", "foo.json", "--baseline", "bar.json", "--json", "--strict"]);
|
|
198
|
+
assert.deepEqual(parsed, { config: "foo.json", baseline: "bar.json", json: true, strict: true });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("parseArgv defaults to the shared pin-drift consumer registry and the runbook baseline", () => {
|
|
202
|
+
const parsed = parseArgv([]);
|
|
203
|
+
assert.equal(parsed.config, "scripts/pin-drift-consumers.json");
|
|
204
|
+
assert.equal(parsed.baseline, "docs/runbooks/repo-settings.json");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
let tmpDir;
|
|
208
|
+
|
|
209
|
+
beforeEach(() => {
|
|
210
|
+
tmpDir = mkdtempSync(join(tmpdir(), "repo-settings-test-"));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
afterEach(() => {
|
|
214
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("runCli --strict exits 1 when drift is present; exits 0 without --strict (non-blocking default)", () => {
|
|
218
|
+
const config = { consumers: [{ name: "domio", repo: "dsj1984/domio" }] };
|
|
219
|
+
const runGh = fakeRunGh({
|
|
220
|
+
"repos/dsj1984/domio": {
|
|
221
|
+
allow_squash_merge: true,
|
|
222
|
+
allow_merge_commit: true, // drift
|
|
223
|
+
allow_rebase_merge: false,
|
|
224
|
+
squash_merge_commit_title: "PR_TITLE",
|
|
225
|
+
squash_merge_commit_message: "PR_BODY",
|
|
226
|
+
delete_branch_on_merge: true,
|
|
227
|
+
allow_auto_merge: true,
|
|
228
|
+
},
|
|
229
|
+
"repos/dsj1984/domio/actions/permissions/workflow": {
|
|
230
|
+
default_workflow_permissions: "read",
|
|
231
|
+
can_approve_pull_request_reviews: false,
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const stdout = { write: () => {} };
|
|
236
|
+
const stderr = { write: () => {} };
|
|
237
|
+
|
|
238
|
+
const configPath = join(tmpDir, "consumers.json");
|
|
239
|
+
const baselinePath = join(tmpDir, "baseline.json");
|
|
240
|
+
writeFileSync(configPath, JSON.stringify(config));
|
|
241
|
+
writeFileSync(baselinePath, JSON.stringify(BASELINE));
|
|
242
|
+
|
|
243
|
+
const exitNonStrict = runCli({
|
|
244
|
+
argv: ["--config", configPath, "--baseline", baselinePath],
|
|
245
|
+
cwd: tmpDir,
|
|
246
|
+
stdout,
|
|
247
|
+
stderr,
|
|
248
|
+
runGh,
|
|
249
|
+
summaryPath: undefined,
|
|
250
|
+
});
|
|
251
|
+
assert.equal(exitNonStrict, 0, "non-strict never fails on drift");
|
|
252
|
+
|
|
253
|
+
const exitStrict = runCli({
|
|
254
|
+
argv: ["--config", configPath, "--baseline", baselinePath, "--strict"],
|
|
255
|
+
cwd: tmpDir,
|
|
256
|
+
stdout,
|
|
257
|
+
stderr,
|
|
258
|
+
runGh,
|
|
259
|
+
summaryPath: undefined,
|
|
260
|
+
});
|
|
261
|
+
assert.equal(exitStrict, 1, "--strict fails on drift");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("runCli --json emits a machine-readable envelope with the drift verdict", () => {
|
|
265
|
+
const config = { consumers: [{ name: "swarm-os", repo: "Beestera/swarm-os" }] };
|
|
266
|
+
const runGh = fakeRunGh({
|
|
267
|
+
"repos/Beestera/swarm-os": {
|
|
268
|
+
allow_squash_merge: true,
|
|
269
|
+
allow_merge_commit: false,
|
|
270
|
+
allow_rebase_merge: false,
|
|
271
|
+
squash_merge_commit_title: "PR_TITLE",
|
|
272
|
+
squash_merge_commit_message: "PR_BODY",
|
|
273
|
+
delete_branch_on_merge: true,
|
|
274
|
+
allow_auto_merge: true,
|
|
275
|
+
},
|
|
276
|
+
"repos/Beestera/swarm-os/actions/permissions/workflow": {
|
|
277
|
+
default_workflow_permissions: "read",
|
|
278
|
+
can_approve_pull_request_reviews: false,
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
let out = "";
|
|
283
|
+
const stdout = { write: (s) => (out += s) };
|
|
284
|
+
const stderr = { write: () => {} };
|
|
285
|
+
|
|
286
|
+
const configPath = join(tmpDir, "consumers.json");
|
|
287
|
+
const baselinePath = join(tmpDir, "baseline.json");
|
|
288
|
+
writeFileSync(configPath, JSON.stringify(config));
|
|
289
|
+
writeFileSync(baselinePath, JSON.stringify(BASELINE));
|
|
290
|
+
|
|
291
|
+
const exit = runCli({
|
|
292
|
+
argv: ["--config", configPath, "--baseline", baselinePath, "--json"],
|
|
293
|
+
cwd: tmpDir,
|
|
294
|
+
stdout,
|
|
295
|
+
stderr,
|
|
296
|
+
runGh,
|
|
297
|
+
summaryPath: undefined,
|
|
298
|
+
});
|
|
299
|
+
assert.equal(exit, 0);
|
|
300
|
+
const parsed = JSON.parse(out);
|
|
301
|
+
assert.equal(parsed.kind, "repo-settings-report");
|
|
302
|
+
assert.equal(parsed.drift, false);
|
|
303
|
+
assert.equal(parsed.consumers[0].status, "current");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("runCli exits 1 on a fatal config-read error (missing file)", () => {
|
|
307
|
+
let errOut = "";
|
|
308
|
+
const exit = runCli({
|
|
309
|
+
argv: ["--config", join(tmpDir, "does-not-exist.json")],
|
|
310
|
+
cwd: tmpDir,
|
|
311
|
+
stdout: { write: () => {} },
|
|
312
|
+
stderr: { write: (s) => (errOut += s) },
|
|
313
|
+
runGh: () => {
|
|
314
|
+
throw new Error("should not be called");
|
|
315
|
+
},
|
|
316
|
+
summaryPath: undefined,
|
|
317
|
+
});
|
|
318
|
+
assert.equal(exit, 1);
|
|
319
|
+
assert.match(errOut, /failed to read config/);
|
|
320
|
+
});
|