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.
@@ -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
- The hook needs no per-runner editing: it derives `RUNNER_DIR` from its own
171
- location, so the same file works verbatim on every runner.
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 — it is parameterized) and diff
203
- `.env.example` against the live `.env`, then `./svc.sh stop && ./svc.sh
204
- start`. There is no `mandrel sync` equivalent for a runner host's
205
- filesystem — this is an operator-applied step.
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