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
@@ -85,7 +85,23 @@
85
85
 
86
86
  import { readFileSync, appendFileSync } from "node:fs";
87
87
  import { resolve } from "node:path";
88
- import { execFileSync } from "node:child_process";
88
+
89
+ import {
90
+ defaultGhRunner,
91
+ ghApiJson,
92
+ isNotFound,
93
+ } from "./lib/gh-json.mjs";
94
+ import {
95
+ compareSemver,
96
+ parseDurationMs,
97
+ parseSemver,
98
+ } from "./lib/semver-duration.mjs";
99
+
100
+ // Re-export the extracted seams so existing importers (platform-repair.mjs,
101
+ // the test suite) keep their `check-pin-drift.mjs` import paths. The canonical
102
+ // homes are scripts/lib/gh-json.mjs and scripts/lib/semver-duration.mjs
103
+ // (Story #198).
104
+ export { compareSemver, parseDurationMs, parseSemver, defaultGhRunner };
89
105
 
90
106
  // ---------------------------------------------------------------------------
91
107
  // Arg parsing
@@ -318,79 +334,6 @@ export function classifyConsumer(pins, latestReleaseSha) {
318
334
  };
319
335
  }
320
336
 
321
- /**
322
- * Extract a comparable `x.y.z` semver core from a release tag or version spec.
323
- * The platform tags releases as `mandrel-platform-v<semver>`; consumer specs
324
- * may carry a range prefix (`^0.11.3`, `~0.11.3`). Returns the dotted triple
325
- * or null when no numeric semver core is present (`workspace:*`, `latest`, a
326
- * git URL).
327
- *
328
- * @param {unknown} value
329
- * @returns {string | null}
330
- */
331
- export function parseSemver(value) {
332
- if (typeof value !== "string") return null;
333
- const m = /(\d+)\.(\d+)\.(\d+)/.exec(value);
334
- return m ? `${m[1]}.${m[2]}.${m[3]}` : null;
335
- }
336
-
337
- /**
338
- * Compare two `x.y.z` semver cores. Returns -1 when a < b, 0 when equal, 1
339
- * when a > b. Inputs MUST already be normalized dotted triples (see
340
- * `parseSemver`).
341
- *
342
- * @param {string} a
343
- * @param {string} b
344
- * @returns {-1 | 0 | 1}
345
- */
346
- export function compareSemver(a, b) {
347
- const pa = a.split(".").map(Number);
348
- const pb = b.split(".").map(Number);
349
- for (let i = 0; i < 3; i += 1) {
350
- if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
351
- }
352
- return 0;
353
- }
354
-
355
- /**
356
- * Parse a Renovate-style `minimumReleaseAge` duration into milliseconds. The
357
- * preset uses human strings like `"3 days"`, `"36 hours"`, `"1 week"`; this
358
- * accepts an integer (or float) count followed by a unit (the same units
359
- * Renovate's `ms`-backed parser accepts). Returns null for an unparseable or
360
- * non-positive value so the caller can fall back to "no hold window".
361
- *
362
- * @param {unknown} value
363
- * @returns {number | null} Window length in ms, or null.
364
- */
365
- export function parseDurationMs(value) {
366
- if (typeof value === "number" && Number.isFinite(value) && value > 0) {
367
- // Bare number is interpreted as days (the preset's unit of record).
368
- return value * 24 * 60 * 60 * 1000;
369
- }
370
- if (typeof value !== "string") return null;
371
- const m = /^\s*(\d+(?:\.\d+)?)\s*([a-z]+)\s*$/i.exec(value.trim());
372
- if (!m) return null;
373
- const count = Number.parseFloat(m[1]);
374
- if (!Number.isFinite(count) || count <= 0) return null;
375
- const unit = m[2].toLowerCase();
376
- const units = {
377
- minute: 60 * 1000,
378
- minutes: 60 * 1000,
379
- min: 60 * 1000,
380
- mins: 60 * 1000,
381
- hour: 60 * 60 * 1000,
382
- hours: 60 * 60 * 1000,
383
- hr: 60 * 60 * 1000,
384
- hrs: 60 * 60 * 1000,
385
- day: 24 * 60 * 60 * 1000,
386
- days: 24 * 60 * 60 * 1000,
387
- week: 7 * 24 * 60 * 60 * 1000,
388
- weeks: 7 * 24 * 60 * 60 * 1000,
389
- };
390
- const factor = units[unit];
391
- return factor ? count * factor : null;
392
- }
393
-
394
337
  /**
395
338
  * Is the latest platform release still inside the `minimumReleaseAge` hold
396
339
  * window? During this window Renovate has not yet raised the bump PR, so EVERY
@@ -756,33 +699,12 @@ export function renderReport(report) {
756
699
  }
757
700
 
758
701
  // ---------------------------------------------------------------------------
759
- // GitHub access (via gh CLI) thin, injectable seam for testing
702
+ // GitHub access — the injectable `gh` seam lives in scripts/lib/gh-json.mjs
703
+ // (Story #198). `ghApiJson` surfaces the HTTP status on any error; the
704
+ // per-consumer fetchers below swallow ONLY a 404 into an "absent" sentinel and
705
+ // rethrow every other error so the strict gate fails CLOSED.
760
706
  // ---------------------------------------------------------------------------
761
707
 
762
- /**
763
- * Run `gh api <path>` and parse the JSON response.
764
- *
765
- * @param {string} apiPath e.g. "repos/owner/repo/releases/latest".
766
- * @param {(args: string[]) => string} runGh Injectable runner (default execFileSync gh).
767
- * @returns {unknown}
768
- */
769
- function ghApiJson(apiPath, runGh) {
770
- const raw = runGh(["api", apiPath, "-H", "Accept: application/vnd.github+json"]);
771
- return JSON.parse(raw);
772
- }
773
-
774
- /**
775
- * Default gh runner — shells out to the `gh` CLI.
776
- * @param {string[]} args
777
- * @returns {string}
778
- */
779
- export function defaultGhRunner(args) {
780
- return execFileSync("gh", args, {
781
- encoding: "utf-8",
782
- maxBuffer: 32 * 1024 * 1024,
783
- });
784
- }
785
-
786
708
  /**
787
709
  * Resolve the latest platform release tag, the commit SHA that tag points at,
788
710
  * and the release `published_at` timestamp (used to evaluate the
@@ -798,8 +720,15 @@ export function resolveLatestRelease(platformRepo, runGh) {
798
720
  let release;
799
721
  try {
800
722
  release = ghApiJson(`repos/${platformRepo}/releases/latest`, runGh);
801
- } catch {
802
- return { tag: null, sha: null, publishedAt: null };
723
+ } catch (err) {
724
+ // A genuine 404 means the platform repo has no published release yet — a
725
+ // legitimate "no lag baseline" state, so return nulls. Every other error
726
+ // (5xx / 403 rate-limit / transport) must propagate so the strict gate
727
+ // fails CLOSED rather than silently classifying the whole fleet as
728
+ // lagState "unknown" (drift=false). Mirrors the per-consumer fetchers'
729
+ // fail-closed contract in scripts/lib/gh-json.mjs.
730
+ if (isNotFound(err)) return { tag: null, sha: null, publishedAt: null };
731
+ throw err;
803
732
  }
804
733
  const tag = release && typeof release.tag_name === "string" ? release.tag_name : null;
805
734
  const publishedAt =
@@ -820,8 +749,12 @@ export function resolveLatestRelease(platformRepo, runGh) {
820
749
  sha = tagObj?.object?.sha ?? sha;
821
750
  }
822
751
  return { tag, sha: sha ? sha.toLowerCase() : null, publishedAt };
823
- } catch {
824
- return { tag, sha: null, publishedAt };
752
+ } catch (err) {
753
+ // Same fail-closed contract as the release fetch above: a 404 (tag
754
+ // vanished) degrades to sha:null, but a transient/auth error propagates
755
+ // so the strict gate fails closed instead of suppressing lag detection.
756
+ if (isNotFound(err)) return { tag, sha: null, publishedAt };
757
+ throw err;
825
758
  }
826
759
  }
827
760
 
@@ -842,8 +775,13 @@ export function fetchConsumerWorkflows(repo, branch, runGh) {
842
775
  `repos/${repo}/contents/.github/workflows?ref=${encodeURIComponent(branch)}`,
843
776
  runGh,
844
777
  );
845
- } catch {
846
- return [];
778
+ } catch (err) {
779
+ // A 404 genuinely means the consumer has no `.github/workflows/` dir —
780
+ // return "no files". Any OTHER error (403 / 429 / 5xx / transport) must
781
+ // fail CLOSED: rethrow so buildReport records an `error` row for this
782
+ // consumer instead of silently reading it as "no pins → no drift".
783
+ if (isNotFound(err)) return [];
784
+ throw err;
847
785
  }
848
786
  if (!Array.isArray(listing)) return [];
849
787
  const files = [];
@@ -860,7 +798,10 @@ export function fetchConsumerWorkflows(repo, branch, runGh) {
860
798
  if (blob?.encoding === "base64" && typeof blob.content === "string") {
861
799
  text = Buffer.from(blob.content, "base64").toString("utf-8");
862
800
  }
863
- } catch {
801
+ } catch (err) {
802
+ // Same fail-closed rule for the per-file blob fetch: a missing blob
803
+ // (404) yields empty text; any other failure propagates.
804
+ if (!isNotFound(err)) throw err;
864
805
  text = "";
865
806
  }
866
807
  }
@@ -888,8 +829,13 @@ export function fetchConsumerPackageJson(repo, branch, runGh) {
888
829
  `repos/${repo}/contents/package.json?ref=${encodeURIComponent(branch)}`,
889
830
  runGh,
890
831
  );
891
- } catch {
892
- return null;
832
+ } catch (err) {
833
+ // A 404 is the legitimate "no package.json / doesn't adopt the npm config
834
+ // package" case → treat as absent (null). Any OTHER error must fail
835
+ // CLOSED: rethrow so buildReport records an `error` row rather than
836
+ // silently reading the consumer as "npm absent → no drift".
837
+ if (isNotFound(err)) return null;
838
+ throw err;
893
839
  }
894
840
  if (obj && obj.encoding === "base64" && typeof obj.content === "string") {
895
841
  return Buffer.from(obj.content, "base64").toString("utf-8");
@@ -910,8 +856,13 @@ export function resolveBranch(consumer, runGh) {
910
856
  try {
911
857
  const repoMeta = ghApiJson(`repos/${consumer.repo}`, runGh);
912
858
  return repoMeta?.default_branch || "main";
913
- } catch {
914
- return "main";
859
+ } catch (err) {
860
+ // A 404 means the repo (or our access to it) is genuinely gone — fall back
861
+ // to "main" as before. Any OTHER error (403 / 429 / 5xx / transport) must
862
+ // fail CLOSED: rethrow so buildReport records an `error` row instead of
863
+ // guessing a branch and silently reporting "no drift".
864
+ if (isNotFound(err)) return "main";
865
+ throw err;
915
866
  }
916
867
  }
917
868
 
@@ -35,8 +35,10 @@ import {
35
35
  parseDurationMs,
36
36
  parseSemver,
37
37
  renderReport,
38
+ resolveLatestRelease,
38
39
  runCli,
39
40
  } from "./check-pin-drift.mjs";
41
+ import { httpStatusOf, isNotFound } from "./lib/gh-json.mjs";
40
42
 
41
43
  // ---------------------------------------------------------------------------
42
44
  // parseSemver
@@ -384,9 +386,29 @@ function pkgJson(version) {
384
386
  return { name: "consumer", devDependencies };
385
387
  }
386
388
 
389
+ /**
390
+ * Build an error shaped like the one `execFileSync('gh', …)` throws when
391
+ * `gh api` exits non-zero: the HTTP status is carried in the `(HTTP <code>)`
392
+ * marker `gh` writes to stderr. This is exactly what the fail-closed seam
393
+ * (`httpStatusOf` / `isNotFound` in scripts/lib/gh-json.mjs) parses.
394
+ *
395
+ * @param {number} status HTTP status code (e.g. 404, 500).
396
+ * @param {string} [label] Human label gh prints before the marker.
397
+ * @returns {Error}
398
+ */
399
+ function ghHttpError(status, label = "Error") {
400
+ const err = new Error(`Command failed: gh api …\ngh: ${label} (HTTP ${status})`);
401
+ // execFileSync surfaces the CLI's stderr on `.stderr`; the status parser
402
+ // reads it there first.
403
+ err.stderr = `gh: ${label} (HTTP ${status})\n`;
404
+ return err;
405
+ }
406
+
387
407
  /**
388
408
  * Build an injectable gh runner from a per-repo fixture map:
389
409
  * { "owner/repo": { workflowSha, npm: string | null | "throw" } }
410
+ * `npm: "throw"` simulates a 404 on the package.json fetch (the legitimate
411
+ * "consumer has no package.json" case → treated as absent).
390
412
  */
391
413
  function makeRunGh(fixtures) {
392
414
  return (args) => {
@@ -407,7 +429,8 @@ function makeRunGh(fixtures) {
407
429
  ]);
408
430
  }
409
431
  if (path === `repos/${repo}/contents/package.json?ref=main`) {
410
- if (cfg.npm === "throw") throw new Error("404 Not Found");
432
+ // A 404 on package.json is the "no npm config package" case → absent.
433
+ if (cfg.npm === "throw") throw ghHttpError(404, "Not Found");
411
434
  return JSON.stringify({ encoding: "base64", content: b64(pkgJson(cfg.npm)) });
412
435
  }
413
436
  }
@@ -548,13 +571,20 @@ test("buildReport: a stale pin literal beyond uses: is drift (Story #110)", () =
548
571
  assert.match(text, /STALE PIN LITERAL/);
549
572
  });
550
573
 
551
- test("fetchConsumerPackageJson returns null when the file is missing", () => {
574
+ test("fetchConsumerPackageJson returns null when the file is missing (404)", () => {
552
575
  const runGh = () => {
553
- throw new Error("404");
576
+ throw ghHttpError(404, "Not Found");
554
577
  };
555
578
  assert.equal(fetchConsumerPackageJson("o/x", "main", runGh), null);
556
579
  });
557
580
 
581
+ test("fetchConsumerPackageJson rethrows a non-404 error (fail closed)", () => {
582
+ const runGh = () => {
583
+ throw ghHttpError(500, "Server Error");
584
+ };
585
+ assert.throws(() => fetchConsumerPackageJson("o/x", "main", runGh), /HTTP 500/);
586
+ });
587
+
558
588
  // ---------------------------------------------------------------------------
559
589
  // CLI: --json, --strict, exit codes
560
590
  // ---------------------------------------------------------------------------
@@ -610,6 +640,148 @@ test("runCli --strict exits 1 when drift is present", () => {
610
640
  }
611
641
  });
612
642
 
643
+ // ---------------------------------------------------------------------------
644
+ // Fail-closed on a non-404 gh error (Story #198) — the fail-open the strict
645
+ // gate must not have. Historically EVERY gh error was swallowed into an
646
+ // "absent" sentinel, so a 500/403/network blip read as "no drift" and
647
+ // `--strict` exited 0. The seam now surfaces the HTTP status: only a 404 is
648
+ // swallowed; every other error propagates to an `error` row.
649
+ // ---------------------------------------------------------------------------
650
+
651
+ const ERROR_CONFIG = {
652
+ platformRepo: PLATFORM,
653
+ consumers: [{ name: "flaky", repo: "o/flaky", branch: "main" }],
654
+ };
655
+
656
+ /**
657
+ * A gh runner whose workflow-listing call fails. `status` selects the HTTP
658
+ * code so a single helper drives both the 404 (swallow → absent) and non-404
659
+ * (rethrow → error row) cases.
660
+ */
661
+ function makeFailingRunGh(status) {
662
+ return (args) => {
663
+ const path = args[1];
664
+ if (path === `repos/${PLATFORM}/releases/latest`) {
665
+ return JSON.stringify({ tag_name: TAG });
666
+ }
667
+ if (path === `repos/${PLATFORM}/git/ref/tags/${TAG}`) {
668
+ return JSON.stringify({ object: { sha: LATEST_SHA, type: "commit" } });
669
+ }
670
+ if (path === "repos/o/flaky/contents/.github/workflows?ref=main") {
671
+ throw ghHttpError(status, status === 404 ? "Not Found" : "Server Error");
672
+ }
673
+ if (path === "repos/o/flaky/contents/package.json?ref=main") {
674
+ return JSON.stringify({ encoding: "base64", content: b64(pkgJson("1.4.0")) });
675
+ }
676
+ throw new Error(`unexpected gh api path: ${path}`);
677
+ };
678
+ }
679
+
680
+ test("httpStatusOf / isNotFound parse the HTTP status out of a gh error", () => {
681
+ assert.equal(httpStatusOf(ghHttpError(404)), 404);
682
+ assert.equal(httpStatusOf(ghHttpError(500)), 500);
683
+ assert.equal(isNotFound(ghHttpError(404)), true);
684
+ assert.equal(isNotFound(ghHttpError(500)), false);
685
+ // A bare error with no parseable status is NOT a 404 → must fail closed.
686
+ assert.equal(httpStatusOf(new Error("socket hang up")), null);
687
+ assert.equal(isNotFound(new Error("socket hang up")), false);
688
+ });
689
+
690
+ test("buildReport: a non-404 gh error yields an `error` row (fail closed)", () => {
691
+ const report = buildReport(ERROR_CONFIG, makeFailingRunGh(500));
692
+ const r = byName(report, "flaky");
693
+ assert.notEqual(r.error, undefined);
694
+ assert.match(r.error, /HTTP 500/);
695
+ assert.equal(r.drift, false); // drift itself is false…
696
+ // …but hasDrift counts the error row, so the report is NOT clean.
697
+ assert.equal(hasDrift(report), true);
698
+ });
699
+
700
+ test("buildReport: a 404-shaped gh error is classified no-pins, drift false", () => {
701
+ const report = buildReport(ERROR_CONFIG, makeFailingRunGh(404));
702
+ const r = byName(report, "flaky");
703
+ assert.equal(r.error, undefined);
704
+ assert.equal(r.verdict.lagState, "no-pins");
705
+ assert.equal(r.drift, false);
706
+ assert.equal(hasDrift(report), false);
707
+ });
708
+
709
+ test("resolveLatestRelease rethrows a non-404 error on releases/latest (fail closed)", () => {
710
+ // A transient 5xx/403 on the platform's own release resolution must NOT be
711
+ // swallowed into sha:null — that would classify the whole fleet as lagState
712
+ // "unknown" (drift=false) and silently pass the strict gate.
713
+ const runGh = (args) => {
714
+ if (args[1] === `repos/${PLATFORM}/releases/latest`) {
715
+ throw ghHttpError(500, "Server Error");
716
+ }
717
+ throw new Error(`unexpected gh api path: ${args[1]}`);
718
+ };
719
+ assert.throws(() => resolveLatestRelease(PLATFORM, runGh), /HTTP 500/);
720
+ });
721
+
722
+ test("resolveLatestRelease returns nulls on a 404 (repo has no release yet)", () => {
723
+ const runGh = (args) => {
724
+ if (args[1] === `repos/${PLATFORM}/releases/latest`) {
725
+ throw ghHttpError(404, "Not Found");
726
+ }
727
+ throw new Error(`unexpected gh api path: ${args[1]}`);
728
+ };
729
+ assert.deepEqual(resolveLatestRelease(PLATFORM, runGh), {
730
+ tag: null,
731
+ sha: null,
732
+ publishedAt: null,
733
+ });
734
+ });
735
+
736
+ test("resolveLatestRelease rethrows a non-404 error on the tag→sha deref (fail closed)", () => {
737
+ const runGh = (args) => {
738
+ if (args[1] === `repos/${PLATFORM}/releases/latest`) {
739
+ return JSON.stringify({ tag_name: TAG });
740
+ }
741
+ if (args[1] === `repos/${PLATFORM}/git/ref/tags/${TAG}`) {
742
+ throw ghHttpError(503, "Service Unavailable");
743
+ }
744
+ throw new Error(`unexpected gh api path: ${args[1]}`);
745
+ };
746
+ assert.throws(() => resolveLatestRelease(PLATFORM, runGh), /HTTP 503/);
747
+ });
748
+
749
+ test("runCli --strict exits non-zero on a non-404 gh error (fail closed)", () => {
750
+ cfgDir = mkdtempSync(join(tmpdir(), "pin-drift-failclosed-"));
751
+ const p = join(cfgDir, "consumers.json");
752
+ writeFileSync(p, JSON.stringify(ERROR_CONFIG));
753
+ try {
754
+ const code = runCli({
755
+ argv: ["--config", p, "--strict"],
756
+ runGh: makeFailingRunGh(500),
757
+ stdout: capture(),
758
+ stderr: capture(),
759
+ summaryPath: undefined,
760
+ });
761
+ assert.equal(code, 1);
762
+ } finally {
763
+ rmSync(cfgDir, { recursive: true, force: true });
764
+ }
765
+ });
766
+
767
+ test("runCli --strict exits 0 on a 404-shaped gh error (genuine absence)", () => {
768
+ cfgDir = mkdtempSync(join(tmpdir(), "pin-drift-404-"));
769
+ const p = join(cfgDir, "consumers.json");
770
+ writeFileSync(p, JSON.stringify(ERROR_CONFIG));
771
+ try {
772
+ const code = runCli({
773
+ argv: ["--config", p, "--strict"],
774
+ runGh: makeFailingRunGh(404),
775
+ stdout: capture(),
776
+ stderr: capture(),
777
+ summaryPath: undefined,
778
+ });
779
+ assert.equal(code, 0);
780
+ } finally {
781
+ rmSync(cfgDir, { recursive: true, force: true });
782
+ }
783
+ });
784
+
613
785
  // ---------------------------------------------------------------------------
614
786
  // minimumReleaseAge hold window — the false-positive guard (Story #107)
615
787
  // ---------------------------------------------------------------------------