mandrel-platform 0.17.2 → 0.19.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 +254 -34
- package/config/commitlint.base.mjs +36 -0
- package/config/edge-security/rate-limit.mjs +103 -20
- package/config/repo-settings.schema.json +78 -0
- package/default.json +4 -19
- package/package.json +2 -1
- package/scripts/apply-uptime-monitors.mjs +378 -0
- package/scripts/apply-uptime-monitors.test.mjs +372 -0
- package/scripts/audit-check.mjs +321 -180
- package/scripts/audit-check.test.mjs +263 -0
- package/scripts/check-action-pins.mjs +106 -173
- package/scripts/check-coverage-threshold.mjs +44 -6
- package/scripts/check-coverage-threshold.test.mjs +43 -0
- package/scripts/check-docs-staleness.mjs +130 -81
- package/scripts/check-docs-staleness.test.mjs +130 -0
- package/scripts/check-pin-drift.mjs +61 -110
- package/scripts/check-pin-drift.test.mjs +175 -3
- 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-workflow-portability.mjs +163 -118
- package/scripts/check-workflow-portability.test.mjs +199 -0
- package/scripts/check-wrangler-baseline.mjs +514 -0
- package/scripts/check-wrangler-baseline.test.mjs +454 -0
- package/scripts/edge-security.test.mjs +81 -1
- package/scripts/lib/args.mjs +93 -0
- package/scripts/lib/args.test.mjs +152 -0
- package/scripts/lib/gh-json.mjs +119 -0
- package/scripts/lib/semver-duration.mjs +84 -0
- package/scripts/lib/uses-pins.mjs +220 -0
- package/scripts/lib/uses-pins.test.mjs +219 -0
- package/scripts/lib/walk.mjs +74 -0
- package/scripts/platform-repair.mjs +9 -3
- package/scripts/platform-sync.mjs +533 -5
- package/scripts/platform-sync.test.mjs +477 -0
- package/scripts/update-semgrep-rules.mjs +76 -5
- package/templates/runbooks/README.md +9 -5
- package/templates/runbooks/branch-protection-setup.md +9 -3
- package/templates/workflows/deploy-staging.yml +86 -0
- package/templates/workflows/uptime-apply.yml +54 -0
|
@@ -28,6 +28,29 @@
|
|
|
28
28
|
* the SSOT (`github>dsj1984/mandrel-platform` for Renovate,
|
|
29
29
|
* `mandrel-platform/tsconfig.base.json` for TypeScript).
|
|
30
30
|
*
|
|
31
|
+
* It also runs a fourth, **advisory-only** check (Story #173): whether the
|
|
32
|
+
* consumer's `.github/workflows/ci.yml` matches the canonical CI-caller
|
|
33
|
+
* naming triplet (file `ci.yml`, display name `CI`, caller job id `ci` →
|
|
34
|
+
* required context `ci / ci-required`; see
|
|
35
|
+
* `docs/reusable-workflows.md` § "Canonical caller naming"). This never
|
|
36
|
+
* renames or rewrites anything — renaming an existing caller must land
|
|
37
|
+
* atomically with its own branch-protection ruleset context update, which is
|
|
38
|
+
* a deliberate per-consumer Story, not an automatic sync side-effect.
|
|
39
|
+
*
|
|
40
|
+
* It also materializes canonical workflow **caller templates** from
|
|
41
|
+
* `templates/workflows/` into the consumer's `.github/workflows/` (Story
|
|
42
|
+
* #175) — e.g. `deploy-staging.yml`, the one-paved-road `workflow_run` caller
|
|
43
|
+
* for the shared `deploy-cloudflare.yml`'s CI-green guard. Same link-don't-
|
|
44
|
+
* copy semantics as the runbook stubs: never overwrites an existing file.
|
|
45
|
+
*
|
|
46
|
+
* A fifth, **report-only** GitHub-side mode (Story #178, `--check-ruleset`)
|
|
47
|
+
* reads a consumer's LIVE branch ruleset over the GitHub Rulesets API and
|
|
48
|
+
* reports drift against `docs/runbooks/main-protection.json` — the same
|
|
49
|
+
* non-blocking posture as `--check-settings`, but deliberately with no
|
|
50
|
+
* `--apply-ruleset` counterpart: a ruleset gates merge eligibility for every
|
|
51
|
+
* in-flight PR, so an automated PATCH here has a materially different blast
|
|
52
|
+
* radius than the same-repo settings toggles `--apply-settings` patches.
|
|
53
|
+
*
|
|
31
54
|
* Idempotent: re-running on an already-synced consumer makes no changes and
|
|
32
55
|
* reports `unchanged`. `--dry-run` prints the planned diff without touching
|
|
33
56
|
* disk or the network mutation.
|
|
@@ -82,6 +105,20 @@ const opts = {
|
|
|
82
105
|
templates: null,
|
|
83
106
|
repo: "dsj1984/mandrel-platform",
|
|
84
107
|
json: false,
|
|
108
|
+
// GitHub-side repo-settings check/apply mode (Story #171). This is a
|
|
109
|
+
// distinct mode from the local-checkout file sync above — it operates over
|
|
110
|
+
// the GitHub API against a `--consumer-repo owner/repo` slug rather than a
|
|
111
|
+
// local `--consumer <dir>` checkout, so it short-circuits main() below
|
|
112
|
+
// rather than composing with the pin/runbook/extends reconciliation.
|
|
113
|
+
checkSettings: false,
|
|
114
|
+
applySettings: false,
|
|
115
|
+
consumerRepo: null,
|
|
116
|
+
baseline: null,
|
|
117
|
+
// GitHub-side branch-ruleset drift check (Story #178). Report-only by
|
|
118
|
+
// design — see the SETTINGS_PATCHABLE_FIELDS comment below for why
|
|
119
|
+
// rulesets deliberately have no --apply-ruleset counterpart.
|
|
120
|
+
checkRuleset: false,
|
|
121
|
+
contract: null,
|
|
85
122
|
};
|
|
86
123
|
|
|
87
124
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -93,6 +130,12 @@ for (let i = 0; i < args.length; i++) {
|
|
|
93
130
|
else if (a === "--templates" && args[i + 1]) opts.templates = resolve(args[++i]);
|
|
94
131
|
else if (a === "--repo" && args[i + 1]) opts.repo = args[++i];
|
|
95
132
|
else if (a === "--json") opts.json = true;
|
|
133
|
+
else if (a === "--check-settings") opts.checkSettings = true;
|
|
134
|
+
else if (a === "--apply-settings") opts.applySettings = true;
|
|
135
|
+
else if (a === "--consumer-repo" && args[i + 1]) opts.consumerRepo = args[++i];
|
|
136
|
+
else if (a === "--baseline" && args[i + 1]) opts.baseline = resolve(args[++i]);
|
|
137
|
+
else if (a === "--check-ruleset") opts.checkRuleset = true;
|
|
138
|
+
else if (a === "--contract" && args[i + 1]) opts.contract = resolve(args[++i]);
|
|
96
139
|
else if (a === "--help" || a === "-h") {
|
|
97
140
|
printHelp();
|
|
98
141
|
process.exit(0);
|
|
@@ -111,14 +154,32 @@ function printHelp() {
|
|
|
111
154
|
" node node_modules/mandrel-platform/scripts/platform-sync.mjs --ref <ref> [--dry-run]",
|
|
112
155
|
"",
|
|
113
156
|
"Flags:",
|
|
114
|
-
" --ref <ref> (required) release
|
|
115
|
-
"
|
|
157
|
+
" --ref <ref> (required unless --check-settings/--apply-settings) release",
|
|
158
|
+
" tag / branch / floating tag to pin to.",
|
|
159
|
+
" --dry-run plan only; no disk writes / no GitHub-side mutation.",
|
|
116
160
|
" --sha <40-hex> skip ref->SHA resolution; pin to this SHA (offline mode).",
|
|
117
161
|
" --consumer <dir> consumer repo root (default: cwd).",
|
|
118
162
|
" --templates <dir> mandrel-platform templates/ dir (default: resolved from script).",
|
|
119
163
|
" --repo <owner/repo> first-party slug to pin (default: dsj1984/mandrel-platform).",
|
|
120
164
|
" --json emit the result envelope as JSON.",
|
|
121
165
|
"",
|
|
166
|
+
"GitHub-side repo-settings check/apply (Story #171):",
|
|
167
|
+
" --check-settings read a consumer's LIVE repo settings via `gh api` and",
|
|
168
|
+
" report drift against the baseline (never blocks; report only).",
|
|
169
|
+
" --apply-settings same read, then PATCH the drifted fields to match the",
|
|
170
|
+
" baseline (safe subset only — see README). Implies --check-settings.",
|
|
171
|
+
" --consumer-repo <owner/repo> (required with --check-settings/--apply-settings) the",
|
|
172
|
+
" consumer repo to read/patch over the GitHub API.",
|
|
173
|
+
" --baseline <path> path to the repo-settings baseline JSON",
|
|
174
|
+
" (default: docs/runbooks/repo-settings.json next to this script's package).",
|
|
175
|
+
"",
|
|
176
|
+
"GitHub-side branch-ruleset drift check (Story #178, report-only — no --apply-ruleset):",
|
|
177
|
+
" --check-ruleset read a consumer's LIVE branch ruleset via `gh api` and report",
|
|
178
|
+
" drift against the main-protection contract (never blocks; never mutates).",
|
|
179
|
+
" --consumer-repo <owner/repo> (required with --check-ruleset) the consumer repo to read.",
|
|
180
|
+
" --contract <path> path to the main-protection contract JSON",
|
|
181
|
+
" (default: docs/runbooks/main-protection.json next to this script's package).",
|
|
182
|
+
"",
|
|
122
183
|
].join("\n")
|
|
123
184
|
);
|
|
124
185
|
}
|
|
@@ -140,9 +201,28 @@ function log(msg) {
|
|
|
140
201
|
// Defaults requiring resolution
|
|
141
202
|
// ---------------------------------------------------------------------------
|
|
142
203
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
204
|
+
const settingsMode = opts.checkSettings || opts.applySettings;
|
|
205
|
+
const rulesetMode = opts.checkRuleset;
|
|
206
|
+
|
|
207
|
+
if (settingsMode) {
|
|
208
|
+
if (!opts.consumerRepo) {
|
|
209
|
+
fail("--consumer-repo <owner/repo> is required with --check-settings/--apply-settings.");
|
|
210
|
+
}
|
|
211
|
+
if (!opts.baseline) {
|
|
212
|
+
opts.baseline = resolve(__dirname, "..", "docs", "runbooks", "repo-settings.json");
|
|
213
|
+
}
|
|
214
|
+
} else if (rulesetMode) {
|
|
215
|
+
if (!opts.consumerRepo) {
|
|
216
|
+
fail("--consumer-repo <owner/repo> is required with --check-ruleset.");
|
|
217
|
+
}
|
|
218
|
+
if (!opts.contract) {
|
|
219
|
+
opts.contract = resolve(__dirname, "..", "docs", "runbooks", "main-protection.json");
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
if (!opts.ref) fail("--ref <release-tag|branch|floating-tag> is required.");
|
|
223
|
+
if (opts.sha && !/^[0-9a-fA-F]{40}$/.test(opts.sha)) {
|
|
224
|
+
fail(`--sha must be a 40-character hex commit SHA (got: ${opts.sha}).`);
|
|
225
|
+
}
|
|
146
226
|
}
|
|
147
227
|
|
|
148
228
|
// templates/ defaults to the dir adjacent to this script's package root.
|
|
@@ -152,6 +232,7 @@ if (!opts.templates) {
|
|
|
152
232
|
opts.templates = resolve(__dirname, "..", "templates");
|
|
153
233
|
}
|
|
154
234
|
const runbookTemplatesDir = join(opts.templates, "runbooks");
|
|
235
|
+
const workflowTemplatesDir = join(opts.templates, "workflows");
|
|
155
236
|
|
|
156
237
|
// ---------------------------------------------------------------------------
|
|
157
238
|
// 1. Resolve the chosen ref → commit SHA
|
|
@@ -254,6 +335,53 @@ function pinWorkflows(targetSha) {
|
|
|
254
335
|
return changes;
|
|
255
336
|
}
|
|
256
337
|
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// 2a. Canonical CI-caller-naming advisory (Story #173)
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Non-blocking advisory: does the consumer's `.github/workflows/` carry the
|
|
344
|
+
* canonical `ci.yml` / `CI` / `ci` caller triplet documented in
|
|
345
|
+
* docs/reusable-workflows.md § "Canonical caller naming"? This never mutates
|
|
346
|
+
* anything — renaming an existing caller file/job id is a deliberate,
|
|
347
|
+
* atomic per-consumer migration (rename + branch-protection ruleset context
|
|
348
|
+
* update together), never an automatic sync side-effect. Mirrors the
|
|
349
|
+
* warn-only posture of `check-required-contexts.mjs`'s naming lint.
|
|
350
|
+
*/
|
|
351
|
+
function checkCiCallerNaming() {
|
|
352
|
+
const workflowsDir = join(opts.consumer, ".github", "workflows");
|
|
353
|
+
const files = collectYaml(workflowsDir).map((f) => rel(f));
|
|
354
|
+
const canonicalPath = files.find((f) => f === ".github/workflows/ci.yml");
|
|
355
|
+
|
|
356
|
+
if (!canonicalPath) {
|
|
357
|
+
return {
|
|
358
|
+
status: "no-canonical-file",
|
|
359
|
+
message:
|
|
360
|
+
`no ".github/workflows/ci.yml" found — the canonical CI caller naming is ` +
|
|
361
|
+
`file "ci.yml", display name "CI", caller job id "ci" (required context ` +
|
|
362
|
+
`"ci / ci-required"). See docs/reusable-workflows.md § "Canonical caller naming".`,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const content = readFileSync(join(opts.consumer, canonicalPath), "utf8");
|
|
367
|
+
const nameMatch = content.match(/^name:\s*(.+?)\s*$/m);
|
|
368
|
+
const displayName = nameMatch ? nameMatch[1].replace(/^["']|["']$/g, "") : null;
|
|
369
|
+
const hasCiJob = /^\s{2}ci:\s*$/m.test(content);
|
|
370
|
+
|
|
371
|
+
if (displayName === "CI" && hasCiJob) {
|
|
372
|
+
return { status: "canonical", message: null };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const gaps = [];
|
|
376
|
+
if (displayName !== "CI") gaps.push(`display name is "${displayName ?? "(none)"}" (canonical: "CI")`);
|
|
377
|
+
if (!hasCiJob) gaps.push(`no "ci" job id found (canonical required context: "ci / ci-required")`);
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
status: "non-canonical",
|
|
381
|
+
message: `ci.yml found, but ${gaps.join(" and ")}. See docs/reusable-workflows.md § "Canonical caller naming".`,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
257
385
|
// ---------------------------------------------------------------------------
|
|
258
386
|
// 3. Materialize runbook reference stubs (link, don't copy)
|
|
259
387
|
// ---------------------------------------------------------------------------
|
|
@@ -300,6 +428,55 @@ function materializeRunbooks() {
|
|
|
300
428
|
return { created, skipped, localCopies };
|
|
301
429
|
}
|
|
302
430
|
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
// 3a. Materialize workflow caller templates (link, don't copy) — Story #175
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
// Every materialized workflow template names itself as a "canonical staging-
|
|
436
|
+
// deploy caller template" in its header comment — used the same way
|
|
437
|
+
// STUB_MARKER is used for runbooks: detect an already-materialized (or
|
|
438
|
+
// operator-filled-in) file so re-runs are idempotent and an operator's own
|
|
439
|
+
// customized caller is never clobbered.
|
|
440
|
+
const WORKFLOW_TEMPLATE_MARKER = "Canonical staging-deploy caller template";
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Copy each `templates/workflows/*.yml` into the consumer's
|
|
444
|
+
* `.github/workflows/`, but only when the destination is ABSENT — mirrors
|
|
445
|
+
* `materializeRunbooks`'s link-don't-copy / never-clobber semantics. An
|
|
446
|
+
* existing destination is left untouched; if it doesn't carry the template
|
|
447
|
+
* marker, it's surfaced as a `localCopy` warning so the operator can
|
|
448
|
+
* reconcile a hand-authored caller against the canonical template by hand.
|
|
449
|
+
*/
|
|
450
|
+
function materializeWorkflowStubs() {
|
|
451
|
+
const created = [];
|
|
452
|
+
const skipped = [];
|
|
453
|
+
const localCopies = [];
|
|
454
|
+
if (!existsSync(workflowTemplatesDir)) {
|
|
455
|
+
return { created, skipped, localCopies };
|
|
456
|
+
}
|
|
457
|
+
const destDir = join(opts.consumer, ".github", "workflows");
|
|
458
|
+
for (const entry of readdirSync(workflowTemplatesDir)) {
|
|
459
|
+
if (!/\.ya?ml$/.test(entry)) continue;
|
|
460
|
+
const src = join(workflowTemplatesDir, entry);
|
|
461
|
+
const dest = join(destDir, entry);
|
|
462
|
+
if (existsSync(dest)) {
|
|
463
|
+
const body = readFileSync(dest, "utf8");
|
|
464
|
+
if (body.includes(WORKFLOW_TEMPLATE_MARKER)) {
|
|
465
|
+
skipped.push(rel(dest)); // already materialized — idempotent no-op
|
|
466
|
+
} else {
|
|
467
|
+
localCopies.push(rel(dest)); // hand-authored caller — operator must reconcile
|
|
468
|
+
}
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (!opts.dryRun) {
|
|
472
|
+
mkdirSync(destDir, { recursive: true });
|
|
473
|
+
writeFileSync(dest, readFileSync(src, "utf8"));
|
|
474
|
+
}
|
|
475
|
+
created.push(rel(dest));
|
|
476
|
+
}
|
|
477
|
+
return { created, skipped, localCopies };
|
|
478
|
+
}
|
|
479
|
+
|
|
303
480
|
// ---------------------------------------------------------------------------
|
|
304
481
|
// 4. Reconcile renovate / tsconfig `extends`
|
|
305
482
|
// ---------------------------------------------------------------------------
|
|
@@ -369,6 +546,331 @@ function reconcileTsconfig() {
|
|
|
369
546
|
return { action: "reconciled", file: rel(path), added: TSCONFIG_BASE };
|
|
370
547
|
}
|
|
371
548
|
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
// 5. GitHub-side repo-settings check/apply (Story #171)
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Fields safe to PATCH automatically. Deliberately excludes nothing today —
|
|
555
|
+
* every dimension the baseline governs (merge methods, squash source,
|
|
556
|
+
* auto-merge, delete-branch-on-merge, Actions default token permissions,
|
|
557
|
+
* Actions PR-approval) is a same-repo settings toggle with no destructive
|
|
558
|
+
* blast radius, unlike e.g. branch-protection rulesets (out of scope — see
|
|
559
|
+
* the companion check-ruleset.mjs story) or anything that could strand
|
|
560
|
+
* in-flight PRs. Kept as an explicit allow-list (not "patch every mismatch")
|
|
561
|
+
* so a future baseline addition must be deliberately added here before
|
|
562
|
+
* --apply-settings will touch it.
|
|
563
|
+
*/
|
|
564
|
+
const SETTINGS_PATCHABLE_FIELDS = new Set([
|
|
565
|
+
"allowSquashMerge",
|
|
566
|
+
"allowMergeCommit",
|
|
567
|
+
"allowRebaseMerge",
|
|
568
|
+
"squashMergeCommitTitle",
|
|
569
|
+
"squashMergeCommitMessage",
|
|
570
|
+
"deleteBranchOnMerge",
|
|
571
|
+
"allowAutoMerge",
|
|
572
|
+
]);
|
|
573
|
+
const SETTINGS_ACTIONS_FIELDS = new Set([
|
|
574
|
+
"actionsDefaultWorkflowPermissions",
|
|
575
|
+
"actionsCanApprovePullRequestReviews",
|
|
576
|
+
]);
|
|
577
|
+
|
|
578
|
+
const REPO_FIELD_TO_API_KEY = {
|
|
579
|
+
allowSquashMerge: "allow_squash_merge",
|
|
580
|
+
allowMergeCommit: "allow_merge_commit",
|
|
581
|
+
allowRebaseMerge: "allow_rebase_merge",
|
|
582
|
+
squashMergeCommitTitle: "squash_merge_commit_title",
|
|
583
|
+
squashMergeCommitMessage: "squash_merge_commit_message",
|
|
584
|
+
deleteBranchOnMerge: "delete_branch_on_merge",
|
|
585
|
+
allowAutoMerge: "allow_auto_merge",
|
|
586
|
+
};
|
|
587
|
+
const ACTIONS_FIELD_TO_API_KEY = {
|
|
588
|
+
actionsDefaultWorkflowPermissions: "default_workflow_permissions",
|
|
589
|
+
actionsCanApprovePullRequestReviews: "can_approve_pull_request_reviews",
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
function ghApiJson(apiPath) {
|
|
593
|
+
const raw = execFileSync("gh", ["api", apiPath, "-H", "Accept: application/vnd.github+json"], {
|
|
594
|
+
encoding: "utf8",
|
|
595
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
596
|
+
});
|
|
597
|
+
return JSON.parse(raw);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function ghApiPatch(apiPath, fields) {
|
|
601
|
+
const args = ["api", "-X", "PATCH", apiPath, "-H", "Accept: application/vnd.github+json"];
|
|
602
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
603
|
+
// -f serializes as a string field; -F lets gh infer type (bool/number)
|
|
604
|
+
// from the literal, which is what PATCH /repos and the Actions
|
|
605
|
+
// permissions endpoint both expect for boolean fields.
|
|
606
|
+
args.push("-F", `${key}=${value}`);
|
|
607
|
+
}
|
|
608
|
+
execFileSync("gh", args, { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Read a consumer's live settings across both endpoints, mapped to the baseline's camelCase field shape. */
|
|
612
|
+
function fetchLiveSettings(repo) {
|
|
613
|
+
const repoPayload = ghApiJson(`repos/${repo}`);
|
|
614
|
+
const actionsPayload = ghApiJson(`repos/${repo}/actions/permissions/workflow`);
|
|
615
|
+
const live = {};
|
|
616
|
+
for (const field of SETTINGS_PATCHABLE_FIELDS) live[field] = repoPayload[REPO_FIELD_TO_API_KEY[field]];
|
|
617
|
+
for (const field of SETTINGS_ACTIONS_FIELDS) live[field] = actionsPayload[ACTIONS_FIELD_TO_API_KEY[field]];
|
|
618
|
+
return live;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Diff `live` against `baseline` for every baseline-declared field. Unknown/extra baseline keys are ignored. */
|
|
622
|
+
function diffSettings(live, baseline) {
|
|
623
|
+
const mismatches = [];
|
|
624
|
+
for (const field of [...SETTINGS_PATCHABLE_FIELDS, ...SETTINGS_ACTIONS_FIELDS]) {
|
|
625
|
+
if (!(field in baseline)) continue;
|
|
626
|
+
if (live[field] !== baseline[field]) {
|
|
627
|
+
mismatches.push({ field, expected: baseline[field], actual: live[field] });
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return mismatches;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Apply the drifted fields to the consumer repo via two PATCH calls (one per
|
|
635
|
+
* endpoint — GitHub does not expose a combined settings write surface).
|
|
636
|
+
* Never touches a field absent from `mismatches`.
|
|
637
|
+
*/
|
|
638
|
+
function applySettings(repo, mismatches) {
|
|
639
|
+
const repoPatch = {};
|
|
640
|
+
const actionsPatch = {};
|
|
641
|
+
for (const { field, expected } of mismatches) {
|
|
642
|
+
if (SETTINGS_PATCHABLE_FIELDS.has(field)) repoPatch[REPO_FIELD_TO_API_KEY[field]] = expected;
|
|
643
|
+
else if (SETTINGS_ACTIONS_FIELDS.has(field)) actionsPatch[ACTIONS_FIELD_TO_API_KEY[field]] = expected;
|
|
644
|
+
}
|
|
645
|
+
if (Object.keys(repoPatch).length > 0) ghApiPatch(`repos/${repo}`, repoPatch);
|
|
646
|
+
if (Object.keys(actionsPatch).length > 0) ghApiPatch(`repos/${repo}/actions/permissions/workflow`, actionsPatch);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function runSettingsMode() {
|
|
650
|
+
let baseline;
|
|
651
|
+
try {
|
|
652
|
+
baseline = JSON.parse(readFileSync(opts.baseline, "utf8"));
|
|
653
|
+
} catch (err) {
|
|
654
|
+
fail(`could not read baseline at ${opts.baseline}: ${err.message}`);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
log(`▶ platform-sync — repo-settings ${opts.applySettings ? "check+apply" : "check"} for ${opts.consumerRepo}`);
|
|
658
|
+
log(" (non-blocking: drift is reported, never a hard gate — standing decision #10)");
|
|
659
|
+
|
|
660
|
+
let live;
|
|
661
|
+
let mismatches;
|
|
662
|
+
let error = null;
|
|
663
|
+
try {
|
|
664
|
+
live = fetchLiveSettings(opts.consumerRepo);
|
|
665
|
+
mismatches = diffSettings(live, baseline);
|
|
666
|
+
} catch (err) {
|
|
667
|
+
error = err.message;
|
|
668
|
+
live = null;
|
|
669
|
+
mismatches = [];
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
let applied = false;
|
|
673
|
+
if (opts.applySettings && mismatches.length > 0 && !error) {
|
|
674
|
+
if (opts.dryRun) {
|
|
675
|
+
log(` (dry-run: would PATCH ${mismatches.length} field(s) on ${opts.consumerRepo})`);
|
|
676
|
+
} else {
|
|
677
|
+
applySettings(opts.consumerRepo, mismatches);
|
|
678
|
+
applied = true;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
log("");
|
|
683
|
+
if (error) {
|
|
684
|
+
log(` ⚠️ error reading ${opts.consumerRepo}: ${error}`);
|
|
685
|
+
} else if (mismatches.length === 0) {
|
|
686
|
+
log(` ✅ ${opts.consumerRepo} matches the repo-settings baseline — no drift.`);
|
|
687
|
+
} else {
|
|
688
|
+
log(` ❌ drift on ${opts.consumerRepo}:`);
|
|
689
|
+
for (const m of mismatches) {
|
|
690
|
+
log(` - ${m.field}: expected ${JSON.stringify(m.expected)}, got ${JSON.stringify(m.actual)}`);
|
|
691
|
+
}
|
|
692
|
+
if (applied) log(` ✅ applied: ${mismatches.length} field(s) patched to match the baseline.`);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (opts.json) {
|
|
696
|
+
process.stdout.write(
|
|
697
|
+
`${JSON.stringify(
|
|
698
|
+
{
|
|
699
|
+
mode: opts.applySettings ? "apply-settings" : "check-settings",
|
|
700
|
+
consumerRepo: opts.consumerRepo,
|
|
701
|
+
baseline,
|
|
702
|
+
live,
|
|
703
|
+
drift: mismatches.length > 0,
|
|
704
|
+
mismatches,
|
|
705
|
+
applied,
|
|
706
|
+
dryRun: opts.dryRun,
|
|
707
|
+
error,
|
|
708
|
+
},
|
|
709
|
+
null,
|
|
710
|
+
2
|
|
711
|
+
)}\n`
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Report-only by design (standing decision #10): drift never fails this
|
|
716
|
+
// command's exit code. A hard error reading the consumer IS fatal.
|
|
717
|
+
if (error) process.exit(1);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ---------------------------------------------------------------------------
|
|
721
|
+
// 6. GitHub-side branch-ruleset drift check (Story #178)
|
|
722
|
+
// ---------------------------------------------------------------------------
|
|
723
|
+
//
|
|
724
|
+
// Report-only — deliberately no --apply-ruleset. Unlike the repo-settings
|
|
725
|
+
// toggles above (same-repo settings PATCHes with no destructive blast
|
|
726
|
+
// radius), a branch ruleset governs merge eligibility for every in-flight
|
|
727
|
+
// PR; an automated PATCH here could strand a PR mid-review. This mode only
|
|
728
|
+
// reads and reports (see the companion scripts/check-ruleset.mjs, which
|
|
729
|
+
// this composes with — same contract, same consumer-registry shape, same
|
|
730
|
+
// non-blocking posture).
|
|
731
|
+
|
|
732
|
+
/** Find the ruleset (from a full per-ruleset detail fetch) targeting refs/heads/<branch>. */
|
|
733
|
+
function findRuleset(rulesets, branch) {
|
|
734
|
+
const targetRef = `refs/heads/${branch}`;
|
|
735
|
+
return (
|
|
736
|
+
rulesets.find((rs) => {
|
|
737
|
+
if (rs.enforcement !== "active") return false;
|
|
738
|
+
const include = rs.conditions?.ref_name?.include ?? [];
|
|
739
|
+
return include.includes(targetRef) || include.includes("~DEFAULT_BRANCH");
|
|
740
|
+
}) ?? null
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Map a full ruleset object's rules[] into the main-protection contract's field shape. */
|
|
745
|
+
function mapRuleset(ruleset) {
|
|
746
|
+
const rules = Array.isArray(ruleset.rules) ? ruleset.rules : [];
|
|
747
|
+
const byType = Object.fromEntries(rules.map((r) => [r.type, r]));
|
|
748
|
+
const statusCheckRule = byType.required_status_checks;
|
|
749
|
+
const statusChecks = (statusCheckRule?.parameters?.required_status_checks ?? []).map((c) => c.context);
|
|
750
|
+
const bypassActors = Array.isArray(ruleset.bypass_actors) ? ruleset.bypass_actors : [];
|
|
751
|
+
return {
|
|
752
|
+
pullRequestRequired: Boolean(byType.pull_request),
|
|
753
|
+
bypassActorsEmpty: bypassActors.length === 0,
|
|
754
|
+
requiredStatusChecks: statusChecks,
|
|
755
|
+
strictRequiredStatusChecksPolicy: Boolean(statusCheckRule?.parameters?.strict_required_status_checks_policy),
|
|
756
|
+
allowForcePushes: !byType.non_fast_forward,
|
|
757
|
+
allowDeletions: !byType.deletion,
|
|
758
|
+
requireLinearHistory: Boolean(byType.required_linear_history),
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Diff a mapped live ruleset against the main-protection contract. */
|
|
763
|
+
function diffRuleset(live, contract) {
|
|
764
|
+
const mismatches = [];
|
|
765
|
+
if (contract.requiredStatusChecks !== undefined) {
|
|
766
|
+
const expected = [...contract.requiredStatusChecks].sort();
|
|
767
|
+
const actual = [...(live.requiredStatusChecks ?? [])].sort();
|
|
768
|
+
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
|
|
769
|
+
mismatches.push({
|
|
770
|
+
field: "requiredStatusChecks",
|
|
771
|
+
expected: contract.requiredStatusChecks,
|
|
772
|
+
actual: live.requiredStatusChecks,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (live.pullRequestRequired !== true) {
|
|
777
|
+
mismatches.push({ field: "pullRequestRequired", expected: true, actual: live.pullRequestRequired });
|
|
778
|
+
}
|
|
779
|
+
if (live.bypassActorsEmpty !== true) {
|
|
780
|
+
mismatches.push({ field: "bypassActorsEmpty", expected: true, actual: live.bypassActorsEmpty });
|
|
781
|
+
}
|
|
782
|
+
if (live.strictRequiredStatusChecksPolicy !== true) {
|
|
783
|
+
mismatches.push({
|
|
784
|
+
field: "strictRequiredStatusChecksPolicy",
|
|
785
|
+
expected: true,
|
|
786
|
+
actual: live.strictRequiredStatusChecksPolicy,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
for (const [field, expected] of [
|
|
790
|
+
["requireLinearHistory", contract.requireLinearHistory],
|
|
791
|
+
["allowForcePushes", contract.allowForcePushes],
|
|
792
|
+
["allowDeletions", contract.allowDeletions],
|
|
793
|
+
]) {
|
|
794
|
+
if (expected === undefined) continue;
|
|
795
|
+
if (live[field] !== expected) mismatches.push({ field, expected, actual: live[field] });
|
|
796
|
+
}
|
|
797
|
+
return mismatches;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function runRulesetMode() {
|
|
801
|
+
let contract;
|
|
802
|
+
try {
|
|
803
|
+
contract = JSON.parse(readFileSync(opts.contract, "utf8"));
|
|
804
|
+
} catch (err) {
|
|
805
|
+
fail(`could not read contract at ${opts.contract}: ${err.message}`);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
log(`▶ platform-sync — branch-ruleset check for ${opts.consumerRepo}`);
|
|
809
|
+
log(" (non-blocking: drift is reported, never a hard gate — standing decision #10)");
|
|
810
|
+
log(" (report-only: this mode never mutates a live ruleset)");
|
|
811
|
+
|
|
812
|
+
const branch = contract.branch ?? "main";
|
|
813
|
+
let live = null;
|
|
814
|
+
let mismatches = [];
|
|
815
|
+
let error = null;
|
|
816
|
+
let status = "current";
|
|
817
|
+
try {
|
|
818
|
+
const list = ghApiJson(`repos/${opts.consumerRepo}/rulesets`);
|
|
819
|
+
const detailed = (Array.isArray(list) ? list : []).map((rs) =>
|
|
820
|
+
ghApiJson(`repos/${opts.consumerRepo}/rulesets/${rs.id}`)
|
|
821
|
+
);
|
|
822
|
+
const ruleset = findRuleset(detailed, branch);
|
|
823
|
+
if (!ruleset) {
|
|
824
|
+
status = "missing";
|
|
825
|
+
error = `no active ruleset targets refs/heads/${branch}`;
|
|
826
|
+
} else {
|
|
827
|
+
live = mapRuleset(ruleset);
|
|
828
|
+
mismatches = diffRuleset(live, contract);
|
|
829
|
+
status = mismatches.length > 0 ? "drift" : "current";
|
|
830
|
+
}
|
|
831
|
+
} catch (err) {
|
|
832
|
+
error = err.message;
|
|
833
|
+
status = "error";
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
log("");
|
|
837
|
+
if (status === "error") {
|
|
838
|
+
log(` ⚠️ error reading ${opts.consumerRepo}: ${error}`);
|
|
839
|
+
} else if (status === "missing") {
|
|
840
|
+
log(` ⚠️ ${opts.consumerRepo}: ${error}`);
|
|
841
|
+
} else if (status === "current") {
|
|
842
|
+
log(` ✅ ${opts.consumerRepo} matches the main-protection contract — no drift.`);
|
|
843
|
+
} else {
|
|
844
|
+
log(` ❌ drift on ${opts.consumerRepo}:`);
|
|
845
|
+
for (const m of mismatches) {
|
|
846
|
+
log(` - ${m.field}: expected ${JSON.stringify(m.expected)}, got ${JSON.stringify(m.actual)}`);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (opts.json) {
|
|
851
|
+
process.stdout.write(
|
|
852
|
+
`${JSON.stringify(
|
|
853
|
+
{
|
|
854
|
+
mode: "check-ruleset",
|
|
855
|
+
consumerRepo: opts.consumerRepo,
|
|
856
|
+
contract,
|
|
857
|
+
live,
|
|
858
|
+
drift: status === "drift" || status === "missing",
|
|
859
|
+
mismatches,
|
|
860
|
+
status,
|
|
861
|
+
error,
|
|
862
|
+
},
|
|
863
|
+
null,
|
|
864
|
+
2
|
|
865
|
+
)}\n`
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Report-only by design (standing decision #10): drift never fails this
|
|
870
|
+
// command's exit code. A hard error reading the consumer IS fatal.
|
|
871
|
+
if (status === "error") process.exit(1);
|
|
872
|
+
}
|
|
873
|
+
|
|
372
874
|
// ---------------------------------------------------------------------------
|
|
373
875
|
// Helpers
|
|
374
876
|
// ---------------------------------------------------------------------------
|
|
@@ -382,6 +884,15 @@ function rel(p) {
|
|
|
382
884
|
// ---------------------------------------------------------------------------
|
|
383
885
|
|
|
384
886
|
function main() {
|
|
887
|
+
if (settingsMode) {
|
|
888
|
+
runSettingsMode();
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
if (rulesetMode) {
|
|
892
|
+
runRulesetMode();
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
|
|
385
896
|
if (!existsSync(opts.consumer)) {
|
|
386
897
|
fail(`consumer dir not found: ${opts.consumer}`);
|
|
387
898
|
}
|
|
@@ -391,13 +902,16 @@ function main() {
|
|
|
391
902
|
if (opts.dryRun) log(" (dry-run: no files will be written)");
|
|
392
903
|
|
|
393
904
|
const pins = pinWorkflows(targetSha);
|
|
905
|
+
const ciNaming = checkCiCallerNaming();
|
|
394
906
|
const runbooks = materializeRunbooks();
|
|
907
|
+
const workflowStubs = materializeWorkflowStubs();
|
|
395
908
|
const renovate = reconcileRenovate();
|
|
396
909
|
const tsconfig = reconcileTsconfig();
|
|
397
910
|
|
|
398
911
|
const changed =
|
|
399
912
|
pins.length > 0 ||
|
|
400
913
|
runbooks.created.length > 0 ||
|
|
914
|
+
workflowStubs.created.length > 0 ||
|
|
401
915
|
renovate.action === "reconciled" ||
|
|
402
916
|
tsconfig.action === "reconciled";
|
|
403
917
|
|
|
@@ -405,6 +919,9 @@ function main() {
|
|
|
405
919
|
log("");
|
|
406
920
|
log(` pins: ${pins.length} workflow pin(s) ${opts.dryRun ? "would be " : ""}updated`);
|
|
407
921
|
for (const c of pins) log(` - ${c.file}: ${c.from} → ${c.to}`);
|
|
922
|
+
if (ciNaming.status !== "canonical") {
|
|
923
|
+
log(` ⚠ CI caller naming: ${ciNaming.message}`);
|
|
924
|
+
}
|
|
408
925
|
log(
|
|
409
926
|
` runbooks: ${runbooks.created.length} stub(s) ${
|
|
410
927
|
opts.dryRun ? "would be " : ""
|
|
@@ -414,6 +931,15 @@ function main() {
|
|
|
414
931
|
for (const f of runbooks.localCopies) {
|
|
415
932
|
log(` ⚠ ${f}: full local copy detected — reconcile to a reference stub by hand (§2.2)`);
|
|
416
933
|
}
|
|
934
|
+
log(
|
|
935
|
+
` workflows: ${workflowStubs.created.length} caller template(s) ${
|
|
936
|
+
opts.dryRun ? "would be " : ""
|
|
937
|
+
}materialized, ${workflowStubs.skipped.length} already present`
|
|
938
|
+
);
|
|
939
|
+
for (const f of workflowStubs.created) log(` + ${f}`);
|
|
940
|
+
for (const f of workflowStubs.localCopies) {
|
|
941
|
+
log(` ⚠ ${f}: hand-authored caller detected — reconcile against the canonical template by hand`);
|
|
942
|
+
}
|
|
417
943
|
log(` renovate: ${renovate.action}${renovate.file ? ` (${renovate.file})` : ""}`);
|
|
418
944
|
log(` tsconfig: ${tsconfig.action}${tsconfig.file ? ` (${tsconfig.file})` : ""}`);
|
|
419
945
|
log("");
|
|
@@ -436,7 +962,9 @@ function main() {
|
|
|
436
962
|
dryRun: opts.dryRun,
|
|
437
963
|
changed,
|
|
438
964
|
pins,
|
|
965
|
+
ciNaming,
|
|
439
966
|
runbooks,
|
|
967
|
+
workflowStubs,
|
|
440
968
|
renovate,
|
|
441
969
|
tsconfig,
|
|
442
970
|
},
|