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
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * uses-pins.test.mjs — node:test suite for the shared `uses:`-line / SHA-pin
4
+ * primitives (`scripts/lib/uses-pins.mjs`, Story #203).
5
+ *
6
+ * Covers value stripping, line parsing, reference classification (incl. the
7
+ * new `subpath` field), the 40-char-SHA predicate, and the intra-repo
8
+ * single-pin invariant that `check-action-pins.mjs` now enforces. Pure — no
9
+ * temp dirs, no git, fully offline.
10
+ *
11
+ * Run: node --test scripts/lib/uses-pins.test.mjs
12
+ */
13
+
14
+ import assert from "node:assert/strict";
15
+ import { test } from "node:test";
16
+
17
+ import {
18
+ DEFAULT_FIRST_PARTY_OWNER,
19
+ stripUsesValue,
20
+ parseUsesLine,
21
+ classifyUses,
22
+ isSha40,
23
+ collectFirstPartyPins,
24
+ findSinglePinViolations,
25
+ } from "./uses-pins.mjs";
26
+
27
+ const SHA = "11bd71901bbe5b1630ceea73d27597364c9af683"; // 40 hex
28
+ const SHA2 = "0000000000000000000000000000000000000000"; // 40 hex, distinct
29
+ const SHORT = "11bd719"; // 7 hex
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // isSha40
33
+ // ---------------------------------------------------------------------------
34
+
35
+ test("isSha40 accepts exactly 40 hex chars (any case)", () => {
36
+ assert.equal(isSha40(SHA), true);
37
+ assert.equal(isSha40(SHA.toUpperCase()), true);
38
+ });
39
+
40
+ test("isSha40 rejects tags, short SHAs, branches, and off-by-one", () => {
41
+ assert.equal(isSha40("v4"), false);
42
+ assert.equal(isSha40(SHORT), false);
43
+ assert.equal(isSha40("main"), false);
44
+ assert.equal(isSha40(SHA + "0"), false); // 41 chars
45
+ assert.equal(isSha40("g".repeat(40)), false); // non-hex
46
+ });
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // stripUsesValue
50
+ // ---------------------------------------------------------------------------
51
+
52
+ test("stripUsesValue drops the trailing # tag comment", () => {
53
+ assert.equal(
54
+ stripUsesValue(`actions/checkout@${SHA} # v4.2.2`),
55
+ `actions/checkout@${SHA}`
56
+ );
57
+ });
58
+
59
+ test("stripUsesValue unwraps surrounding quotes and no-comment inputs", () => {
60
+ assert.equal(stripUsesValue(`actions/checkout@${SHA}`), `actions/checkout@${SHA}`);
61
+ assert.equal(stripUsesValue(`"actions/checkout@${SHA}"`), `actions/checkout@${SHA}`);
62
+ assert.equal(stripUsesValue(`'actions/checkout@${SHA}'`), `actions/checkout@${SHA}`);
63
+ });
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // parseUsesLine
67
+ // ---------------------------------------------------------------------------
68
+
69
+ test("parseUsesLine returns the bare ref for a mapping-key uses line", () => {
70
+ assert.equal(parseUsesLine(` - uses: actions/checkout@${SHA} # v4`), `actions/checkout@${SHA}`);
71
+ assert.equal(parseUsesLine(` uses: actions/checkout@${SHA}`), `actions/checkout@${SHA}`);
72
+ });
73
+
74
+ test("parseUsesLine returns null for comments and non-uses lines", () => {
75
+ assert.equal(parseUsesLine("# uses: actions/checkout@v4"), null);
76
+ assert.equal(parseUsesLine(" steps:"), null);
77
+ assert.equal(parseUsesLine(" run: echo uses: not-a-key"), null);
78
+ });
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // classifyUses (incl. subpath)
82
+ // ---------------------------------------------------------------------------
83
+
84
+ test("classifyUses flags external owner/repo as third-party with subpath", () => {
85
+ const c = classifyUses(`github/codeql-action/analyze@${SHA}`);
86
+ assert.equal(c.kind, "third-party");
87
+ assert.equal(c.owner, "github/codeql-action");
88
+ assert.equal(c.subpath, "analyze");
89
+ assert.equal(c.ref, SHA);
90
+ });
91
+
92
+ test("classifyUses treats the default first-party owner as exempt and exposes subpath", () => {
93
+ const c = classifyUses(
94
+ `dsj1984/mandrel-platform/.github/actions/setup-toolchain@${SHA}`
95
+ );
96
+ assert.equal(c.kind, "first-party");
97
+ assert.equal(c.owner, DEFAULT_FIRST_PARTY_OWNER);
98
+ assert.equal(c.subpath, ".github/actions/setup-toolchain");
99
+ assert.equal(c.ref, SHA);
100
+ });
101
+
102
+ test("classifyUses honours a custom first-party owner", () => {
103
+ const c = classifyUses(`my-org/my-repo/.github/workflows/x.yml@v1`, "my-org/my-repo");
104
+ assert.equal(c.kind, "first-party");
105
+ assert.equal(c.subpath, ".github/workflows/x.yml");
106
+ });
107
+
108
+ test("classifyUses exempts local and docker refs", () => {
109
+ assert.equal(classifyUses("./.github/actions/foo").kind, "local");
110
+ assert.equal(classifyUses("../shared/action").kind, "local");
111
+ assert.equal(classifyUses("docker://alpine:3.19").kind, "docker");
112
+ });
113
+
114
+ test("classifyUses reports empty subpath for a bare owner/repo self-ref", () => {
115
+ const c = classifyUses(`dsj1984/mandrel-platform@${SHA}`);
116
+ assert.equal(c.kind, "first-party");
117
+ assert.equal(c.subpath, "");
118
+ });
119
+
120
+ test("classifyUses returns unparseable for a ref with no @ or too few segments", () => {
121
+ assert.equal(classifyUses("").kind, "unparseable");
122
+ assert.equal(classifyUses("actions/checkout").kind, "unparseable");
123
+ assert.equal(classifyUses(`justowner@${SHA}`).kind, "unparseable");
124
+ });
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // collectFirstPartyPins
128
+ // ---------------------------------------------------------------------------
129
+
130
+ test("collectFirstPartyPins keys first-party subpath refs by target", () => {
131
+ const content = [
132
+ " steps:",
133
+ ` - uses: dsj1984/mandrel-platform/.github/actions/setup-toolchain@${SHA}`,
134
+ ` - uses: actions/checkout@${SHA}`, // third-party → ignored
135
+ ` - uses: dsj1984/mandrel-platform@${SHA}`, // bare self-ref, no subpath → ignored
136
+ ].join("\n");
137
+ const byTarget = collectFirstPartyPins(content, "wf.yml");
138
+ assert.equal(byTarget.size, 1);
139
+ const occs = byTarget.get("dsj1984/mandrel-platform/.github/actions/setup-toolchain");
140
+ assert.equal(occs.length, 1);
141
+ assert.equal(occs[0].ref, SHA);
142
+ assert.equal(occs[0].line, 2);
143
+ assert.equal(occs[0].file, "wf.yml");
144
+ });
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // findSinglePinViolations (the single-pin invariant)
148
+ // ---------------------------------------------------------------------------
149
+
150
+ test("findSinglePinViolations is clean when a target is pinned consistently", () => {
151
+ const files = [
152
+ {
153
+ file: "a.yml",
154
+ content: ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA}`,
155
+ },
156
+ {
157
+ file: "b.yml",
158
+ content: ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA}`,
159
+ },
160
+ ];
161
+ assert.deepEqual(findSinglePinViolations(files), []);
162
+ });
163
+
164
+ test("findSinglePinViolations flags a target pinned to two different SHAs", () => {
165
+ const files = [
166
+ {
167
+ file: "a.yml",
168
+ content: ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA}`,
169
+ },
170
+ {
171
+ file: "b.yml",
172
+ content: ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA2}`,
173
+ },
174
+ ];
175
+ const v = findSinglePinViolations(files);
176
+ assert.equal(v.length, 1);
177
+ assert.equal(v[0].target, "dsj1984/mandrel-platform/.github/actions/foo");
178
+ assert.equal(v[0].shas.length, 2);
179
+ assert.ok(v[0].shas.includes(SHA));
180
+ assert.ok(v[0].shas.includes(SHA2));
181
+ assert.equal(v[0].occurrences.length, 2);
182
+ assert.deepEqual(
183
+ v[0].occurrences.map((o) => o.file).sort(),
184
+ ["a.yml", "b.yml"]
185
+ );
186
+ });
187
+
188
+ test("findSinglePinViolations catches drift within a single file too", () => {
189
+ const files = [
190
+ {
191
+ file: "a.yml",
192
+ content: [
193
+ ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA}`,
194
+ ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA2}`,
195
+ ].join("\n"),
196
+ },
197
+ ];
198
+ const v = findSinglePinViolations(files);
199
+ assert.equal(v.length, 1);
200
+ assert.equal(v[0].shas.length, 2);
201
+ });
202
+
203
+ test("findSinglePinViolations ignores third-party targets (cross-repo dashboard owns those)", () => {
204
+ const files = [
205
+ { file: "a.yml", content: ` - uses: github/codeql-action/analyze@${SHA}` },
206
+ { file: "b.yml", content: ` - uses: github/codeql-action/analyze@${SHA2}` },
207
+ ];
208
+ assert.deepEqual(findSinglePinViolations(files), []);
209
+ });
210
+
211
+ test("findSinglePinViolations honours a custom first-party owner", () => {
212
+ const files = [
213
+ { file: "a.yml", content: ` - uses: my-org/my-repo/actions/x@${SHA}` },
214
+ { file: "b.yml", content: ` - uses: my-org/my-repo/actions/x@${SHA2}` },
215
+ ];
216
+ const v = findSinglePinViolations(files, "my-org/my-repo");
217
+ assert.equal(v.length, 1);
218
+ assert.equal(v[0].target, "my-org/my-repo/actions/x");
219
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * scripts/lib/walk.mjs
3
+ *
4
+ * The single directory-discovery seam for the pin-tooling scripts. Both
5
+ * `check-action-pins.mjs` and `check-workflow-portability.mjs` had grown their
6
+ * own `listWorkflowFiles` / `listActionFiles` pair — same intent, subtly
7
+ * different code (one sorted, one didn't; one guarded `statSync`, one didn't).
8
+ * Story #203 consolidates them here.
9
+ *
10
+ * All discovery is best-effort: a missing directory or an unreadable entry
11
+ * yields `[]` / is skipped rather than throwing, so a repo without a
12
+ * `.github/actions/` tree lints cleanly. Results are sorted for deterministic
13
+ * output.
14
+ */
15
+
16
+ import { readdirSync, statSync, existsSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ /**
20
+ * List `*.yml` / `*.yaml` files directly under a workflows dir
21
+ * (non-recursive — GitHub only runs top-level workflow files).
22
+ *
23
+ * @param {string} dir
24
+ * @returns {string[]} Sorted absolute/relative paths (as joined from `dir`).
25
+ */
26
+ export function listWorkflowFiles(dir) {
27
+ if (!existsSync(dir)) return [];
28
+ let entries;
29
+ try {
30
+ entries = readdirSync(dir);
31
+ } catch {
32
+ return [];
33
+ }
34
+ return entries
35
+ .filter((f) => /\.ya?ml$/.test(f))
36
+ .map((f) => join(dir, f))
37
+ .filter((p) => {
38
+ try {
39
+ return statSync(p).isFile();
40
+ } catch {
41
+ return false;
42
+ }
43
+ })
44
+ .sort();
45
+ }
46
+
47
+ /**
48
+ * Recursively list composite `action.yml` / `action.yaml` files under a dir.
49
+ *
50
+ * @param {string} dir
51
+ * @returns {string[]} Sorted paths.
52
+ */
53
+ export function listActionFiles(dir) {
54
+ const out = [];
55
+ if (!existsSync(dir)) return out;
56
+ const walk = (d) => {
57
+ let entries;
58
+ try {
59
+ entries = readdirSync(d, { withFileTypes: true });
60
+ } catch {
61
+ return;
62
+ }
63
+ for (const e of entries) {
64
+ const full = join(d, e.name);
65
+ if (e.isDirectory()) {
66
+ walk(full);
67
+ } else if (/^action\.ya?ml$/.test(e.name)) {
68
+ out.push(full);
69
+ }
70
+ }
71
+ };
72
+ walk(dir);
73
+ return out.sort();
74
+ }
@@ -68,7 +68,9 @@ import { tmpdir } from "node:os";
68
68
  import { dirname, join, resolve } from "node:path";
69
69
  import { fileURLToPath } from "node:url";
70
70
 
71
- import { buildReport, defaultGhRunner, isFullSha } from "./check-pin-drift.mjs";
71
+ import { buildReport, isFullSha } from "./check-pin-drift.mjs";
72
+ import { defaultGhRunner } from "./lib/gh-json.mjs";
73
+ import { parseSemver } from "./lib/semver-duration.mjs";
72
74
 
73
75
  const __dirname = dirname(fileURLToPath(import.meta.url));
74
76
 
@@ -188,12 +190,16 @@ export function describeDrift(result) {
188
190
  const short = v.pinnedSha ? v.pinnedSha.slice(0, 7) : "?";
189
191
  out.push(`**Release lag** — workflow \`uses:\` pins \`${short}\`, behind the latest release.`);
190
192
  }
193
+ // Normalize the npm version through the shared parser (Story #198): the
194
+ // detector already emits a dotted triple, but re-parsing keeps the repair
195
+ // PR body robust to a raw spec ever reaching here (`^0.11.7` → `0.11.7`).
196
+ const npmVersion = parseSemver(result.npm?.version ?? null) ?? result.npm?.version ?? "?";
191
197
  if (result.surfaceSkew === true) {
192
198
  out.push(
193
- `**Surface skew** — the npm \`mandrel-platform\` dependency (\`${result.npm?.version ?? "?"}\`) and the workflow \`uses:\` pins are on different releases.`,
199
+ `**Surface skew** — the npm \`mandrel-platform\` dependency (\`${npmVersion}\`) and the workflow \`uses:\` pins are on different releases.`,
194
200
  );
195
201
  } else if (result.npm?.npmState === "lagging") {
196
- out.push(`**npm lag** — \`mandrel-platform@${result.npm.version}\` is behind the latest release.`);
202
+ out.push(`**npm lag** — \`mandrel-platform@${npmVersion}\` is behind the latest release.`);
197
203
  }
198
204
  return out;
199
205
  }