mandrel-platform 0.12.0 → 0.14.2

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.
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-action-pins.mjs
4
+ *
5
+ * Action-pin ratchet (Story #112).
6
+ *
7
+ * Third-party GitHub Actions referenced by `uses:` in this repo's workflows
8
+ * and composite actions are SHA-pinned by convention — but until now NOTHING
9
+ * ENFORCED it. A single `uses: owner/repo@v4` that slipped past review would
10
+ * silently re-introduce mutable-tag risk: the tag can be force-moved to a
11
+ * malicious commit after review, and because the shared `pr-quality.yml` is
12
+ * inherited by every consumer, a tag-pinned regression has 3× blast radius
13
+ * across all three consumer repos (the threat this Story closes alongside the
14
+ * harden-runner egress baseline).
15
+ *
16
+ * This lint is the ratchet: it walks every workflow file under
17
+ * `.github/workflows/` and every composite `action.yml` under
18
+ * `.github/actions/`, extracts each `uses:` reference, and FAILS if any
19
+ * THIRD-PARTY action is pinned to anything other than a full 40-character
20
+ * commit SHA. It runs in mandrel-platform's own `ci-required` (ci.yml), so a
21
+ * non-SHA third-party pin can never reach `main`.
22
+ *
23
+ * Classification — what MUST be a 40-hex SHA, and what is exempt:
24
+ *
25
+ * • THIRD-PARTY `owner/repo[/subpath]@ref` → MUST be a 40-char hex SHA.
26
+ * (e.g. `actions/checkout`, `step-security/harden-runner`,
27
+ * `pnpm/action-setup`.) A non-SHA ref (a tag like `v4`, a branch, a short
28
+ * SHA) FAILS the lint.
29
+ *
30
+ * • FIRST-PARTY self-references — `dsj1984/mandrel-platform/...@<ref>` — are
31
+ * EXEMPT from this ratchet. They are this repo's OWN reusable workflows /
32
+ * composite actions, governed by the cross-repo portability lint's pin-lag
33
+ * guard (`check-workflow-portability.mjs`, Rule 3), and they carry a
34
+ * release-tag shape at publish time. The first-party owner is overridable
35
+ * via `--first-party-owner` for a fork.
36
+ *
37
+ * • LOCAL `./path` references and `docker://image` references are EXEMPT —
38
+ * a local path has no upstream tag to move, and a docker ref is pinned by
39
+ * its own digest convention, out of scope for this action-tag ratchet.
40
+ *
41
+ * The reference is read from the `uses:` value with any trailing `# comment`
42
+ * (the conventional `# v4.2.2` tag annotation) stripped first, so the human
43
+ * tag note alongside the SHA never confuses the parse.
44
+ *
45
+ * Usage:
46
+ * node scripts/check-action-pins.mjs
47
+ * node scripts/check-action-pins.mjs --workflows-dir .github/workflows
48
+ * node scripts/check-action-pins.mjs --actions-dir .github/actions
49
+ * node scripts/check-action-pins.mjs --first-party-owner my-org/my-repo
50
+ *
51
+ * 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).
55
+ *
56
+ * Consumer adoption:
57
+ * Copy this script into your project's `scripts/` directory and wire it into
58
+ * your CI alongside check-required-contexts.mjs / check-workflow-portability.mjs:
59
+ *
60
+ * - name: Lint third-party action pins
61
+ * run: node scripts/check-action-pins.mjs --first-party-owner <owner/repo>
62
+ *
63
+ * It is dependency-free (no YAML parser) so it copies cleanly into any repo.
64
+ */
65
+
66
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
67
+ import { resolve, join, relative } from "node:path";
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Pure helpers (exported for the sibling node:test suite)
71
+ // ---------------------------------------------------------------------------
72
+
73
+ const DEFAULT_FIRST_PARTY_OWNER = "dsj1984/mandrel-platform";
74
+
75
+ /** A full git commit SHA is exactly 40 lowercase/uppercase hex characters. */
76
+ const SHA40_RE = /^[0-9a-fA-F]{40}$/;
77
+
78
+ /**
79
+ * Parse the CLI argv (array AFTER `node script.mjs`) into an options object.
80
+ * Throws on an unknown flag or a flag missing its value so the lint fails
81
+ * loudly rather than silently mis-reading its own configuration.
82
+ */
83
+ 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 };
178
+ }
179
+
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
+ }
184
+
185
+ /**
186
+ * Scan a single file's TEXT for `uses:` step keys and evaluate each third-party
187
+ * reference. Returns { violations: [...], scanned: <count> }. A violation is
188
+ * `{ file, line, ref, owner, reason }`. `file` is left as passed-in (the
189
+ * caller supplies a display path).
190
+ *
191
+ * Only lines whose first non-space token is `uses:` (a YAML mapping key) are
192
+ * inspected — `uses:` appearing inside a comment or a `run:` heredoc never
193
+ * starts a YAML key at column-leading position, so this avoids false hits on
194
+ * documentation examples embedded in `#` comments (those are indented past a
195
+ * leading `#`).
196
+ */
197
+ export function scanContent(content, displayFile, firstPartyOwner = DEFAULT_FIRST_PARTY_OWNER) {
198
+ const violations = [];
199
+ let scanned = 0;
200
+ 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
+ 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]);
212
+ const cls = classifyUses(bareRef, firstPartyOwner);
213
+ if (cls.kind !== "third-party") continue; // local/docker/first-party/unparseable → exempt
214
+ scanned++;
215
+ if (!isSha40(cls.ref)) {
216
+ violations.push({
217
+ file: displayFile,
218
+ line: i + 1,
219
+ ref: bareRef,
220
+ owner: cls.owner,
221
+ reason: `third-party action "${cls.owner}" is pinned to "${cls.ref}", not a full 40-char commit SHA`,
222
+ });
223
+ }
224
+ }
225
+ return { violations, scanned };
226
+ }
227
+
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
+ // ---------------------------------------------------------------------------
273
+ // Orchestration
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /**
277
+ * 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.
280
+ */
281
+ export function runLint(opts) {
282
+ const cwd = opts.cwd || process.cwd();
283
+ const wfDir = resolve(cwd, opts.workflowsDir);
284
+ const acDir = resolve(cwd, opts.actionsDir);
285
+ const files = [...listWorkflowFiles(wfDir), ...listActionFiles(acDir)];
286
+
287
+ const violations = [];
288
+ let scanned = 0;
289
+ for (const file of files) {
290
+ let content;
291
+ try {
292
+ content = readFileSync(file, "utf8");
293
+ } catch {
294
+ continue;
295
+ }
296
+ const display = relative(cwd, file) || file;
297
+ const res = scanContent(content, display, opts.firstPartyOwner);
298
+ violations.push(...res.violations);
299
+ scanned += res.scanned;
300
+ }
301
+ return { ok: violations.length === 0, violations, scanned, files };
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // CLI entrypoint (skipped under `node --test` import)
306
+ // ---------------------------------------------------------------------------
307
+
308
+ export function runCli(argv, { log = console.log, err = console.error } = {}) {
309
+ let opts;
310
+ try {
311
+ opts = parseArgs(argv);
312
+ } catch (e) {
313
+ err(`[action-pins] ❌ ${e.message}`);
314
+ return 1;
315
+ }
316
+
317
+ const result = runLint(opts);
318
+
319
+ if (!result.ok) {
320
+ err(`[action-pins] ❌ ${result.violations.length} unpinned third-party action(s):`);
321
+ for (const v of result.violations) {
322
+ err(` • ${v.file}:${v.line} — ${v.reason}`);
323
+ }
324
+ err(
325
+ "[action-pins] Pin every third-party action to a full 40-char commit SHA " +
326
+ "(keep the `# vX.Y.Z` tag note as a comment). A mutable tag can be " +
327
+ "force-moved to a malicious commit after review."
328
+ );
329
+ return 1;
330
+ }
331
+
332
+ log(
333
+ `[action-pins] ✅ all ${result.scanned} third-party action reference(s) are SHA-pinned ` +
334
+ `(${result.files.length} file(s) scanned).`
335
+ );
336
+ return 0;
337
+ }
338
+
339
+ // Only run when executed directly, not when imported by the test suite.
340
+ const invokedDirectly =
341
+ process.argv[1] && resolve(process.argv[1]).endsWith("check-action-pins.mjs");
342
+ if (invokedDirectly) {
343
+ process.exit(runCli(process.argv.slice(2)));
344
+ }
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-action-pins.test.mjs — node:test suite for the action-pin ratchet that
4
+ * backs mandrel-platform's `ci-required` third-party-action SHA-pin gate
5
+ * (Story #112).
6
+ *
7
+ * This is the "equivalent self-test" the Story's acceptance criteria call for:
8
+ * it exercises the ratchet's classification (third-party vs first-party vs
9
+ * local vs docker), the 40-char-SHA assertion, the `# tag` comment strip, and
10
+ * a full content scan with both a SHA-pinned (pass) and a tag-pinned (fail)
11
+ * fixture. Pure helpers + a temp-dir fixture keep the whole pipeline offline.
12
+ *
13
+ * Run: node scripts/check-action-pins.test.mjs (or `node --test scripts/`)
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { test } from "node:test";
21
+
22
+ import {
23
+ parseArgs,
24
+ stripUsesValue,
25
+ classifyUses,
26
+ isSha40,
27
+ scanContent,
28
+ listWorkflowFiles,
29
+ listActionFiles,
30
+ runLint,
31
+ runCli,
32
+ } from "./check-action-pins.mjs";
33
+
34
+ const SHA = "11bd71901bbe5b1630ceea73d27597364c9af683"; // 40 hex
35
+ const SHORT = "11bd719"; // 7 hex
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // isSha40
39
+ // ---------------------------------------------------------------------------
40
+
41
+ test("isSha40 accepts exactly 40 hex chars", () => {
42
+ assert.equal(isSha40(SHA), true);
43
+ assert.equal(isSha40(SHA.toUpperCase()), true);
44
+ });
45
+
46
+ test("isSha40 rejects tags, short SHAs, branches", () => {
47
+ assert.equal(isSha40("v4"), false);
48
+ assert.equal(isSha40("v4.2.2"), false);
49
+ assert.equal(isSha40(SHORT), false);
50
+ assert.equal(isSha40("main"), false);
51
+ assert.equal(isSha40(SHA + "0"), false); // 41 chars
52
+ assert.equal(isSha40("g".repeat(40)), false); // non-hex
53
+ });
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // stripUsesValue
57
+ // ---------------------------------------------------------------------------
58
+
59
+ test("stripUsesValue drops the trailing # tag comment", () => {
60
+ assert.equal(
61
+ stripUsesValue(`actions/checkout@${SHA} # v4.2.2`),
62
+ `actions/checkout@${SHA}`
63
+ );
64
+ });
65
+
66
+ test("stripUsesValue handles no comment and surrounding quotes", () => {
67
+ assert.equal(stripUsesValue(`actions/checkout@${SHA}`), `actions/checkout@${SHA}`);
68
+ assert.equal(stripUsesValue(`"actions/checkout@${SHA}"`), `actions/checkout@${SHA}`);
69
+ assert.equal(stripUsesValue(`'actions/checkout@${SHA}'`), `actions/checkout@${SHA}`);
70
+ });
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // classifyUses
74
+ // ---------------------------------------------------------------------------
75
+
76
+ test("classifyUses flags external owner/repo as third-party", () => {
77
+ const c = classifyUses(`actions/checkout@${SHA}`);
78
+ assert.equal(c.kind, "third-party");
79
+ assert.equal(c.owner, "actions/checkout");
80
+ assert.equal(c.ref, SHA);
81
+ });
82
+
83
+ test("classifyUses treats the first-party owner as exempt", () => {
84
+ const c = classifyUses(
85
+ `dsj1984/mandrel-platform/.github/actions/setup-toolchain@${SHA}`
86
+ );
87
+ assert.equal(c.kind, "first-party");
88
+ // The subpath after owner/repo is preserved out of `ref`; ref is the gitref.
89
+ assert.equal(c.ref, SHA);
90
+ });
91
+
92
+ test("classifyUses honours a custom --first-party-owner", () => {
93
+ const c = classifyUses(`my-org/my-repo/.github/workflows/x.yml@v1`, "my-org/my-repo");
94
+ assert.equal(c.kind, "first-party");
95
+ });
96
+
97
+ test("classifyUses exempts local and docker refs", () => {
98
+ assert.equal(classifyUses("./.github/actions/foo").kind, "local");
99
+ assert.equal(classifyUses("../shared/action").kind, "local");
100
+ assert.equal(classifyUses("docker://alpine:3.19").kind, "docker");
101
+ });
102
+
103
+ test("classifyUses isolates the git ref after the LAST @ (subpath-safe)", () => {
104
+ const c = classifyUses(`github/codeql-action/analyze@${SHA}`);
105
+ assert.equal(c.kind, "third-party");
106
+ assert.equal(c.owner, "github/codeql-action");
107
+ assert.equal(c.ref, SHA);
108
+ });
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // scanContent
112
+ // ---------------------------------------------------------------------------
113
+
114
+ test("scanContent passes a fully SHA-pinned third-party uses", () => {
115
+ const yaml = [
116
+ "jobs:",
117
+ " build:",
118
+ " steps:",
119
+ ` - uses: actions/checkout@${SHA} # v4.2.2`,
120
+ ` - uses: step-security/harden-runner@${SHA} # v2.19.4`,
121
+ ].join("\n");
122
+ const { violations, scanned } = scanContent(yaml, "wf.yml");
123
+ assert.equal(scanned, 2);
124
+ assert.deepEqual(violations, []);
125
+ });
126
+
127
+ test("scanContent fails a tag-pinned third-party uses", () => {
128
+ const yaml = [
129
+ " steps:",
130
+ " - uses: actions/checkout@v4",
131
+ ` - uses: pnpm/action-setup@${SHA}`,
132
+ ].join("\n");
133
+ const { violations, scanned } = scanContent(yaml, "wf.yml");
134
+ assert.equal(scanned, 2);
135
+ assert.equal(violations.length, 1);
136
+ assert.equal(violations[0].owner, "actions/checkout");
137
+ assert.equal(violations[0].line, 2);
138
+ assert.match(violations[0].reason, /not a full 40-char commit SHA/);
139
+ });
140
+
141
+ test("scanContent ignores first-party, local, docker, and comment lines", () => {
142
+ const yaml = [
143
+ "# uses: actions/checkout@v4 (this is a comment example, must be ignored)",
144
+ " steps:",
145
+ ` - uses: dsj1984/mandrel-platform/.github/actions/setup-toolchain@${SHA}`,
146
+ " - uses: ./.github/actions/local-thing",
147
+ " - uses: docker://alpine:3.19",
148
+ ].join("\n");
149
+ const { violations, scanned } = scanContent(yaml, "wf.yml");
150
+ assert.equal(scanned, 0); // none are third-party
151
+ assert.deepEqual(violations, []);
152
+ });
153
+
154
+ test("scanContent flags a short-SHA third-party pin", () => {
155
+ const yaml = ` - uses: actions/setup-node@${SHORT}`;
156
+ const { violations } = scanContent(yaml, "wf.yml");
157
+ assert.equal(violations.length, 1);
158
+ assert.match(violations[0].reason, /not a full 40-char commit SHA/);
159
+ });
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // parseArgs
163
+ // ---------------------------------------------------------------------------
164
+
165
+ test("parseArgs applies defaults and overrides", () => {
166
+ const d = parseArgs([]);
167
+ assert.equal(d.workflowsDir, ".github/workflows");
168
+ assert.equal(d.actionsDir, ".github/actions");
169
+ assert.equal(d.firstPartyOwner, "dsj1984/mandrel-platform");
170
+
171
+ const o = parseArgs(["--first-party-owner", "x/y", "--workflows-dir", "wf"]);
172
+ assert.equal(o.firstPartyOwner, "x/y");
173
+ assert.equal(o.workflowsDir, "wf");
174
+ });
175
+
176
+ test("parseArgs throws on unknown flag and missing value", () => {
177
+ assert.throws(() => parseArgs(["--nope"]), /unknown argument/);
178
+ assert.throws(() => parseArgs(["--first-party-owner"]), /missing value/);
179
+ });
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // runLint + runCli over a temp fixture tree
183
+ // ---------------------------------------------------------------------------
184
+
185
+ function fixtureRepo({ workflow }) {
186
+ const root = mkdtempSync(join(tmpdir(), "pin-ratchet-"));
187
+ mkdirSync(join(root, ".github", "workflows"), { recursive: true });
188
+ writeFileSync(join(root, ".github", "workflows", "ci.yml"), workflow);
189
+ return root;
190
+ }
191
+
192
+ test("runLint is green when every third-party action is SHA-pinned", () => {
193
+ const root = fixtureRepo({
194
+ workflow: [
195
+ " steps:",
196
+ ` - uses: actions/checkout@${SHA} # v4`,
197
+ ` - uses: dsj1984/mandrel-platform/.github/actions/setup-toolchain@${SHA}`,
198
+ ].join("\n"),
199
+ });
200
+ try {
201
+ const res = runLint({
202
+ cwd: root,
203
+ workflowsDir: ".github/workflows",
204
+ actionsDir: ".github/actions",
205
+ firstPartyOwner: "dsj1984/mandrel-platform",
206
+ });
207
+ assert.equal(res.ok, true);
208
+ assert.equal(res.scanned, 1); // only the third-party checkout counts
209
+ } finally {
210
+ rmSync(root, { recursive: true, force: true });
211
+ }
212
+ });
213
+
214
+ test("runLint / runCli are red on a tag-pinned third-party action", () => {
215
+ const root = fixtureRepo({
216
+ workflow: [" steps:", " - uses: actions/checkout@v4"].join("\n"),
217
+ });
218
+ try {
219
+ const res = runLint({
220
+ cwd: root,
221
+ workflowsDir: ".github/workflows",
222
+ actionsDir: ".github/actions",
223
+ firstPartyOwner: "dsj1984/mandrel-platform",
224
+ });
225
+ assert.equal(res.ok, false);
226
+ assert.equal(res.violations.length, 1);
227
+
228
+ const errs = [];
229
+ const code = runCli(["--cwd", root], { log: () => {}, err: (m) => errs.push(m) });
230
+ assert.equal(code, 1);
231
+ assert.ok(errs.some((m) => /unpinned third-party action/.test(m)));
232
+ } finally {
233
+ rmSync(root, { recursive: true, force: true });
234
+ }
235
+ });
236
+
237
+ test("listWorkflowFiles / listActionFiles return [] for a missing dir", () => {
238
+ assert.deepEqual(listWorkflowFiles("/no/such/dir/workflows"), []);
239
+ assert.deepEqual(listActionFiles("/no/such/dir/actions"), []);
240
+ });