mandrel-platform 1.0.1 → 1.2.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,489 @@
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
+ runCheck,
40
+ runCli,
41
+ } from "./check-first-party-pin-freshness.mjs";
42
+
43
+ const OWNER = "test-owner/test-repo";
44
+ const SUBPATH = ".github/actions/demo";
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Fixture helpers
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /** Run git in `cwd`, returning trimmed stdout. */
51
+ function git(cwd, ...args) {
52
+ return execFileSync("git", args, {
53
+ cwd,
54
+ encoding: "utf8",
55
+ stdio: ["ignore", "pipe", "ignore"],
56
+ }).trim();
57
+ }
58
+
59
+ /** Write a file, creating parent directories as needed. */
60
+ function put(root, relPath, body) {
61
+ const full = join(root, relPath);
62
+ mkdirSync(join(full, ".."), { recursive: true });
63
+ writeFileSync(full, body, "utf8");
64
+ }
65
+
66
+ /** An action manifest body, parameterised by the line that matters. */
67
+ function manifest(tmpLine) {
68
+ return [
69
+ "name: demo",
70
+ "description: fixture composite action",
71
+ "runs:",
72
+ " using: composite",
73
+ " steps:",
74
+ " - shell: bash",
75
+ ` run: ${tmpLine}`,
76
+ "",
77
+ ].join("\n");
78
+ }
79
+
80
+ const STALE_BODY = manifest('tmp="$(mktemp -d)"');
81
+ const FIXED_BODY = manifest('tmp="$(mktemp -d "${RUNNER_TEMP}/demo.XXXXXX")"');
82
+
83
+ /** A workflow whose single first-party step pins `sha`. */
84
+ function workflow(sha, extraUses = []) {
85
+ return [
86
+ "name: fixture",
87
+ "on:",
88
+ " push:",
89
+ " branches: [main]",
90
+ "jobs:",
91
+ " demo:",
92
+ " runs-on: ubuntu-latest",
93
+ " steps:",
94
+ ...extraUses.map((u) => ` - uses: ${u}`),
95
+ ` - uses: ${OWNER}/${SUBPATH}@${sha}`,
96
+ "",
97
+ ].join("\n");
98
+ }
99
+
100
+ /**
101
+ * Create a git repo with an initial `demo` action whose manifest is
102
+ * `STALE_BODY`, then a second commit fixing it to `FIXED_BODY`. Returns the
103
+ * repo root and both commit SHAs. The caller writes the workflow file
104
+ * afterwards (the checker reads workflows from the working tree, so they need
105
+ * not be committed).
106
+ */
107
+ function makeRepo(label) {
108
+ const root = mkdtempSync(join(tmpdir(), `pinfresh-${label}-`));
109
+ git(root, "init", "-b", "main");
110
+ // Repo-local identity + neutered hooks so the fixture never depends on the
111
+ // developer's global git config or a global hooksPath.
112
+ git(root, "config", "user.email", "fixture@example.invalid");
113
+ git(root, "config", "user.name", "Pin Freshness Fixture");
114
+ git(root, "config", "commit.gpgsign", "false");
115
+ git(root, "config", "core.hooksPath", join(root, ".no-hooks"));
116
+
117
+ put(root, `${SUBPATH}/action.yml`, STALE_BODY);
118
+ git(root, "add", "-A");
119
+ git(root, "commit", "-m", "initial action");
120
+ const before = git(root, "rev-parse", "HEAD");
121
+
122
+ put(root, `${SUBPATH}/action.yml`, FIXED_BODY);
123
+ git(root, "add", "-A");
124
+ git(root, "commit", "-m", "scope extraction to RUNNER_TEMP");
125
+ const after = git(root, "rev-parse", "HEAD");
126
+
127
+ return { root, before, after };
128
+ }
129
+
130
+ /** Capture a runCli invocation's streams alongside its exit code. */
131
+ function capture(argv) {
132
+ const out = [];
133
+ const errs = [];
134
+ const code = runCli(argv, {
135
+ log: (s) => out.push(String(s)),
136
+ err: (s) => errs.push(String(s)),
137
+ });
138
+ return { code, stdout: out.join("\n"), stderr: errs.join("\n") };
139
+ }
140
+
141
+ const cleanups = [];
142
+ test.after(() => {
143
+ for (const dir of cleanups) rmSync(dir, { recursive: true, force: true });
144
+ });
145
+ function track(root) {
146
+ cleanups.push(root);
147
+ return root;
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // AC-4 — behavioural lag is detected, and the report names file/line/subpath/SHA
152
+ // ---------------------------------------------------------------------------
153
+
154
+ test("stale: a pin whose manifest lags the working tree exits non-zero", () => {
155
+ const { root, before } = makeRepo("stale");
156
+ track(root);
157
+ put(root, ".github/workflows/fixture.yml", workflow(before));
158
+
159
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
160
+
161
+ assert.equal(result.ok, false);
162
+ assert.equal(result.stale.length, 1);
163
+ assert.equal(result.unreachable.length, 0);
164
+ assert.equal(result.stale[0].subpath, SUBPATH);
165
+ assert.equal(result.stale[0].sha, before);
166
+ });
167
+
168
+ test("stale: the CLI names the referencing file, line, subpath and pinned SHA", () => {
169
+ const { root, before } = makeRepo("stale-cli");
170
+ track(root);
171
+ const body = workflow(before);
172
+ put(root, ".github/workflows/fixture.yml", body);
173
+ // Derive the expected line from the fixture rather than hard-coding it, so
174
+ // the assertion pins the REPORTED line to the REAL one.
175
+ const pinLine = body.split("\n").findIndex((l) => l.includes(`${OWNER}/${SUBPATH}@`)) + 1;
176
+
177
+ const { code, stderr } = capture([
178
+ "--cwd",
179
+ root,
180
+ "--first-party-owner",
181
+ OWNER,
182
+ ]);
183
+
184
+ assert.equal(code, 1);
185
+ // The four facts an operator needs to act without re-deriving anything.
186
+ assert.ok(
187
+ stderr.includes(`.github/workflows/fixture.yml:${pinLine}`),
188
+ `report names the referencing file and line ${pinLine}`
189
+ );
190
+ assert.ok(stderr.includes(SUBPATH), "report names the subpath");
191
+ assert.ok(stderr.includes(before), "report names the full pinned SHA");
192
+ assert.match(stderr, /\[stale\]/);
193
+ });
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // AC-5 — an off-branch pin is a DISTINCT class, even when content-identical
197
+ // ---------------------------------------------------------------------------
198
+
199
+ test("unreachable: an off-branch pin is classified separately from stale", () => {
200
+ const { root, after } = makeRepo("unreachable");
201
+ track(root);
202
+
203
+ // A side-branch commit whose action manifest is byte-identical to main's —
204
+ // exactly the pre-squash `setup-toolchain@1ace1d82` shape. A content-only
205
+ // check would call this clean.
206
+ git(root, "checkout", "-b", "side");
207
+ put(root, "unrelated.txt", "side-branch only\n");
208
+ git(root, "add", "-A");
209
+ git(root, "commit", "-m", "side branch commit");
210
+ const offBranch = git(root, "rev-parse", "HEAD");
211
+ git(root, "checkout", "main");
212
+
213
+ assert.notEqual(offBranch, after);
214
+ put(root, ".github/workflows/fixture.yml", workflow(offBranch));
215
+
216
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
217
+
218
+ assert.equal(result.ok, false);
219
+ assert.equal(result.unreachable.length, 1);
220
+ assert.equal(result.stale.length, 0, "an unreachable pin is not double-reported as stale");
221
+ assert.equal(result.unreachable[0].sha, offBranch);
222
+
223
+ const { code, stderr } = capture(["--cwd", root, "--first-party-owner", OWNER]);
224
+ assert.equal(code, 1);
225
+ assert.match(stderr, /\[unreachable\]/);
226
+ assert.ok(!/\[stale\]/.test(stderr), "the unreachable finding is not also labelled stale");
227
+ });
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // AC-6 — a fresh, reachable tree exits 0
231
+ // ---------------------------------------------------------------------------
232
+
233
+ test("clean: reachable pins matching the working tree exit 0", () => {
234
+ const { root, after } = makeRepo("clean");
235
+ track(root);
236
+ put(root, ".github/workflows/fixture.yml", workflow(after));
237
+
238
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
239
+ assert.equal(result.ok, true);
240
+ assert.equal(result.scanned, 1);
241
+
242
+ const { code, stdout } = capture(["--cwd", root, "--first-party-owner", OWNER]);
243
+ assert.equal(code, 0);
244
+ assert.match(stdout, /✅/);
245
+ });
246
+
247
+ test("clean: two call sites pinning the same fresh SHA both pass", () => {
248
+ const { root, after } = makeRepo("clean-multi");
249
+ track(root);
250
+ put(root, ".github/workflows/one.yml", workflow(after));
251
+ put(root, ".github/workflows/two.yml", workflow(after));
252
+
253
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
254
+ assert.equal(result.ok, true);
255
+ assert.equal(result.scanned, 2);
256
+ });
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // AC-7 — third-party / local / docker references are never classified
260
+ // ---------------------------------------------------------------------------
261
+
262
+ test("non-first-party references are excluded from the scan entirely", () => {
263
+ const { root, after } = makeRepo("exclusions");
264
+ track(root);
265
+ put(
266
+ root,
267
+ ".github/workflows/fixture.yml",
268
+ workflow(after, [
269
+ "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2",
270
+ "./.github/actions/local-thing",
271
+ "docker://alpine:3.20",
272
+ ])
273
+ );
274
+
275
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
276
+
277
+ assert.equal(result.ok, true);
278
+ assert.equal(result.scanned, 1, "only the first-party pin is classified");
279
+ assert.equal(result.unpinnedRefs.length, 0);
280
+ });
281
+
282
+ test("collectPinnedRefs: third-party, local and docker refs yield no records", () => {
283
+ const content = [
284
+ " - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2",
285
+ " - uses: ./.github/actions/local-thing",
286
+ " - uses: docker://alpine:3.20",
287
+ " - uses: other-org/other-repo/.github/actions/x@0000000000000000000000000000000000000000",
288
+ ].join("\n");
289
+
290
+ const { pins, unpinnedRefs } = collectPinnedRefs(content, "w.yml", OWNER);
291
+ assert.deepEqual(pins, []);
292
+ assert.deepEqual(unpinnedRefs, []);
293
+ });
294
+
295
+ test("collectPinnedRefs: a commented-out example `uses:` is not a pin", () => {
296
+ const content = [
297
+ `# uses: ${OWNER}/${SUBPATH}@1111111111111111111111111111111111111111`,
298
+ ` - uses: ${OWNER}/${SUBPATH}@2222222222222222222222222222222222222222`,
299
+ ].join("\n");
300
+
301
+ const { pins } = collectPinnedRefs(content, "w.yml", OWNER);
302
+ assert.equal(pins.length, 1);
303
+ assert.equal(pins[0].line, 2);
304
+ assert.equal(pins[0].sha, "2222222222222222222222222222222222222222");
305
+ });
306
+
307
+ test("collectPinnedRefs: a first-party non-SHA ref is noted, not classified as a pin", () => {
308
+ const content = ` - uses: ${OWNER}/${SUBPATH}@main`;
309
+ const { pins, unpinnedRefs } = collectPinnedRefs(content, "w.yml", OWNER);
310
+ assert.deepEqual(pins, []);
311
+ assert.equal(unpinnedRefs.length, 1);
312
+ assert.equal(unpinnedRefs[0].ref, "main");
313
+ });
314
+
315
+ test("collectPinnedRefs: a bare owner/repo self-ref has no subpath and is skipped", () => {
316
+ const content = ` - uses: ${OWNER}@1111111111111111111111111111111111111111`;
317
+ const { pins, unpinnedRefs } = collectPinnedRefs(content, "w.yml", OWNER);
318
+ assert.deepEqual(pins, []);
319
+ assert.deepEqual(unpinnedRefs, []);
320
+ });
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // Manifest resolution + comparison
324
+ // ---------------------------------------------------------------------------
325
+
326
+ test("resolveManifest: a directory subpath resolves to its action.yml", () => {
327
+ const { root } = makeRepo("resolve-dir");
328
+ track(root);
329
+ assert.deepEqual(resolveManifest(root, SUBPATH), {
330
+ path: `${SUBPATH}/action.yml`,
331
+ kind: "action",
332
+ });
333
+ });
334
+
335
+ test("resolveManifest: a workflow-file subpath resolves to itself", () => {
336
+ const { root } = makeRepo("resolve-file");
337
+ track(root);
338
+ put(root, ".github/workflows/reusable.yml", "on: workflow_call\njobs: {}\n");
339
+ assert.deepEqual(resolveManifest(root, ".github/workflows/reusable.yml"), {
340
+ path: ".github/workflows/reusable.yml",
341
+ kind: "workflow",
342
+ });
343
+ });
344
+
345
+ test("resolveManifest: a missing subpath resolves to null", () => {
346
+ const { root } = makeRepo("resolve-missing");
347
+ track(root);
348
+ assert.equal(resolveManifest(root, ".github/actions/nope"), null);
349
+ });
350
+
351
+ test("manifestsMatch: identical bodies match across CRLF/LF line endings", () => {
352
+ assert.equal(manifestsMatch("a\nb\n", "a\r\nb\r\n"), true);
353
+ assert.equal(manifestsMatch("a\nb\n", "a\nc\n"), false);
354
+ });
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Fatal paths — the check refuses to guess when history is unavailable
358
+ // ---------------------------------------------------------------------------
359
+
360
+ const OK_GIT = {
361
+ isRepo: () => true,
362
+ isShallow: () => false,
363
+ resolveRef: () => "0".repeat(40),
364
+ isAncestor: () => true,
365
+ show: () => "",
366
+ };
367
+
368
+ test("runCheck: a non-git directory is a fatal refusal, not a silent pass", () => {
369
+ const result = runCheck({ cwd: process.cwd() }, { ...OK_GIT, isRepo: () => false });
370
+ assert.equal(result.ok, false);
371
+ assert.match(result.fatal, /not a git repository/);
372
+ });
373
+
374
+ test("runCheck: a shallow clone is refused with the fetch-depth remedy", () => {
375
+ const result = runCheck({ cwd: process.cwd() }, { ...OK_GIT, isShallow: () => true });
376
+ assert.equal(result.ok, false);
377
+ assert.match(result.fatal, /fetch-depth: 0/);
378
+ });
379
+
380
+ test("runCheck: an unresolvable --ref is fatal", () => {
381
+ const result = runCheck({ cwd: process.cwd(), ref: "nope" }, { ...OK_GIT, resolveRef: () => null });
382
+ assert.equal(result.ok, false);
383
+ assert.match(result.fatal, /does not resolve/);
384
+ });
385
+
386
+ test("runCli: a fatal condition exits 1", () => {
387
+ const { root } = makeRepo("fatal-cli");
388
+ track(root);
389
+ const { code, stderr } = capture(["--cwd", root, "--ref", "no-such-ref"]);
390
+ assert.equal(code, 1);
391
+ assert.match(stderr, /does not resolve/);
392
+ });
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // Arg parsing
396
+ // ---------------------------------------------------------------------------
397
+
398
+ test("parseArgs: defaults target the conventional trees and HEAD", () => {
399
+ const opts = parseArgs([]);
400
+ assert.equal(opts.workflowsDir, ".github/workflows");
401
+ assert.equal(opts.actionsDir, ".github/actions");
402
+ assert.equal(opts.ref, "HEAD");
403
+ assert.equal(opts.firstPartyOwner, "dsj1984/mandrel-platform");
404
+ assert.equal(opts.help, false);
405
+ });
406
+
407
+ test("parseArgs: flags override the defaults", () => {
408
+ const opts = parseArgs(["--ref", "origin/main", "--first-party-owner", "my-org/my-repo"]);
409
+ assert.equal(opts.ref, "origin/main");
410
+ assert.equal(opts.firstPartyOwner, "my-org/my-repo");
411
+ });
412
+
413
+ test("parseArgs: an unknown flag throws rather than silently disabling the check", () => {
414
+ assert.throws(() => parseArgs(["--no-such-flag"]), /unknown argument/);
415
+ });
416
+
417
+ test("runCli: an unknown flag exits 1 with usage", () => {
418
+ const { code, stderr } = capture(["--no-such-flag"]);
419
+ assert.equal(code, 1);
420
+ assert.match(stderr, /unknown argument/);
421
+ });
422
+
423
+ test("runCli: --help prints usage and exits 0", () => {
424
+ const { code, stdout } = capture(["--help"]);
425
+ assert.equal(code, 0);
426
+ assert.match(stdout, /Usage: node scripts\/check-first-party-pin-freshness\.mjs/);
427
+ });
428
+
429
+ // ---------------------------------------------------------------------------
430
+ // Wiring — the check must run where a bump PR can actually satisfy it
431
+ //
432
+ // Read as text rather than parsed YAML: this repo ships no YAML parser as a
433
+ // dependency (the checkers themselves are dependency-free by design), and the
434
+ // invariants below are all line-shaped.
435
+ // ---------------------------------------------------------------------------
436
+
437
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
438
+ const SCRIPT_INVOCATION = "node scripts/check-first-party-pin-freshness.mjs";
439
+
440
+ /** Extract one top-level job block (2-space indented key) from a workflow. */
441
+ function jobBlock(workflowText, jobName) {
442
+ const lines = workflowText.split("\n");
443
+ const start = lines.findIndex((l) => l === ` ${jobName}:`);
444
+ if (start === -1) return null;
445
+ let end = lines.length;
446
+ for (let i = start + 1; i < lines.length; i++) {
447
+ if (/^ {2}\S/.test(lines[i])) {
448
+ end = i;
449
+ break;
450
+ }
451
+ }
452
+ return lines.slice(start, end).join("\n");
453
+ }
454
+
455
+ test("wiring: pin-drift.yml runs the check on push to main and on the schedule", () => {
456
+ const wf = readFileSync(join(REPO_ROOT, ".github/workflows/pin-drift.yml"), "utf8");
457
+
458
+ assert.ok(wf.includes(SCRIPT_INVOCATION), "pin-drift.yml invokes the checker");
459
+ assert.match(wf, /^ {2}schedule:$/m, "the existing weekly schedule is retained");
460
+ assert.match(
461
+ wf,
462
+ /^ {2}push:\n {4}branches: \[main\]$/m,
463
+ "the workflow is triggered by push to main"
464
+ );
465
+ });
466
+
467
+ test("wiring: the checking job checks out full history (fetch-depth: 0)", () => {
468
+ const wf = readFileSync(join(REPO_ROOT, ".github/workflows/pin-drift.yml"), "utf8");
469
+ const block = jobBlock(wf, "first-party-pin-freshness");
470
+
471
+ assert.ok(block, "the first-party-pin-freshness job exists");
472
+ assert.ok(block.includes(SCRIPT_INVOCATION), "the job invokes the checker");
473
+ assert.match(
474
+ block,
475
+ /fetch-depth: 0/,
476
+ "a shallow checkout cannot resolve pinned manifests or ancestry"
477
+ );
478
+ });
479
+
480
+ test("wiring: the check is absent from the PR-gating ci.yml", () => {
481
+ // A PR that edits a composite action cannot pin its own not-yet-existing
482
+ // merge commit, so a PR-time gate would be unsatisfiable on exactly the
483
+ // changes this check exists to protect.
484
+ const ci = readFileSync(join(REPO_ROOT, ".github/workflows/ci.yml"), "utf8");
485
+ assert.ok(
486
+ !ci.includes("check-first-party-pin-freshness"),
487
+ "ci.yml must not invoke the freshness check"
488
+ );
489
+ });