mandrel-platform 1.1.0 → 1.3.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.
@@ -0,0 +1,624 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-first-party-pin-freshness.test.mjs — node:test suite for the
4
+ * first-party self-pin freshness guard (Story #354).
5
+ *
6
+ * The two failure classes this checker exists to separate can only be
7
+ * exercised against real git history, so the suite builds three throwaway
8
+ * fixture repositories under the OS temp dir:
9
+ *
10
+ * • STALE — the workflow pins the commit BEFORE the action manifest
11
+ * was fixed, so the pinned manifest lags the working tree.
12
+ * This is issue #352 in miniature.
13
+ * • UNREACHABLE — the workflow pins a commit made on a side branch that was
14
+ * never merged. Its manifest is byte-identical to the
15
+ * working tree, so a content-only check would call it clean;
16
+ * it is one `gc` away from breaking every consumer.
17
+ * • CLEAN — every pin resolves to a reachable, identical manifest.
18
+ *
19
+ * Each fixture is a handful of tiny files and 2–3 commits, so the suite stays
20
+ * fast; the pure-text and fatal-path cases below use the injectable git seam
21
+ * and touch no filesystem at all.
22
+ *
23
+ * Run: node --test scripts/check-first-party-pin-freshness.test.mjs
24
+ */
25
+
26
+ import assert from "node:assert/strict";
27
+ import { test } from "node:test";
28
+ import { execFileSync } from "node:child_process";
29
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
30
+ import { tmpdir } from "node:os";
31
+ import { join, dirname } from "node:path";
32
+ import { fileURLToPath } from "node:url";
33
+
34
+ import {
35
+ parseArgs,
36
+ collectPinnedRefs,
37
+ resolveManifest,
38
+ manifestsMatch,
39
+ diffSubpathAtSha,
40
+ runCheck,
41
+ runCli,
42
+ } from "./check-first-party-pin-freshness.mjs";
43
+
44
+ const OWNER = "test-owner/test-repo";
45
+ const SUBPATH = ".github/actions/demo";
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Fixture helpers
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /** Run git in `cwd`, returning trimmed stdout. */
52
+ function git(cwd, ...args) {
53
+ return execFileSync("git", args, {
54
+ cwd,
55
+ encoding: "utf8",
56
+ stdio: ["ignore", "pipe", "ignore"],
57
+ }).trim();
58
+ }
59
+
60
+ /** Write a file, creating parent directories as needed. */
61
+ function put(root, relPath, body) {
62
+ const full = join(root, relPath);
63
+ mkdirSync(join(full, ".."), { recursive: true });
64
+ writeFileSync(full, body, "utf8");
65
+ }
66
+
67
+ /** An action manifest body, parameterised by the line that matters. */
68
+ function manifest(tmpLine) {
69
+ return [
70
+ "name: demo",
71
+ "description: fixture composite action",
72
+ "runs:",
73
+ " using: composite",
74
+ " steps:",
75
+ " - shell: bash",
76
+ ` run: ${tmpLine}`,
77
+ "",
78
+ ].join("\n");
79
+ }
80
+
81
+ const STALE_BODY = manifest('tmp="$(mktemp -d)"');
82
+ const FIXED_BODY = manifest('tmp="$(mktemp -d "${RUNNER_TEMP}/demo.XXXXXX")"');
83
+
84
+ /** A workflow whose single first-party step pins `sha`. */
85
+ function workflow(sha, extraUses = []) {
86
+ return [
87
+ "name: fixture",
88
+ "on:",
89
+ " push:",
90
+ " branches: [main]",
91
+ "jobs:",
92
+ " demo:",
93
+ " runs-on: ubuntu-latest",
94
+ " steps:",
95
+ ...extraUses.map((u) => ` - uses: ${u}`),
96
+ ` - uses: ${OWNER}/${SUBPATH}@${sha}`,
97
+ "",
98
+ ].join("\n");
99
+ }
100
+
101
+ /**
102
+ * Create a git repo with an initial `demo` action whose manifest is
103
+ * `STALE_BODY`, then a second commit fixing it to `FIXED_BODY`. Returns the
104
+ * repo root and both commit SHAs. The caller writes the workflow file
105
+ * afterwards (the checker reads workflows from the working tree, so they need
106
+ * not be committed).
107
+ */
108
+ function makeRepo(label) {
109
+ const root = mkdtempSync(join(tmpdir(), `pinfresh-${label}-`));
110
+ git(root, "init", "-b", "main");
111
+ // Repo-local identity + neutered hooks so the fixture never depends on the
112
+ // developer's global git config or a global hooksPath.
113
+ git(root, "config", "user.email", "fixture@example.invalid");
114
+ git(root, "config", "user.name", "Pin Freshness Fixture");
115
+ git(root, "config", "commit.gpgsign", "false");
116
+ git(root, "config", "core.hooksPath", join(root, ".no-hooks"));
117
+
118
+ put(root, `${SUBPATH}/action.yml`, STALE_BODY);
119
+ git(root, "add", "-A");
120
+ git(root, "commit", "-m", "initial action");
121
+ const before = git(root, "rev-parse", "HEAD");
122
+
123
+ put(root, `${SUBPATH}/action.yml`, FIXED_BODY);
124
+ git(root, "add", "-A");
125
+ git(root, "commit", "-m", "scope extraction to RUNNER_TEMP");
126
+ const after = git(root, "rev-parse", "HEAD");
127
+
128
+ return { root, before, after };
129
+ }
130
+
131
+ /** Capture a runCli invocation's streams alongside its exit code. */
132
+ function capture(argv) {
133
+ const out = [];
134
+ const errs = [];
135
+ const code = runCli(argv, {
136
+ log: (s) => out.push(String(s)),
137
+ err: (s) => errs.push(String(s)),
138
+ });
139
+ return { code, stdout: out.join("\n"), stderr: errs.join("\n") };
140
+ }
141
+
142
+ const cleanups = [];
143
+ test.after(() => {
144
+ for (const dir of cleanups) rmSync(dir, { recursive: true, force: true });
145
+ });
146
+ function track(root) {
147
+ cleanups.push(root);
148
+ return root;
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // AC-4 — behavioural lag is detected, and the report names file/line/subpath/SHA
153
+ // ---------------------------------------------------------------------------
154
+
155
+ test("stale: a pin whose manifest lags the working tree exits non-zero", () => {
156
+ const { root, before } = makeRepo("stale");
157
+ track(root);
158
+ put(root, ".github/workflows/fixture.yml", workflow(before));
159
+
160
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
161
+
162
+ assert.equal(result.ok, false);
163
+ assert.equal(result.stale.length, 1);
164
+ assert.equal(result.unreachable.length, 0);
165
+ assert.equal(result.stale[0].subpath, SUBPATH);
166
+ assert.equal(result.stale[0].sha, before);
167
+ });
168
+
169
+ test("stale: the CLI names the referencing file, line, subpath and pinned SHA", () => {
170
+ const { root, before } = makeRepo("stale-cli");
171
+ track(root);
172
+ const body = workflow(before);
173
+ put(root, ".github/workflows/fixture.yml", body);
174
+ // Derive the expected line from the fixture rather than hard-coding it, so
175
+ // the assertion pins the REPORTED line to the REAL one.
176
+ const pinLine = body.split("\n").findIndex((l) => l.includes(`${OWNER}/${SUBPATH}@`)) + 1;
177
+
178
+ const { code, stderr } = capture([
179
+ "--cwd",
180
+ root,
181
+ "--first-party-owner",
182
+ OWNER,
183
+ ]);
184
+
185
+ assert.equal(code, 1);
186
+ // The four facts an operator needs to act without re-deriving anything.
187
+ assert.ok(
188
+ stderr.includes(`.github/workflows/fixture.yml:${pinLine}`),
189
+ `report names the referencing file and line ${pinLine}`
190
+ );
191
+ assert.ok(stderr.includes(SUBPATH), "report names the subpath");
192
+ assert.ok(stderr.includes(before), "report names the full pinned SHA");
193
+ assert.match(stderr, /\[stale\]/);
194
+ });
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // AC-5 — an off-branch pin is a DISTINCT class, even when content-identical
198
+ // ---------------------------------------------------------------------------
199
+
200
+ test("unreachable: an off-branch pin is classified separately from stale", () => {
201
+ const { root, after } = makeRepo("unreachable");
202
+ track(root);
203
+
204
+ // A side-branch commit whose action manifest is byte-identical to main's —
205
+ // exactly the pre-squash `setup-toolchain@1ace1d82` shape. A content-only
206
+ // check would call this clean.
207
+ git(root, "checkout", "-b", "side");
208
+ put(root, "unrelated.txt", "side-branch only\n");
209
+ git(root, "add", "-A");
210
+ git(root, "commit", "-m", "side branch commit");
211
+ const offBranch = git(root, "rev-parse", "HEAD");
212
+ git(root, "checkout", "main");
213
+
214
+ assert.notEqual(offBranch, after);
215
+ put(root, ".github/workflows/fixture.yml", workflow(offBranch));
216
+
217
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
218
+
219
+ assert.equal(result.ok, false);
220
+ assert.equal(result.unreachable.length, 1);
221
+ assert.equal(result.stale.length, 0, "an unreachable pin is not double-reported as stale");
222
+ assert.equal(result.unreachable[0].sha, offBranch);
223
+
224
+ const { code, stderr } = capture(["--cwd", root, "--first-party-owner", OWNER]);
225
+ assert.equal(code, 1);
226
+ assert.match(stderr, /\[unreachable\]/);
227
+ assert.ok(!/\[stale\]/.test(stderr), "the unreachable finding is not also labelled stale");
228
+ });
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // AC-6 — a fresh, reachable tree exits 0
232
+ // ---------------------------------------------------------------------------
233
+
234
+ test("clean: reachable pins matching the working tree exit 0", () => {
235
+ const { root, after } = makeRepo("clean");
236
+ track(root);
237
+ put(root, ".github/workflows/fixture.yml", workflow(after));
238
+
239
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
240
+ assert.equal(result.ok, true);
241
+ assert.equal(result.scanned, 1);
242
+
243
+ const { code, stdout } = capture(["--cwd", root, "--first-party-owner", OWNER]);
244
+ assert.equal(code, 0);
245
+ assert.match(stdout, /✅/);
246
+ });
247
+
248
+ test("clean: two call sites pinning the same fresh SHA both pass", () => {
249
+ const { root, after } = makeRepo("clean-multi");
250
+ track(root);
251
+ put(root, ".github/workflows/one.yml", workflow(after));
252
+ put(root, ".github/workflows/two.yml", workflow(after));
253
+
254
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
255
+ assert.equal(result.ok, true);
256
+ assert.equal(result.scanned, 2);
257
+ });
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Story #379 — the comparison is the whole action DIRECTORY, not the manifest
261
+ //
262
+ // A composite action whose behaviour lives in a sibling script is the majority
263
+ // of this repo's action surface (`osv-scan`, `osv-track-issue`), and a
264
+ // manifest-only comparison is structurally unable to see a change there. That
265
+ // blind spot shipped live: Story #365 rewrote `osv-scan/osv-report-gate.mjs`
266
+ // (+189/-12) without touching `action.yml`, so both call sites read as fresh
267
+ // while executing the old gate.
268
+ // ---------------------------------------------------------------------------
269
+
270
+ const SIBLING_MANIFEST = [
271
+ "name: demo",
272
+ "description: fixture composite action whose behaviour lives in a sibling script",
273
+ "runs:",
274
+ " using: composite",
275
+ " steps:",
276
+ " - shell: bash",
277
+ " run: node ./gate.mjs",
278
+ "",
279
+ ].join("\n");
280
+
281
+ /**
282
+ * Create a repo whose action manifest NEVER changes — only the sibling
283
+ * `gate.mjs` does. The `before` commit therefore carries a byte-identical
284
+ * `action.yml` alongside a stale `gate.mjs`.
285
+ */
286
+ function makeSiblingScriptRepo(label) {
287
+ const root = mkdtempSync(join(tmpdir(), `pinfresh-${label}-`));
288
+ git(root, "init", "-b", "main");
289
+ git(root, "config", "user.email", "fixture@example.invalid");
290
+ git(root, "config", "user.name", "Pin Freshness Fixture");
291
+ git(root, "config", "commit.gpgsign", "false");
292
+ git(root, "config", "core.hooksPath", join(root, ".no-hooks"));
293
+
294
+ put(root, `${SUBPATH}/action.yml`, SIBLING_MANIFEST);
295
+ put(root, `${SUBPATH}/gate.mjs`, "process.exit(0);\n");
296
+ git(root, "add", "-A");
297
+ git(root, "commit", "-m", "initial action");
298
+ const before = git(root, "rev-parse", "HEAD");
299
+
300
+ // Behaviour change, manifest untouched.
301
+ put(root, `${SUBPATH}/gate.mjs`, "process.exit(process.env.FAIL ? 1 : 0);\n");
302
+ git(root, "add", "-A");
303
+ git(root, "commit", "-m", "harden the gate");
304
+ const after = git(root, "rev-parse", "HEAD");
305
+
306
+ return { root, before, after };
307
+ }
308
+
309
+ /** `git show <sha>:<path>` without the trimming the `git` helper applies. */
310
+ function showRaw(root, sha, relPath) {
311
+ return execFileSync("git", ["show", `${sha}:${relPath}`], { cwd: root, encoding: "utf8" });
312
+ }
313
+
314
+ test("stale: a sibling script that differs at the pinned SHA is stale even when action.yml is byte-identical", () => {
315
+ const { root, before } = makeSiblingScriptRepo("sibling-stale");
316
+ track(root);
317
+ put(root, ".github/workflows/fixture.yml", workflow(before));
318
+
319
+ // The premise, asserted rather than assumed: a manifest-only comparison
320
+ // would have called this pin fresh.
321
+ assert.equal(
322
+ showRaw(root, before, `${SUBPATH}/action.yml`),
323
+ readFileSync(join(root, SUBPATH, "action.yml"), "utf8"),
324
+ "premise: action.yml is byte-identical at the pinned SHA"
325
+ );
326
+
327
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
328
+
329
+ assert.equal(result.ok, false);
330
+ assert.equal(result.stale.length, 1);
331
+ assert.equal(result.unreachable.length, 0);
332
+ assert.match(result.stale[0].reason, /gate\.mjs/, "the report names the drifting sibling");
333
+
334
+ const { code, stderr } = capture(["--cwd", root, "--first-party-owner", OWNER]);
335
+ assert.equal(code, 1);
336
+ assert.match(stderr, /gate\.mjs/);
337
+ });
338
+
339
+ test("clean: a pin carrying the current sibling script exits 0", () => {
340
+ const { root, after } = makeSiblingScriptRepo("sibling-clean");
341
+ track(root);
342
+ put(root, ".github/workflows/fixture.yml", workflow(after));
343
+
344
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
345
+ assert.equal(result.ok, true);
346
+ });
347
+
348
+ test("stale: a file added to the action directory since the pinned SHA is stale", () => {
349
+ const { root, after } = makeSiblingScriptRepo("sibling-added");
350
+ track(root);
351
+ put(root, `${SUBPATH}/helper.mjs`, "export const help = () => 1;\n");
352
+ git(root, "add", "-A");
353
+ put(root, ".github/workflows/fixture.yml", workflow(after));
354
+
355
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
356
+
357
+ assert.equal(result.ok, false);
358
+ assert.match(result.stale[0].reason, /helper\.mjs/);
359
+ });
360
+
361
+ test("stale: a file removed from the action directory since the pinned SHA is stale", () => {
362
+ const { root, after } = makeSiblingScriptRepo("sibling-removed");
363
+ track(root);
364
+ git(root, "rm", "-q", `${SUBPATH}/gate.mjs`);
365
+ put(root, ".github/workflows/fixture.yml", workflow(after));
366
+
367
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
368
+
369
+ assert.equal(result.ok, false);
370
+ assert.match(result.stale[0].reason, /gate\.mjs/);
371
+ });
372
+
373
+ test("diffSubpathAtSha: a tracked file missing from disk is drift, not a silent skip", () => {
374
+ const path = `${SUBPATH}/gone.mjs`;
375
+ const fake = { lsTree: () => [path], lsFiles: () => [path], show: () => "body\n" };
376
+
377
+ const drift = diffSubpathAtSha(fake, "/no-such-root", "0".repeat(40), SUBPATH);
378
+
379
+ assert.deepEqual(drift, [{ path, kind: "unreadable" }]);
380
+ });
381
+
382
+ test("diffSubpathAtSha: a blob git cannot resolve at the pinned SHA is drift, not a pass", () => {
383
+ const { root } = makeSiblingScriptRepo("sibling-unresolvable");
384
+ track(root);
385
+ const path = `${SUBPATH}/gate.mjs`;
386
+ const fake = { lsTree: () => [path], lsFiles: () => [path], show: () => null };
387
+
388
+ const drift = diffSubpathAtSha(fake, root, "0".repeat(40), SUBPATH);
389
+
390
+ assert.deepEqual(drift, [{ path, kind: "differs" }]);
391
+ });
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // AC-7 — third-party / local / docker references are never classified
395
+ // ---------------------------------------------------------------------------
396
+
397
+ test("non-first-party references are excluded from the scan entirely", () => {
398
+ const { root, after } = makeRepo("exclusions");
399
+ track(root);
400
+ put(
401
+ root,
402
+ ".github/workflows/fixture.yml",
403
+ workflow(after, [
404
+ "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2",
405
+ "./.github/actions/local-thing",
406
+ "docker://alpine:3.20",
407
+ ])
408
+ );
409
+
410
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
411
+
412
+ assert.equal(result.ok, true);
413
+ assert.equal(result.scanned, 1, "only the first-party pin is classified");
414
+ assert.equal(result.unpinnedRefs.length, 0);
415
+ });
416
+
417
+ test("collectPinnedRefs: third-party, local and docker refs yield no records", () => {
418
+ const content = [
419
+ " - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2",
420
+ " - uses: ./.github/actions/local-thing",
421
+ " - uses: docker://alpine:3.20",
422
+ " - uses: other-org/other-repo/.github/actions/x@0000000000000000000000000000000000000000",
423
+ ].join("\n");
424
+
425
+ const { pins, unpinnedRefs } = collectPinnedRefs(content, "w.yml", OWNER);
426
+ assert.deepEqual(pins, []);
427
+ assert.deepEqual(unpinnedRefs, []);
428
+ });
429
+
430
+ test("collectPinnedRefs: a commented-out example `uses:` is not a pin", () => {
431
+ const content = [
432
+ `# uses: ${OWNER}/${SUBPATH}@1111111111111111111111111111111111111111`,
433
+ ` - uses: ${OWNER}/${SUBPATH}@2222222222222222222222222222222222222222`,
434
+ ].join("\n");
435
+
436
+ const { pins } = collectPinnedRefs(content, "w.yml", OWNER);
437
+ assert.equal(pins.length, 1);
438
+ assert.equal(pins[0].line, 2);
439
+ assert.equal(pins[0].sha, "2222222222222222222222222222222222222222");
440
+ });
441
+
442
+ test("collectPinnedRefs: a first-party non-SHA ref is noted, not classified as a pin", () => {
443
+ const content = ` - uses: ${OWNER}/${SUBPATH}@main`;
444
+ const { pins, unpinnedRefs } = collectPinnedRefs(content, "w.yml", OWNER);
445
+ assert.deepEqual(pins, []);
446
+ assert.equal(unpinnedRefs.length, 1);
447
+ assert.equal(unpinnedRefs[0].ref, "main");
448
+ });
449
+
450
+ test("collectPinnedRefs: a bare owner/repo self-ref has no subpath and is skipped", () => {
451
+ const content = ` - uses: ${OWNER}@1111111111111111111111111111111111111111`;
452
+ const { pins, unpinnedRefs } = collectPinnedRefs(content, "w.yml", OWNER);
453
+ assert.deepEqual(pins, []);
454
+ assert.deepEqual(unpinnedRefs, []);
455
+ });
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // Manifest resolution + comparison
459
+ // ---------------------------------------------------------------------------
460
+
461
+ test("resolveManifest: a directory subpath resolves to its action.yml", () => {
462
+ const { root } = makeRepo("resolve-dir");
463
+ track(root);
464
+ assert.deepEqual(resolveManifest(root, SUBPATH), {
465
+ path: `${SUBPATH}/action.yml`,
466
+ kind: "action",
467
+ });
468
+ });
469
+
470
+ test("resolveManifest: a workflow-file subpath resolves to itself", () => {
471
+ const { root } = makeRepo("resolve-file");
472
+ track(root);
473
+ put(root, ".github/workflows/reusable.yml", "on: workflow_call\njobs: {}\n");
474
+ assert.deepEqual(resolveManifest(root, ".github/workflows/reusable.yml"), {
475
+ path: ".github/workflows/reusable.yml",
476
+ kind: "workflow",
477
+ });
478
+ });
479
+
480
+ test("resolveManifest: a missing subpath resolves to null", () => {
481
+ const { root } = makeRepo("resolve-missing");
482
+ track(root);
483
+ assert.equal(resolveManifest(root, ".github/actions/nope"), null);
484
+ });
485
+
486
+ test("manifestsMatch: identical bodies match across CRLF/LF line endings", () => {
487
+ assert.equal(manifestsMatch("a\nb\n", "a\r\nb\r\n"), true);
488
+ assert.equal(manifestsMatch("a\nb\n", "a\nc\n"), false);
489
+ });
490
+
491
+ // ---------------------------------------------------------------------------
492
+ // Fatal paths — the check refuses to guess when history is unavailable
493
+ // ---------------------------------------------------------------------------
494
+
495
+ const OK_GIT = {
496
+ isRepo: () => true,
497
+ isShallow: () => false,
498
+ resolveRef: () => "0".repeat(40),
499
+ isAncestor: () => true,
500
+ show: () => "",
501
+ };
502
+
503
+ test("runCheck: a non-git directory is a fatal refusal, not a silent pass", () => {
504
+ const result = runCheck({ cwd: process.cwd() }, { ...OK_GIT, isRepo: () => false });
505
+ assert.equal(result.ok, false);
506
+ assert.match(result.fatal, /not a git repository/);
507
+ });
508
+
509
+ test("runCheck: a shallow clone is refused with the fetch-depth remedy", () => {
510
+ const result = runCheck({ cwd: process.cwd() }, { ...OK_GIT, isShallow: () => true });
511
+ assert.equal(result.ok, false);
512
+ assert.match(result.fatal, /fetch-depth: 0/);
513
+ });
514
+
515
+ test("runCheck: an unresolvable --ref is fatal", () => {
516
+ const result = runCheck({ cwd: process.cwd(), ref: "nope" }, { ...OK_GIT, resolveRef: () => null });
517
+ assert.equal(result.ok, false);
518
+ assert.match(result.fatal, /does not resolve/);
519
+ });
520
+
521
+ test("runCli: a fatal condition exits 1", () => {
522
+ const { root } = makeRepo("fatal-cli");
523
+ track(root);
524
+ const { code, stderr } = capture(["--cwd", root, "--ref", "no-such-ref"]);
525
+ assert.equal(code, 1);
526
+ assert.match(stderr, /does not resolve/);
527
+ });
528
+
529
+ // ---------------------------------------------------------------------------
530
+ // Arg parsing
531
+ // ---------------------------------------------------------------------------
532
+
533
+ test("parseArgs: defaults target the conventional trees and HEAD", () => {
534
+ const opts = parseArgs([]);
535
+ assert.equal(opts.workflowsDir, ".github/workflows");
536
+ assert.equal(opts.actionsDir, ".github/actions");
537
+ assert.equal(opts.ref, "HEAD");
538
+ assert.equal(opts.firstPartyOwner, "dsj1984/mandrel-platform");
539
+ assert.equal(opts.help, false);
540
+ });
541
+
542
+ test("parseArgs: flags override the defaults", () => {
543
+ const opts = parseArgs(["--ref", "origin/main", "--first-party-owner", "my-org/my-repo"]);
544
+ assert.equal(opts.ref, "origin/main");
545
+ assert.equal(opts.firstPartyOwner, "my-org/my-repo");
546
+ });
547
+
548
+ test("parseArgs: an unknown flag throws rather than silently disabling the check", () => {
549
+ assert.throws(() => parseArgs(["--no-such-flag"]), /unknown argument/);
550
+ });
551
+
552
+ test("runCli: an unknown flag exits 1 with usage", () => {
553
+ const { code, stderr } = capture(["--no-such-flag"]);
554
+ assert.equal(code, 1);
555
+ assert.match(stderr, /unknown argument/);
556
+ });
557
+
558
+ test("runCli: --help prints usage and exits 0", () => {
559
+ const { code, stdout } = capture(["--help"]);
560
+ assert.equal(code, 0);
561
+ assert.match(stdout, /Usage: node scripts\/check-first-party-pin-freshness\.mjs/);
562
+ });
563
+
564
+ // ---------------------------------------------------------------------------
565
+ // Wiring — the check must run where a bump PR can actually satisfy it
566
+ //
567
+ // Read as text rather than parsed YAML: this repo ships no YAML parser as a
568
+ // dependency (the checkers themselves are dependency-free by design), and the
569
+ // invariants below are all line-shaped.
570
+ // ---------------------------------------------------------------------------
571
+
572
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
573
+ const SCRIPT_INVOCATION = "node scripts/check-first-party-pin-freshness.mjs";
574
+
575
+ /** Extract one top-level job block (2-space indented key) from a workflow. */
576
+ function jobBlock(workflowText, jobName) {
577
+ const lines = workflowText.split("\n");
578
+ const start = lines.findIndex((l) => l === ` ${jobName}:`);
579
+ if (start === -1) return null;
580
+ let end = lines.length;
581
+ for (let i = start + 1; i < lines.length; i++) {
582
+ if (/^ {2}\S/.test(lines[i])) {
583
+ end = i;
584
+ break;
585
+ }
586
+ }
587
+ return lines.slice(start, end).join("\n");
588
+ }
589
+
590
+ test("wiring: pin-drift.yml runs the check on push to main and on the schedule", () => {
591
+ const wf = readFileSync(join(REPO_ROOT, ".github/workflows/pin-drift.yml"), "utf8");
592
+
593
+ assert.ok(wf.includes(SCRIPT_INVOCATION), "pin-drift.yml invokes the checker");
594
+ assert.match(wf, /^ {2}schedule:$/m, "the existing weekly schedule is retained");
595
+ assert.match(
596
+ wf,
597
+ /^ {2}push:\n {4}branches: \[main\]$/m,
598
+ "the workflow is triggered by push to main"
599
+ );
600
+ });
601
+
602
+ test("wiring: the checking job checks out full history (fetch-depth: 0)", () => {
603
+ const wf = readFileSync(join(REPO_ROOT, ".github/workflows/pin-drift.yml"), "utf8");
604
+ const block = jobBlock(wf, "first-party-pin-freshness");
605
+
606
+ assert.ok(block, "the first-party-pin-freshness job exists");
607
+ assert.ok(block.includes(SCRIPT_INVOCATION), "the job invokes the checker");
608
+ assert.match(
609
+ block,
610
+ /fetch-depth: 0/,
611
+ "a shallow checkout cannot resolve pinned manifests or ancestry"
612
+ );
613
+ });
614
+
615
+ test("wiring: the check is absent from the PR-gating ci.yml", () => {
616
+ // A PR that edits a composite action cannot pin its own not-yet-existing
617
+ // merge commit, so a PR-time gate would be unsatisfiable on exactly the
618
+ // changes this check exists to protect.
619
+ const ci = readFileSync(join(REPO_ROOT, ".github/workflows/ci.yml"), "utf8");
620
+ assert.ok(
621
+ !ci.includes("check-first-party-pin-freshness"),
622
+ "ci.yml must not invoke the freshness check"
623
+ );
624
+ });