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
|
@@ -85,7 +85,23 @@
|
|
|
85
85
|
|
|
86
86
|
import { readFileSync, appendFileSync } from "node:fs";
|
|
87
87
|
import { resolve } from "node:path";
|
|
88
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
// ---------------------------------------------------------------------------
|