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.
- package/README.md +69 -12
- package/config/stryker.base.json +7 -2
- package/package.json +1 -1
- package/scripts/audit-check.mjs +331 -6
- package/scripts/audit-check.test.mjs +382 -1
- package/scripts/check-action-pins.mjs +87 -15
- package/scripts/check-action-pins.test.mjs +103 -4
- package/scripts/check-affected-mode.test.mjs +5 -51
- package/scripts/check-codeql-gating.test.mjs +649 -0
- package/scripts/check-destructive-migration.mjs +277 -11
- package/scripts/check-destructive-migration.test.mjs +334 -0
- package/scripts/check-environments-isolation-audit.test.mjs +212 -0
- package/scripts/check-fail-fast-attribution.test.mjs +90 -4
- package/scripts/check-first-party-pin-freshness.mjs +648 -0
- package/scripts/check-first-party-pin-freshness.test.mjs +624 -0
- package/scripts/check-gitleaks-allowlist.test.mjs +312 -0
- package/scripts/check-osv-scan-mode.test.mjs +5 -50
- package/scripts/check-release-type.mjs +591 -0
- package/scripts/check-release-type.test.mjs +678 -0
- package/scripts/check-setup-toolchain-store.test.mjs +139 -0
- package/scripts/check-toolchain-cache-default.test.mjs +308 -0
- package/scripts/lib/yaml-step.mjs +109 -0
- package/scripts/lib/yaml-step.test.mjs +156 -0
- package/scripts/osv-report-gate.test.mjs +289 -0
- package/scripts/runner-env-drift.test.mjs +554 -0
- package/scripts/stryker-base-config.test.mjs +256 -0
- package/templates/runbooks/runner-provisioning.md +50 -6
- package/templates/runner/check-runner-env-drift.sh +248 -0
|
@@ -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
|
+
});
|