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.
- package/README.md +69 -12
- package/config/stryker.base.json +7 -2
- package/package.json +2 -2
- 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 +200 -22
- package/scripts/check-first-party-pin-freshness.test.mjs +296 -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/osv-track-issue.test.mjs +155 -0
- package/scripts/stryker-base-config.test.mjs +256 -0
- package/scripts/track-issue.test.mjs +375 -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,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
|
|
34
|
-
* working-tree
|
|
35
|
-
* BUMP the pin to a commit
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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:
|
|
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
|
|
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
|
-
"
|
|
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
|
|
520
|
-
`matching the working tree and reachable from ${opts.ref}
|
|
521
|
-
|
|
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
|
}
|