mandrel-platform 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-environments-isolation-audit.test.mjs — behavioural guard for the
4
+ * environments isolation audit's verdicts (Story #367).
5
+ *
6
+ * The bug this pins: reading an environment's `deployment_branch_policy`
7
+ * requires repo Administration: read, which the default GITHUB_TOKEN cannot be
8
+ * granted. Without it GitHub omits the field from the response entirely — and
9
+ * the audit read that absence as "this environment has NO deployment branch
10
+ * policy" and failed with a security finding it had never observed. A check
11
+ * that reports the insecure conclusion when the truth is that it could not look
12
+ * is a check operators learn to wave through, which is why no consumer in the
13
+ * fleet had it enabled.
14
+ *
15
+ * Absent-because-unreadable and absent-because-unset are different states and
16
+ * must produce different outcomes. Reading the YAML cannot prove that: what
17
+ * decides the verdict is a shell branch over a `jq` probe. So this extracts the
18
+ * real `run:` body and executes it against a stub `gh` serving fixture
19
+ * responses — the same read-then-execute approach as
20
+ * check-setup-toolchain-store.test.mjs / check-osv-scan-mode.test.mjs.
21
+ *
22
+ * Run: node --test scripts/check-environments-isolation-audit.test.mjs
23
+ */
24
+
25
+ import assert from "node:assert/strict";
26
+ import { test } from "node:test";
27
+ import { execFileSync } from "node:child_process";
28
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import path from "node:path";
31
+
32
+ import { stepByName, runScript } from "./lib/yaml-step.mjs";
33
+
34
+ const ACTION = ".github/actions/environments-isolation-audit/action.yml";
35
+ const REPO = "acme/widgets";
36
+
37
+ const auditScript = runScript(stepByName(readFileSync(ACTION, "utf8"), "Audit deployment branch policies"));
38
+
39
+ /**
40
+ * Run the extracted audit body against a stub `gh` that serves `responses`
41
+ * keyed by API path. A path with no fixture makes the stub exit non-zero with
42
+ * no output — exactly how `gh api` behaves on a 404 or a permission failure.
43
+ *
44
+ * @param {object} opts
45
+ * @param {Record<string, unknown>} opts.responses API path → JSON body.
46
+ * @param {string} [opts.environments] ENVIRONMENTS_CSV.
47
+ * @param {string} [opts.allowedBranch] ALLOWED_BRANCH.
48
+ * @returns {{ code: number, output: string }} `output` is stdout+stderr;
49
+ * GitHub's `::error::` annotations are written to stdout.
50
+ */
51
+ function runAudit({ responses, environments = "staging", allowedBranch = "main" }) {
52
+ const dir = mkdtempSync(path.join(tmpdir(), "env-isolation-audit-"));
53
+ try {
54
+ const fixtures = path.join(dir, "fixtures");
55
+ mkdirSync(fixtures);
56
+ for (const [apiPath, body] of Object.entries(responses)) {
57
+ writeFileSync(path.join(fixtures, apiPath.replace(/\//g, "_") + ".json"), JSON.stringify(body));
58
+ }
59
+ const stub = path.join(dir, "gh");
60
+ writeFileSync(
61
+ stub,
62
+ "#!/bin/sh\n" +
63
+ 'f="$FIXTURE_DIR/$(printf %s "$2" | tr / _).json"\n' +
64
+ 'if [ -f "$f" ]; then cat "$f"; exit 0; fi\n' +
65
+ 'echo "gh: Not Found (HTTP 404)" >&2\n' +
66
+ "exit 1\n"
67
+ );
68
+ chmodSync(stub, 0o755);
69
+ const script = path.join(dir, "audit.sh");
70
+ writeFileSync(script, auditScript);
71
+ const env = {
72
+ PATH: `${dir}${path.delimiter}${process.env.PATH}`,
73
+ FIXTURE_DIR: fixtures,
74
+ GITHUB_REPOSITORY: REPO,
75
+ AUDIT_REPO: "",
76
+ ENVIRONMENTS_CSV: environments,
77
+ ALLOWED_BRANCH: allowedBranch,
78
+ };
79
+ try {
80
+ const stdout = execFileSync("bash", [script], { cwd: dir, encoding: "utf8", env });
81
+ return { code: 0, output: stdout };
82
+ } catch (err) {
83
+ return { code: err.status, output: `${err.stdout ?? ""}${err.stderr ?? ""}` };
84
+ }
85
+ } finally {
86
+ rmSync(dir, { recursive: true, force: true });
87
+ }
88
+ }
89
+
90
+ const ENV_API = `repos/${REPO}/environments/staging`;
91
+ const POLICIES_API = `${ENV_API}/deployment-branch-policies`;
92
+
93
+ // ── the canonical posture still passes ─────────────────────────────────────
94
+
95
+ test("an environment restricted to the allowed branch passes", () => {
96
+ const res = runAudit({
97
+ responses: {
98
+ [ENV_API]: {
99
+ name: "staging",
100
+ deployment_branch_policy: { protected_branches: false, custom_branch_policies: true },
101
+ },
102
+ [POLICIES_API]: { total_count: 1, branch_policies: [{ name: "main" }] },
103
+ },
104
+ });
105
+ assert.equal(res.code, 0, res.output);
106
+ assert.match(res.output, /restricts deploys to 'main'/);
107
+ });
108
+
109
+ // ── unreadable ≠ unset (the Story #367 split) ──────────────────────────────
110
+
111
+ test("a policy field the token cannot read is reported as unreadable, not as absent", () => {
112
+ // What GitHub actually returns without Administration: read — the key is
113
+ // simply not in the response.
114
+ const res = runAudit({ responses: { [ENV_API]: { name: "staging", id: 1 } } });
115
+ assert.equal(res.code, 1);
116
+ assert.match(res.output, /UNREADABLE/);
117
+ assert.match(res.output, /Administration: read/);
118
+ assert.doesNotMatch(
119
+ res.output,
120
+ /has NO deployment branch policy/,
121
+ "the audit must not assert a conclusion it never observed"
122
+ );
123
+ });
124
+
125
+ test("a genuinely unset policy still fails as a real finding", () => {
126
+ const res = runAudit({
127
+ responses: { [ENV_API]: { name: "staging", deployment_branch_policy: null } },
128
+ });
129
+ assert.equal(res.code, 1);
130
+ assert.match(res.output, /has NO deployment branch policy/);
131
+ assert.doesNotMatch(res.output, /UNREADABLE/, "a null field is observed, not unreadable");
132
+ });
133
+
134
+ test("the two absent-policy states produce different messages", () => {
135
+ const unreadable = runAudit({ responses: { [ENV_API]: { name: "staging" } } }).output;
136
+ const unset = runAudit({
137
+ responses: { [ENV_API]: { name: "staging", deployment_branch_policy: null } },
138
+ }).output;
139
+ assert.notEqual(unreadable, unset);
140
+ });
141
+
142
+ test("an unreadable read is summarised as not-a-verdict at the end of the run", () => {
143
+ const res = runAudit({ responses: { [ENV_API]: { name: "staging" } } });
144
+ assert.match(res.output, /are NOT policy verdicts/);
145
+ });
146
+
147
+ test("unreadable named branch policies are not reported as ZERO policies", () => {
148
+ // The policy field reads fine; the follow-up policies call fails.
149
+ const res = runAudit({
150
+ responses: {
151
+ [ENV_API]: {
152
+ name: "staging",
153
+ deployment_branch_policy: { protected_branches: false, custom_branch_policies: true },
154
+ },
155
+ },
156
+ });
157
+ assert.equal(res.code, 1);
158
+ assert.match(res.output, /named branch policies UNREADABLE/);
159
+ assert.doesNotMatch(res.output, /ZERO named policies/);
160
+ });
161
+
162
+ // ── every other finding is unchanged ───────────────────────────────────────
163
+
164
+ test("protected-branches-only still fails with its own message", () => {
165
+ const res = runAudit({
166
+ responses: {
167
+ [ENV_API]: {
168
+ name: "staging",
169
+ deployment_branch_policy: { protected_branches: true, custom_branch_policies: false },
170
+ },
171
+ },
172
+ });
173
+ assert.equal(res.code, 1);
174
+ assert.match(res.output, /protected branches only/);
175
+ assert.doesNotMatch(res.output, /UNREADABLE/);
176
+ });
177
+
178
+ test("zero named policies, a wildcard, and a wrong branch each still fail", () => {
179
+ const custom = {
180
+ name: "staging",
181
+ deployment_branch_policy: { protected_branches: false, custom_branch_policies: true },
182
+ };
183
+ const zero = runAudit({
184
+ responses: { [ENV_API]: custom, [POLICIES_API]: { total_count: 0, branch_policies: [] } },
185
+ });
186
+ assert.equal(zero.code, 1);
187
+ assert.match(zero.output, /ZERO named policies/);
188
+
189
+ const wildcard = runAudit({
190
+ responses: {
191
+ [ENV_API]: custom,
192
+ [POLICIES_API]: { total_count: 1, branch_policies: [{ name: "release/*" }] },
193
+ },
194
+ });
195
+ assert.equal(wildcard.code, 1);
196
+ assert.match(wildcard.output, /wildcard branch policy/);
197
+
198
+ const wrongBranch = runAudit({
199
+ responses: {
200
+ [ENV_API]: custom,
201
+ [POLICIES_API]: { total_count: 1, branch_policies: [{ name: "develop" }] },
202
+ },
203
+ });
204
+ assert.equal(wrongBranch.code, 1);
205
+ assert.match(wrongBranch.output, /allows branch 'develop', not 'main'/);
206
+ });
207
+
208
+ test("a missing environment still fails with the does-not-exist message", () => {
209
+ const res = runAudit({ responses: {} });
210
+ assert.equal(res.code, 1);
211
+ assert.match(res.output, /does not exist on/);
212
+ });
@@ -136,12 +136,29 @@ const jobs = extractJobs();
136
136
  // are wired into every tier job that participates in fail-fast.
137
137
  // ---------------------------------------------------------------------------
138
138
 
139
- test("the collateral explainer fires only on cancellation under fail-fast", () => {
139
+ test("the collateral explainer fires on cancellation and nothing else", () => {
140
+ // Story #364 widened this from `cancelled() && inputs.fail-fast` to
141
+ // `cancelled()`: a job killed for exceeding its own `timeout-minutes` is also
142
+ // recorded as `cancelled`, and with fail-fast off nothing explained it at all.
143
+ // `cancelled()` remains the ceiling — an `always()` gate would fire the step
144
+ // on the green path, and `success()`/bare truthiness would fire it on a real
145
+ // failure, where the tier's own logs are the evidence.
140
146
  assert.match(
141
147
  explainStep,
142
- /^\s+if:\s+\$\{\{\s*cancelled\(\)\s*&&\s*inputs\.fail-fast\s*\}\}\s*$/m,
143
- "`&explain-cancellation` must be gated `cancelled() && inputs.fail-fast` " +
144
- "an `always()` gate would fire it on the green path"
148
+ /^\s+if:\s+\$\{\{\s*cancelled\(\)\s*\}\}\s*$/m,
149
+ "`&explain-cancellation` must be gated `cancelled()` alone"
150
+ );
151
+ assert.doesNotMatch(
152
+ explainStep,
153
+ /^\s+if:\s+\$\{\{\s*always\(\)/m,
154
+ "an `always()` gate would fire the explainer on the green path"
155
+ );
156
+ // The fail-fast input still has to reach the script — it is what separates a
157
+ // collateral cancellation from a timeout kill.
158
+ assert.match(
159
+ explainStep,
160
+ /^\s+FAIL_FAST:\s+\$\{\{\s*inputs\.fail-fast\s*\}\}\s*$/m,
161
+ "the explainer must receive inputs.fail-fast to classify the cancellation"
145
162
  );
146
163
  });
147
164
 
@@ -288,6 +305,9 @@ function runStep(script, { ghExit = 0, ghStdout = "", env = {} } = {}) {
288
305
  RUN_ID: "30168441137",
289
306
  REPO: "Beestera/swarm-os",
290
307
  TIER_ID: "typecheck",
308
+ // The collateral branch is the fail-fast-on case; a case that is about
309
+ // the timeout branch overrides this explicitly.
310
+ FAIL_FAST: "true",
291
311
  ...env,
292
312
  },
293
313
  });
@@ -369,6 +389,7 @@ test("collateral side: still explains itself when gh is absent entirely", () =>
369
389
  REPO: "o/r",
370
390
  TIER_ID: "lint",
371
391
  GH_TOKEN: "stub-token",
392
+ FAIL_FAST: "true",
372
393
  },
373
394
  });
374
395
  assert.equal(r.status, 0, r.stderr);
@@ -379,6 +400,71 @@ test("collateral side: still explains itself when gh is absent entirely", () =>
379
400
  }
380
401
  });
381
402
 
403
+ // ---------------------------------------------------------------------------
404
+ // Timeout provenance (Story #364) — the other half of the same step. A job
405
+ // killed for exceeding `timeout-minutes` is recorded as `cancelled`, not
406
+ // `failure`, and `ci-required` reads `cancelled` as a red gate. With every work
407
+ // step green, the operator previously had only timings to go on.
408
+ // ---------------------------------------------------------------------------
409
+
410
+ test("timeout side: a cancellation with no failing sibling is not reported as collateral", () => {
411
+ // Lookup SUCCEEDS and returns no failing tier — evidence, not an unknown.
412
+ const r = runStep(explainScript, { ghExit: 0, ghStdout: "" });
413
+ assert.equal(r.status, 0, r.stderr);
414
+ assert.doesNotMatch(
415
+ r.stdout,
416
+ /fail-fast collateral/,
417
+ "blaming a sibling that did not fail is the same misattribution in reverse"
418
+ );
419
+ assert.match(r.stdout, /::notice title=cancelled without a failing sibling::/);
420
+ });
421
+
422
+ test("timeout side: names the job's own ceiling and the post-job teardown that eats it", () => {
423
+ const r = runStep(explainScript, { ghExit: 0, ghStdout: "" });
424
+ assert.match(r.summary, /timeout-minutes/, "the summary must name the ceiling");
425
+ assert.match(
426
+ r.summary,
427
+ /cache save/,
428
+ "the summary must name the teardown that runs after the last work step"
429
+ );
430
+ assert.match(
431
+ r.summary,
432
+ /toolchain-cache/,
433
+ "the summary must point at the input that turns the save off"
434
+ );
435
+ assert.match(r.summary, /run-wide cancel/i, "the other live cause must stay named");
436
+ });
437
+
438
+ test("timeout side: an unknown lookup under fail-fast stays collateral, not a timeout claim", () => {
439
+ // Absence of evidence is not evidence of absence: a failed lookup must not be
440
+ // read as "no sibling failed", or a genuine collateral cancel gets a timeout
441
+ // diagnosis and the operator triages the wrong job.
442
+ const r = runStep(explainScript, { ghExit: 1 });
443
+ assert.match(r.stdout, /fail-fast collateral/);
444
+ assert.doesNotMatch(r.stdout, /cancelled without a failing sibling/);
445
+ });
446
+
447
+ test("timeout side: with fail-fast disabled the cancellation is never collateral", () => {
448
+ // fail-fast off means no sibling could have cancelled this job, whatever the
449
+ // lookup returns.
450
+ const r = runStep(explainScript, {
451
+ ghExit: 0,
452
+ ghStdout: "e2e",
453
+ env: { FAIL_FAST: "false" },
454
+ });
455
+ assert.equal(r.status, 0, r.stderr);
456
+ assert.doesNotMatch(r.stdout, /fail-fast collateral/);
457
+ assert.match(r.summary, /fail-fast is disabled on this run/);
458
+ });
459
+
460
+ test("timeout side: stays non-fatal and emits no failure annotation", () => {
461
+ // Same terms as the collateral branch — an explainer that can fail a job
462
+ // would turn a diagnostic into a second failure mode.
463
+ const r = runStep(explainScript, { ghExit: 0, ghStdout: "", env: { FAIL_FAST: "false" } });
464
+ assert.equal(r.status, 0, r.stderr);
465
+ assert.doesNotMatch(r.stdout, /::error/);
466
+ });
467
+
382
468
  test("collateral side: never claims this tier failed", () => {
383
469
  const r = runStep(explainScript, { ghStdout: "Accessibility (2/3)" });
384
470
  assert.doesNotMatch(
@@ -26,13 +26,42 @@
26
26
  * consumer on a self-hosted fleet kept leaking ~164 MB per run into the
27
27
  * host-shared temp root on the latest release.
28
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
+ * ## Why a subpath can have COMPANIONS (Story #389)
42
+ *
43
+ * The subpaths this checker knows are exactly the ones named on `uses:` lines,
44
+ * which reopens the same blind spot one level up the moment an action's
45
+ * behaviour moves into a SIBLING DIRECTORY. Story #389 reduced
46
+ * `.github/actions/osv-track-issue` to a thin preset that executes
47
+ * `.github/actions/track-issue/track-issue.mjs` — nothing `uses:` the generic
48
+ * action, so a rewrite of that shared core would leave every `osv-track-issue`
49
+ * pin reading fresh while the pinned SHA runs the old state machine.
50
+ *
51
+ * `COMPANION_SUBPATHS` closes it: a subpath's companions are folded into the
52
+ * tree comparison and into the drift cache key, so an edit confined to the
53
+ * shared core marks every call site of every dependent preset stale. The map
54
+ * is deliberately explicit rather than inferred — a heuristic over `run:`
55
+ * bodies would both miss indirection and invent false drift.
56
+ *
29
57
  * This checker closes it by classifying every first-party SHA pin into one of
30
58
  * two failure classes — deliberately kept distinct, because their remedies
31
59
  * differ:
32
60
  *
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.
61
+ * • `stale` — the SUBPATH TREE at the pinned SHA differs from the
62
+ * working-tree copy any file under it, not just the
63
+ * manifest. The fix is to BUMP the pin to a commit
64
+ * carrying the current tree.
36
65
  * • `unreachable` — the pinned SHA is not an ancestor of the checked-out
37
66
  * ref. Typically a pre-squash branch commit: content-
38
67
  * identical to `main` today, resolvable only until GitHub
@@ -279,9 +308,146 @@ export function createGit(repoRoot) {
279
308
  return null;
280
309
  }
281
310
  },
311
+ /**
312
+ * Repo-relative paths of every blob a subpath covers AT `sha`. A directory
313
+ * subpath yields its whole tree; a file subpath yields just itself. `-z`
314
+ * so a path with a space or a quote survives intact.
315
+ */
316
+ lsTree(sha, subpath) {
317
+ try {
318
+ return run(["ls-tree", "-r", "--name-only", "-z", sha, "--", subpath])
319
+ .split("\0")
320
+ .filter(Boolean);
321
+ } catch {
322
+ return [];
323
+ }
324
+ },
325
+ /**
326
+ * Repo-relative paths of every TRACKED working-tree file under a subpath.
327
+ * Tracked, not on-disk: an ignored build artefact or a stray `.DS_Store`
328
+ * inside an action directory is not something a consumer ever runs.
329
+ */
330
+ lsFiles(subpath) {
331
+ try {
332
+ return run(["ls-files", "-z", "--", subpath]).split("\0").filter(Boolean);
333
+ } catch {
334
+ return [];
335
+ }
336
+ },
282
337
  };
283
338
  }
284
339
 
340
+ // ---------------------------------------------------------------------------
341
+ // Subpath tree comparison
342
+ // ---------------------------------------------------------------------------
343
+
344
+ /**
345
+ * Subpaths whose runtime behaviour lives partly in ANOTHER directory that no
346
+ * `uses:` line names. Each key's companions are compared alongside it, so an
347
+ * edit confined to a shared core still marks the dependent pins stale.
348
+ *
349
+ * Keep this map in step with any preset/core split under `.github/actions/`:
350
+ * an entry omitted here is a pin that reads fresh while running old code.
351
+ *
352
+ * @type {Record<string, string[]>}
353
+ */
354
+ export const COMPANION_SUBPATHS = {
355
+ // Story #389 — the preset executes ../track-issue/track-issue.mjs.
356
+ ".github/actions/osv-track-issue": [".github/actions/track-issue"],
357
+ };
358
+
359
+ /**
360
+ * Companion subpaths for a `uses:` subpath (empty when it stands alone).
361
+ * Trailing slashes are tolerated so `foo/` and `foo` resolve identically.
362
+ *
363
+ * @param {string} subpath
364
+ * @param {Record<string, string[]>} [map]
365
+ * @returns {string[]}
366
+ */
367
+ export function companionsFor(subpath, map = COMPANION_SUBPATHS) {
368
+ return map[String(subpath).replace(/\/+$/, "")] || [];
369
+ }
370
+
371
+ /** How each drift kind reads in the report. */
372
+ const DRIFT_PHRASE = {
373
+ differs: "differs from the working-tree copy",
374
+ added: "is absent at the pinned SHA (added since)",
375
+ removed: "is gone from the working tree (removed since)",
376
+ unreadable: "is tracked but unreadable in the working tree",
377
+ };
378
+
379
+ /**
380
+ * Compare every file a `uses:` subpath covers at `sha` against the working
381
+ * tree, and return one record per drifting path (empty when the tree matches).
382
+ *
383
+ * The union of both sides is walked, so a sibling script ADDED or REMOVED
384
+ * since the pinned revision is drift just as much as one whose bytes changed —
385
+ * all three change what the pinned revision actually executes.
386
+ *
387
+ * `companions` extends the comparison to directories the subpath EXECUTES but
388
+ * no `uses:` line names (Story #389). They are compared identically: a shared
389
+ * core that differs at the pinned SHA is exactly as inert as a sibling script.
390
+ *
391
+ * @param {{lsTree: Function, lsFiles: Function, show: Function}} git
392
+ * @param {string} repoRoot
393
+ * @param {string} sha
394
+ * @param {string} subpath
395
+ * @param {string[]} [companions]
396
+ * @returns {Array<{path: string, kind: "differs" | "added" | "removed" | "unreadable"}>}
397
+ */
398
+ export function diffSubpathAtSha(git, repoRoot, sha, subpath, companions = []) {
399
+ const roots = [subpath, ...companions];
400
+ const pinned = new Set(roots.flatMap((root) => git.lsTree(sha, root)));
401
+ const working = new Set(roots.flatMap((root) => git.lsFiles(root)));
402
+ const drift = [];
403
+
404
+ for (const path of [...new Set([...pinned, ...working])].sort()) {
405
+ if (!working.has(path)) {
406
+ drift.push({ path, kind: "removed" });
407
+ continue;
408
+ }
409
+ if (!pinned.has(path)) {
410
+ drift.push({ path, kind: "added" });
411
+ continue;
412
+ }
413
+ let workingBody;
414
+ try {
415
+ workingBody = readFileSync(join(repoRoot, path), "utf8");
416
+ } catch {
417
+ drift.push({ path, kind: "unreadable" });
418
+ continue;
419
+ }
420
+ const pinnedBody = git.show(sha, path);
421
+ if (pinnedBody === null || !manifestsMatch(pinnedBody, workingBody)) {
422
+ drift.push({ path, kind: "differs" });
423
+ }
424
+ }
425
+
426
+ return drift;
427
+ }
428
+
429
+ /**
430
+ * Render a drift list as the one-line `reason` a finding carries. Action
431
+ * directories hold a handful of files, so every drifting path is named rather
432
+ * than summarised — the operator needs to know WHICH file is inert.
433
+ *
434
+ * @param {string} subpath
435
+ * @param {ReturnType<typeof diffSubpathAtSha>} drift
436
+ * @param {string[]} [companions]
437
+ * @returns {string}
438
+ */
439
+ export function describeDrift(subpath, drift, companions = []) {
440
+ const detail = drift.map((d) => `${d.path} ${DRIFT_PHRASE[d.kind]}`).join("; ");
441
+ const scope =
442
+ companions.length > 0
443
+ ? `${subpath} (and the shared code it executes: ${companions.join(", ")})`
444
+ : subpath;
445
+ return (
446
+ `${drift.length} file(s) under ${scope} lag the pinned SHA — the pinned ` +
447
+ `revision is what actually runs: ${detail}`
448
+ );
449
+ }
450
+
285
451
  // ---------------------------------------------------------------------------
286
452
  // Check
287
453
  // ---------------------------------------------------------------------------
@@ -294,7 +460,11 @@ export function createGit(repoRoot) {
294
460
  * `git` is injectable so a caller can drive the classification against a
295
461
  * substitute history; it defaults to the real git bound to `opts.cwd`.
296
462
  *
297
- * @param {{cwd?: string, workflowsDir?: string, actionsDir?: string, firstPartyOwner?: string, ref?: string}} opts
463
+ * `opts.companions` overrides {@link COMPANION_SUBPATHS} injectable so a
464
+ * fixture can exercise the shared-core relationship without depending on this
465
+ * repo's own action layout.
466
+ *
467
+ * @param {{cwd?: string, workflowsDir?: string, actionsDir?: string, firstPartyOwner?: string, ref?: string, companions?: Record<string, string[]>}} opts
298
468
  * @param {ReturnType<typeof createGit>} [git]
299
469
  * @returns {{ok: boolean, fatal: string|null, stale: object[], unreachable: object[], unpinnedRefs: object[], scanned: number, files: string[], headSha: string|null}}
300
470
  */
@@ -343,6 +513,21 @@ export function runCheck(opts = {}, git = createGit(resolve(opts.cwd || process.
343
513
  const unpinnedRefs = [];
344
514
  let scanned = 0;
345
515
 
516
+ // Every call site for a subpath must move together, so the same
517
+ // (sha, subpath) pair is compared repeatedly — `setup-toolchain` alone has
518
+ // five. Resolve each tree once. The companions are part of the key: two
519
+ // subpaths sharing a core must not collide, and a companion map override
520
+ // must not read a cache entry computed without it.
521
+ const companionMap = opts.companions || COMPANION_SUBPATHS;
522
+ const driftCache = new Map();
523
+ const driftFor = (sha, subpath, companions) => {
524
+ const key = `${sha}:${[subpath, ...companions].join(",")}`;
525
+ if (!driftCache.has(key)) {
526
+ driftCache.set(key, diffSubpathAtSha(git, repoRoot, sha, subpath, companions));
527
+ }
528
+ return driftCache.get(key);
529
+ };
530
+
346
531
  for (const file of files) {
347
532
  let content;
348
533
  try {
@@ -379,8 +564,7 @@ export function runCheck(opts = {}, git = createGit(resolve(opts.cwd || process.
379
564
  continue;
380
565
  }
381
566
 
382
- const pinnedBody = git.show(pin.sha, manifest.path);
383
- if (pinnedBody === null) {
567
+ if (git.show(pin.sha, manifest.path) === null) {
384
568
  stale.push({
385
569
  ...pin,
386
570
  reason: `${manifest.path} does not exist at the pinned SHA — the pin predates the manifest`,
@@ -388,19 +572,13 @@ export function runCheck(opts = {}, git = createGit(resolve(opts.cwd || process.
388
572
  continue;
389
573
  }
390
574
 
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)) {
575
+ const companions = companionsFor(pin.subpath, companionMap);
576
+ const drift = driftFor(pin.sha, pin.subpath, companions);
577
+ if (drift.length > 0) {
400
578
  stale.push({
401
579
  ...pin,
402
580
  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`,
581
+ reason: describeDrift(pin.subpath, drift, companions),
404
582
  });
405
583
  }
406
584
  }
@@ -485,13 +663,13 @@ export function runCli(argv, { log = console.log, err = console.error } = {}) {
485
663
  if (result.stale.length > 0) {
486
664
  err(
487
665
  `[pin-freshness] ❌ ${result.stale.length} stale first-party pin(s) — ` +
488
- `the pinned manifest lags the working tree:`
666
+ `the pinned revision lags the working tree:`
489
667
  );
490
668
  for (const f of result.stale) err(formatFinding(f, "stale"));
491
669
  err(
492
670
  "[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 " +
671
+ "action directory matches the working-tree copy. Every call site for a " +
672
+ "given subpath must move together (check-action-pins.mjs enforces the " +
495
673
  "single-pin invariant per subpath)."
496
674
  );
497
675
  }
@@ -516,9 +694,9 @@ export function runCli(argv, { log = console.log, err = console.error } = {}) {
516
694
  }
517
695
 
518
696
  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.`
697
+ `[pin-freshness] ✅ all ${result.scanned} first-party pin(s) resolve to an action ` +
698
+ `directory matching the working tree and reachable from ${opts.ref} ` +
699
+ `(${result.headSha.slice(0, 7)}); ${result.files.length} file(s) scanned.`
522
700
  );
523
701
  return 0;
524
702
  }