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.
Files changed (43) hide show
  1. package/README.md +254 -34
  2. package/config/commitlint.base.mjs +36 -0
  3. package/config/edge-security/rate-limit.mjs +103 -20
  4. package/config/repo-settings.schema.json +78 -0
  5. package/default.json +4 -19
  6. package/package.json +2 -1
  7. package/scripts/apply-uptime-monitors.mjs +378 -0
  8. package/scripts/apply-uptime-monitors.test.mjs +372 -0
  9. package/scripts/audit-check.mjs +321 -180
  10. package/scripts/audit-check.test.mjs +263 -0
  11. package/scripts/check-action-pins.mjs +106 -173
  12. package/scripts/check-coverage-threshold.mjs +44 -6
  13. package/scripts/check-coverage-threshold.test.mjs +43 -0
  14. package/scripts/check-docs-staleness.mjs +130 -81
  15. package/scripts/check-docs-staleness.test.mjs +130 -0
  16. package/scripts/check-pin-drift.mjs +61 -110
  17. package/scripts/check-pin-drift.test.mjs +175 -3
  18. package/scripts/check-repo-settings.mjs +363 -0
  19. package/scripts/check-repo-settings.test.mjs +320 -0
  20. package/scripts/check-required-contexts.mjs +247 -129
  21. package/scripts/check-required-contexts.test.mjs +137 -0
  22. package/scripts/check-ruleset.mjs +435 -0
  23. package/scripts/check-ruleset.test.mjs +439 -0
  24. package/scripts/check-workflow-portability.mjs +163 -118
  25. package/scripts/check-workflow-portability.test.mjs +199 -0
  26. package/scripts/check-wrangler-baseline.mjs +514 -0
  27. package/scripts/check-wrangler-baseline.test.mjs +454 -0
  28. package/scripts/edge-security.test.mjs +81 -1
  29. package/scripts/lib/args.mjs +93 -0
  30. package/scripts/lib/args.test.mjs +152 -0
  31. package/scripts/lib/gh-json.mjs +119 -0
  32. package/scripts/lib/semver-duration.mjs +84 -0
  33. package/scripts/lib/uses-pins.mjs +220 -0
  34. package/scripts/lib/uses-pins.test.mjs +219 -0
  35. package/scripts/lib/walk.mjs +74 -0
  36. package/scripts/platform-repair.mjs +9 -3
  37. package/scripts/platform-sync.mjs +533 -5
  38. package/scripts/platform-sync.test.mjs +477 -0
  39. package/scripts/update-semgrep-rules.mjs +76 -5
  40. package/templates/runbooks/README.md +9 -5
  41. package/templates/runbooks/branch-protection-setup.md +9 -3
  42. package/templates/workflows/deploy-staging.yml +86 -0
  43. 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 tag / branch / floating tag to pin to.",
115
- " --dry-run plan only; no disk writes.",
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
- if (!opts.ref) fail("--ref <release-tag|branch|floating-tag> is required.");
144
- if (opts.sha && !/^[0-9a-fA-F]{40}$/.test(opts.sha)) {
145
- fail(`--sha must be a 40-character hex commit SHA (got: ${opts.sha}).`);
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
  },