mandrel-platform 1.1.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-action-pins.mjs +87 -15
- package/scripts/check-action-pins.test.mjs +103 -4
- 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 +648 -0
- package/scripts/check-first-party-pin-freshness.test.mjs +624 -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/runner-env-drift.test.mjs +554 -0
- package/scripts/stryker-base-config.test.mjs +256 -0
- package/templates/runbooks/runner-provisioning.md +50 -6
- package/templates/runner/check-runner-env-drift.sh +248 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* stryker-base-config.test.mjs — regression guard for the shared Stryker base
|
|
4
|
+
* config's bail and timeout shape.
|
|
5
|
+
*
|
|
6
|
+
* The bug this pins: with Stryker's default bail (`disableBail: false`), the
|
|
7
|
+
* vitest runner can mark a mutant Survived having completed zero tests — the
|
|
8
|
+
* mutant carries a non-empty covering-test list while its completed-test count
|
|
9
|
+
* is zero. A consumer measured 92 of 345 mutants flipping verdict between two
|
|
10
|
+
* identical runs, and a recorded score of 53.5% against a real 72.29%. A
|
|
11
|
+
* committed baseline taken under bail is therefore a floor under a number
|
|
12
|
+
* nothing measured: the gate is not merely noisy, it is wrong in the direction
|
|
13
|
+
* that hides surviving mutants.
|
|
14
|
+
*
|
|
15
|
+
* Disabling bail is not free — every mutant now runs its full covering set, so
|
|
16
|
+
* the run lengthens. The timeouts in this same base config must move with it,
|
|
17
|
+
* or the suite trades a wrong number for a silent overrun, and an overrun that
|
|
18
|
+
* preserves the prior result and exits clean is the same failure wearing a
|
|
19
|
+
* different mask. That coupling is why bail and the timeouts are asserted
|
|
20
|
+
* together here rather than in two independent tests: re-tightening either half
|
|
21
|
+
* alone reintroduces the defect.
|
|
22
|
+
*
|
|
23
|
+
* This repository ships the config but runs no mutation suite of its own, so
|
|
24
|
+
* the contract is asserted by shape. The run-to-run stability it buys is
|
|
25
|
+
* observable only in a consumer.
|
|
26
|
+
*
|
|
27
|
+
* The delivery half is asserted end-to-end rather than by shape: the suite
|
|
28
|
+
* imports the base through its published package specifier — the same
|
|
29
|
+
* resolution a consumer's `stryker.config.mjs` performs — and checks the
|
|
30
|
+
* settings that arrive. Stryker has no `extends` option (adjudicated against
|
|
31
|
+
* @stryker-mutator/core and @stryker-mutator/api 9.6.1), so the spread import
|
|
32
|
+
* is the only mechanism that delivers anything at all, and it is the only one
|
|
33
|
+
* documented.
|
|
34
|
+
*
|
|
35
|
+
* Run: node --test scripts/stryker-base-config.test.mjs
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import assert from "node:assert/strict";
|
|
39
|
+
import { test } from "node:test";
|
|
40
|
+
import { readFileSync } from "node:fs";
|
|
41
|
+
|
|
42
|
+
const CONFIG_PATH = "config/stryker.base.json";
|
|
43
|
+
const PACKAGE_PATH = "package.json";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The specifier a consumer's `stryker.config.mjs` imports. Node resolves it
|
|
47
|
+
* through this package's own `exports` map (self-reference), so importing it
|
|
48
|
+
* here exercises the same resolution a consumer gets rather than a stand-in
|
|
49
|
+
* for it.
|
|
50
|
+
*/
|
|
51
|
+
const PACKAGE_SPECIFIER = "mandrel-platform/stryker.base.json";
|
|
52
|
+
|
|
53
|
+
/** Stryker's own defaults, per https://stryker-mutator.io/docs/stryker-js/configuration. */
|
|
54
|
+
const STRYKER_DEFAULTS = Object.freeze({
|
|
55
|
+
timeoutMS: 5000,
|
|
56
|
+
timeoutFactor: 1.5,
|
|
57
|
+
dryRunTimeoutMinutes: 5,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The timeout floor this config carried *before* bail was disabled. Bail
|
|
62
|
+
* cut every mutant's run short, so 60s absolute was survivable; without it the
|
|
63
|
+
* covering set runs to completion and the budget has to grow. Asserting
|
|
64
|
+
* strictly above the old value is what makes "raised alongside the bail
|
|
65
|
+
* change" mechanically checkable rather than a claim in a commit message.
|
|
66
|
+
*/
|
|
67
|
+
const PRE_CHANGE_TIMEOUT_MS = 60000;
|
|
68
|
+
|
|
69
|
+
function readConfig() {
|
|
70
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test("base config disables bail so every mutant runs its full covering set", () => {
|
|
74
|
+
const config = readConfig();
|
|
75
|
+
|
|
76
|
+
assert.equal(
|
|
77
|
+
config.disableBail,
|
|
78
|
+
true,
|
|
79
|
+
`${CONFIG_PATH} must set "disableBail": true. Stryker defaults it to false, ` +
|
|
80
|
+
"and under bail the runner can score a mutant Survived having completed " +
|
|
81
|
+
"zero of its covering tests.",
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("timeouts are raised above both Stryker's defaults and the pre-change floor", async (t) => {
|
|
86
|
+
const config = readConfig();
|
|
87
|
+
|
|
88
|
+
await t.test("timeoutMS clears the pre-bail-change floor", () => {
|
|
89
|
+
assert.equal(
|
|
90
|
+
typeof config.timeoutMS,
|
|
91
|
+
"number",
|
|
92
|
+
`${CONFIG_PATH} must pin "timeoutMS" explicitly, not inherit it.`,
|
|
93
|
+
);
|
|
94
|
+
assert.ok(
|
|
95
|
+
config.timeoutMS > PRE_CHANGE_TIMEOUT_MS,
|
|
96
|
+
`"timeoutMS" is ${config.timeoutMS}; it must exceed the pre-change ` +
|
|
97
|
+
`${PRE_CHANGE_TIMEOUT_MS} because disabling bail lengthens every mutant's run.`,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await t.test("timeoutFactor is pinned above Stryker's default", () => {
|
|
102
|
+
assert.equal(
|
|
103
|
+
typeof config.timeoutFactor,
|
|
104
|
+
"number",
|
|
105
|
+
`${CONFIG_PATH} must pin "timeoutFactor" explicitly, not inherit it.`,
|
|
106
|
+
);
|
|
107
|
+
assert.ok(
|
|
108
|
+
config.timeoutFactor > STRYKER_DEFAULTS.timeoutFactor,
|
|
109
|
+
`"timeoutFactor" is ${config.timeoutFactor}; it must exceed Stryker's ` +
|
|
110
|
+
`${STRYKER_DEFAULTS.timeoutFactor} default so a full covering set is not ` +
|
|
111
|
+
"clipped as a false Timeout.",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await t.test("dryRunTimeoutMinutes is pinned above Stryker's default", () => {
|
|
116
|
+
assert.equal(
|
|
117
|
+
typeof config.dryRunTimeoutMinutes,
|
|
118
|
+
"number",
|
|
119
|
+
`${CONFIG_PATH} must pin "dryRunTimeoutMinutes" explicitly, not inherit it.`,
|
|
120
|
+
);
|
|
121
|
+
assert.ok(
|
|
122
|
+
config.dryRunTimeoutMinutes > STRYKER_DEFAULTS.dryRunTimeoutMinutes,
|
|
123
|
+
`"dryRunTimeoutMinutes" is ${config.dryRunTimeoutMinutes}; it must exceed ` +
|
|
124
|
+
`Stryker's ${STRYKER_DEFAULTS.dryRunTimeoutMinutes}-minute default.`,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a Timeout is not silently absorbed into the score", () => {
|
|
130
|
+
const config = readConfig();
|
|
131
|
+
|
|
132
|
+
// `ignoreStatic` legitimately drops static mutants from the denominator.
|
|
133
|
+
// Nothing else may: an option that reclassifies or suppresses a timed-out or
|
|
134
|
+
// errored mutant would let a run that overran still report the prior number
|
|
135
|
+
// and exit clean — the exact failure disabling bail is meant to end.
|
|
136
|
+
assert.equal(
|
|
137
|
+
Object.hasOwn(config, "maxTestRunnerReuse"),
|
|
138
|
+
false,
|
|
139
|
+
'Do not pin "maxTestRunnerReuse" in the shared base; it masks runner-level ' +
|
|
140
|
+
"instability that the timeout budget is supposed to surface.",
|
|
141
|
+
);
|
|
142
|
+
assert.notEqual(
|
|
143
|
+
config.allowEmpty,
|
|
144
|
+
true,
|
|
145
|
+
'"allowEmpty" must stay false/absent: a dry run that executed no tests must ' +
|
|
146
|
+
"fail loudly rather than score an empty suite.",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("the documented spread-import mechanism delivers the bail-free settings", async () => {
|
|
151
|
+
// This is the consumer's own resolution path, not a proxy for it: the
|
|
152
|
+
// specifier below is resolved through the published `exports` map by Node,
|
|
153
|
+
// exactly as `stryker.config.mjs` in a consuming repo resolves it. Asserting
|
|
154
|
+
// the exports-map *string* instead would pass while the file it points at
|
|
155
|
+
// carried the wrong values, or while the entry was absent from `files` — the
|
|
156
|
+
// two ways the recipe can be documented correctly and still deliver nothing.
|
|
157
|
+
const { default: base } = await import(PACKAGE_SPECIFIER, {
|
|
158
|
+
with: { type: "json" },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
assert.equal(
|
|
162
|
+
base.disableBail,
|
|
163
|
+
true,
|
|
164
|
+
`Importing "${PACKAGE_SPECIFIER}" must yield "disableBail": true. A consumer ` +
|
|
165
|
+
"spreading this object into stryker.config.mjs gets whatever this " +
|
|
166
|
+
"resolves to, so a broken export or a stale published file silently " +
|
|
167
|
+
"restores bail.",
|
|
168
|
+
);
|
|
169
|
+
assert.ok(
|
|
170
|
+
base.timeoutMS > PRE_CHANGE_TIMEOUT_MS,
|
|
171
|
+
`Importing "${PACKAGE_SPECIFIER}" must also carry the raised timeout budget ` +
|
|
172
|
+
`that bail-free runs need; got timeoutMS ${base.timeoutMS}.`,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// The exports map must reach *this* file, or the assertions in the rest of
|
|
176
|
+
// this suite are guarding a config no consumer receives.
|
|
177
|
+
assert.deepEqual(
|
|
178
|
+
base,
|
|
179
|
+
readConfig(),
|
|
180
|
+
`"${PACKAGE_SPECIFIER}" must resolve to ${CONFIG_PATH} — the file every ` +
|
|
181
|
+
"other test here asserts.",
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("the config declares no `extends`, which Stryker does not support", () => {
|
|
186
|
+
const config = readConfig();
|
|
187
|
+
|
|
188
|
+
// Adjudicated against @stryker-mutator/core 9.6.1 and @stryker-mutator/api
|
|
189
|
+
// 9.6.1: the config reader loads exactly one config file and deep-merges CLI
|
|
190
|
+
// arguments over it — there is no extends resolution step anywhere in it —
|
|
191
|
+
// and `extends` is absent from the 45 top-level properties in the published
|
|
192
|
+
// stryker-core.json schema. A base that advertises an `extends` recipe sends
|
|
193
|
+
// consumers down a path where the settings below arrive not at all.
|
|
194
|
+
assert.equal(
|
|
195
|
+
Object.hasOwn(config, "extends"),
|
|
196
|
+
false,
|
|
197
|
+
`${CONFIG_PATH} must not declare "extends". Stryker has no such option; ` +
|
|
198
|
+
"the supported mechanism is importing this file by its package " +
|
|
199
|
+
"specifier and spreading it (see the README).",
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("annotations use the `_comment` suffix Stryker's validator exempts", () => {
|
|
204
|
+
const config = readConfig();
|
|
205
|
+
|
|
206
|
+
// Stryker warns "Unknown stryker config option \"<key>\"" for any top-level
|
|
207
|
+
// key that is neither in its schema nor suffixed `_comment`. A prefix-named
|
|
208
|
+
// key like `_comment_disableBail` fails that suffix check, so documenting
|
|
209
|
+
// the base costs every consumer a warning on every run.
|
|
210
|
+
const STRYKER_OPTIONS = new Set([
|
|
211
|
+
"$schema",
|
|
212
|
+
"packageManager",
|
|
213
|
+
"reporters",
|
|
214
|
+
"coverageAnalysis",
|
|
215
|
+
"ignoreStatic",
|
|
216
|
+
"cleanTempDir",
|
|
217
|
+
"disableBail",
|
|
218
|
+
"timeoutMS",
|
|
219
|
+
"timeoutFactor",
|
|
220
|
+
"dryRunTimeoutMinutes",
|
|
221
|
+
"thresholds",
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
const wouldWarn = Object.keys(config).filter(
|
|
225
|
+
(key) => !STRYKER_OPTIONS.has(key) && !key.endsWith("_comment"),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
assert.deepEqual(
|
|
229
|
+
wouldWarn,
|
|
230
|
+
[],
|
|
231
|
+
`${CONFIG_PATH} keys ${JSON.stringify(wouldWarn)} are neither pinned Stryker ` +
|
|
232
|
+
'options nor suffixed "_comment", so Stryker reports each as an unknown ' +
|
|
233
|
+
"config option in every consumer run. Rename annotations to " +
|
|
234
|
+
"`<topic>_comment`.",
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("every pinned Stryker option is still exported to consumers", () => {
|
|
239
|
+
const pkg = JSON.parse(readFileSync(PACKAGE_PATH, "utf8"));
|
|
240
|
+
|
|
241
|
+
// The import test above proves resolution works *here*, where Node's
|
|
242
|
+
// self-reference falls back to the local file. Publication is what carries it
|
|
243
|
+
// to a consumer, and that needs both the exports entry and the files
|
|
244
|
+
// allowlist.
|
|
245
|
+
assert.equal(
|
|
246
|
+
pkg.exports["./stryker.base.json"],
|
|
247
|
+
`./${CONFIG_PATH}`,
|
|
248
|
+
"The ./stryker.base.json export must point at the file this test asserts, " +
|
|
249
|
+
"or consumers import a config nothing guards.",
|
|
250
|
+
);
|
|
251
|
+
assert.ok(
|
|
252
|
+
pkg.files.includes("config/"),
|
|
253
|
+
'The "files" allowlist must publish config/, or the export resolves to a ' +
|
|
254
|
+
"file absent from the tarball.",
|
|
255
|
+
);
|
|
256
|
+
});
|
|
@@ -142,6 +142,8 @@ Copy the kit from the platform payload into the runner root:
|
|
|
142
142
|
```bash
|
|
143
143
|
cp node_modules/mandrel-platform/templates/runner/job-cleanup.sh <RUNNER_DIR>/job-cleanup.sh
|
|
144
144
|
chmod +x <RUNNER_DIR>/job-cleanup.sh
|
|
145
|
+
cp node_modules/mandrel-platform/templates/runner/check-runner-env-drift.sh <RUNNER_DIR>/check-runner-env-drift.sh
|
|
146
|
+
chmod +x <RUNNER_DIR>/check-runner-env-drift.sh
|
|
145
147
|
cp node_modules/mandrel-platform/templates/runner/.env.example <RUNNER_DIR>/.env
|
|
146
148
|
```
|
|
147
149
|
|
|
@@ -167,8 +169,48 @@ with the runner root's absolute path. The resulting file wires:
|
|
|
167
169
|
(two env names, one dir; some actions read the legacy name).
|
|
168
170
|
- `LANG=en_US.UTF-8`.
|
|
169
171
|
|
|
170
|
-
|
|
171
|
-
location, so the same
|
|
172
|
+
Neither shipped script needs per-runner editing: each derives its paths from
|
|
173
|
+
its own location, so the same files work verbatim on every runner.
|
|
174
|
+
|
|
175
|
+
### Confirm the pool is uniform (`check-runner-env-drift.sh`)
|
|
176
|
+
|
|
177
|
+
Run this **after provisioning each runner**, and again whenever two runners
|
|
178
|
+
behave differently on the same job. It walks the pool and names the runners
|
|
179
|
+
missing any of the four mandated keys:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
cd <RUNNER_DIR>
|
|
183
|
+
./check-runner-env-drift.sh # pool root = this dir's parent
|
|
184
|
+
./check-runner-env-drift.sh --pool-root <POOL_ROOT>
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The pool root is the directory holding one subdirectory per runner (§1); a
|
|
188
|
+
child directory counts as a runner iff it contains `config.sh`. The checker is
|
|
189
|
+
read-only — it never writes into a runner root and never touches a service.
|
|
190
|
+
|
|
191
|
+
| Exit | Meaning | Operator response |
|
|
192
|
+
|------|---------|-------------------|
|
|
193
|
+
| `0` | No drift. Every mandated key is set on every runner, or unset on every runner. | None. |
|
|
194
|
+
| `1` | **Drift** — a key is set on some runners but not all. | Copy `.env.example` onto each runner the report names, substitute its `<RUNNER_DIR>`, then `./svc.sh stop && ./svc.sh start` on those runners so they reload `.env`. |
|
|
195
|
+
| `2` | Usage error — bad flag, or a pool root holding no runner directories. | Re-check `--pool-root`. |
|
|
196
|
+
|
|
197
|
+
A key absent from **every** runner is reported as a uniform gap and does *not*
|
|
198
|
+
exit non-zero, so a fleet that has deliberately not adopted a key is not a
|
|
199
|
+
standing alarm.
|
|
200
|
+
|
|
201
|
+
**Why this check exists at all:** `scripts/check-runner-health.mjs` monitors
|
|
202
|
+
the fleet through the GitHub runners API, which reports a runner's name,
|
|
203
|
+
labels and online status — it cannot see `<RUNNER_DIR>/.env`. So partial
|
|
204
|
+
provisioning never surfaces as a configuration fault; it surfaces as an
|
|
205
|
+
unattributable behavioural difference between two runs of the same job. In
|
|
206
|
+
issue #343, 16 of 19 runners on one host carried the hook, and the resulting
|
|
207
|
+
`Set up runner` spread (5m29s against 54s) took far longer to attribute than
|
|
208
|
+
reading nineteen `.env` files would have.
|
|
209
|
+
|
|
210
|
+
Do **not** wire this into `ACTIONS_RUNNER_HOOK_JOB_STARTED`. That hook runs
|
|
211
|
+
inside the job's clock, where every read is billed to `Set up runner` and
|
|
212
|
+
counts against the job's `timeout-minutes` — the exact cost model that made
|
|
213
|
+
#343 a job-killer. This is an operator-run tool.
|
|
172
214
|
|
|
173
215
|
## 5. Install as a launchd service (`svc.sh`)
|
|
174
216
|
|
|
@@ -199,10 +241,12 @@ The runner loads `.env` at service start — after any `.env` change, restart:
|
|
|
199
241
|
`svc.sh`.
|
|
200
242
|
- **Kit updates.** The hook and `.env.example` are versioned in
|
|
201
243
|
mandrel-platform. On a platform release that touches `templates/runner/`,
|
|
202
|
-
re-copy `job-cleanup.sh` (verbatim —
|
|
203
|
-
`.env.example` against the live `.env`,
|
|
204
|
-
start`. There is no `mandrel sync`
|
|
205
|
-
filesystem — this is an operator-applied
|
|
244
|
+
re-copy `job-cleanup.sh` and `check-runner-env-drift.sh` (both verbatim —
|
|
245
|
+
they are parameterized) and diff `.env.example` against the live `.env`,
|
|
246
|
+
then `./svc.sh stop && ./svc.sh start`. There is no `mandrel sync`
|
|
247
|
+
equivalent for a runner host's filesystem — this is an operator-applied
|
|
248
|
+
step. Re-run `./check-runner-env-drift.sh` afterwards: a kit update applied
|
|
249
|
+
to some runners and not others is exactly the drift it reports.
|
|
206
250
|
- **Token/registration rotation.** Registration tokens are one-shot at
|
|
207
251
|
config time; nothing persists to rotate. To move a runner between repos or
|
|
208
252
|
rename it: `./svc.sh stop && ./svc.sh uninstall && ./config.sh remove
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# check-runner-env-drift.sh — report per-runner `.env` configuration drift
|
|
4
|
+
# across one host's runner pool (mandrel-platform runner kit).
|
|
5
|
+
#
|
|
6
|
+
# ── WHY THIS EXISTS ─────────────────────────────────────────────────────────
|
|
7
|
+
#
|
|
8
|
+
# Nothing else observes a runner's LOCAL configuration. The fleet monitor
|
|
9
|
+
# (`scripts/check-runner-health.mjs`) reaches runners through
|
|
10
|
+
# `GET /repos/{owner}/{repo}/actions/runners`, which reports a runner's name,
|
|
11
|
+
# labels and online status — the endpoint cannot see `<RUNNER_DIR>/.env`, so
|
|
12
|
+
# hook configuration is invisible to it.
|
|
13
|
+
#
|
|
14
|
+
# The consequence is that a partially-provisioned pool never presents as a
|
|
15
|
+
# configuration fault. It presents as an unattributable behavioural difference
|
|
16
|
+
# between two runs of the SAME job on the SAME repo. That is issue #343: on one
|
|
17
|
+
# host, 16 of 19 runners carried ACTIONS_RUNNER_HOOK_JOB_STARTED; two of the
|
|
18
|
+
# hooked ones sat 5m29s in `Set up runner` while the same job on an unhooked
|
|
19
|
+
# runner finished in 54 seconds. Attributing that took far longer than reading
|
|
20
|
+
# nineteen `.env` files would have — which is precisely what this script does.
|
|
21
|
+
#
|
|
22
|
+
# ── WHAT IT REPORTS ─────────────────────────────────────────────────────────
|
|
23
|
+
#
|
|
24
|
+
# PRESENCE — never value — of the four keys `.env.example` mandates:
|
|
25
|
+
# ACTIONS_RUNNER_HOOK_JOB_STARTED, RUNNER_TOOL_CACHE, AGENT_TOOLSDIRECTORY,
|
|
26
|
+
# LANG. Values are deliberately not compared: every one of them embeds the
|
|
27
|
+
# runner's own absolute root path, so they are SUPPOSED to differ per runner.
|
|
28
|
+
#
|
|
29
|
+
# The drift signal is a key set on SOME runners but not all — the 16-of-19
|
|
30
|
+
# shape. A key absent from EVERY runner is a uniform gap: reported as such, and
|
|
31
|
+
# not on its own a non-zero exit. A fleet that has deliberately not adopted a
|
|
32
|
+
# key must not be a standing alarm, or the operator learns to ignore the exit
|
|
33
|
+
# code and the signal is worth nothing when it does fire.
|
|
34
|
+
#
|
|
35
|
+
# ── OPERATOR CONTRACT ───────────────────────────────────────────────────────
|
|
36
|
+
#
|
|
37
|
+
# check-runner-env-drift.sh [--pool-root <dir>]
|
|
38
|
+
#
|
|
39
|
+
# --pool-root <dir> Directory holding one subdirectory per runner. Defaults
|
|
40
|
+
# to the PARENT of the directory containing this script:
|
|
41
|
+
# the kit installs it into <RUNNER_DIR>, and the runbook
|
|
42
|
+
# mandates one directory per runner under a common root,
|
|
43
|
+
# so the default is correct on any kit-provisioned host.
|
|
44
|
+
#
|
|
45
|
+
# A child directory counts as a runner iff it contains `config.sh`. That
|
|
46
|
+
# predicate keeps unrelated siblings (shared caches, scratch dirs) out of the
|
|
47
|
+
# report without inventing a naming convention.
|
|
48
|
+
#
|
|
49
|
+
# exit 0 — no drift: every mandated key is uniform across the pool (set
|
|
50
|
+
# everywhere, or unset everywhere).
|
|
51
|
+
# exit 1 — drift: at least one key is set on some runners but not all. The
|
|
52
|
+
# non-zero exit IS the alert channel, matching the posture
|
|
53
|
+
# `scripts/check-runner-health.mjs` already uses, so this can be
|
|
54
|
+
# scheduled.
|
|
55
|
+
# exit 2 — usage error: unknown flag, or a pool root that is not a directory
|
|
56
|
+
# or holds no runners. Deliberately distinct from 0: reporting "no
|
|
57
|
+
# drift" over an empty walk would read as evidence the fleet is
|
|
58
|
+
# uniform.
|
|
59
|
+
#
|
|
60
|
+
# READ-ONLY, and never fails soft on a broken runner. It writes nothing into a
|
|
61
|
+
# runner root and never touches a launchd service. A runner whose `.env` is
|
|
62
|
+
# missing or unreadable is recorded as all four keys unset and the walk
|
|
63
|
+
# continues — one broken runner must not shrink the sample the verdict is
|
|
64
|
+
# computed over.
|
|
65
|
+
#
|
|
66
|
+
# This is an OPERATOR-run tool, not a job hook. Do not wire it into
|
|
67
|
+
# ACTIONS_RUNNER_HOOK_JOB_STARTED: that hook runs inside the job's clock, where
|
|
68
|
+
# every read is billed to `Set up runner` and counts against the job's
|
|
69
|
+
# `timeout-minutes` (issue #343). A pool-wide walk belongs outside that clock.
|
|
70
|
+
#
|
|
71
|
+
# ── PORTABILITY ─────────────────────────────────────────────────────────────
|
|
72
|
+
#
|
|
73
|
+
# Runs on the host with no repo checkout and no Node runtime, and stays
|
|
74
|
+
# compatible with macOS's system bash 3.2 — the same constraint
|
|
75
|
+
# `.github/actions/gitleaks-scan/action.yml` documents for this fleet. That
|
|
76
|
+
# rules out `declare -A`, `mapfile`/`readarray`, and `${var,,}`; it does NOT
|
|
77
|
+
# rule out plain INDEXED arrays, which 3.2 supports and which the accumulators
|
|
78
|
+
# below use. Only the per-key tally needs a second pass, because keeping a
|
|
79
|
+
# key->count table is the one thing an indexed array cannot do.
|
|
80
|
+
#
|
|
81
|
+
# Accumulating into arrays rather than splitting a delimited string on a
|
|
82
|
+
# reassigned `IFS` is deliberate and load-bearing: reassigning IFS globally is
|
|
83
|
+
# flagged by the platform's own SAST ruleset (`bash.lang.security.ifs-tampering`)
|
|
84
|
+
# because it silently changes the splitting behaviour of every later unquoted
|
|
85
|
+
# expansion in the script. Arrays give the same grouping with no global state
|
|
86
|
+
# and no quoting hazard for a runner directory whose name contains whitespace.
|
|
87
|
+
|
|
88
|
+
set -u
|
|
89
|
+
|
|
90
|
+
MANDATED_KEYS=(
|
|
91
|
+
ACTIONS_RUNNER_HOOK_JOB_STARTED
|
|
92
|
+
RUNNER_TOOL_CACHE
|
|
93
|
+
AGENT_TOOLSDIRECTORY
|
|
94
|
+
LANG
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
|
98
|
+
POOL_ROOT=$(dirname "$SCRIPT_DIR")
|
|
99
|
+
|
|
100
|
+
usage() {
|
|
101
|
+
cat <<'USAGE'
|
|
102
|
+
Usage: check-runner-env-drift.sh [--pool-root <dir>]
|
|
103
|
+
|
|
104
|
+
Reports which runners in a pool are missing the `.env` keys the runner kit
|
|
105
|
+
mandates. Read-only.
|
|
106
|
+
|
|
107
|
+
--pool-root <dir> Pool root (default: the parent of this script's dir).
|
|
108
|
+
-h, --help Show this help.
|
|
109
|
+
|
|
110
|
+
Exit: 0 no drift · 1 drift · 2 usage error.
|
|
111
|
+
USAGE
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
while [ $# -gt 0 ]; do
|
|
115
|
+
case "$1" in
|
|
116
|
+
--pool-root)
|
|
117
|
+
if [ $# -lt 2 ]; then
|
|
118
|
+
printf 'check-runner-env-drift: --pool-root requires a directory\n' >&2
|
|
119
|
+
exit 2
|
|
120
|
+
fi
|
|
121
|
+
POOL_ROOT=$2
|
|
122
|
+
shift 2
|
|
123
|
+
;;
|
|
124
|
+
--pool-root=*)
|
|
125
|
+
POOL_ROOT=${1#--pool-root=}
|
|
126
|
+
shift
|
|
127
|
+
;;
|
|
128
|
+
-h | --help)
|
|
129
|
+
usage
|
|
130
|
+
exit 0
|
|
131
|
+
;;
|
|
132
|
+
*)
|
|
133
|
+
printf 'check-runner-env-drift: unknown argument: %s\n' "$1" >&2
|
|
134
|
+
usage >&2
|
|
135
|
+
exit 2
|
|
136
|
+
;;
|
|
137
|
+
esac
|
|
138
|
+
done
|
|
139
|
+
|
|
140
|
+
if [ ! -d "$POOL_ROOT" ]; then
|
|
141
|
+
printf 'check-runner-env-drift: pool root is not a directory: %s\n' "$POOL_ROOT" >&2
|
|
142
|
+
exit 2
|
|
143
|
+
fi
|
|
144
|
+
POOL_ROOT=$(cd "$POOL_ROOT" && pwd)
|
|
145
|
+
|
|
146
|
+
# Presence test for one key in one runner's `.env`.
|
|
147
|
+
#
|
|
148
|
+
# The assignment form is `^[[:space:]]*KEY=` on a non-comment line. A commented
|
|
149
|
+
# line cannot match (the `#` is not whitespace), which is the case that matters:
|
|
150
|
+
# `.env.example` ships every key inside a block of explanatory prose, so a
|
|
151
|
+
# half-applied copy where the operator never uncommented a line is the most
|
|
152
|
+
# likely real drift shape — and matching the key name anywhere in the file would
|
|
153
|
+
# report that runner as fully provisioned. The trailing `=` is equally
|
|
154
|
+
# load-bearing: without it `LANGUAGE=` would satisfy `LANG`.
|
|
155
|
+
#
|
|
156
|
+
# A missing, unreadable, or non-regular `.env` returns "unset" rather than
|
|
157
|
+
# aborting, so the caller records all keys unset and keeps walking.
|
|
158
|
+
env_has_key() {
|
|
159
|
+
env_file=$1
|
|
160
|
+
env_key=$2
|
|
161
|
+
|
|
162
|
+
[ -f "$env_file" ] || return 1
|
|
163
|
+
[ -r "$env_file" ] || return 1
|
|
164
|
+
grep -Eq "^[[:space:]]*${env_key}=" "$env_file" 2>/dev/null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
key_total=${#MANDATED_KEYS[@]}
|
|
168
|
+
|
|
169
|
+
# Enumerate runners. A glob matching nothing stays literal and fails the `-d`
|
|
170
|
+
# test, so an empty pool root falls through to the exit-2 branch below.
|
|
171
|
+
runner_names=()
|
|
172
|
+
for candidate in "$POOL_ROOT"/*/; do
|
|
173
|
+
[ -d "$candidate" ] || continue
|
|
174
|
+
[ -f "${candidate}config.sh" ] || continue
|
|
175
|
+
runner_names+=("$(basename "$candidate")")
|
|
176
|
+
done
|
|
177
|
+
runner_count=${#runner_names[@]}
|
|
178
|
+
|
|
179
|
+
if [ "$runner_count" -eq 0 ]; then
|
|
180
|
+
printf 'check-runner-env-drift: no runner directories under %s\n' "$POOL_ROOT" >&2
|
|
181
|
+
printf 'check-runner-env-drift: a runner is a child directory containing config.sh — is this the pool root?\n' >&2
|
|
182
|
+
exit 2
|
|
183
|
+
fi
|
|
184
|
+
|
|
185
|
+
printf 'runner .env configuration drift report\n'
|
|
186
|
+
printf ' pool root: %s\n' "$POOL_ROOT"
|
|
187
|
+
printf ' runners: %d\n' "$runner_count"
|
|
188
|
+
printf '\n'
|
|
189
|
+
|
|
190
|
+
printf 'per-runner:\n'
|
|
191
|
+
for name in "${runner_names[@]}"; do
|
|
192
|
+
unset_keys=()
|
|
193
|
+
for key in "${MANDATED_KEYS[@]}"; do
|
|
194
|
+
if ! env_has_key "$POOL_ROOT/$name/.env" "$key"; then
|
|
195
|
+
unset_keys+=("$key")
|
|
196
|
+
fi
|
|
197
|
+
done
|
|
198
|
+
|
|
199
|
+
if [ "${#unset_keys[@]}" -eq 0 ]; then
|
|
200
|
+
printf ' %s: all %d mandated keys set\n' "$name" "$key_total"
|
|
201
|
+
else
|
|
202
|
+
# `${arr[*]}` joins on the first character of IFS — a space, since this
|
|
203
|
+
# script never reassigns it. Safe for key names, which carry no whitespace;
|
|
204
|
+
# runner names are printed one per line below for exactly that reason.
|
|
205
|
+
printf ' %s: unset %s\n' "$name" "${unset_keys[*]}"
|
|
206
|
+
fi
|
|
207
|
+
done
|
|
208
|
+
printf '\n'
|
|
209
|
+
|
|
210
|
+
printf 'per-key:\n'
|
|
211
|
+
drift_count=0
|
|
212
|
+
for key in "${MANDATED_KEYS[@]}"; do
|
|
213
|
+
set_count=0
|
|
214
|
+
unset_names=()
|
|
215
|
+
for name in "${runner_names[@]}"; do
|
|
216
|
+
if env_has_key "$POOL_ROOT/$name/.env" "$key"; then
|
|
217
|
+
set_count=$((set_count + 1))
|
|
218
|
+
else
|
|
219
|
+
unset_names+=("$name")
|
|
220
|
+
fi
|
|
221
|
+
done
|
|
222
|
+
|
|
223
|
+
if [ "$set_count" -eq "$runner_count" ]; then
|
|
224
|
+
printf ' %s: ok — set on %d of %d runners\n' "$key" "$set_count" "$runner_count"
|
|
225
|
+
elif [ "$set_count" -eq 0 ]; then
|
|
226
|
+
printf ' %s: uniformly unset — set on 0 of %d runners; a uniform gap, not drift\n' "$key" "$runner_count"
|
|
227
|
+
else
|
|
228
|
+
# Reached only when 0 < set_count < runner_count, so unset_names is
|
|
229
|
+
# non-empty here — one name per line, because a runner directory name may
|
|
230
|
+
# contain whitespace and a joined list would make it unactionable.
|
|
231
|
+
drift_count=$((drift_count + 1))
|
|
232
|
+
printf ' %s: DRIFT — set on %d of %d runners; unset on:\n' "$key" "$set_count" "$runner_count"
|
|
233
|
+
for name in "${unset_names[@]}"; do
|
|
234
|
+
printf ' %s\n' "$name"
|
|
235
|
+
done
|
|
236
|
+
fi
|
|
237
|
+
done
|
|
238
|
+
printf '\n'
|
|
239
|
+
|
|
240
|
+
if [ "$drift_count" -eq 0 ]; then
|
|
241
|
+
printf 'no drift: every mandated key is uniform across the pool.\n'
|
|
242
|
+
exit 0
|
|
243
|
+
fi
|
|
244
|
+
|
|
245
|
+
printf 'DRIFT: %d of %d mandated keys are set on some runners but not all.\n' "$drift_count" "$key_total"
|
|
246
|
+
printf 'Provision the runners named above from templates/runner/.env.example,\n'
|
|
247
|
+
printf 'then restart each one so it reloads .env: ./svc.sh stop && ./svc.sh start\n'
|
|
248
|
+
exit 1
|