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.
- package/package.json +1 -1
- package/scripts/audit-check.mjs +112 -12
- package/scripts/audit-check.test.mjs +195 -0
- package/scripts/check-action-pins.mjs +87 -15
- package/scripts/check-action-pins.test.mjs +103 -4
- package/scripts/check-cancelled-provenance.test.mjs +493 -1
- package/scripts/check-docs-staleness.mjs +15 -2
- package/scripts/check-docs-staleness.test.mjs +114 -9
- package/scripts/check-first-party-pin-freshness.mjs +532 -0
- package/scripts/check-first-party-pin-freshness.test.mjs +489 -0
- package/scripts/job-cleanup-hook.test.mjs +234 -0
- package/scripts/runner-env-drift.test.mjs +554 -0
- package/templates/runbooks/runner-provisioning.md +62 -9
- package/templates/runner/.env.example +8 -2
- package/templates/runner/check-runner-env-drift.sh +248 -0
- package/templates/runner/job-cleanup.sh +49 -20
|
@@ -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
|
+
});
|