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
@@ -12,6 +12,12 @@
12
12
  * overrides preserved),
13
13
  * 4. idempotency + `--dry-run` non-mutation.
14
14
  *
15
+ * Also exercises the two GitHub-side drift-check modes, which short-circuit
16
+ * the local-checkout sync above and operate over the GitHub API instead:
17
+ * 5. `--check-settings` / `--apply-settings` (Story #171, repo settings).
18
+ * 6. `--check-ruleset` (Story #178, branch rulesets — report-only, no
19
+ * `--apply-ruleset`).
20
+ *
15
21
  * Run: node scripts/platform-sync.test.mjs (or `node --test scripts/`)
16
22
  */
17
23
 
@@ -76,6 +82,7 @@ test("--dry-run does not mutate any file", () => {
76
82
  const after = readFileSync(join(consumer, ".github", "workflows", "ci.yml"), "utf8");
77
83
  assert.equal(after, before, "ci.yml must be untouched in dry-run");
78
84
  assert.ok(!existsSync(join(consumer, "docs", "runbooks", "observability.md")));
85
+ assert.ok(!existsSync(join(consumer, ".github", "workflows", "deploy-staging.yml")));
79
86
  });
80
87
 
81
88
  test("apply pins first-party SHAs, leaves external actions untouched", () => {
@@ -87,6 +94,38 @@ test("apply pins first-party SHAs, leaves external actions untouched", () => {
87
94
  assert.ok(ci.includes(`actions/checkout@${"2".repeat(40)} # external`), "external action untouched");
88
95
  });
89
96
 
97
+ test("ciNaming: flags a non-canonical job id (seeded fixture uses job id 'q', not 'ci')", () => {
98
+ const out = JSON.parse(run([]));
99
+ assert.equal(out.ciNaming.status, "non-canonical");
100
+ assert.match(out.ciNaming.message, /no "ci" job id/);
101
+ });
102
+
103
+ test("ciNaming: reports 'no-canonical-file' when no ci.yml exists", () => {
104
+ rmSync(join(consumer, ".github", "workflows", "ci.yml"));
105
+ const out = JSON.parse(run([]));
106
+ assert.equal(out.ciNaming.status, "no-canonical-file");
107
+ });
108
+
109
+ test("ciNaming: reports 'canonical' when ci.yml matches the triplet (display name CI + job id ci)", () => {
110
+ writeFileSync(
111
+ join(consumer, ".github", "workflows", "ci.yml"),
112
+ ["name: CI", "jobs:", " ci:", " steps:", " - run: echo hi", ""].join("\n")
113
+ );
114
+ const out = JSON.parse(run([]));
115
+ assert.equal(out.ciNaming.status, "canonical");
116
+ assert.equal(out.ciNaming.message, null);
117
+ });
118
+
119
+ test("ciNaming never mutates ci.yml — it is advisory only", () => {
120
+ const before = readFileSync(join(consumer, ".github", "workflows", "ci.yml"), "utf8");
121
+ run([]);
122
+ const after = readFileSync(join(consumer, ".github", "workflows", "ci.yml"), "utf8");
123
+ // Only the pin rewrite (already covered above) may change bytes; re-read the
124
+ // *naming* fields specifically — name:/job id text must be byte-identical.
125
+ assert.equal(before.match(/^name:.*$/m)?.[0], after.match(/^name:.*$/m)?.[0]);
126
+ assert.equal(before.includes(" q:"), after.includes(" q:"));
127
+ });
128
+
90
129
  test("apply materializes runbook reference stubs (link, don't copy)", () => {
91
130
  run([]);
92
131
  const stub = join(consumer, "docs", "runbooks", "deploy-promotion.md");
@@ -138,3 +177,441 @@ test("an existing reference stub is skipped idempotently", () => {
138
177
  assert.ok(out.runbooks.skipped.length >= 8, "already-present stubs are skipped, not re-created");
139
178
  assert.equal(out.runbooks.created.length, 0);
140
179
  });
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // --check-settings / --apply-settings (Story #171)
183
+ //
184
+ // platform-sync.mjs shells out to the real `gh` CLI for the GitHub-side
185
+ // settings mode (no dependency-injection seam like check-pin-drift.mjs), so
186
+ // these tests stub `gh` as a fake executable on PATH and drive the CLI
187
+ // end-to-end. The pure diff/classification logic itself is unit-tested in
188
+ // check-repo-settings.test.mjs; this suite exercises platform-sync's own
189
+ // argument wiring, PATCH-call shape, and non-blocking exit-code contract.
190
+ // ---------------------------------------------------------------------------
191
+
192
+ let settingsDir;
193
+ let fakeGhDir;
194
+ let fakeGhLogPath;
195
+
196
+ /**
197
+ * Write a fake `gh` shell script onto a scratch PATH dir that:
198
+ * - responds to `gh api repos/<repo>` and `gh api repos/<repo>/actions/permissions/workflow`
199
+ * with the JSON bodies given in `responses` (keyed by the API path),
200
+ * - logs every invocation's args (one JSON line per call) to `fakeGhLogPath`
201
+ * so PATCH calls can be asserted on,
202
+ * - exits 0 for any recognized `gh api ...` / `gh api -X PATCH ...` call.
203
+ */
204
+ function writeFakeGh(responses) {
205
+ const script = `#!/usr/bin/env node
206
+ const fs = require("node:fs");
207
+ const args = process.argv.slice(2);
208
+ fs.appendFileSync(${JSON.stringify(fakeGhLogPath)}, JSON.stringify(args) + "\\n");
209
+ const responses = ${JSON.stringify(responses)};
210
+ if (args[0] === "api") {
211
+ const isPatch = args[1] === "-X" && args[2] === "PATCH";
212
+ const path = isPatch ? args[3] : args[1];
213
+ if (isPatch) {
214
+ process.exit(0);
215
+ }
216
+ if (Object.prototype.hasOwnProperty.call(responses, path)) {
217
+ process.stdout.write(JSON.stringify(responses[path]));
218
+ process.exit(0);
219
+ }
220
+ process.stderr.write("no stub for " + path + "\\n");
221
+ process.exit(1);
222
+ }
223
+ process.exit(1);
224
+ `;
225
+ const ghPath = join(fakeGhDir, "gh");
226
+ writeFileSync(ghPath, script, { mode: 0o755 });
227
+ }
228
+
229
+ function runSettings(extraArgs) {
230
+ return execFileSync("node", [CLI, ...extraArgs], {
231
+ encoding: "utf8",
232
+ env: { ...process.env, PATH: `${fakeGhDir}:${process.env.PATH}` },
233
+ });
234
+ }
235
+
236
+ const BASELINE_SETTINGS = {
237
+ allowSquashMerge: true,
238
+ allowMergeCommit: false,
239
+ allowRebaseMerge: false,
240
+ squashMergeCommitTitle: "PR_TITLE",
241
+ squashMergeCommitMessage: "PR_BODY",
242
+ deleteBranchOnMerge: true,
243
+ allowAutoMerge: true,
244
+ actionsDefaultWorkflowPermissions: "read",
245
+ actionsCanApprovePullRequestReviews: false,
246
+ };
247
+
248
+ beforeEach(() => {
249
+ settingsDir = mkdtempSync(join(tmpdir(), "platform-sync-settings-test-"));
250
+ fakeGhDir = mkdtempSync(join(tmpdir(), "platform-sync-fakegh-"));
251
+ fakeGhLogPath = join(settingsDir, "gh-calls.log");
252
+ writeFileSync(fakeGhLogPath, "");
253
+ writeFileSync(join(settingsDir, "baseline.json"), JSON.stringify(BASELINE_SETTINGS));
254
+ });
255
+
256
+ afterEach(() => {
257
+ rmSync(settingsDir, { recursive: true, force: true });
258
+ rmSync(fakeGhDir, { recursive: true, force: true });
259
+ });
260
+
261
+ test("--check-settings reports no drift when live settings match the baseline", () => {
262
+ writeFakeGh({
263
+ "repos/dsj1984/swarm-os": {
264
+ allow_squash_merge: true,
265
+ allow_merge_commit: false,
266
+ allow_rebase_merge: false,
267
+ squash_merge_commit_title: "PR_TITLE",
268
+ squash_merge_commit_message: "PR_BODY",
269
+ delete_branch_on_merge: true,
270
+ allow_auto_merge: true,
271
+ },
272
+ "repos/dsj1984/swarm-os/actions/permissions/workflow": {
273
+ default_workflow_permissions: "read",
274
+ can_approve_pull_request_reviews: false,
275
+ },
276
+ });
277
+ const out = JSON.parse(
278
+ runSettings([
279
+ "--check-settings",
280
+ "--consumer-repo",
281
+ "dsj1984/swarm-os",
282
+ "--baseline",
283
+ join(settingsDir, "baseline.json"),
284
+ "--json",
285
+ ])
286
+ );
287
+ assert.equal(out.mode, "check-settings");
288
+ assert.equal(out.drift, false);
289
+ assert.deepEqual(out.mismatches, []);
290
+ });
291
+
292
+ test("--check-settings reports drift (domio-shaped: write token perms) without mutating anything", () => {
293
+ writeFakeGh({
294
+ "repos/dsj1984/domio": {
295
+ allow_squash_merge: true,
296
+ allow_merge_commit: false,
297
+ allow_rebase_merge: false,
298
+ squash_merge_commit_title: "PR_TITLE",
299
+ squash_merge_commit_message: "PR_BODY",
300
+ delete_branch_on_merge: true,
301
+ allow_auto_merge: true,
302
+ },
303
+ "repos/dsj1984/domio/actions/permissions/workflow": {
304
+ default_workflow_permissions: "write",
305
+ can_approve_pull_request_reviews: false,
306
+ },
307
+ });
308
+ const out = JSON.parse(
309
+ runSettings([
310
+ "--check-settings",
311
+ "--consumer-repo",
312
+ "dsj1984/domio",
313
+ "--baseline",
314
+ join(settingsDir, "baseline.json"),
315
+ "--json",
316
+ ])
317
+ );
318
+ assert.equal(out.drift, true);
319
+ assert.deepEqual(out.mismatches, [
320
+ { field: "actionsDefaultWorkflowPermissions", expected: "read", actual: "write" },
321
+ ]);
322
+ assert.equal(out.applied, false, "--check-settings never applies");
323
+ const calls = readFileSync(fakeGhLogPath, "utf8").trim().split("\n").filter(Boolean);
324
+ assert.ok(
325
+ calls.every((line) => !JSON.parse(line).includes("PATCH")),
326
+ "no PATCH call issued by --check-settings"
327
+ );
328
+ });
329
+
330
+ test("--check-settings never fails the exit code on drift (non-blocking, standing decision #10)", () => {
331
+ writeFakeGh({
332
+ "repos/dsj1984/athportal": {
333
+ allow_squash_merge: true,
334
+ allow_merge_commit: true,
335
+ allow_rebase_merge: true,
336
+ squash_merge_commit_title: "PR_TITLE",
337
+ squash_merge_commit_message: "COMMIT_MESSAGES",
338
+ delete_branch_on_merge: true,
339
+ allow_auto_merge: true,
340
+ },
341
+ "repos/dsj1984/athportal/actions/permissions/workflow": {
342
+ default_workflow_permissions: "read",
343
+ can_approve_pull_request_reviews: true,
344
+ },
345
+ });
346
+ // execFileSync throws on non-zero exit; a clean return proves exit 0.
347
+ assert.doesNotThrow(() => {
348
+ runSettings([
349
+ "--check-settings",
350
+ "--consumer-repo",
351
+ "dsj1984/athportal",
352
+ "--baseline",
353
+ join(settingsDir, "baseline.json"),
354
+ "--json",
355
+ ]);
356
+ });
357
+ });
358
+
359
+ test("--apply-settings PATCHes only the drifted fields, across both endpoints", () => {
360
+ writeFakeGh({
361
+ "repos/dsj1984/athportal": {
362
+ allow_squash_merge: true,
363
+ allow_merge_commit: true, // drift
364
+ allow_rebase_merge: true, // drift
365
+ squash_merge_commit_title: "PR_TITLE",
366
+ squash_merge_commit_message: "PR_BODY",
367
+ delete_branch_on_merge: true,
368
+ allow_auto_merge: true,
369
+ },
370
+ "repos/dsj1984/athportal/actions/permissions/workflow": {
371
+ default_workflow_permissions: "read",
372
+ can_approve_pull_request_reviews: true, // drift
373
+ },
374
+ });
375
+ const out = JSON.parse(
376
+ runSettings([
377
+ "--apply-settings",
378
+ "--consumer-repo",
379
+ "dsj1984/athportal",
380
+ "--baseline",
381
+ join(settingsDir, "baseline.json"),
382
+ "--json",
383
+ ])
384
+ );
385
+ assert.equal(out.applied, true);
386
+ assert.equal(out.mismatches.length, 3);
387
+
388
+ const calls = readFileSync(fakeGhLogPath, "utf8")
389
+ .trim()
390
+ .split("\n")
391
+ .filter(Boolean)
392
+ .map((l) => JSON.parse(l));
393
+ const patchCalls = calls.filter((c) => c[1] === "-X" && c[2] === "PATCH");
394
+ assert.equal(patchCalls.length, 2, "one PATCH for the repo endpoint, one for the Actions endpoint");
395
+ const repoPatch = patchCalls.find((c) => c[3] === "repos/dsj1984/athportal");
396
+ const actionsPatch = patchCalls.find((c) => c[3] === "repos/dsj1984/athportal/actions/permissions/workflow");
397
+ assert.ok(repoPatch, "repo-settings PATCH issued");
398
+ assert.ok(actionsPatch, "Actions-permissions PATCH issued");
399
+ });
400
+
401
+ test("--apply-settings --dry-run reports the plan without issuing any PATCH", () => {
402
+ writeFakeGh({
403
+ "repos/dsj1984/domio": {
404
+ allow_squash_merge: true,
405
+ allow_merge_commit: false,
406
+ allow_rebase_merge: false,
407
+ squash_merge_commit_title: "PR_TITLE",
408
+ squash_merge_commit_message: "PR_BODY",
409
+ delete_branch_on_merge: true,
410
+ allow_auto_merge: true,
411
+ },
412
+ "repos/dsj1984/domio/actions/permissions/workflow": {
413
+ default_workflow_permissions: "write", // drift
414
+ can_approve_pull_request_reviews: false,
415
+ },
416
+ });
417
+ const out = JSON.parse(
418
+ runSettings([
419
+ "--apply-settings",
420
+ "--dry-run",
421
+ "--consumer-repo",
422
+ "dsj1984/domio",
423
+ "--baseline",
424
+ join(settingsDir, "baseline.json"),
425
+ "--json",
426
+ ])
427
+ );
428
+ assert.equal(out.dryRun, true);
429
+ assert.equal(out.applied, false, "dry-run never applies");
430
+ const calls = readFileSync(fakeGhLogPath, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
431
+ assert.ok(
432
+ calls.every((c) => !(c[1] === "-X" && c[2] === "PATCH")),
433
+ "no PATCH issued under --dry-run"
434
+ );
435
+ });
436
+
437
+ test("--check-settings requires --consumer-repo", () => {
438
+ assert.throws(() => {
439
+ execFileSync("node", [CLI, "--check-settings"], { encoding: "utf8" });
440
+ }, /consumer-repo/);
441
+ });
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // --check-ruleset (Story #178) — report-only branch-ruleset drift check.
445
+ //
446
+ // Same fake-`gh`-on-PATH harness as --check-settings above. The pure
447
+ // diff/classification logic is unit-tested in check-ruleset.test.mjs; this
448
+ // suite exercises platform-sync's own argument wiring and the (deliberate)
449
+ // absence of any mutating call — there is no --apply-ruleset.
450
+ // ---------------------------------------------------------------------------
451
+
452
+ const MAIN_PROTECTION_CONTRACT = {
453
+ branch: "main",
454
+ requiredStatusChecks: ["ci-required"],
455
+ requireLinearHistory: false,
456
+ allowForcePushes: false,
457
+ allowDeletions: false,
458
+ };
459
+
460
+ function compliantRulesetPayload() {
461
+ return {
462
+ id: 1,
463
+ enforcement: "active",
464
+ conditions: { ref_name: { include: ["refs/heads/main"] } },
465
+ bypass_actors: [],
466
+ rules: [
467
+ { type: "pull_request" },
468
+ {
469
+ type: "required_status_checks",
470
+ parameters: {
471
+ required_status_checks: [{ context: "ci-required" }],
472
+ strict_required_status_checks_policy: true,
473
+ },
474
+ },
475
+ { type: "non_fast_forward" },
476
+ { type: "deletion" },
477
+ ],
478
+ };
479
+ }
480
+
481
+ test("--check-ruleset reports no drift when the live ruleset matches the contract", () => {
482
+ writeFakeGh({
483
+ "repos/Beestera/swarm-os/rulesets": [{ id: 1 }],
484
+ "repos/Beestera/swarm-os/rulesets/1": compliantRulesetPayload(),
485
+ });
486
+ writeFileSync(join(settingsDir, "contract.json"), JSON.stringify(MAIN_PROTECTION_CONTRACT));
487
+ const out = JSON.parse(
488
+ runSettings([
489
+ "--check-ruleset",
490
+ "--consumer-repo",
491
+ "Beestera/swarm-os",
492
+ "--contract",
493
+ join(settingsDir, "contract.json"),
494
+ "--json",
495
+ ])
496
+ );
497
+ assert.equal(out.mode, "check-ruleset");
498
+ assert.equal(out.drift, false);
499
+ assert.deepEqual(out.mismatches, []);
500
+ });
501
+
502
+ test("--check-ruleset reports drift (bypass actor added) and issues no mutating call", () => {
503
+ writeFakeGh({
504
+ "repos/dsj1984/domio/rulesets": [{ id: 7 }],
505
+ "repos/dsj1984/domio/rulesets/7": { ...compliantRulesetPayload(), bypass_actors: [{ actor_id: 1 }] },
506
+ });
507
+ writeFileSync(join(settingsDir, "contract.json"), JSON.stringify(MAIN_PROTECTION_CONTRACT));
508
+ const out = JSON.parse(
509
+ runSettings([
510
+ "--check-ruleset",
511
+ "--consumer-repo",
512
+ "dsj1984/domio",
513
+ "--contract",
514
+ join(settingsDir, "contract.json"),
515
+ "--json",
516
+ ])
517
+ );
518
+ assert.equal(out.drift, true);
519
+ assert.deepEqual(out.mismatches, [{ field: "bypassActorsEmpty", expected: true, actual: false }]);
520
+ const calls = readFileSync(fakeGhLogPath, "utf8").trim().split("\n").filter(Boolean);
521
+ assert.ok(
522
+ calls.every((line) => !JSON.parse(line).includes("PATCH")),
523
+ "--check-ruleset never issues a PATCH — report-only, no --apply-ruleset exists"
524
+ );
525
+ });
526
+
527
+ test("--check-ruleset never fails the exit code on drift (non-blocking, standing decision #10)", () => {
528
+ writeFakeGh({
529
+ "repos/dsj1984/athportal/rulesets": [{ id: 1 }],
530
+ "repos/dsj1984/athportal/rulesets/1": {
531
+ ...compliantRulesetPayload(),
532
+ rules: compliantRulesetPayload().rules.filter((r) => r.type !== "non_fast_forward"),
533
+ },
534
+ });
535
+ writeFileSync(join(settingsDir, "contract.json"), JSON.stringify(MAIN_PROTECTION_CONTRACT));
536
+ assert.doesNotThrow(() => {
537
+ runSettings([
538
+ "--check-ruleset",
539
+ "--consumer-repo",
540
+ "dsj1984/athportal",
541
+ "--contract",
542
+ join(settingsDir, "contract.json"),
543
+ "--json",
544
+ ]);
545
+ });
546
+ });
547
+
548
+ test("--check-ruleset reports 'missing' when no active ruleset targets the branch", () => {
549
+ writeFakeGh({
550
+ "repos/owner/no-ruleset/rulesets": [],
551
+ });
552
+ writeFileSync(join(settingsDir, "contract.json"), JSON.stringify(MAIN_PROTECTION_CONTRACT));
553
+ const out = JSON.parse(
554
+ runSettings([
555
+ "--check-ruleset",
556
+ "--consumer-repo",
557
+ "owner/no-ruleset",
558
+ "--contract",
559
+ join(settingsDir, "contract.json"),
560
+ "--json",
561
+ ])
562
+ );
563
+ assert.equal(out.status, "missing");
564
+ assert.equal(out.drift, true);
565
+ });
566
+
567
+ test("--check-ruleset requires --consumer-repo", () => {
568
+ assert.throws(() => {
569
+ execFileSync("node", [CLI, "--check-ruleset"], { encoding: "utf8" });
570
+ }, /consumer-repo/);
571
+ });
572
+
573
+ test("apply materializes the canonical deploy-staging.yml workflow caller template", () => {
574
+ const out = JSON.parse(run([]));
575
+ const stub = join(consumer, ".github", "workflows", "deploy-staging.yml");
576
+ assert.ok(existsSync(stub));
577
+ assert.ok(
578
+ out.workflowStubs.created.some((f) => f.endsWith("deploy-staging.yml")),
579
+ "deploy-staging.yml reported as created"
580
+ );
581
+ const body = readFileSync(stub, "utf8");
582
+ assert.ok(
583
+ body.includes("Canonical staging-deploy caller template"),
584
+ "materialized workflow carries the template marker"
585
+ );
586
+ assert.ok(
587
+ body.includes("dsj1984/mandrel-platform/.github/workflows/deploy-cloudflare.yml"),
588
+ "template calls the shared deploy-cloudflare.yml reusable workflow"
589
+ );
590
+ });
591
+
592
+ test("workflow caller template materialization is idempotent", () => {
593
+ run([]); // materialize
594
+ const out = JSON.parse(run([])); // second pass
595
+ assert.ok(
596
+ out.workflowStubs.skipped.some((f) => f.endsWith("deploy-staging.yml")),
597
+ "already-materialized template is skipped, not re-created"
598
+ );
599
+ assert.equal(out.workflowStubs.created.length, 0);
600
+ });
601
+
602
+ test("a hand-authored deploy-staging.yml is flagged, not overwritten", () => {
603
+ const destDir = join(consumer, ".github", "workflows");
604
+ mkdirSync(destDir, { recursive: true });
605
+ const handAuthored = "name: deploy-staging\n# fully custom, no template marker\njobs: {}\n";
606
+ writeFileSync(join(destDir, "deploy-staging.yml"), handAuthored);
607
+ const out = JSON.parse(run([]));
608
+ assert.ok(
609
+ out.workflowStubs.localCopies.some((f) => f.endsWith("deploy-staging.yml")),
610
+ "hand-authored caller surfaced as a warning"
611
+ );
612
+ assert.equal(
613
+ readFileSync(join(destDir, "deploy-staging.yml"), "utf8"),
614
+ handAuthored,
615
+ "operator's hand-authored caller is never clobbered"
616
+ );
617
+ });
@@ -50,16 +50,18 @@
50
50
  * node scripts/update-semgrep-rules.mjs --semgrep-pin semgrep==1.97.0
51
51
  * node scripts/update-semgrep-rules.mjs --out .semgrep/rules.json --dry-run
52
52
  *
53
- * Requires network egress to PyPI (to install the pinned `semgrep` package)
54
- * and to the Semgrep registry (to resolve `p/default`) this script is run
55
- * by a human/agent deliberately bumping the ruleset, NOT by CI on every PR.
53
+ * Requires network egress to PyPI (to install the pinned `semgrep` package,
54
+ * whose artifact is verified against the recorded SHA-256 hashes via pip's
55
+ * `--require-hashes`) and to the Semgrep registry (to resolve `p/default`)
56
+ * this script is run by a human/agent deliberately bumping the ruleset, NOT
57
+ * by CI on every PR.
56
58
  *
57
59
  * Exit codes:
58
60
  * 0 — rules file written (or, with --dry-run, would-write reported).
59
61
  * 1 — semgrep install or rule resolution failed.
60
62
  */
61
63
 
62
- import { execFileSync, spawnSync } from "node:child_process";
64
+ import { spawnSync } from "node:child_process";
63
65
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
64
66
  import { tmpdir } from "node:os";
65
67
  import { dirname, join, resolve } from "node:path";
@@ -75,6 +77,26 @@ const REPO_ROOT = resolve(__dirname, "..");
75
77
  // generate the file is a (harmless but inconsistent) version skew.
76
78
  const DEFAULT_SEMGREP_PIN = "semgrep==1.97.0";
77
79
 
80
+ // SHA-256 hashes for every `DEFAULT_SEMGREP_PIN` distribution published on
81
+ // PyPI (the four platform wheels + the sdist). pip's `--require-hashes` mode
82
+ // verifies the downloaded `semgrep` artifact against this set before it is
83
+ // installed, so a compromised or swapped PyPI artifact for this exact version
84
+ // is rejected at install time — the same "pin the supply-chain input" posture
85
+ // the action-pin ratchet (SHA-pinned Actions) and the OSV advisory pin apply
86
+ // to their inputs. When bumping `DEFAULT_SEMGREP_PIN`, refresh this map from
87
+ // `https://pypi.org/pypi/semgrep/<version>/json` (the `urls[].digests.sha256`
88
+ // values) — a version with no hash entry here fails fast rather than
89
+ // installing unverified.
90
+ const SEMGREP_HASHES = {
91
+ "1.97.0": [
92
+ "sha256:0ddaa25ee45e669e1fef87e88dcef73b2aee0874b507e09f618862c42452a205",
93
+ "sha256:9184500bf8c49ad19d0fb2d84923abb4aa53058b0ece7008b57a3b0b5e6ce3ee",
94
+ "sha256:f7d21d6499d4e6fafb4c0b04b1750e9f4b704a26bd78f0aff19f1c73d44843b4",
95
+ "sha256:996fe0b2bfac3a4d4511e470fdf5f3bca96b1f794f398e0336c8388802c218de",
96
+ "sha256:c585164358e03cd7868e1f0d38fbcb422c88dbe08795b83caeb5e36cf18874aa",
97
+ ],
98
+ };
99
+
78
100
  const DEFAULT_OUT = join(REPO_ROOT, ".semgrep", "rules.json");
79
101
 
80
102
  // Languages this platform's reusable workflows + consumer trees actually
@@ -143,19 +165,67 @@ function resolveRegistryRules(semgrepPin) {
143
165
  const venvDir = join(mkdtempSync(join(tmpdir(), "semgrep-vendor-")), "venv");
144
166
  const targetDir = mkdtempSync(join(tmpdir(), "semgrep-vendor-target-"));
145
167
  const semgrepHome = mkdtempSync(join(tmpdir(), "semgrep-vendor-home-"));
168
+ const reqsFile = join(mkdtempSync(join(tmpdir(), "semgrep-vendor-reqs-")), "semgrep.txt");
146
169
 
147
170
  try {
148
171
  spawnSync("python3", ["-m", "venv", venvDir], { stdio: "inherit" });
149
172
  const pip = join(venvDir, "bin", "pip");
150
173
  const semgrep = join(venvDir, "bin", "semgrep");
151
174
 
175
+ // Resolve the exact version from the pin (`semgrep==<version>`) so we can
176
+ // look up its published artifact hashes. Only the `==` form is hashable;
177
+ // an unpinned or range pin cannot be verified.
178
+ const versionMatch = /^semgrep==(.+)$/.exec(semgrepPin.trim());
179
+ if (!versionMatch) {
180
+ throw new Error(
181
+ `cannot hash-pin ${semgrepPin}: only an exact 'semgrep==<version>' pin is supported`
182
+ );
183
+ }
184
+ const version = versionMatch[1];
185
+ const hashes = SEMGREP_HASHES[version];
186
+ if (!hashes || hashes.length === 0) {
187
+ throw new Error(
188
+ `no published artifact hashes recorded for ${semgrepPin} in SEMGREP_HASHES — ` +
189
+ `refresh from https://pypi.org/pypi/semgrep/${version}/json before pinning`
190
+ );
191
+ }
192
+
193
+ // Hash-pinned install: write a requirements file that pins `semgrep` to
194
+ // the exact version with every published artifact hash, then install it
195
+ // with `--require-hashes --no-deps`. pip verifies the downloaded semgrep
196
+ // artifact against this set before installing — a swapped/compromised
197
+ // artifact for this version is rejected. Dependencies are resolved in a
198
+ // separate, non-hashed step (semgrep is already satisfied), keeping the
199
+ // supply-chain-critical `semgrep` binary itself hash-verified.
200
+ const hashFlags = hashes.map((h) => ` --hash=${h}`).join(" \\\n");
201
+ writeFileSync(reqsFile, `semgrep==${version} \\\n${hashFlags}\n`, "utf8");
202
+
203
+ const installSemgrep = spawnSync(
204
+ pip,
205
+ [
206
+ "install",
207
+ "--quiet",
208
+ "--disable-pip-version-check",
209
+ "--require-hashes",
210
+ "--no-deps",
211
+ "-r",
212
+ reqsFile,
213
+ ],
214
+ { stdio: "inherit" }
215
+ );
216
+ if (installSemgrep.status !== 0) {
217
+ throw new Error(
218
+ `hash-pinned pip install ${semgrepPin} failed (exit ${installSemgrep.status})`
219
+ );
220
+ }
221
+
152
222
  const install = spawnSync(
153
223
  pip,
154
224
  ["install", "--quiet", "--disable-pip-version-check", "setuptools", semgrepPin],
155
225
  { stdio: "inherit" }
156
226
  );
157
227
  if (install.status !== 0) {
158
- throw new Error(`pip install ${semgrepPin} failed (exit ${install.status})`);
228
+ throw new Error(`pip install ${semgrepPin} (dependencies) failed (exit ${install.status})`);
159
229
  }
160
230
 
161
231
  // A throwaway target file gives semgrep something to "scan" so it
@@ -196,6 +266,7 @@ function resolveRegistryRules(semgrepPin) {
196
266
  rmSync(venvDir, { recursive: true, force: true });
197
267
  rmSync(targetDir, { recursive: true, force: true });
198
268
  rmSync(semgrepHome, { recursive: true, force: true });
269
+ rmSync(dirname(reqsFile), { recursive: true, force: true });
199
270
  }
200
271
  }
201
272
 
@@ -1,10 +1,14 @@
1
1
  # Runbook Templates (copyable thin stubs)
2
2
 
3
- These are **copyable thin-stub templates** one per canonical mandrel-platform
4
- runbook in [`docs/runbooks/`](https://github.com/dsj1984/mandrel-platform/tree/main/docs/runbooks).
5
- They implement the MP-9 adoption model (§7.7 / F1): *replace each duplicated
6
- process runbook with a thin local doc that holds project-specific values plus a
7
- link to the canonical runbook.*
3
+ These are **copyable thin-stub templates** for the eight most commonly-adopted
4
+ canonical mandrel-platform runbooks in
5
+ [`docs/runbooks/`](https://github.com/dsj1984/mandrel-platform/tree/main/docs/runbooks)
6
+ (listed in the table below). It is **not** a stub-per-canonical-runbook set
7
+ several canonical runbooks (`rollback.md`, `slo.md`, `secret-rotation.md`,
8
+ `pin-drift-dashboard.md`) are platform-process docs a consumer reads directly
9
+ and ships no local stub for. They implement the MP-9 adoption model
10
+ (§7.7 / F1): *replace each duplicated process runbook with a thin local doc
11
+ that holds project-specific values plus a link to the canonical runbook.*
8
12
 
9
13
  Each stub:
10
14
 
@@ -21,9 +21,15 @@
21
21
  ## Apply & Verify
22
22
 
23
23
  ```bash
24
- # Preview / apply
25
- node scripts/apply-branch-protection.mjs --dry-run
26
- node scripts/apply-branch-protection.mjs --apply
24
+ # Apply PUT the protection with the aggregator as the only required context.
25
+ # (There is no apply-branch-protection script; use gh api directly — see the
26
+ # canonical runbook § 3.)
27
+ gh api repos/<OWNER>/<REPO>/branches/<PROTECTED_BRANCH>/protection \
28
+ --method PUT \
29
+ --raw-field required_status_checks='{"strict":false,"contexts":["<AGGREGATOR_CHECK>"]}' \
30
+ --field enforce_admins=false \
31
+ --raw-field required_pull_request_reviews=null \
32
+ --raw-field restrictions=null
27
33
 
28
34
  # Verify
29
35
  gh api repos/<OWNER>/<REPO>/branches/<PROTECTED_BRANCH>/protection \