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,648 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-first-party-pin-freshness.mjs
|
|
4
|
+
*
|
|
5
|
+
* First-party self-pin freshness guard (Story #354).
|
|
6
|
+
*
|
|
7
|
+
* This repo publishes its own composite actions and reusable workflows and
|
|
8
|
+
* then CALLS them by absolute `owner/repo/subpath@<sha>` reference — the same
|
|
9
|
+
* form a consumer uses. That self-pin is the ONLY thing that decides which
|
|
10
|
+
* revision of the action actually runs. Fixing an action file in the working
|
|
11
|
+
* tree therefore changes nothing at runtime until every call site is
|
|
12
|
+
* repointed, and nothing in the repo detected that gap:
|
|
13
|
+
*
|
|
14
|
+
* • `check-action-pins.mjs` enforces only the single-pin invariant — that
|
|
15
|
+
* every call site for a subpath agrees. A fleet of call sites agreeing on
|
|
16
|
+
* ONE STALE sha is perfectly green. (At the time it also EXEMPTED
|
|
17
|
+
* first-party refs from the SHA ratchet entirely; that exemption was
|
|
18
|
+
* retired by the Story #354 audit — see the `unpinnedRefs` note below.)
|
|
19
|
+
* • `check-workflow-portability.mjs` Rule 3 does read the manifest at the
|
|
20
|
+
* pinned SHA, but re-runs only its own Rules 1–2 against it (relative
|
|
21
|
+
* `uses:`, `${{ }}` in input descriptions/defaults). A BEHAVIOURAL lag —
|
|
22
|
+
* e.g. `mktemp -d` → `${RUNNER_TEMP}` — is invisible to it.
|
|
23
|
+
*
|
|
24
|
+
* That blind spot shipped issue #352: PR #345 scoped the gitleaks/osv
|
|
25
|
+
* extraction dirs to `${RUNNER_TEMP}`, no call site was repointed, and every
|
|
26
|
+
* consumer on a self-hosted fleet kept leaking ~164 MB per run into the
|
|
27
|
+
* host-shared temp root on the latest release.
|
|
28
|
+
*
|
|
29
|
+
* ## Why the comparison is the DIRECTORY, not the manifest (Story #379)
|
|
30
|
+
*
|
|
31
|
+
* The first cut of this checker compared only `action.yml`, which is
|
|
32
|
+
* structurally unable to protect a composite action whose behaviour lives in a
|
|
33
|
+
* sibling script — the majority of this repo's action surface. Story #365
|
|
34
|
+
* rewrote `.github/actions/osv-scan/osv-report-gate.mjs` (+189/-12) without
|
|
35
|
+
* touching `action.yml`, so this guard reported `osv-scan` fresh while both
|
|
36
|
+
* call sites ran a 689-line gate against 866 lines on `main`. The comparison
|
|
37
|
+
* is therefore the whole subpath tree — every file `git ls-tree -r <sha> --
|
|
38
|
+
* <subpath>` names, plus every tracked working-tree file under it, so an added
|
|
39
|
+
* or removed sibling is drift too.
|
|
40
|
+
*
|
|
41
|
+
* This checker closes it by classifying every first-party SHA pin into one of
|
|
42
|
+
* two failure classes — deliberately kept distinct, because their remedies
|
|
43
|
+
* differ:
|
|
44
|
+
*
|
|
45
|
+
* • `stale` — the SUBPATH TREE at the pinned SHA differs from the
|
|
46
|
+
* working-tree copy — any file under it, not just the
|
|
47
|
+
* manifest. The fix is to BUMP the pin to a commit
|
|
48
|
+
* carrying the current tree.
|
|
49
|
+
* • `unreachable` — the pinned SHA is not an ancestor of the checked-out
|
|
50
|
+
* ref. Typically a pre-squash branch commit: content-
|
|
51
|
+
* identical to `main` today, resolvable only until GitHub
|
|
52
|
+
* garbage-collects it, after which every consumer fails at
|
|
53
|
+
* action-load time. The fix is to RE-PIN to the squashed
|
|
54
|
+
* commit on `main`.
|
|
55
|
+
*
|
|
56
|
+
* A first-party ref that is NOT a 40-hex SHA has no freshness answer at all —
|
|
57
|
+
* a branch or tag names a revision that can move after this check runs — so it
|
|
58
|
+
* is collected into `unpinnedRefs` and merely REPORTED. Enforcing the SHA
|
|
59
|
+
* shape belongs upstream of freshness, in `check-action-pins.mjs`, which
|
|
60
|
+
* ratchets first-party refs alongside third-party ones and runs in the
|
|
61
|
+
* PR-gating `ci.yml` where this check deliberately does not (see below). The
|
|
62
|
+
* note here is the backstop for a consumer that adopted only this script.
|
|
63
|
+
*
|
|
64
|
+
* Requires full git history — run the checkout with `fetch-depth: 0`. A
|
|
65
|
+
* shallow clone cannot answer either question and is refused loudly rather
|
|
66
|
+
* than reported as a wall of false `unreachable` findings.
|
|
67
|
+
*
|
|
68
|
+
* ## Where this runs, and why not on PRs
|
|
69
|
+
*
|
|
70
|
+
* Wired onto push-to-`main` and the `pin-drift.yml` schedule; deliberately
|
|
71
|
+
* ABSENT from the PR-gating `ci.yml`. A PR that edits a composite action
|
|
72
|
+
* cannot pin its own not-yet-existing merge commit, so a PR-time gate would
|
|
73
|
+
* be unsatisfiable on exactly the changes it exists to protect. A red `main`
|
|
74
|
+
* is instead the signal to open the follow-up bump PR — the land-then-bump
|
|
75
|
+
* sequence documented in docs/reusable-workflows.md.
|
|
76
|
+
*
|
|
77
|
+
* Usage:
|
|
78
|
+
* node scripts/check-first-party-pin-freshness.mjs
|
|
79
|
+
* node scripts/check-first-party-pin-freshness.mjs --cwd /path/to/repo
|
|
80
|
+
* node scripts/check-first-party-pin-freshness.mjs --ref origin/main
|
|
81
|
+
* node scripts/check-first-party-pin-freshness.mjs --first-party-owner my-org/my-repo
|
|
82
|
+
*
|
|
83
|
+
* Exit codes:
|
|
84
|
+
* 0 — every first-party pin resolves to a fresh, reachable manifest.
|
|
85
|
+
* 1 — one or more `stale` / `unreachable` pins (each named in stderr with
|
|
86
|
+
* file, line, subpath and SHA), or the history needed to decide is
|
|
87
|
+
* unavailable.
|
|
88
|
+
*
|
|
89
|
+
* Consumer adoption:
|
|
90
|
+
* Copy this script into your project's `scripts/` directory alongside
|
|
91
|
+
* `scripts/lib/{args,uses-pins,walk}.mjs` (its only dependencies — no YAML
|
|
92
|
+
* parser), then run it on push to your default branch:
|
|
93
|
+
*
|
|
94
|
+
* - uses: actions/checkout@<sha>
|
|
95
|
+
* with: { fetch-depth: 0 }
|
|
96
|
+
* - run: node scripts/check-first-party-pin-freshness.mjs --first-party-owner <owner/repo>
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
100
|
+
import { execFileSync } from "node:child_process";
|
|
101
|
+
import { resolve, join, relative, basename } from "node:path";
|
|
102
|
+
|
|
103
|
+
import { parseFlags } from "./lib/args.mjs";
|
|
104
|
+
import {
|
|
105
|
+
DEFAULT_FIRST_PARTY_OWNER,
|
|
106
|
+
parseUsesLine,
|
|
107
|
+
classifyUses,
|
|
108
|
+
isSha40,
|
|
109
|
+
} from "./lib/uses-pins.mjs";
|
|
110
|
+
import { listWorkflowFiles, listActionFiles } from "./lib/walk.mjs";
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Arg parsing
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Parse the CLI argv slice (everything AFTER `node script.mjs`) into an
|
|
118
|
+
* options object. Throws on an unknown flag so a typo fails loudly rather than
|
|
119
|
+
* silently disabling half the check.
|
|
120
|
+
*
|
|
121
|
+
* @param {string[]} argv
|
|
122
|
+
* @returns {{workflowsDir: string, actionsDir: string, firstPartyOwner: string, cwd: string, ref: string, help: boolean}}
|
|
123
|
+
*/
|
|
124
|
+
export function parseArgs(argv) {
|
|
125
|
+
return parseFlags(argv, {
|
|
126
|
+
flags: {
|
|
127
|
+
"--workflows-dir": { type: "string", dest: "workflowsDir", default: ".github/workflows" },
|
|
128
|
+
"--actions-dir": { type: "string", dest: "actionsDir", default: ".github/actions" },
|
|
129
|
+
"--first-party-owner": {
|
|
130
|
+
type: "string",
|
|
131
|
+
dest: "firstPartyOwner",
|
|
132
|
+
default: DEFAULT_FIRST_PARTY_OWNER,
|
|
133
|
+
},
|
|
134
|
+
"--cwd": { type: "string", dest: "cwd", default: process.cwd() },
|
|
135
|
+
"--ref": { type: "string", dest: "ref", default: "HEAD" },
|
|
136
|
+
"--help": { type: "boolean", dest: "help", value: true, default: false },
|
|
137
|
+
},
|
|
138
|
+
aliases: { "-h": "--help" },
|
|
139
|
+
onUnknown: "throw",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Pin collection (pure text)
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Scan a file's TEXT for first-party `uses:` references and split them into
|
|
149
|
+
* the SHA-pinned refs this checker classifies and the non-SHA refs it can only
|
|
150
|
+
* note. Third-party, local (`./path`) and `docker://` references are dropped
|
|
151
|
+
* here and can therefore never reach the report.
|
|
152
|
+
*
|
|
153
|
+
* A bare `owner/repo@ref` self-reference is skipped: with no subpath there is
|
|
154
|
+
* no manifest to resolve. Whole-line `#` comments never match (the doc-example
|
|
155
|
+
* `uses:` lines every action.yml carries in its header are comments).
|
|
156
|
+
*
|
|
157
|
+
* @param {string} content
|
|
158
|
+
* @param {string} displayFile Path as it should appear in the report.
|
|
159
|
+
* @param {string} [firstPartyOwner]
|
|
160
|
+
* @returns {{pins: Array<{file: string, line: number, subpath: string, sha: string}>, unpinnedRefs: Array<{file: string, line: number, subpath: string, ref: string}>}}
|
|
161
|
+
*/
|
|
162
|
+
export function collectPinnedRefs(
|
|
163
|
+
content,
|
|
164
|
+
displayFile,
|
|
165
|
+
firstPartyOwner = DEFAULT_FIRST_PARTY_OWNER
|
|
166
|
+
) {
|
|
167
|
+
const pins = [];
|
|
168
|
+
const unpinnedRefs = [];
|
|
169
|
+
const lines = String(content).split(/\r?\n/);
|
|
170
|
+
for (let i = 0; i < lines.length; i++) {
|
|
171
|
+
const bareRef = parseUsesLine(lines[i]);
|
|
172
|
+
if (bareRef === null) continue;
|
|
173
|
+
const cls = classifyUses(bareRef, firstPartyOwner);
|
|
174
|
+
if (cls.kind !== "first-party") continue;
|
|
175
|
+
if (!cls.subpath) continue; // bare owner/repo self-ref — no manifest to resolve
|
|
176
|
+
const record = { file: displayFile, line: i + 1, subpath: cls.subpath };
|
|
177
|
+
if (isSha40(cls.ref)) pins.push({ ...record, sha: cls.ref });
|
|
178
|
+
else unpinnedRefs.push({ ...record, ref: cls.ref });
|
|
179
|
+
}
|
|
180
|
+
return { pins, unpinnedRefs };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Resolve the manifest a `uses:` subpath points at, relative to a repo root.
|
|
185
|
+
* A directory subpath resolves to its `action.yml` / `action.yaml`; a `.yml` /
|
|
186
|
+
* `.yaml` subpath resolves to itself. Returns null when the subpath does not
|
|
187
|
+
* exist in the working tree or holds no manifest.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} repoRoot
|
|
190
|
+
* @param {string} subpath
|
|
191
|
+
* @returns {{path: string, kind: "action" | "workflow"} | null}
|
|
192
|
+
*/
|
|
193
|
+
export function resolveManifest(repoRoot, subpath) {
|
|
194
|
+
const local = join(repoRoot, subpath);
|
|
195
|
+
let st;
|
|
196
|
+
try {
|
|
197
|
+
st = statSync(local);
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
if (st.isDirectory()) {
|
|
202
|
+
for (const name of ["action.yml", "action.yaml"]) {
|
|
203
|
+
if (existsSync(join(local, name))) return { path: `${subpath}/${name}`, kind: "action" };
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
if (/\.ya?ml$/.test(subpath)) {
|
|
208
|
+
return { path: subpath, kind: basename(subpath).startsWith("action.") ? "action" : "workflow" };
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Compare two manifest bodies for equality, tolerating line-ending drift so a
|
|
215
|
+
* CRLF working tree on Windows does not report every pin as stale.
|
|
216
|
+
*
|
|
217
|
+
* @param {string} a
|
|
218
|
+
* @param {string} b
|
|
219
|
+
* @returns {boolean}
|
|
220
|
+
*/
|
|
221
|
+
export function manifestsMatch(a, b) {
|
|
222
|
+
const norm = (s) => String(s).replace(/\r\n/g, "\n");
|
|
223
|
+
return norm(a) === norm(b);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Git seam
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Build the narrow git interface `runCheck` needs, bound to a repo root. Every
|
|
232
|
+
* method is failure-tolerant: a missing object or an unreadable path answers
|
|
233
|
+
* "no" rather than throwing, so a broken pin is reported as a finding instead
|
|
234
|
+
* of crashing the lint.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} repoRoot
|
|
237
|
+
*/
|
|
238
|
+
export function createGit(repoRoot) {
|
|
239
|
+
const run = (args) =>
|
|
240
|
+
execFileSync("git", args, {
|
|
241
|
+
cwd: repoRoot,
|
|
242
|
+
encoding: "utf8",
|
|
243
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
244
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
/** True when `repoRoot` sits inside a git work tree. */
|
|
249
|
+
isRepo() {
|
|
250
|
+
try {
|
|
251
|
+
run(["rev-parse", "--is-inside-work-tree"]);
|
|
252
|
+
return true;
|
|
253
|
+
} catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
/** True when the clone is shallow (history truncated — cannot decide). */
|
|
258
|
+
isShallow() {
|
|
259
|
+
try {
|
|
260
|
+
return run(["rev-parse", "--is-shallow-repository"]).trim() === "true";
|
|
261
|
+
} catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
/** Abbreviated SHA of a ref, or null when it does not resolve. */
|
|
266
|
+
resolveRef(ref) {
|
|
267
|
+
try {
|
|
268
|
+
return run(["rev-parse", ref]).trim();
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
/** True when `sha` is an ancestor of (or equal to) `ref`. */
|
|
274
|
+
isAncestor(sha, ref) {
|
|
275
|
+
try {
|
|
276
|
+
execFileSync("git", ["merge-base", "--is-ancestor", sha, ref], {
|
|
277
|
+
cwd: repoRoot,
|
|
278
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
279
|
+
});
|
|
280
|
+
return true;
|
|
281
|
+
} catch {
|
|
282
|
+
// Exit 1 = not an ancestor; exit 128 = unknown object. Both mean the
|
|
283
|
+
// pin is not reachable from the checked-out ref.
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
/** `git show <sha>:<path>` → content, or null when unresolvable. */
|
|
288
|
+
show(sha, path) {
|
|
289
|
+
try {
|
|
290
|
+
return run(["show", `${sha}:${path}`]);
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
/**
|
|
296
|
+
* Repo-relative paths of every blob a subpath covers AT `sha`. A directory
|
|
297
|
+
* subpath yields its whole tree; a file subpath yields just itself. `-z`
|
|
298
|
+
* so a path with a space or a quote survives intact.
|
|
299
|
+
*/
|
|
300
|
+
lsTree(sha, subpath) {
|
|
301
|
+
try {
|
|
302
|
+
return run(["ls-tree", "-r", "--name-only", "-z", sha, "--", subpath])
|
|
303
|
+
.split("\0")
|
|
304
|
+
.filter(Boolean);
|
|
305
|
+
} catch {
|
|
306
|
+
return [];
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
/**
|
|
310
|
+
* Repo-relative paths of every TRACKED working-tree file under a subpath.
|
|
311
|
+
* Tracked, not on-disk: an ignored build artefact or a stray `.DS_Store`
|
|
312
|
+
* inside an action directory is not something a consumer ever runs.
|
|
313
|
+
*/
|
|
314
|
+
lsFiles(subpath) {
|
|
315
|
+
try {
|
|
316
|
+
return run(["ls-files", "-z", "--", subpath]).split("\0").filter(Boolean);
|
|
317
|
+
} catch {
|
|
318
|
+
return [];
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// Subpath tree comparison
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
/** How each drift kind reads in the report. */
|
|
329
|
+
const DRIFT_PHRASE = {
|
|
330
|
+
differs: "differs from the working-tree copy",
|
|
331
|
+
added: "is absent at the pinned SHA (added since)",
|
|
332
|
+
removed: "is gone from the working tree (removed since)",
|
|
333
|
+
unreadable: "is tracked but unreadable in the working tree",
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Compare every file a `uses:` subpath covers at `sha` against the working
|
|
338
|
+
* tree, and return one record per drifting path (empty when the tree matches).
|
|
339
|
+
*
|
|
340
|
+
* The union of both sides is walked, so a sibling script ADDED or REMOVED
|
|
341
|
+
* since the pinned revision is drift just as much as one whose bytes changed —
|
|
342
|
+
* all three change what the pinned revision actually executes.
|
|
343
|
+
*
|
|
344
|
+
* @param {{lsTree: Function, lsFiles: Function, show: Function}} git
|
|
345
|
+
* @param {string} repoRoot
|
|
346
|
+
* @param {string} sha
|
|
347
|
+
* @param {string} subpath
|
|
348
|
+
* @returns {Array<{path: string, kind: "differs" | "added" | "removed" | "unreadable"}>}
|
|
349
|
+
*/
|
|
350
|
+
export function diffSubpathAtSha(git, repoRoot, sha, subpath) {
|
|
351
|
+
const pinned = new Set(git.lsTree(sha, subpath));
|
|
352
|
+
const working = new Set(git.lsFiles(subpath));
|
|
353
|
+
const drift = [];
|
|
354
|
+
|
|
355
|
+
for (const path of [...new Set([...pinned, ...working])].sort()) {
|
|
356
|
+
if (!working.has(path)) {
|
|
357
|
+
drift.push({ path, kind: "removed" });
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (!pinned.has(path)) {
|
|
361
|
+
drift.push({ path, kind: "added" });
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
let workingBody;
|
|
365
|
+
try {
|
|
366
|
+
workingBody = readFileSync(join(repoRoot, path), "utf8");
|
|
367
|
+
} catch {
|
|
368
|
+
drift.push({ path, kind: "unreadable" });
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const pinnedBody = git.show(sha, path);
|
|
372
|
+
if (pinnedBody === null || !manifestsMatch(pinnedBody, workingBody)) {
|
|
373
|
+
drift.push({ path, kind: "differs" });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return drift;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Render a drift list as the one-line `reason` a finding carries. Action
|
|
382
|
+
* directories hold a handful of files, so every drifting path is named rather
|
|
383
|
+
* than summarised — the operator needs to know WHICH file is inert.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} subpath
|
|
386
|
+
* @param {ReturnType<typeof diffSubpathAtSha>} drift
|
|
387
|
+
* @returns {string}
|
|
388
|
+
*/
|
|
389
|
+
export function describeDrift(subpath, drift) {
|
|
390
|
+
const detail = drift.map((d) => `${d.path} ${DRIFT_PHRASE[d.kind]}`).join("; ");
|
|
391
|
+
return (
|
|
392
|
+
`${drift.length} file(s) under ${subpath} lag the pinned SHA — the pinned ` +
|
|
393
|
+
`revision is what actually runs: ${detail}`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
// Check
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Classify every first-party SHA pin under the repo's workflow and action
|
|
403
|
+
* trees. Returns a structured result; formatting and the exit code live in
|
|
404
|
+
* {@link runCli}.
|
|
405
|
+
*
|
|
406
|
+
* `git` is injectable so a caller can drive the classification against a
|
|
407
|
+
* substitute history; it defaults to the real git bound to `opts.cwd`.
|
|
408
|
+
*
|
|
409
|
+
* @param {{cwd?: string, workflowsDir?: string, actionsDir?: string, firstPartyOwner?: string, ref?: string}} opts
|
|
410
|
+
* @param {ReturnType<typeof createGit>} [git]
|
|
411
|
+
* @returns {{ok: boolean, fatal: string|null, stale: object[], unreachable: object[], unpinnedRefs: object[], scanned: number, files: string[], headSha: string|null}}
|
|
412
|
+
*/
|
|
413
|
+
export function runCheck(opts = {}, git = createGit(resolve(opts.cwd || process.cwd()))) {
|
|
414
|
+
const repoRoot = resolve(opts.cwd || process.cwd());
|
|
415
|
+
const ref = opts.ref || "HEAD";
|
|
416
|
+
const firstPartyOwner = opts.firstPartyOwner || DEFAULT_FIRST_PARTY_OWNER;
|
|
417
|
+
const wfDir = resolve(repoRoot, opts.workflowsDir || ".github/workflows");
|
|
418
|
+
const acDir = resolve(repoRoot, opts.actionsDir || ".github/actions");
|
|
419
|
+
|
|
420
|
+
const empty = {
|
|
421
|
+
ok: false,
|
|
422
|
+
fatal: null,
|
|
423
|
+
stale: [],
|
|
424
|
+
unreachable: [],
|
|
425
|
+
unpinnedRefs: [],
|
|
426
|
+
scanned: 0,
|
|
427
|
+
files: [],
|
|
428
|
+
headSha: null,
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
if (!git.isRepo()) {
|
|
432
|
+
return {
|
|
433
|
+
...empty,
|
|
434
|
+
fatal:
|
|
435
|
+
"not a git repository — this check resolves each pinned manifest from " +
|
|
436
|
+
"git history and cannot run without it",
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
if (git.isShallow()) {
|
|
440
|
+
return {
|
|
441
|
+
...empty,
|
|
442
|
+
fatal:
|
|
443
|
+
"shallow clone — pinned manifests and ancestry are unresolvable. Run " +
|
|
444
|
+
"the checkout with `fetch-depth: 0`",
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
const headSha = git.resolveRef(ref);
|
|
448
|
+
if (headSha === null) {
|
|
449
|
+
return { ...empty, fatal: `ref "${ref}" does not resolve in this repository` };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const files = [...listWorkflowFiles(wfDir), ...listActionFiles(acDir)];
|
|
453
|
+
const stale = [];
|
|
454
|
+
const unreachable = [];
|
|
455
|
+
const unpinnedRefs = [];
|
|
456
|
+
let scanned = 0;
|
|
457
|
+
|
|
458
|
+
// Every call site for a subpath must move together, so the same
|
|
459
|
+
// (sha, subpath) pair is compared repeatedly — `setup-toolchain` alone has
|
|
460
|
+
// five. Resolve each tree once.
|
|
461
|
+
const driftCache = new Map();
|
|
462
|
+
const driftFor = (sha, subpath) => {
|
|
463
|
+
const key = `${sha}:${subpath}`;
|
|
464
|
+
if (!driftCache.has(key)) {
|
|
465
|
+
driftCache.set(key, diffSubpathAtSha(git, repoRoot, sha, subpath));
|
|
466
|
+
}
|
|
467
|
+
return driftCache.get(key);
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
for (const file of files) {
|
|
471
|
+
let content;
|
|
472
|
+
try {
|
|
473
|
+
content = readFileSync(file, "utf8");
|
|
474
|
+
} catch {
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const display = relative(repoRoot, file) || file;
|
|
478
|
+
const collected = collectPinnedRefs(content, display, firstPartyOwner);
|
|
479
|
+
unpinnedRefs.push(...collected.unpinnedRefs);
|
|
480
|
+
|
|
481
|
+
for (const pin of collected.pins) {
|
|
482
|
+
scanned++;
|
|
483
|
+
|
|
484
|
+
// Reachability first: an unknown or off-branch object cannot be compared
|
|
485
|
+
// in the first place, and its remedy (re-pin to the squashed commit)
|
|
486
|
+
// differs from a bump.
|
|
487
|
+
if (!git.isAncestor(pin.sha, ref)) {
|
|
488
|
+
unreachable.push({
|
|
489
|
+
...pin,
|
|
490
|
+
reason:
|
|
491
|
+
`pinned SHA is not an ancestor of ${ref} — a dangling or pre-squash ` +
|
|
492
|
+
`branch commit that breaks every consumer once it is garbage-collected`,
|
|
493
|
+
});
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const manifest = resolveManifest(repoRoot, pin.subpath);
|
|
498
|
+
if (manifest === null) {
|
|
499
|
+
stale.push({
|
|
500
|
+
...pin,
|
|
501
|
+
reason: `no manifest at ${pin.subpath} in the working tree — the pin references a path that no longer exists`,
|
|
502
|
+
});
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (git.show(pin.sha, manifest.path) === null) {
|
|
507
|
+
stale.push({
|
|
508
|
+
...pin,
|
|
509
|
+
reason: `${manifest.path} does not exist at the pinned SHA — the pin predates the manifest`,
|
|
510
|
+
});
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const drift = driftFor(pin.sha, pin.subpath);
|
|
515
|
+
if (drift.length > 0) {
|
|
516
|
+
stale.push({
|
|
517
|
+
...pin,
|
|
518
|
+
manifest: manifest.path,
|
|
519
|
+
reason: describeDrift(pin.subpath, drift),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return {
|
|
526
|
+
ok: stale.length === 0 && unreachable.length === 0,
|
|
527
|
+
fatal: null,
|
|
528
|
+
stale,
|
|
529
|
+
unreachable,
|
|
530
|
+
unpinnedRefs,
|
|
531
|
+
scanned,
|
|
532
|
+
files,
|
|
533
|
+
headSha,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// CLI
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
const USAGE =
|
|
542
|
+
"Usage: node scripts/check-first-party-pin-freshness.mjs " +
|
|
543
|
+
"[--cwd <dir>] [--ref <git-ref>] [--workflows-dir <dir>] [--actions-dir <dir>] " +
|
|
544
|
+
"[--first-party-owner <owner/repo>]";
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Format one finding as a two-line report entry naming the referencing file,
|
|
548
|
+
* the line, the subpath and the full pinned SHA (the four facts needed to act
|
|
549
|
+
* on it without re-deriving anything).
|
|
550
|
+
*/
|
|
551
|
+
function formatFinding(f, cls) {
|
|
552
|
+
return (
|
|
553
|
+
` • ${f.file}:${f.line} — ${f.subpath}@${f.sha} [${cls}]\n` +
|
|
554
|
+
` ${f.reason}`
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Run the check and return a POSIX exit code. `log` / `err` are injectable so
|
|
560
|
+
* the sibling node:test suite can capture output without touching the real
|
|
561
|
+
* streams.
|
|
562
|
+
*
|
|
563
|
+
* @param {string[]} argv
|
|
564
|
+
* @param {{log?: Function, err?: Function}} [io]
|
|
565
|
+
* @returns {number}
|
|
566
|
+
*/
|
|
567
|
+
export function runCli(argv, { log = console.log, err = console.error } = {}) {
|
|
568
|
+
let opts;
|
|
569
|
+
try {
|
|
570
|
+
opts = parseArgs(argv);
|
|
571
|
+
} catch (e) {
|
|
572
|
+
err(`[pin-freshness] ❌ ${e.message}`);
|
|
573
|
+
err(USAGE);
|
|
574
|
+
return 1;
|
|
575
|
+
}
|
|
576
|
+
if (opts.help) {
|
|
577
|
+
log(USAGE);
|
|
578
|
+
return 0;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const result = runCheck(opts);
|
|
582
|
+
|
|
583
|
+
if (result.fatal !== null) {
|
|
584
|
+
err(`[pin-freshness] ❌ ${result.fatal}.`);
|
|
585
|
+
return 1;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (result.unreachable.length > 0) {
|
|
589
|
+
err(
|
|
590
|
+
`[pin-freshness] ❌ ${result.unreachable.length} unreachable first-party pin(s) — ` +
|
|
591
|
+
`not an ancestor of ${opts.ref}:`
|
|
592
|
+
);
|
|
593
|
+
for (const f of result.unreachable) err(formatFinding(f, "unreachable"));
|
|
594
|
+
err(
|
|
595
|
+
"[pin-freshness] Re-pin each to the squashed commit on the default branch. " +
|
|
596
|
+
"These resolve today only because the pre-squash commit has not been " +
|
|
597
|
+
"garbage-collected yet."
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (result.stale.length > 0) {
|
|
602
|
+
err(
|
|
603
|
+
`[pin-freshness] ❌ ${result.stale.length} stale first-party pin(s) — ` +
|
|
604
|
+
`the pinned revision lags the working tree:`
|
|
605
|
+
);
|
|
606
|
+
for (const f of result.stale) err(formatFinding(f, "stale"));
|
|
607
|
+
err(
|
|
608
|
+
"[pin-freshness] Bump each pin to a commit on the default branch whose " +
|
|
609
|
+
"action directory matches the working-tree copy. Every call site for a " +
|
|
610
|
+
"given subpath must move together (check-action-pins.mjs enforces the " +
|
|
611
|
+
"single-pin invariant per subpath)."
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (!result.ok) {
|
|
616
|
+
err(
|
|
617
|
+
"[pin-freshness] A first-party fix that no call site pins is inert: the " +
|
|
618
|
+
"pinned revision is what runs. See docs/reusable-workflows.md " +
|
|
619
|
+
"(First-party self-pin freshness) for the land-then-bump sequence."
|
|
620
|
+
);
|
|
621
|
+
return 1;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (result.unpinnedRefs.length > 0) {
|
|
625
|
+
log(
|
|
626
|
+
`[pin-freshness] ℹ️ ${result.unpinnedRefs.length} first-party ref(s) are not SHA-pinned ` +
|
|
627
|
+
`(freshness undecidable — reported, not failed):`
|
|
628
|
+
);
|
|
629
|
+
for (const u of result.unpinnedRefs) {
|
|
630
|
+
log(` ${u.file}:${u.line} — ${u.subpath}@${u.ref}`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
log(
|
|
635
|
+
`[pin-freshness] ✅ all ${result.scanned} first-party pin(s) resolve to an action ` +
|
|
636
|
+
`directory matching the working tree and reachable from ${opts.ref} ` +
|
|
637
|
+
`(${result.headSha.slice(0, 7)}); ${result.files.length} file(s) scanned.`
|
|
638
|
+
);
|
|
639
|
+
return 0;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Only run when executed directly, not when imported by the test suite.
|
|
643
|
+
const invokedDirectly =
|
|
644
|
+
process.argv[1] &&
|
|
645
|
+
resolve(process.argv[1]).endsWith("check-first-party-pin-freshness.mjs");
|
|
646
|
+
if (invokedDirectly) {
|
|
647
|
+
process.exit(runCli(process.argv.slice(2)));
|
|
648
|
+
}
|