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.
- 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-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 +137 -21
- package/scripts/check-first-party-pin-freshness.test.mjs +135 -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/stryker-base-config.test.mjs +256 -0
|
@@ -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
|
|
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
|
|
143
|
-
"`&explain-cancellation` must be gated `cancelled()
|
|
144
|
-
|
|
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
|
|
34
|
-
* working-tree
|
|
35
|
-
* BUMP the pin to a commit
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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:
|
|
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
|
|
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
|
-
"
|
|
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
|
|
520
|
-
`matching the working tree and reachable from ${opts.ref}
|
|
521
|
-
|
|
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
|
}
|