mandrel-platform 1.2.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.
@@ -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,26 @@
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
+ *
29
41
  * This checker closes it by classifying every first-party SHA pin into one of
30
42
  * two failure classes — deliberately kept distinct, because their remedies
31
43
  * differ:
32
44
  *
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.
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.
36
49
  * • `unreachable` — the pinned SHA is not an ancestor of the checked-out
37
50
  * ref. Typically a pre-squash branch commit: content-
38
51
  * identical to `main` today, resolvable only until GitHub
@@ -279,9 +292,108 @@ export function createGit(repoRoot) {
279
292
  return null;
280
293
  }
281
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
+ },
282
321
  };
283
322
  }
284
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
+
285
397
  // ---------------------------------------------------------------------------
286
398
  // Check
287
399
  // ---------------------------------------------------------------------------
@@ -343,6 +455,18 @@ export function runCheck(opts = {}, git = createGit(resolve(opts.cwd || process.
343
455
  const unpinnedRefs = [];
344
456
  let scanned = 0;
345
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
+
346
470
  for (const file of files) {
347
471
  let content;
348
472
  try {
@@ -379,8 +503,7 @@ export function runCheck(opts = {}, git = createGit(resolve(opts.cwd || process.
379
503
  continue;
380
504
  }
381
505
 
382
- const pinnedBody = git.show(pin.sha, manifest.path);
383
- if (pinnedBody === null) {
506
+ if (git.show(pin.sha, manifest.path) === null) {
384
507
  stale.push({
385
508
  ...pin,
386
509
  reason: `${manifest.path} does not exist at the pinned SHA — the pin predates the manifest`,
@@ -388,19 +511,12 @@ export function runCheck(opts = {}, git = createGit(resolve(opts.cwd || process.
388
511
  continue;
389
512
  }
390
513
 
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)) {
514
+ const drift = driftFor(pin.sha, pin.subpath);
515
+ if (drift.length > 0) {
400
516
  stale.push({
401
517
  ...pin,
402
518
  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`,
519
+ reason: describeDrift(pin.subpath, drift),
404
520
  });
405
521
  }
406
522
  }
@@ -485,13 +601,13 @@ export function runCli(argv, { log = console.log, err = console.error } = {}) {
485
601
  if (result.stale.length > 0) {
486
602
  err(
487
603
  `[pin-freshness] ❌ ${result.stale.length} stale first-party pin(s) — ` +
488
- `the pinned manifest lags the working tree:`
604
+ `the pinned revision lags the working tree:`
489
605
  );
490
606
  for (const f of result.stale) err(formatFinding(f, "stale"));
491
607
  err(
492
608
  "[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 " +
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 " +
495
611
  "single-pin invariant per subpath)."
496
612
  );
497
613
  }
@@ -516,9 +632,9 @@ export function runCli(argv, { log = console.log, err = console.error } = {}) {
516
632
  }
517
633
 
518
634
  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.`
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.`
522
638
  );
523
639
  return 0;
524
640
  }