mandrel-platform 1.1.0 → 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/check-action-pins.mjs +87 -15
- package/scripts/check-action-pins.test.mjs +103 -4
- package/scripts/check-first-party-pin-freshness.mjs +532 -0
- package/scripts/check-first-party-pin-freshness.test.mjs +489 -0
- package/scripts/runner-env-drift.test.mjs +554 -0
- package/templates/runbooks/runner-provisioning.md +50 -6
- package/templates/runner/check-runner-env-drift.sh +248 -0
|
@@ -0,0 +1,532 @@
|
|
|
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
|
+
* This checker closes it by classifying every first-party SHA pin into one of
|
|
30
|
+
* two failure classes — deliberately kept distinct, because their remedies
|
|
31
|
+
* differ:
|
|
32
|
+
*
|
|
33
|
+
* • `stale` — the manifest AT THE PINNED SHA differs from the
|
|
34
|
+
* working-tree manifest at the same subpath. The fix is to
|
|
35
|
+
* BUMP the pin to a commit carrying the current manifest.
|
|
36
|
+
* • `unreachable` — the pinned SHA is not an ancestor of the checked-out
|
|
37
|
+
* ref. Typically a pre-squash branch commit: content-
|
|
38
|
+
* identical to `main` today, resolvable only until GitHub
|
|
39
|
+
* garbage-collects it, after which every consumer fails at
|
|
40
|
+
* action-load time. The fix is to RE-PIN to the squashed
|
|
41
|
+
* commit on `main`.
|
|
42
|
+
*
|
|
43
|
+
* A first-party ref that is NOT a 40-hex SHA has no freshness answer at all —
|
|
44
|
+
* a branch or tag names a revision that can move after this check runs — so it
|
|
45
|
+
* is collected into `unpinnedRefs` and merely REPORTED. Enforcing the SHA
|
|
46
|
+
* shape belongs upstream of freshness, in `check-action-pins.mjs`, which
|
|
47
|
+
* ratchets first-party refs alongside third-party ones and runs in the
|
|
48
|
+
* PR-gating `ci.yml` where this check deliberately does not (see below). The
|
|
49
|
+
* note here is the backstop for a consumer that adopted only this script.
|
|
50
|
+
*
|
|
51
|
+
* Requires full git history — run the checkout with `fetch-depth: 0`. A
|
|
52
|
+
* shallow clone cannot answer either question and is refused loudly rather
|
|
53
|
+
* than reported as a wall of false `unreachable` findings.
|
|
54
|
+
*
|
|
55
|
+
* ## Where this runs, and why not on PRs
|
|
56
|
+
*
|
|
57
|
+
* Wired onto push-to-`main` and the `pin-drift.yml` schedule; deliberately
|
|
58
|
+
* ABSENT from the PR-gating `ci.yml`. A PR that edits a composite action
|
|
59
|
+
* cannot pin its own not-yet-existing merge commit, so a PR-time gate would
|
|
60
|
+
* be unsatisfiable on exactly the changes it exists to protect. A red `main`
|
|
61
|
+
* is instead the signal to open the follow-up bump PR — the land-then-bump
|
|
62
|
+
* sequence documented in docs/reusable-workflows.md.
|
|
63
|
+
*
|
|
64
|
+
* Usage:
|
|
65
|
+
* node scripts/check-first-party-pin-freshness.mjs
|
|
66
|
+
* node scripts/check-first-party-pin-freshness.mjs --cwd /path/to/repo
|
|
67
|
+
* node scripts/check-first-party-pin-freshness.mjs --ref origin/main
|
|
68
|
+
* node scripts/check-first-party-pin-freshness.mjs --first-party-owner my-org/my-repo
|
|
69
|
+
*
|
|
70
|
+
* Exit codes:
|
|
71
|
+
* 0 — every first-party pin resolves to a fresh, reachable manifest.
|
|
72
|
+
* 1 — one or more `stale` / `unreachable` pins (each named in stderr with
|
|
73
|
+
* file, line, subpath and SHA), or the history needed to decide is
|
|
74
|
+
* unavailable.
|
|
75
|
+
*
|
|
76
|
+
* Consumer adoption:
|
|
77
|
+
* Copy this script into your project's `scripts/` directory alongside
|
|
78
|
+
* `scripts/lib/{args,uses-pins,walk}.mjs` (its only dependencies — no YAML
|
|
79
|
+
* parser), then run it on push to your default branch:
|
|
80
|
+
*
|
|
81
|
+
* - uses: actions/checkout@<sha>
|
|
82
|
+
* with: { fetch-depth: 0 }
|
|
83
|
+
* - run: node scripts/check-first-party-pin-freshness.mjs --first-party-owner <owner/repo>
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
87
|
+
import { execFileSync } from "node:child_process";
|
|
88
|
+
import { resolve, join, relative, basename } from "node:path";
|
|
89
|
+
|
|
90
|
+
import { parseFlags } from "./lib/args.mjs";
|
|
91
|
+
import {
|
|
92
|
+
DEFAULT_FIRST_PARTY_OWNER,
|
|
93
|
+
parseUsesLine,
|
|
94
|
+
classifyUses,
|
|
95
|
+
isSha40,
|
|
96
|
+
} from "./lib/uses-pins.mjs";
|
|
97
|
+
import { listWorkflowFiles, listActionFiles } from "./lib/walk.mjs";
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Arg parsing
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parse the CLI argv slice (everything AFTER `node script.mjs`) into an
|
|
105
|
+
* options object. Throws on an unknown flag so a typo fails loudly rather than
|
|
106
|
+
* silently disabling half the check.
|
|
107
|
+
*
|
|
108
|
+
* @param {string[]} argv
|
|
109
|
+
* @returns {{workflowsDir: string, actionsDir: string, firstPartyOwner: string, cwd: string, ref: string, help: boolean}}
|
|
110
|
+
*/
|
|
111
|
+
export function parseArgs(argv) {
|
|
112
|
+
return parseFlags(argv, {
|
|
113
|
+
flags: {
|
|
114
|
+
"--workflows-dir": { type: "string", dest: "workflowsDir", default: ".github/workflows" },
|
|
115
|
+
"--actions-dir": { type: "string", dest: "actionsDir", default: ".github/actions" },
|
|
116
|
+
"--first-party-owner": {
|
|
117
|
+
type: "string",
|
|
118
|
+
dest: "firstPartyOwner",
|
|
119
|
+
default: DEFAULT_FIRST_PARTY_OWNER,
|
|
120
|
+
},
|
|
121
|
+
"--cwd": { type: "string", dest: "cwd", default: process.cwd() },
|
|
122
|
+
"--ref": { type: "string", dest: "ref", default: "HEAD" },
|
|
123
|
+
"--help": { type: "boolean", dest: "help", value: true, default: false },
|
|
124
|
+
},
|
|
125
|
+
aliases: { "-h": "--help" },
|
|
126
|
+
onUnknown: "throw",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Pin collection (pure text)
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Scan a file's TEXT for first-party `uses:` references and split them into
|
|
136
|
+
* the SHA-pinned refs this checker classifies and the non-SHA refs it can only
|
|
137
|
+
* note. Third-party, local (`./path`) and `docker://` references are dropped
|
|
138
|
+
* here and can therefore never reach the report.
|
|
139
|
+
*
|
|
140
|
+
* A bare `owner/repo@ref` self-reference is skipped: with no subpath there is
|
|
141
|
+
* no manifest to resolve. Whole-line `#` comments never match (the doc-example
|
|
142
|
+
* `uses:` lines every action.yml carries in its header are comments).
|
|
143
|
+
*
|
|
144
|
+
* @param {string} content
|
|
145
|
+
* @param {string} displayFile Path as it should appear in the report.
|
|
146
|
+
* @param {string} [firstPartyOwner]
|
|
147
|
+
* @returns {{pins: Array<{file: string, line: number, subpath: string, sha: string}>, unpinnedRefs: Array<{file: string, line: number, subpath: string, ref: string}>}}
|
|
148
|
+
*/
|
|
149
|
+
export function collectPinnedRefs(
|
|
150
|
+
content,
|
|
151
|
+
displayFile,
|
|
152
|
+
firstPartyOwner = DEFAULT_FIRST_PARTY_OWNER
|
|
153
|
+
) {
|
|
154
|
+
const pins = [];
|
|
155
|
+
const unpinnedRefs = [];
|
|
156
|
+
const lines = String(content).split(/\r?\n/);
|
|
157
|
+
for (let i = 0; i < lines.length; i++) {
|
|
158
|
+
const bareRef = parseUsesLine(lines[i]);
|
|
159
|
+
if (bareRef === null) continue;
|
|
160
|
+
const cls = classifyUses(bareRef, firstPartyOwner);
|
|
161
|
+
if (cls.kind !== "first-party") continue;
|
|
162
|
+
if (!cls.subpath) continue; // bare owner/repo self-ref — no manifest to resolve
|
|
163
|
+
const record = { file: displayFile, line: i + 1, subpath: cls.subpath };
|
|
164
|
+
if (isSha40(cls.ref)) pins.push({ ...record, sha: cls.ref });
|
|
165
|
+
else unpinnedRefs.push({ ...record, ref: cls.ref });
|
|
166
|
+
}
|
|
167
|
+
return { pins, unpinnedRefs };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Resolve the manifest a `uses:` subpath points at, relative to a repo root.
|
|
172
|
+
* A directory subpath resolves to its `action.yml` / `action.yaml`; a `.yml` /
|
|
173
|
+
* `.yaml` subpath resolves to itself. Returns null when the subpath does not
|
|
174
|
+
* exist in the working tree or holds no manifest.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} repoRoot
|
|
177
|
+
* @param {string} subpath
|
|
178
|
+
* @returns {{path: string, kind: "action" | "workflow"} | null}
|
|
179
|
+
*/
|
|
180
|
+
export function resolveManifest(repoRoot, subpath) {
|
|
181
|
+
const local = join(repoRoot, subpath);
|
|
182
|
+
let st;
|
|
183
|
+
try {
|
|
184
|
+
st = statSync(local);
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
if (st.isDirectory()) {
|
|
189
|
+
for (const name of ["action.yml", "action.yaml"]) {
|
|
190
|
+
if (existsSync(join(local, name))) return { path: `${subpath}/${name}`, kind: "action" };
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
if (/\.ya?ml$/.test(subpath)) {
|
|
195
|
+
return { path: subpath, kind: basename(subpath).startsWith("action.") ? "action" : "workflow" };
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Compare two manifest bodies for equality, tolerating line-ending drift so a
|
|
202
|
+
* CRLF working tree on Windows does not report every pin as stale.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} a
|
|
205
|
+
* @param {string} b
|
|
206
|
+
* @returns {boolean}
|
|
207
|
+
*/
|
|
208
|
+
export function manifestsMatch(a, b) {
|
|
209
|
+
const norm = (s) => String(s).replace(/\r\n/g, "\n");
|
|
210
|
+
return norm(a) === norm(b);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Git seam
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Build the narrow git interface `runCheck` needs, bound to a repo root. Every
|
|
219
|
+
* method is failure-tolerant: a missing object or an unreadable path answers
|
|
220
|
+
* "no" rather than throwing, so a broken pin is reported as a finding instead
|
|
221
|
+
* of crashing the lint.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} repoRoot
|
|
224
|
+
*/
|
|
225
|
+
export function createGit(repoRoot) {
|
|
226
|
+
const run = (args) =>
|
|
227
|
+
execFileSync("git", args, {
|
|
228
|
+
cwd: repoRoot,
|
|
229
|
+
encoding: "utf8",
|
|
230
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
231
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
/** True when `repoRoot` sits inside a git work tree. */
|
|
236
|
+
isRepo() {
|
|
237
|
+
try {
|
|
238
|
+
run(["rev-parse", "--is-inside-work-tree"]);
|
|
239
|
+
return true;
|
|
240
|
+
} catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
/** True when the clone is shallow (history truncated — cannot decide). */
|
|
245
|
+
isShallow() {
|
|
246
|
+
try {
|
|
247
|
+
return run(["rev-parse", "--is-shallow-repository"]).trim() === "true";
|
|
248
|
+
} catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
/** Abbreviated SHA of a ref, or null when it does not resolve. */
|
|
253
|
+
resolveRef(ref) {
|
|
254
|
+
try {
|
|
255
|
+
return run(["rev-parse", ref]).trim();
|
|
256
|
+
} catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
/** True when `sha` is an ancestor of (or equal to) `ref`. */
|
|
261
|
+
isAncestor(sha, ref) {
|
|
262
|
+
try {
|
|
263
|
+
execFileSync("git", ["merge-base", "--is-ancestor", sha, ref], {
|
|
264
|
+
cwd: repoRoot,
|
|
265
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
266
|
+
});
|
|
267
|
+
return true;
|
|
268
|
+
} catch {
|
|
269
|
+
// Exit 1 = not an ancestor; exit 128 = unknown object. Both mean the
|
|
270
|
+
// pin is not reachable from the checked-out ref.
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
/** `git show <sha>:<path>` → content, or null when unresolvable. */
|
|
275
|
+
show(sha, path) {
|
|
276
|
+
try {
|
|
277
|
+
return run(["show", `${sha}:${path}`]);
|
|
278
|
+
} catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Check
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Classify every first-party SHA pin under the repo's workflow and action
|
|
291
|
+
* trees. Returns a structured result; formatting and the exit code live in
|
|
292
|
+
* {@link runCli}.
|
|
293
|
+
*
|
|
294
|
+
* `git` is injectable so a caller can drive the classification against a
|
|
295
|
+
* substitute history; it defaults to the real git bound to `opts.cwd`.
|
|
296
|
+
*
|
|
297
|
+
* @param {{cwd?: string, workflowsDir?: string, actionsDir?: string, firstPartyOwner?: string, ref?: string}} opts
|
|
298
|
+
* @param {ReturnType<typeof createGit>} [git]
|
|
299
|
+
* @returns {{ok: boolean, fatal: string|null, stale: object[], unreachable: object[], unpinnedRefs: object[], scanned: number, files: string[], headSha: string|null}}
|
|
300
|
+
*/
|
|
301
|
+
export function runCheck(opts = {}, git = createGit(resolve(opts.cwd || process.cwd()))) {
|
|
302
|
+
const repoRoot = resolve(opts.cwd || process.cwd());
|
|
303
|
+
const ref = opts.ref || "HEAD";
|
|
304
|
+
const firstPartyOwner = opts.firstPartyOwner || DEFAULT_FIRST_PARTY_OWNER;
|
|
305
|
+
const wfDir = resolve(repoRoot, opts.workflowsDir || ".github/workflows");
|
|
306
|
+
const acDir = resolve(repoRoot, opts.actionsDir || ".github/actions");
|
|
307
|
+
|
|
308
|
+
const empty = {
|
|
309
|
+
ok: false,
|
|
310
|
+
fatal: null,
|
|
311
|
+
stale: [],
|
|
312
|
+
unreachable: [],
|
|
313
|
+
unpinnedRefs: [],
|
|
314
|
+
scanned: 0,
|
|
315
|
+
files: [],
|
|
316
|
+
headSha: null,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
if (!git.isRepo()) {
|
|
320
|
+
return {
|
|
321
|
+
...empty,
|
|
322
|
+
fatal:
|
|
323
|
+
"not a git repository — this check resolves each pinned manifest from " +
|
|
324
|
+
"git history and cannot run without it",
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
if (git.isShallow()) {
|
|
328
|
+
return {
|
|
329
|
+
...empty,
|
|
330
|
+
fatal:
|
|
331
|
+
"shallow clone — pinned manifests and ancestry are unresolvable. Run " +
|
|
332
|
+
"the checkout with `fetch-depth: 0`",
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
const headSha = git.resolveRef(ref);
|
|
336
|
+
if (headSha === null) {
|
|
337
|
+
return { ...empty, fatal: `ref "${ref}" does not resolve in this repository` };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const files = [...listWorkflowFiles(wfDir), ...listActionFiles(acDir)];
|
|
341
|
+
const stale = [];
|
|
342
|
+
const unreachable = [];
|
|
343
|
+
const unpinnedRefs = [];
|
|
344
|
+
let scanned = 0;
|
|
345
|
+
|
|
346
|
+
for (const file of files) {
|
|
347
|
+
let content;
|
|
348
|
+
try {
|
|
349
|
+
content = readFileSync(file, "utf8");
|
|
350
|
+
} catch {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
const display = relative(repoRoot, file) || file;
|
|
354
|
+
const collected = collectPinnedRefs(content, display, firstPartyOwner);
|
|
355
|
+
unpinnedRefs.push(...collected.unpinnedRefs);
|
|
356
|
+
|
|
357
|
+
for (const pin of collected.pins) {
|
|
358
|
+
scanned++;
|
|
359
|
+
|
|
360
|
+
// Reachability first: an unknown or off-branch object cannot be compared
|
|
361
|
+
// in the first place, and its remedy (re-pin to the squashed commit)
|
|
362
|
+
// differs from a bump.
|
|
363
|
+
if (!git.isAncestor(pin.sha, ref)) {
|
|
364
|
+
unreachable.push({
|
|
365
|
+
...pin,
|
|
366
|
+
reason:
|
|
367
|
+
`pinned SHA is not an ancestor of ${ref} — a dangling or pre-squash ` +
|
|
368
|
+
`branch commit that breaks every consumer once it is garbage-collected`,
|
|
369
|
+
});
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const manifest = resolveManifest(repoRoot, pin.subpath);
|
|
374
|
+
if (manifest === null) {
|
|
375
|
+
stale.push({
|
|
376
|
+
...pin,
|
|
377
|
+
reason: `no manifest at ${pin.subpath} in the working tree — the pin references a path that no longer exists`,
|
|
378
|
+
});
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const pinnedBody = git.show(pin.sha, manifest.path);
|
|
383
|
+
if (pinnedBody === null) {
|
|
384
|
+
stale.push({
|
|
385
|
+
...pin,
|
|
386
|
+
reason: `${manifest.path} does not exist at the pinned SHA — the pin predates the manifest`,
|
|
387
|
+
});
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let workingBody;
|
|
392
|
+
try {
|
|
393
|
+
workingBody = readFileSync(join(repoRoot, manifest.path), "utf8");
|
|
394
|
+
} catch {
|
|
395
|
+
stale.push({ ...pin, reason: `cannot read the working-tree ${manifest.path}` });
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!manifestsMatch(pinnedBody, workingBody)) {
|
|
400
|
+
stale.push({
|
|
401
|
+
...pin,
|
|
402
|
+
manifest: manifest.path,
|
|
403
|
+
reason: `the manifest at the pinned SHA differs from the working-tree ${manifest.path} — the pinned revision is what actually runs`,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
ok: stale.length === 0 && unreachable.length === 0,
|
|
411
|
+
fatal: null,
|
|
412
|
+
stale,
|
|
413
|
+
unreachable,
|
|
414
|
+
unpinnedRefs,
|
|
415
|
+
scanned,
|
|
416
|
+
files,
|
|
417
|
+
headSha,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// CLI
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
const USAGE =
|
|
426
|
+
"Usage: node scripts/check-first-party-pin-freshness.mjs " +
|
|
427
|
+
"[--cwd <dir>] [--ref <git-ref>] [--workflows-dir <dir>] [--actions-dir <dir>] " +
|
|
428
|
+
"[--first-party-owner <owner/repo>]";
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Format one finding as a two-line report entry naming the referencing file,
|
|
432
|
+
* the line, the subpath and the full pinned SHA (the four facts needed to act
|
|
433
|
+
* on it without re-deriving anything).
|
|
434
|
+
*/
|
|
435
|
+
function formatFinding(f, cls) {
|
|
436
|
+
return (
|
|
437
|
+
` • ${f.file}:${f.line} — ${f.subpath}@${f.sha} [${cls}]\n` +
|
|
438
|
+
` ${f.reason}`
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Run the check and return a POSIX exit code. `log` / `err` are injectable so
|
|
444
|
+
* the sibling node:test suite can capture output without touching the real
|
|
445
|
+
* streams.
|
|
446
|
+
*
|
|
447
|
+
* @param {string[]} argv
|
|
448
|
+
* @param {{log?: Function, err?: Function}} [io]
|
|
449
|
+
* @returns {number}
|
|
450
|
+
*/
|
|
451
|
+
export function runCli(argv, { log = console.log, err = console.error } = {}) {
|
|
452
|
+
let opts;
|
|
453
|
+
try {
|
|
454
|
+
opts = parseArgs(argv);
|
|
455
|
+
} catch (e) {
|
|
456
|
+
err(`[pin-freshness] ❌ ${e.message}`);
|
|
457
|
+
err(USAGE);
|
|
458
|
+
return 1;
|
|
459
|
+
}
|
|
460
|
+
if (opts.help) {
|
|
461
|
+
log(USAGE);
|
|
462
|
+
return 0;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const result = runCheck(opts);
|
|
466
|
+
|
|
467
|
+
if (result.fatal !== null) {
|
|
468
|
+
err(`[pin-freshness] ❌ ${result.fatal}.`);
|
|
469
|
+
return 1;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (result.unreachable.length > 0) {
|
|
473
|
+
err(
|
|
474
|
+
`[pin-freshness] ❌ ${result.unreachable.length} unreachable first-party pin(s) — ` +
|
|
475
|
+
`not an ancestor of ${opts.ref}:`
|
|
476
|
+
);
|
|
477
|
+
for (const f of result.unreachable) err(formatFinding(f, "unreachable"));
|
|
478
|
+
err(
|
|
479
|
+
"[pin-freshness] Re-pin each to the squashed commit on the default branch. " +
|
|
480
|
+
"These resolve today only because the pre-squash commit has not been " +
|
|
481
|
+
"garbage-collected yet."
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (result.stale.length > 0) {
|
|
486
|
+
err(
|
|
487
|
+
`[pin-freshness] ❌ ${result.stale.length} stale first-party pin(s) — ` +
|
|
488
|
+
`the pinned manifest lags the working tree:`
|
|
489
|
+
);
|
|
490
|
+
for (const f of result.stale) err(formatFinding(f, "stale"));
|
|
491
|
+
err(
|
|
492
|
+
"[pin-freshness] Bump each pin to a commit on the default branch whose " +
|
|
493
|
+
"manifest matches the working-tree copy. Every call site for a given " +
|
|
494
|
+
"subpath must move together (check-action-pins.mjs enforces the " +
|
|
495
|
+
"single-pin invariant per subpath)."
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (!result.ok) {
|
|
500
|
+
err(
|
|
501
|
+
"[pin-freshness] A first-party fix that no call site pins is inert: the " +
|
|
502
|
+
"pinned revision is what runs. See docs/reusable-workflows.md " +
|
|
503
|
+
"(First-party self-pin freshness) for the land-then-bump sequence."
|
|
504
|
+
);
|
|
505
|
+
return 1;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (result.unpinnedRefs.length > 0) {
|
|
509
|
+
log(
|
|
510
|
+
`[pin-freshness] ℹ️ ${result.unpinnedRefs.length} first-party ref(s) are not SHA-pinned ` +
|
|
511
|
+
`(freshness undecidable — reported, not failed):`
|
|
512
|
+
);
|
|
513
|
+
for (const u of result.unpinnedRefs) {
|
|
514
|
+
log(` ${u.file}:${u.line} — ${u.subpath}@${u.ref}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
log(
|
|
519
|
+
`[pin-freshness] ✅ all ${result.scanned} first-party pin(s) resolve to a manifest ` +
|
|
520
|
+
`matching the working tree and reachable from ${opts.ref} (${result.headSha.slice(0, 7)}); ` +
|
|
521
|
+
`${result.files.length} file(s) scanned.`
|
|
522
|
+
);
|
|
523
|
+
return 0;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Only run when executed directly, not when imported by the test suite.
|
|
527
|
+
const invokedDirectly =
|
|
528
|
+
process.argv[1] &&
|
|
529
|
+
resolve(process.argv[1]).endsWith("check-first-party-pin-freshness.mjs");
|
|
530
|
+
if (invokedDirectly) {
|
|
531
|
+
process.exit(runCli(process.argv.slice(2)));
|
|
532
|
+
}
|