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,263 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * audit-check.test.mjs — node:test suite for the CVE-gate core in
4
+ * audit-check.mjs (Story #195).
5
+ *
6
+ * Covers the fail-closed contract and the pure decision core:
7
+ * - an uninterpretable report + non-zero pnpm exit fails closed (exit 1)
8
+ * - a validly-suppressed high advisory (by GHSA id and by CVE id) passes
9
+ * - an expired allowlist entry fails closed (exit 1)
10
+ * - an unsuppressed critical fails closed (exit 1)
11
+ *
12
+ * The suppression/expiry/interpretation logic is exercised through the pure
13
+ * functions (`partitionAllowlist`, `isInterpretableReport`,
14
+ * `extractBlockingAdvisories`, `evaluateReport`) — no pnpm spawn, no
15
+ * filesystem — plus the CLI-level allowlist paths (`runCli` with a fixture
16
+ * allowlist) that decide the exit code before pnpm ever runs.
17
+ *
18
+ * Run: node --test scripts/audit-check.test.mjs (or `node --test scripts/`)
19
+ */
20
+
21
+ import assert from "node:assert/strict";
22
+ import { test } from "node:test";
23
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+
27
+ import {
28
+ partitionAllowlist,
29
+ isInterpretableReport,
30
+ extractBlockingAdvisories,
31
+ evaluateReport,
32
+ parseArgs,
33
+ loadAllowlist,
34
+ runCli,
35
+ } from "./audit-check.mjs";
36
+
37
+ const TODAY = "2026-07-02";
38
+
39
+ // A far-future expiry so "valid" fixtures never age out as the clock moves.
40
+ const FUTURE = "2999-12-31";
41
+ // A date safely in the past.
42
+ const PAST = "2000-01-01";
43
+
44
+ /** Build a pnpm-audit-shaped report from a list of advisories. */
45
+ function reportWith(advisories) {
46
+ const map = {};
47
+ for (const [key, adv] of Object.entries(advisories)) {
48
+ map[key] = adv;
49
+ }
50
+ return { advisories: map, metadata: {} };
51
+ }
52
+
53
+ const HIGH_GHSA = {
54
+ ghsa_id: "GHSA-aaaa-bbbb-cccc",
55
+ cve: ["CVE-2026-1111"],
56
+ severity: "high",
57
+ title: "High severity in transitive dep",
58
+ url: "https://example.test/GHSA-aaaa-bbbb-cccc",
59
+ };
60
+
61
+ const CRITICAL_ADV = {
62
+ ghsa_id: "GHSA-dddd-eeee-ffff",
63
+ cve: ["CVE-2026-2222"],
64
+ severity: "critical",
65
+ title: "Critical RCE",
66
+ url: "https://example.test/GHSA-dddd-eeee-ffff",
67
+ };
68
+
69
+ // ── partitionAllowlist ──────────────────────────────────────────────────────
70
+
71
+ test("partitionAllowlist: active entry lands in suppressed set", () => {
72
+ const { suppressed, expired, invalid } = partitionAllowlist(
73
+ [{ id: "GHSA-aaaa-bbbb-cccc", reason: "accepted", expires: FUTURE }],
74
+ TODAY,
75
+ );
76
+ assert.ok(suppressed.has("GHSA-aaaa-bbbb-cccc"));
77
+ assert.equal(expired.length, 0);
78
+ assert.equal(invalid.length, 0);
79
+ });
80
+
81
+ test("partitionAllowlist: expired entry lands in expired, not suppressed", () => {
82
+ const { suppressed, expired } = partitionAllowlist(
83
+ [{ id: "GHSA-aaaa-bbbb-cccc", reason: "accepted", expires: PAST }],
84
+ TODAY,
85
+ );
86
+ assert.equal(suppressed.size, 0);
87
+ assert.equal(expired.length, 1);
88
+ assert.equal(expired[0].id, "GHSA-aaaa-bbbb-cccc");
89
+ });
90
+
91
+ test("partitionAllowlist: entry missing id or expires is invalid", () => {
92
+ const { invalid } = partitionAllowlist(
93
+ [
94
+ { reason: "no id", expires: FUTURE },
95
+ { id: "GHSA-x", reason: "no expires" },
96
+ ],
97
+ TODAY,
98
+ );
99
+ assert.equal(invalid.length, 2);
100
+ });
101
+
102
+ // ── isInterpretableReport ───────────────────────────────────────────────────
103
+
104
+ test("isInterpretableReport: true for a report with an advisories object", () => {
105
+ assert.equal(isInterpretableReport(reportWith({})), true);
106
+ });
107
+
108
+ test("isInterpretableReport: false for an error envelope without advisories", () => {
109
+ assert.equal(
110
+ isInterpretableReport({ error: { code: "ERR", summary: "boom" } }),
111
+ false,
112
+ );
113
+ assert.equal(isInterpretableReport(null), false);
114
+ assert.equal(isInterpretableReport("not-json-object"), false);
115
+ assert.equal(isInterpretableReport({ advisories: null }), false);
116
+ });
117
+
118
+ // ── extractBlockingAdvisories: suppression matching ─────────────────────────
119
+
120
+ test("extractBlockingAdvisories: high advisory suppressed by GHSA id → no blocking", () => {
121
+ const report = reportWith({ 1: HIGH_GHSA });
122
+ const suppressed = new Set(["GHSA-aaaa-bbbb-cccc"]);
123
+ assert.deepEqual(extractBlockingAdvisories(report, suppressed), []);
124
+ });
125
+
126
+ test("extractBlockingAdvisories: high advisory suppressed by CVE id → no blocking", () => {
127
+ const report = reportWith({ 1: HIGH_GHSA });
128
+ const suppressed = new Set(["CVE-2026-1111"]);
129
+ assert.deepEqual(extractBlockingAdvisories(report, suppressed), []);
130
+ });
131
+
132
+ test("extractBlockingAdvisories: unsuppressed critical is blocking", () => {
133
+ const report = reportWith({ 1: CRITICAL_ADV });
134
+ const blocking = extractBlockingAdvisories(report, new Set());
135
+ assert.equal(blocking.length, 1);
136
+ assert.equal(blocking[0].severity, "critical");
137
+ assert.equal(blocking[0].id, "GHSA-dddd-eeee-ffff");
138
+ });
139
+
140
+ test("extractBlockingAdvisories: moderate/low severities are ignored", () => {
141
+ const report = reportWith({
142
+ 1: { ghsa_id: "GHSA-mod", severity: "moderate", title: "meh" },
143
+ 2: { ghsa_id: "GHSA-low", severity: "low", title: "meh" },
144
+ });
145
+ assert.deepEqual(extractBlockingAdvisories(report, new Set()), []);
146
+ });
147
+
148
+ test("extractBlockingAdvisories: uninterpretable report yields empty (guarded by caller)", () => {
149
+ assert.deepEqual(extractBlockingAdvisories({ error: "boom" }, new Set()), []);
150
+ });
151
+
152
+ // ── evaluateReport: the fail-closed decision core ───────────────────────────
153
+
154
+ test("evaluateReport: uninterpretable report + non-zero pnpm exit → exit 1 (fail closed)", () => {
155
+ const result = evaluateReport({ error: "boom" }, 1, new Set());
156
+ assert.equal(result.exitCode, 1);
157
+ assert.equal(result.reason, "uninterpretable-failclosed");
158
+ });
159
+
160
+ test("evaluateReport: uninterpretable report + zero exit → exit 0 (clean, nothing to report)", () => {
161
+ const result = evaluateReport({ metadata: {} }, 0, new Set());
162
+ assert.equal(result.exitCode, 0);
163
+ assert.equal(result.reason, "clean-no-advisories");
164
+ });
165
+
166
+ test("evaluateReport: validly-suppressed high (GHSA) → exit 0", () => {
167
+ const report = reportWith({ 1: HIGH_GHSA });
168
+ const result = evaluateReport(report, 1, new Set(["GHSA-aaaa-bbbb-cccc"]));
169
+ assert.equal(result.exitCode, 0);
170
+ assert.equal(result.reason, "clean");
171
+ });
172
+
173
+ test("evaluateReport: validly-suppressed high (CVE) → exit 0", () => {
174
+ const report = reportWith({ 1: HIGH_GHSA });
175
+ const result = evaluateReport(report, 1, new Set(["CVE-2026-1111"]));
176
+ assert.equal(result.exitCode, 0);
177
+ assert.equal(result.reason, "clean");
178
+ });
179
+
180
+ test("evaluateReport: unsuppressed critical → exit 1", () => {
181
+ const report = reportWith({ 1: CRITICAL_ADV });
182
+ const result = evaluateReport(report, 1, new Set());
183
+ assert.equal(result.exitCode, 1);
184
+ assert.equal(result.reason, "unsuppressed");
185
+ assert.equal(result.blocking.length, 1);
186
+ });
187
+
188
+ // ── CLI-level: expired allowlist short-circuits before pnpm ─────────────────
189
+
190
+ test("runCli: expired allowlist entry → exit non-zero (before pnpm runs)", () => {
191
+ const dir = mkdtempSync(join(tmpdir(), "audit-check-expired-"));
192
+ try {
193
+ const allowlistPath = join(dir, "audit-allowlist.json");
194
+ writeFileSync(
195
+ allowlistPath,
196
+ JSON.stringify([
197
+ { id: "GHSA-aaaa-bbbb-cccc", reason: "was accepted", expires: PAST },
198
+ ]),
199
+ );
200
+ const exit = runCli(["--allowlist", allowlistPath]);
201
+ assert.equal(exit, 1);
202
+ } finally {
203
+ rmSync(dir, { recursive: true, force: true });
204
+ }
205
+ });
206
+
207
+ test("runCli: malformed allowlist entry (missing expires) → exit non-zero", () => {
208
+ const dir = mkdtempSync(join(tmpdir(), "audit-check-malformed-"));
209
+ try {
210
+ const allowlistPath = join(dir, "audit-allowlist.json");
211
+ writeFileSync(
212
+ allowlistPath,
213
+ JSON.stringify([{ id: "GHSA-aaaa-bbbb-cccc", reason: "no expiry" }]),
214
+ );
215
+ const exit = runCli(["--allowlist", allowlistPath]);
216
+ assert.equal(exit, 1);
217
+ } finally {
218
+ rmSync(dir, { recursive: true, force: true });
219
+ }
220
+ });
221
+
222
+ test("runCli: non-array allowlist → exit non-zero", () => {
223
+ const dir = mkdtempSync(join(tmpdir(), "audit-check-nonarray-"));
224
+ try {
225
+ const allowlistPath = join(dir, "audit-allowlist.json");
226
+ writeFileSync(allowlistPath, JSON.stringify({ not: "an array" }));
227
+ const exit = runCli(["--allowlist", allowlistPath]);
228
+ assert.equal(exit, 1);
229
+ } finally {
230
+ rmSync(dir, { recursive: true, force: true });
231
+ }
232
+ });
233
+
234
+ // ── parseArgs / loadAllowlist ───────────────────────────────────────────────
235
+
236
+ test("parseArgs: --allowlist resolves against cwd; default is audit-allowlist.json", () => {
237
+ assert.equal(
238
+ parseArgs(["--allowlist", "custom.json"], "/repo").allowlistPath,
239
+ "/repo/custom.json",
240
+ );
241
+ assert.equal(
242
+ parseArgs([], "/repo").allowlistPath,
243
+ "/repo/audit-allowlist.json",
244
+ );
245
+ });
246
+
247
+ test("loadAllowlist: absent file returns empty array", () => {
248
+ assert.deepEqual(
249
+ loadAllowlist(join(tmpdir(), "does-not-exist-xyz.json")),
250
+ [],
251
+ );
252
+ });
253
+
254
+ test("loadAllowlist: non-array throws", () => {
255
+ const dir = mkdtempSync(join(tmpdir(), "audit-check-load-"));
256
+ try {
257
+ const p = join(dir, "a.json");
258
+ writeFileSync(p, JSON.stringify({ nope: true }));
259
+ assert.throws(() => loadAllowlist(p), /must be a JSON array/);
260
+ } finally {
261
+ rmSync(dir, { recursive: true, force: true });
262
+ }
263
+ });
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * check-action-pins.mjs
4
4
  *
5
- * Action-pin ratchet (Story #112).
5
+ * Action-pin ratchet (Story #112) + intra-repo single-pin invariant (#203).
6
6
  *
7
7
  * Third-party GitHub Actions referenced by `uses:` in this repo's workflows
8
8
  * and composite actions are SHA-pinned by convention — but until now NOTHING
@@ -32,12 +32,20 @@
32
32
  * composite actions, governed by the cross-repo portability lint's pin-lag
33
33
  * guard (`check-workflow-portability.mjs`, Rule 3), and they carry a
34
34
  * release-tag shape at publish time. The first-party owner is overridable
35
- * via `--first-party-owner` for a fork.
35
+ * via `--first-party-owner` for a fork. They ARE, however, subject to the
36
+ * single-pin invariant below.
36
37
  *
37
38
  * • LOCAL `./path` references and `docker://image` references are EXEMPT —
38
39
  * a local path has no upstream tag to move, and a docker ref is pinned by
39
40
  * its own digest convention, out of scope for this action-tag ratchet.
40
41
  *
42
+ * Single-pin invariant (Story #203): across `.github/workflows/`, two
43
+ * first-party `uses:` refs to the SAME subpath MUST carry the SAME SHA. Two
44
+ * workflows pinning `owner/repo/.github/actions/foo` at different commits is a
45
+ * silent split-brain — one workflow runs the fixed action, the other the
46
+ * stale one. This lint fails when that drift is present. Disable with
47
+ * `--no-single-pin` (e.g. mid-migration).
48
+ *
41
49
  * The reference is read from the `uses:` value with any trailing `# comment`
42
50
  * (the conventional `# v4.2.2` tag annotation) stripped first, so the human
43
51
  * tag note alongside the SHA never confuses the parse.
@@ -47,11 +55,13 @@
47
55
  * node scripts/check-action-pins.mjs --workflows-dir .github/workflows
48
56
  * node scripts/check-action-pins.mjs --actions-dir .github/actions
49
57
  * node scripts/check-action-pins.mjs --first-party-owner my-org/my-repo
58
+ * node scripts/check-action-pins.mjs --no-single-pin
50
59
  *
51
60
  * Exit codes:
52
- * 0 — every third-party `uses:` is pinned to a full 40-char commit SHA.
53
- * 1 — one or more third-party actions are not SHA-pinned (each named in
54
- * stderr with file:line).
61
+ * 0 — every third-party `uses:` is SHA-pinned and the single-pin invariant holds.
62
+ * 1 — one or more third-party actions are not SHA-pinned, or a first-party
63
+ * subpath is pinned to two different SHAs (each named in stderr with
64
+ * file:line).
55
65
  *
56
66
  * Consumer adoption:
57
67
  * Copy this script into your project's `scripts/` directory and wire it into
@@ -60,20 +70,37 @@
60
70
  * - name: Lint third-party action pins
61
71
  * run: node scripts/check-action-pins.mjs --first-party-owner <owner/repo>
62
72
  *
63
- * It is dependency-free (no YAML parser) so it copies cleanly into any repo.
73
+ * It depends only on the sibling `scripts/lib/` helpers (no YAML parser), so
74
+ * copy `scripts/lib/{args,uses-pins,walk}.mjs` alongside it.
64
75
  */
65
76
 
66
- import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
67
- import { resolve, join, relative } from "node:path";
77
+ import { readFileSync } from "node:fs";
78
+ import { resolve, relative } from "node:path";
68
79
 
69
- // ---------------------------------------------------------------------------
70
- // Pure helpers (exported for the sibling node:test suite)
71
- // ---------------------------------------------------------------------------
80
+ import { parseFlags } from "./lib/args.mjs";
81
+ import {
82
+ DEFAULT_FIRST_PARTY_OWNER,
83
+ stripUsesValue,
84
+ parseUsesLine,
85
+ classifyUses,
86
+ isSha40,
87
+ findSinglePinViolations,
88
+ } from "./lib/uses-pins.mjs";
89
+ import { listWorkflowFiles, listActionFiles } from "./lib/walk.mjs";
72
90
 
73
- const DEFAULT_FIRST_PARTY_OWNER = "dsj1984/mandrel-platform";
91
+ // Re-export the shared primitives so the sibling test suite (and any external
92
+ // consumer that imports from this script) keeps its existing import surface.
93
+ export {
94
+ stripUsesValue,
95
+ classifyUses,
96
+ isSha40,
97
+ listWorkflowFiles,
98
+ listActionFiles,
99
+ };
74
100
 
75
- /** A full git commit SHA is exactly 40 lowercase/uppercase hex characters. */
76
- const SHA40_RE = /^[0-9a-fA-F]{40}$/;
101
+ // ---------------------------------------------------------------------------
102
+ // Arg parsing
103
+ // ---------------------------------------------------------------------------
77
104
 
78
105
  /**
79
106
  * Parse the CLI argv (array AFTER `node script.mjs`) into an options object.
@@ -81,106 +108,21 @@ const SHA40_RE = /^[0-9a-fA-F]{40}$/;
81
108
  * loudly rather than silently mis-reading its own configuration.
82
109
  */
83
110
  export function parseArgs(argv) {
84
- const opts = {
85
- workflowsDir: ".github/workflows",
86
- actionsDir: ".github/actions",
87
- firstPartyOwner: DEFAULT_FIRST_PARTY_OWNER,
88
- cwd: process.cwd(),
89
- };
90
- const takeValue = (i, flag) => {
91
- const v = argv[i + 1];
92
- if (v === undefined || v.startsWith("--")) {
93
- throw new Error(`missing value for "${flag}"`);
94
- }
95
- return v;
96
- };
97
- for (let i = 0; i < argv.length; i++) {
98
- const arg = argv[i];
99
- switch (arg) {
100
- case "--workflows-dir":
101
- opts.workflowsDir = takeValue(i, arg);
102
- i++;
103
- break;
104
- case "--actions-dir":
105
- opts.actionsDir = takeValue(i, arg);
106
- i++;
107
- break;
108
- case "--first-party-owner":
109
- opts.firstPartyOwner = takeValue(i, arg);
110
- i++;
111
- break;
112
- case "--cwd":
113
- opts.cwd = takeValue(i, arg);
114
- i++;
115
- break;
116
- default:
117
- throw new Error(`unknown argument "${arg}"`);
118
- }
119
- }
120
- return opts;
121
- }
122
-
123
- /**
124
- * Strip a trailing `# comment` (the conventional `# v4.2.2` tag note) and
125
- * surrounding whitespace/quotes from a raw `uses:` value, returning the bare
126
- * action reference. A `#` inside the ref itself is not valid GitHub syntax,
127
- * so splitting on the first ` #` is safe.
128
- */
129
- export function stripUsesValue(raw) {
130
- let v = String(raw).trim();
131
- // Drop a trailing comment: the first '#' that is preceded by whitespace (or
132
- // at the start) begins a comment. GitHub action refs never contain '#'.
133
- const hashIdx = v.search(/\s#/);
134
- if (hashIdx !== -1) v = v.slice(0, hashIdx);
135
- v = v.trim();
136
- // Unwrap matched surrounding quotes.
137
- if (
138
- (v.startsWith('"') && v.endsWith('"')) ||
139
- (v.startsWith("'") && v.endsWith("'"))
140
- ) {
141
- v = v.slice(1, -1).trim();
142
- }
143
- return v;
144
- }
145
-
146
- /**
147
- * Classify a bare `uses:` reference. Returns one of:
148
- * { kind: 'local' } — `./path` or `../path` (exempt)
149
- * { kind: 'docker' } — `docker://image` (exempt)
150
- * { kind: 'first-party', owner, ref } — the configured first-party owner (exempt)
151
- * { kind: 'third-party', owner, ref } — external action (MUST be SHA-pinned)
152
- * { kind: 'unparseable' } — not a recognizable `uses:` reference
153
- */
154
- export function classifyUses(bareRef, firstPartyOwner = DEFAULT_FIRST_PARTY_OWNER) {
155
- const ref = String(bareRef).trim();
156
- if (ref === "") return { kind: "unparseable" };
157
- if (ref.startsWith("./") || ref.startsWith("../")) return { kind: "local" };
158
- if (ref.startsWith("docker://")) return { kind: "docker" };
159
-
160
- // owner/repo[/subpath]@gitref. The git ref is everything after the LAST '@'
161
- // (an action subpath never contains '@'; the ref does not either).
162
- const atIdx = ref.lastIndexOf("@");
163
- if (atIdx === -1) {
164
- // No `@ref` at all — not a pinnable external reference (e.g. a malformed
165
- // entry). Treat as unparseable so the caller can flag it explicitly.
166
- return { kind: "unparseable", ownerRepoPath: ref };
167
- }
168
- const ownerRepoPath = ref.slice(0, atIdx);
169
- const gitRef = ref.slice(atIdx + 1);
170
- const segments = ownerRepoPath.split("/");
171
- if (segments.length < 2) return { kind: "unparseable", ownerRepoPath, ref: gitRef };
172
-
173
- const ownerRepo = `${segments[0]}/${segments[1]}`;
174
- if (ownerRepo.toLowerCase() === String(firstPartyOwner).toLowerCase()) {
175
- return { kind: "first-party", owner: ownerRepo, ref: gitRef };
176
- }
177
- return { kind: "third-party", owner: ownerRepo, ref: gitRef };
111
+ return parseFlags(argv, {
112
+ flags: {
113
+ "--workflows-dir": { type: "string", dest: "workflowsDir", default: ".github/workflows" },
114
+ "--actions-dir": { type: "string", dest: "actionsDir", default: ".github/actions" },
115
+ "--first-party-owner": { type: "string", dest: "firstPartyOwner", default: DEFAULT_FIRST_PARTY_OWNER },
116
+ "--cwd": { type: "string", dest: "cwd", default: process.cwd() },
117
+ "--no-single-pin": { type: "boolean", dest: "singlePin", value: false, default: true },
118
+ },
119
+ onUnknown: "throw",
120
+ });
178
121
  }
179
122
 
180
- /** True when a git ref is a full 40-character commit SHA. */
181
- export function isSha40(gitRef) {
182
- return SHA40_RE.test(String(gitRef).trim());
183
- }
123
+ // ---------------------------------------------------------------------------
124
+ // Content scan
125
+ // ---------------------------------------------------------------------------
184
126
 
185
127
  /**
186
128
  * Scan a single file's TEXT for `uses:` step keys and evaluate each third-party
@@ -198,17 +140,9 @@ export function scanContent(content, displayFile, firstPartyOwner = DEFAULT_FIRS
198
140
  const violations = [];
199
141
  let scanned = 0;
200
142
  const lines = String(content).split(/\r?\n/);
201
- // Matches a YAML `uses:` mapping key: optional leading whitespace, an
202
- // optional leading `- ` (sequence item), then `uses:` and the value.
203
- const usesRe = /^\s*(?:-\s+)?uses:\s*(\S.*)$/;
204
143
  for (let i = 0; i < lines.length; i++) {
205
- const raw = lines[i];
206
- // Skip whole-line comments outright (defensive; the regex below also
207
- // won't match a leading '#').
208
- if (/^\s*#/.test(raw)) continue;
209
- const m = raw.match(usesRe);
210
- if (!m) continue;
211
- const bareRef = stripUsesValue(m[1]);
144
+ const bareRef = parseUsesLine(lines[i]);
145
+ if (bareRef === null) continue;
212
146
  const cls = classifyUses(bareRef, firstPartyOwner);
213
147
  if (cls.kind !== "third-party") continue; // local/docker/first-party/unparseable → exempt
214
148
  scanned++;
@@ -225,67 +159,29 @@ export function scanContent(content, displayFile, firstPartyOwner = DEFAULT_FIRS
225
159
  return { violations, scanned };
226
160
  }
227
161
 
228
- // ---------------------------------------------------------------------------
229
- // Filesystem walking
230
- // ---------------------------------------------------------------------------
231
-
232
- /** List `*.yml` / `*.yaml` files directly under a workflows dir (non-recursive). */
233
- export function listWorkflowFiles(dir) {
234
- if (!existsSync(dir)) return [];
235
- return readdirSync(dir)
236
- .filter((f) => /\.ya?ml$/.test(f))
237
- .map((f) => join(dir, f))
238
- .filter((p) => {
239
- try {
240
- return statSync(p).isFile();
241
- } catch {
242
- return false;
243
- }
244
- })
245
- .sort();
246
- }
247
-
248
- /** Recursively list composite `action.yml` / `action.yaml` files under a dir. */
249
- export function listActionFiles(dir) {
250
- const out = [];
251
- if (!existsSync(dir)) return out;
252
- const walk = (d) => {
253
- let entries;
254
- try {
255
- entries = readdirSync(d, { withFileTypes: true });
256
- } catch {
257
- return;
258
- }
259
- for (const e of entries) {
260
- const full = join(d, e.name);
261
- if (e.isDirectory()) {
262
- walk(full);
263
- } else if (/^action\.ya?ml$/.test(e.name)) {
264
- out.push(full);
265
- }
266
- }
267
- };
268
- walk(dir);
269
- return out.sort();
270
- }
271
-
272
162
  // ---------------------------------------------------------------------------
273
163
  // Orchestration
274
164
  // ---------------------------------------------------------------------------
275
165
 
276
166
  /**
277
167
  * Run the full lint against the resolved option set. Returns
278
- * `{ ok, violations, scanned, files }`. Pure with respect to stdout — the CLI
279
- * wrapper formats and prints.
168
+ * `{ ok, violations, scanned, files, singlePinViolations }`. Pure with respect
169
+ * to stdout — the CLI wrapper formats and prints. `singlePinViolations` is
170
+ * populated only when `opts.singlePin` is not `false`, and reflects the
171
+ * intra-repo single-pin invariant across the workflow files only.
280
172
  */
281
173
  export function runLint(opts) {
282
174
  const cwd = opts.cwd || process.cwd();
283
175
  const wfDir = resolve(cwd, opts.workflowsDir);
284
176
  const acDir = resolve(cwd, opts.actionsDir);
285
- const files = [...listWorkflowFiles(wfDir), ...listActionFiles(acDir)];
177
+ const workflowFiles = listWorkflowFiles(wfDir);
178
+ const files = [...workflowFiles, ...listActionFiles(acDir)];
286
179
 
287
180
  const violations = [];
288
181
  let scanned = 0;
182
+ // Keep the raw workflow-file contents for the single-pin pass so we read
183
+ // each file from disk once.
184
+ const workflowRecords = [];
289
185
  for (const file of files) {
290
186
  let content;
291
187
  try {
@@ -297,8 +193,23 @@ export function runLint(opts) {
297
193
  const res = scanContent(content, display, opts.firstPartyOwner);
298
194
  violations.push(...res.violations);
299
195
  scanned += res.scanned;
196
+ if (workflowFiles.includes(file)) {
197
+ workflowRecords.push({ file: display, content });
198
+ }
300
199
  }
301
- return { ok: violations.length === 0, violations, scanned, files };
200
+
201
+ const singlePin = opts.singlePin !== false;
202
+ const singlePinViolations = singlePin
203
+ ? findSinglePinViolations(workflowRecords, opts.firstPartyOwner)
204
+ : [];
205
+
206
+ return {
207
+ ok: violations.length === 0 && singlePinViolations.length === 0,
208
+ violations,
209
+ scanned,
210
+ files,
211
+ singlePinViolations,
212
+ };
302
213
  }
303
214
 
304
215
  // ---------------------------------------------------------------------------
@@ -316,7 +227,10 @@ export function runCli(argv, { log = console.log, err = console.error } = {}) {
316
227
 
317
228
  const result = runLint(opts);
318
229
 
319
- if (!result.ok) {
230
+ let failed = false;
231
+
232
+ if (result.violations.length > 0) {
233
+ failed = true;
320
234
  err(`[action-pins] ❌ ${result.violations.length} unpinned third-party action(s):`);
321
235
  for (const v of result.violations) {
322
236
  err(` • ${v.file}:${v.line} — ${v.reason}`);
@@ -326,12 +240,31 @@ export function runCli(argv, { log = console.log, err = console.error } = {}) {
326
240
  "(keep the `# vX.Y.Z` tag note as a comment). A mutable tag can be " +
327
241
  "force-moved to a malicious commit after review."
328
242
  );
329
- return 1;
330
243
  }
331
244
 
245
+ if (result.singlePinViolations.length > 0) {
246
+ failed = true;
247
+ err(
248
+ `[action-pins] ❌ ${result.singlePinViolations.length} first-party subpath(s) pinned to different SHAs (single-pin invariant):`
249
+ );
250
+ for (const v of result.singlePinViolations) {
251
+ err(` • ${v.target} is pinned to ${v.shas.length} distinct refs:`);
252
+ for (const occ of v.occurrences) {
253
+ err(` ${occ.file}:${occ.line} — @${occ.ref}`);
254
+ }
255
+ }
256
+ err(
257
+ "[action-pins] Every first-party `uses:` to the same subpath across " +
258
+ ".github/workflows/ must carry the SAME SHA — otherwise one workflow " +
259
+ "runs the fixed action and another the stale one."
260
+ );
261
+ }
262
+
263
+ if (failed) return 1;
264
+
332
265
  log(
333
266
  `[action-pins] ✅ all ${result.scanned} third-party action reference(s) are SHA-pinned ` +
334
- `(${result.files.length} file(s) scanned).`
267
+ `(${result.files.length} file(s) scanned); first-party single-pin invariant holds.`
335
268
  );
336
269
  return 0;
337
270
  }