mandrel-platform 1.0.1 → 1.2.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/package.json +1 -1
- package/scripts/audit-check.mjs +112 -12
- package/scripts/audit-check.test.mjs +195 -0
- package/scripts/check-action-pins.mjs +87 -15
- package/scripts/check-action-pins.test.mjs +103 -4
- package/scripts/check-cancelled-provenance.test.mjs +493 -1
- package/scripts/check-docs-staleness.mjs +15 -2
- package/scripts/check-docs-staleness.test.mjs +114 -9
- package/scripts/check-first-party-pin-freshness.mjs +532 -0
- package/scripts/check-first-party-pin-freshness.test.mjs +489 -0
- package/scripts/job-cleanup-hook.test.mjs +234 -0
- package/scripts/runner-env-drift.test.mjs +554 -0
- package/templates/runbooks/runner-provisioning.md +62 -9
- package/templates/runner/.env.example +8 -2
- package/templates/runner/check-runner-env-drift.sh +248 -0
- package/templates/runner/job-cleanup.sh +49 -20
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* runner-env-drift.test.mjs — node:test suite for the operator-run pool checker
|
|
4
|
+
* shipped at `templates/runner/check-runner-env-drift.sh` (Story #353).
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS CHECKER NEEDS A SUITE AT ALL
|
|
7
|
+
*
|
|
8
|
+
* A runner's `<RUNNER_DIR>/.env` is invisible to every other observer the fleet
|
|
9
|
+
* has. `scripts/check-runner-health.mjs` reaches runners through
|
|
10
|
+
* `GET /repos/{owner}/{repo}/actions/runners`, which reports name, labels and
|
|
11
|
+
* online status — not local configuration. So a half-provisioned pool never
|
|
12
|
+
* presents as a configuration fault; it presents as an unattributable
|
|
13
|
+
* behavioural difference between two runs of the same job (issue #343: two
|
|
14
|
+
* hooked runners sat 5m29s in `Set up runner` while the same job on an unhooked
|
|
15
|
+
* runner finished in 54 seconds). This checker is the only thing that names the
|
|
16
|
+
* odd runners out, so what the suite pins is that it NAMES them — not merely
|
|
17
|
+
* that it noticed drift exists.
|
|
18
|
+
*
|
|
19
|
+
* The three properties worth stating up front, because each has a counterpart
|
|
20
|
+
* failure that would make the tool actively misleading:
|
|
21
|
+
*
|
|
22
|
+
* 1. Drift is a key set on SOME runners but not all. A key absent from EVERY
|
|
23
|
+
* runner is a uniform gap — a fleet that deliberately has not adopted a
|
|
24
|
+
* key must not be a standing alarm, or the operator learns to ignore the
|
|
25
|
+
* exit code.
|
|
26
|
+
* 2. One broken runner must not hide the pool. A directory with no readable
|
|
27
|
+
* `.env` is recorded as all keys unset and the walk continues; an early
|
|
28
|
+
* exit there would silently shrink the sample the drift verdict is
|
|
29
|
+
* computed over.
|
|
30
|
+
* 3. The run is read-only. This is pointed at production runner roots by an
|
|
31
|
+
* operator; it must never be the reason a runner's configuration changed.
|
|
32
|
+
*
|
|
33
|
+
* The suite executes the real script against synthetic pool fixtures, following
|
|
34
|
+
* the `scripts/job-cleanup-hook.test.mjs` precedent for shell-under-test.
|
|
35
|
+
* Fixtures are a handful of directories: every assertion is on report content
|
|
36
|
+
* and exit code, both count-independent, so planting volume would buy no signal
|
|
37
|
+
* (and costs ~17ms/file on a dev Mac whose endpoint security scans every write).
|
|
38
|
+
*
|
|
39
|
+
* INTERPRETER COVERAGE — what each assertion actually proves
|
|
40
|
+
*
|
|
41
|
+
* The fleet is macOS, where `/bin/bash` is 3.2 (Apple cannot ship a GPL3 bash).
|
|
42
|
+
* That version differs from bash 4.4+ in ways no source scan can see, so this
|
|
43
|
+
* suite is explicit about which interpreter it ran under rather than inheriting
|
|
44
|
+
* whatever `bash` PATH happens to resolve:
|
|
45
|
+
*
|
|
46
|
+
* • `resolveBash()` prefers `/bin/bash`, falls back to PATH `bash`, and is
|
|
47
|
+
* overridable with `RUNNER_KIT_BASH`. Its resolved banner is asserted and
|
|
48
|
+
* printed, so a run can never claim 3.2 coverage it did not have.
|
|
49
|
+
* • The bash-4 construct denylist (AC-8) is a SOURCE scan and proves the same
|
|
50
|
+
* thing under any interpreter.
|
|
51
|
+
* • Every other test is a real execution and proves its property only for
|
|
52
|
+
* `BASH.banner`. On the macOS CI job and on a dev Mac that is genuinely
|
|
53
|
+
* 3.2; on ubuntu it is bash 5.
|
|
54
|
+
*
|
|
55
|
+
* The divergence that motivated this (Story #354 audit follow-up): under
|
|
56
|
+
* `set -u`, bash 3.2 aborts on `"${arr[@]}"` when the array is EMPTY, where
|
|
57
|
+
* bash 4.4+ expands to nothing. The checker runs under `set -u` and builds
|
|
58
|
+
* three accumulators, so an unguarded expansion would pass a bash-5-only CI and
|
|
59
|
+
* then fail with `unbound variable` on the fleet at the operator's first real
|
|
60
|
+
* invocation. `ci.yml`'s `runner-kit-bash32` job runs this suite on
|
|
61
|
+
* `macos-latest` against the system 3.2 for exactly that reason; the canary
|
|
62
|
+
* test below asserts the divergence is actually present before the
|
|
63
|
+
* empty-accumulator test claims to have exercised it.
|
|
64
|
+
*
|
|
65
|
+
* Run: node --test scripts/runner-env-drift.test.mjs
|
|
66
|
+
* RUNNER_KIT_BASH=/bin/bash node --test scripts/runner-env-drift.test.mjs
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
import assert from "node:assert/strict";
|
|
70
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
71
|
+
import {
|
|
72
|
+
copyFileSync,
|
|
73
|
+
mkdirSync,
|
|
74
|
+
mkdtempSync,
|
|
75
|
+
readdirSync,
|
|
76
|
+
readFileSync,
|
|
77
|
+
rmSync,
|
|
78
|
+
statSync,
|
|
79
|
+
writeFileSync,
|
|
80
|
+
existsSync,
|
|
81
|
+
} from "node:fs";
|
|
82
|
+
import { tmpdir } from "node:os";
|
|
83
|
+
import { dirname, join } from "node:path";
|
|
84
|
+
import { fileURLToPath } from "node:url";
|
|
85
|
+
import { after, test } from "node:test";
|
|
86
|
+
|
|
87
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
88
|
+
const SCRIPT = join(HERE, "..", "templates", "runner", "check-runner-env-drift.sh");
|
|
89
|
+
const RUNBOOK = join(HERE, "..", "templates", "runbooks", "runner-provisioning.md");
|
|
90
|
+
|
|
91
|
+
/** The four keys `templates/runner/.env.example` mandates. */
|
|
92
|
+
const HOOK = "ACTIONS_RUNNER_HOOK_JOB_STARTED";
|
|
93
|
+
const MANDATED = [HOOK, "RUNNER_TOOL_CACHE", "AGENT_TOOLSDIRECTORY", "LANG"];
|
|
94
|
+
|
|
95
|
+
/** Representative values — the checker reports PRESENCE, never value. */
|
|
96
|
+
const VALUES = {
|
|
97
|
+
ACTIONS_RUNNER_HOOK_JOB_STARTED: "/Users/ci/runners/a/job-cleanup.sh",
|
|
98
|
+
RUNNER_TOOL_CACHE: "/Users/ci/runners/a/_work/_tool",
|
|
99
|
+
AGENT_TOOLSDIRECTORY: "/Users/ci/runners/a/_work/_tool",
|
|
100
|
+
LANG: "en_US.UTF-8",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** Sandboxes created by the suite, torn down in `after`. */
|
|
104
|
+
const SANDBOXES = [];
|
|
105
|
+
|
|
106
|
+
after(() => {
|
|
107
|
+
for (const dir of SANDBOXES) {
|
|
108
|
+
rmSync(dir, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Render an `.env` body setting exactly the named keys, with the comment
|
|
114
|
+
* preamble a real runner `.env` carries.
|
|
115
|
+
*
|
|
116
|
+
* @param {string[]} keys
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
function envWith(keys) {
|
|
120
|
+
const lines = ["# .env — per-runner environment (fixture)", ""];
|
|
121
|
+
for (const key of keys) {
|
|
122
|
+
lines.push(`${key}=${VALUES[key]}`);
|
|
123
|
+
}
|
|
124
|
+
lines.push("");
|
|
125
|
+
return lines.join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Build a synthetic pool root.
|
|
130
|
+
*
|
|
131
|
+
* Each entry maps a child directory name to its spec:
|
|
132
|
+
* `keys` — mandated keys to set in that runner's `.env` (default: all four)
|
|
133
|
+
* `env` — raw `.env` body, overriding `keys`
|
|
134
|
+
* `noEnv` — create no `.env` at all
|
|
135
|
+
* `isRunner` — false to omit `config.sh`, i.e. not a runner directory
|
|
136
|
+
*
|
|
137
|
+
* @param {Record<string, {keys?: string[], env?: string, noEnv?: boolean, isRunner?: boolean}>} spec
|
|
138
|
+
* @returns {string} absolute pool root
|
|
139
|
+
*/
|
|
140
|
+
function makePool(spec) {
|
|
141
|
+
const root = mkdtempSync(join(tmpdir(), "runner-env-drift-"));
|
|
142
|
+
SANDBOXES.push(root);
|
|
143
|
+
|
|
144
|
+
for (const [name, entry] of Object.entries(spec)) {
|
|
145
|
+
const dir = join(root, name);
|
|
146
|
+
mkdirSync(dir, { recursive: true });
|
|
147
|
+
|
|
148
|
+
// `config.sh` is the runner predicate — the runbook mandates one directory
|
|
149
|
+
// per runner under a common root, and this is what keeps unrelated
|
|
150
|
+
// siblings out of the report without inventing a naming convention.
|
|
151
|
+
if (entry.isRunner !== false) {
|
|
152
|
+
writeFileSync(join(dir, "config.sh"), "#!/bin/sh\nexit 0\n");
|
|
153
|
+
}
|
|
154
|
+
if (entry.noEnv) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
writeFileSync(join(dir, ".env"), entry.env ?? envWith(entry.keys ?? MANDATED));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return root;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Resolve the interpreter every execution test runs under, and record WHICH one
|
|
165
|
+
* it is. Bare `bash` from PATH is deliberately not used: it silently varies by
|
|
166
|
+
* host (3.2 on a stock Mac, 5.x on ubuntu), so a suite that inherits it cannot
|
|
167
|
+
* say what its passes prove. `/bin/bash` is preferred because the fleet's shell
|
|
168
|
+
* is the stricter one; `RUNNER_KIT_BASH` overrides for a deliberate cross-check.
|
|
169
|
+
*
|
|
170
|
+
* @returns {{ cmd: string, banner: string, major: number|null }}
|
|
171
|
+
*/
|
|
172
|
+
function resolveBash() {
|
|
173
|
+
const candidates = process.env.RUNNER_KIT_BASH
|
|
174
|
+
? [process.env.RUNNER_KIT_BASH]
|
|
175
|
+
: ["/bin/bash", "bash"];
|
|
176
|
+
for (const cmd of candidates) {
|
|
177
|
+
let banner;
|
|
178
|
+
try {
|
|
179
|
+
banner = execFileSync(cmd, ["--version"], { encoding: "utf8" }).split("\n")[0].trim();
|
|
180
|
+
} catch {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const m = /version (\d+)\./.exec(banner);
|
|
184
|
+
return { cmd, banner, major: m ? Number(m[1]) : null };
|
|
185
|
+
}
|
|
186
|
+
throw new Error(
|
|
187
|
+
`no usable bash interpreter (tried ${candidates.join(", ")}) — this suite executes a shell script`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const BASH = resolveBash();
|
|
192
|
+
|
|
193
|
+
/** True when the resolved interpreter is the fleet's bash 3.x, not 4.4+. */
|
|
194
|
+
const IS_BASH_3X = BASH.major === 3;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Run the checker under the RESOLVED interpreter, capturing both streams.
|
|
198
|
+
*
|
|
199
|
+
* stderr is returned, not discarded: a bash-3.2 `unbound variable` abort writes
|
|
200
|
+
* there and exits non-zero, which would otherwise be indistinguishable from the
|
|
201
|
+
* checker's own deliberate exit 1 (drift) or exit 2 (usage).
|
|
202
|
+
*
|
|
203
|
+
* @param {string[]} args
|
|
204
|
+
* @param {string} [scriptPath] — defaults to the shipped script
|
|
205
|
+
* @returns {{ status: number, stdout: string, stderr: string }}
|
|
206
|
+
*/
|
|
207
|
+
function runChecker(args, scriptPath = SCRIPT) {
|
|
208
|
+
const res = spawnSync(BASH.cmd, [scriptPath, ...args], {
|
|
209
|
+
encoding: "utf8",
|
|
210
|
+
timeout: 60_000,
|
|
211
|
+
});
|
|
212
|
+
return {
|
|
213
|
+
status: res.status ?? 1,
|
|
214
|
+
stdout: res.stdout ?? "",
|
|
215
|
+
stderr: res.stderr ?? "",
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
test("AC-1: the shipped script is executable", () => {
|
|
220
|
+
const mode = statSync(SCRIPT).mode;
|
|
221
|
+
|
|
222
|
+
assert.equal(
|
|
223
|
+
(mode & 0o100) !== 0,
|
|
224
|
+
true,
|
|
225
|
+
"the kit-copy step in the runbook chmods the hook but copies this verbatim — it must ship executable",
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("AC-2: names every runner missing a key that others have, and exits non-zero", () => {
|
|
230
|
+
// The 16-of-19 shape from issue #343, scaled down: the hook is configured on
|
|
231
|
+
// one runner and absent from two.
|
|
232
|
+
const root = makePool({
|
|
233
|
+
"runner-a": { keys: MANDATED },
|
|
234
|
+
"runner-b": { keys: MANDATED.filter((key) => key !== HOOK) },
|
|
235
|
+
"runner-c": { keys: MANDATED.filter((key) => key !== HOOK) },
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const { status, stdout } = runChecker(["--pool-root", root]);
|
|
239
|
+
|
|
240
|
+
assert.notEqual(status, 0, "drift must be reported through the exit code — that is the alert channel");
|
|
241
|
+
assert.match(stdout, /runner-b/, "the partially-provisioned runner must be named, not just counted");
|
|
242
|
+
assert.match(stdout, /runner-c/, "the partially-provisioned runner must be named, not just counted");
|
|
243
|
+
assert.match(stdout, new RegExp(HOOK), "the drifting key must be named");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("AC-3: a fully provisioned pool exits 0", () => {
|
|
247
|
+
const root = makePool({
|
|
248
|
+
"runner-a": { keys: MANDATED },
|
|
249
|
+
"runner-b": { keys: MANDATED },
|
|
250
|
+
"runner-c": { keys: MANDATED },
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const { status } = runChecker(["--pool-root", root]);
|
|
254
|
+
|
|
255
|
+
assert.equal(status, 0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("AC-4: a key absent from every runner is a uniform gap, not an alarm", () => {
|
|
259
|
+
// A fleet that has deliberately not adopted a key must not be a standing
|
|
260
|
+
// non-zero exit, or the operator learns to ignore the alert channel.
|
|
261
|
+
const withoutLang = MANDATED.filter((key) => key !== "LANG");
|
|
262
|
+
const root = makePool({
|
|
263
|
+
"runner-a": { keys: withoutLang },
|
|
264
|
+
"runner-b": { keys: withoutLang },
|
|
265
|
+
"runner-c": { keys: withoutLang },
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const { status, stdout } = runChecker(["--pool-root", root]);
|
|
269
|
+
|
|
270
|
+
assert.equal(status, 0, "a uniform gap is not drift");
|
|
271
|
+
assert.match(
|
|
272
|
+
stdout,
|
|
273
|
+
/LANG: uniformly unset/,
|
|
274
|
+
"the gap must still be visible in the report — silence would hide a fleet-wide miss",
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("AC-5: a runner with no readable .env is recorded as all-unset and the walk continues", () => {
|
|
279
|
+
const root = makePool({
|
|
280
|
+
"runner-a": { keys: MANDATED },
|
|
281
|
+
"runner-broken": { noEnv: true },
|
|
282
|
+
"runner-c": { keys: MANDATED },
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const { status, stdout } = runChecker(["--pool-root", root]);
|
|
286
|
+
|
|
287
|
+
assert.notEqual(status, 0, "a runner missing every mandated key while others have them is drift");
|
|
288
|
+
assert.match(stdout, /runner-a/, "an early exit on the broken runner would hide the rest of the pool");
|
|
289
|
+
assert.match(stdout, /runner-c/, "an early exit on the broken runner would hide the rest of the pool");
|
|
290
|
+
|
|
291
|
+
const brokenLine = stdout.split("\n").find((line) => line.includes("runner-broken"));
|
|
292
|
+
assert.ok(brokenLine, "the unreadable runner must appear in the per-runner report");
|
|
293
|
+
for (const key of MANDATED) {
|
|
294
|
+
assert.match(
|
|
295
|
+
brokenLine,
|
|
296
|
+
new RegExp(key),
|
|
297
|
+
`an unreadable .env must record ${key} as unset, not as unknown or absent from the report`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("AC-6: a child directory without config.sh is not a runner and never appears", () => {
|
|
303
|
+
const root = makePool({
|
|
304
|
+
"runner-a": { keys: MANDATED },
|
|
305
|
+
"runner-b": { keys: MANDATED },
|
|
306
|
+
// A plausible sibling on a real runner host: a shared scratch dir that
|
|
307
|
+
// happens to carry an `.env`. Counting it would fabricate drift.
|
|
308
|
+
"shared-scratch": { isRunner: false, keys: [] },
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const { status, stdout } = runChecker(["--pool-root", root]);
|
|
312
|
+
|
|
313
|
+
assert.equal(status, 0, "a non-runner sibling with no keys must not fabricate drift");
|
|
314
|
+
assert.equal(
|
|
315
|
+
stdout.includes("shared-scratch"),
|
|
316
|
+
false,
|
|
317
|
+
"only directories containing config.sh are runners",
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("AC-7: the pool root defaults to the parent of the script's own directory", () => {
|
|
322
|
+
// The kit installs the checker into <RUNNER_DIR>, so its own parent IS the
|
|
323
|
+
// pool root — an operator can run it with no arguments from any runner root.
|
|
324
|
+
const root = makePool({
|
|
325
|
+
"runner-a": { keys: MANDATED },
|
|
326
|
+
"runner-b": { keys: MANDATED.filter((key) => key !== HOOK) },
|
|
327
|
+
});
|
|
328
|
+
const installed = join(root, "runner-a", "check-runner-env-drift.sh");
|
|
329
|
+
copyFileSync(SCRIPT, installed);
|
|
330
|
+
|
|
331
|
+
const { status, stdout } = runChecker([], installed);
|
|
332
|
+
|
|
333
|
+
assert.notEqual(status, 0);
|
|
334
|
+
assert.match(stdout, /runner-a/);
|
|
335
|
+
assert.match(stdout, /runner-b/, "the default pool root must reach sibling runners, not just its own");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("AC-7: --pool-root overrides the default", () => {
|
|
339
|
+
const root = makePool({
|
|
340
|
+
"runner-a": { keys: MANDATED },
|
|
341
|
+
"runner-b": { keys: MANDATED.filter((key) => key !== HOOK) },
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const { status, stdout } = runChecker(["--pool-root", root]);
|
|
345
|
+
|
|
346
|
+
assert.notEqual(status, 0);
|
|
347
|
+
assert.match(stdout, new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), "the report names the pool it walked");
|
|
348
|
+
assert.match(stdout, /runner-b/);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("AC-8: the script uses no bash-4-only construct (source scan — interpreter-independent)", () => {
|
|
352
|
+
// These are the bash-4 constructs a maintainer reaches for first when
|
|
353
|
+
// accumulating per-runner state, and each fails with a SYNTAX error rather
|
|
354
|
+
// than a wrong answer — i.e. the operator's first real invocation is where
|
|
355
|
+
// they would find out. A source scan is the right shape for this class
|
|
356
|
+
// precisely because it does not depend on which bash ran the suite.
|
|
357
|
+
//
|
|
358
|
+
// It is NOT sufficient on its own: the runtime divergences below are
|
|
359
|
+
// invisible to any denylist. See the two tests that follow.
|
|
360
|
+
const source = readFileSync(SCRIPT, "utf8");
|
|
361
|
+
const code = source
|
|
362
|
+
.split("\n")
|
|
363
|
+
.filter((line) => !/^\s*#/.test(line))
|
|
364
|
+
.join("\n");
|
|
365
|
+
|
|
366
|
+
assert.equal(/declare\s+-A/.test(code), false, "associative arrays are bash 4+");
|
|
367
|
+
assert.equal(/\bmapfile\b/.test(code), false, "mapfile is bash 4+");
|
|
368
|
+
assert.equal(/\breadarray\b/.test(code), false, "readarray is bash 4+");
|
|
369
|
+
assert.equal(/\$\{[A-Za-z_][A-Za-z0-9_]*,,\}/.test(code), false, "case-conversion expansion is bash 4+");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("the suite reports which interpreter its execution tests actually prove", () => {
|
|
373
|
+
// A passing suite must never be readable as "bash 3.2 verified" when it ran
|
|
374
|
+
// under bash 5. This test does not gate on the version — ubuntu CI legitimately
|
|
375
|
+
// has only bash 5 — it gates on the resolution being KNOWN and reported.
|
|
376
|
+
assert.match(BASH.banner, /GNU bash, version \d+\./, "could not identify the interpreter");
|
|
377
|
+
assert.notEqual(BASH.major, null, "interpreter major version is unparseable");
|
|
378
|
+
console.log(
|
|
379
|
+
` ℹ execution tests ran under: ${BASH.cmd} — ${BASH.banner}` +
|
|
380
|
+
(IS_BASH_3X
|
|
381
|
+
? " [fleet-equivalent bash 3.x]"
|
|
382
|
+
: " [NOT the fleet's 3.x — 3.2-only regressions cannot surface in this run]"),
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("canary: the bash-3.2 empty-array divergence is real on a 3.x interpreter", (t) => {
|
|
387
|
+
// The empty-accumulator test below is only meaningful if the interpreter it
|
|
388
|
+
// runs under actually exhibits the hazard. Assert the divergence directly, so
|
|
389
|
+
// the guard can never be "passing" against a bash that would accept an
|
|
390
|
+
// unguarded expansion anyway.
|
|
391
|
+
if (!IS_BASH_3X) {
|
|
392
|
+
t.skip(`interpreter is bash ${BASH.major}.x — 4.4+ expands an empty "\${arr[@]}" to nothing by design`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const res = spawnSync(BASH.cmd, ["-c", 'set -u; arr=(); for x in "${arr[@]}"; do :; done'], {
|
|
396
|
+
encoding: "utf8",
|
|
397
|
+
});
|
|
398
|
+
assert.notEqual(res.status, 0, "expected bash 3.x to abort on an empty array under `set -u`");
|
|
399
|
+
assert.match(res.stderr, /unbound variable/);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("no reachable path expands a possibly-empty accumulator under `set -u`", () => {
|
|
403
|
+
// The checker runs under `set -u` and builds three accumulators —
|
|
404
|
+
// `runner_names`, `unset_keys`, `unset_names`. Each is expanded only behind a
|
|
405
|
+
// non-empty guard today. This drives every scenario that empties one of them
|
|
406
|
+
// and asserts the script never aborts on an unbound expansion, so a later
|
|
407
|
+
// edit that drops a guard is caught rather than shipped.
|
|
408
|
+
//
|
|
409
|
+
// Under bash 3.x this is a real regression gate. Under 4.4+ it still checks
|
|
410
|
+
// exit codes and output, but the divergence itself cannot fire — which is
|
|
411
|
+
// what the macOS `runner-kit-bash32` CI job exists to cover.
|
|
412
|
+
const scenarios = [
|
|
413
|
+
{
|
|
414
|
+
what: "unset_keys and unset_names both empty (every key set on every runner)",
|
|
415
|
+
pool: makePool({ "runner-a": { keys: MANDATED }, "runner-b": { keys: MANDATED } }),
|
|
416
|
+
expect: 0,
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
what: "unset_names empty for the uniformly-set keys, non-empty for the drifting one",
|
|
420
|
+
pool: makePool({
|
|
421
|
+
"runner-a": { keys: MANDATED },
|
|
422
|
+
"runner-b": { keys: MANDATED.filter((k) => k !== HOOK) },
|
|
423
|
+
}),
|
|
424
|
+
expect: 1,
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
what: "every accumulator empty (a key set on no runner at all)",
|
|
428
|
+
pool: makePool({ "runner-a": { keys: [] }, "runner-b": { keys: [] } }),
|
|
429
|
+
expect: 0,
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
what: "runner_names empty (pool root holds no runner directories)",
|
|
433
|
+
pool: makePool({ "not-a-runner": { isRunner: false } }),
|
|
434
|
+
expect: 2,
|
|
435
|
+
},
|
|
436
|
+
];
|
|
437
|
+
|
|
438
|
+
for (const { what, pool, expect } of scenarios) {
|
|
439
|
+
const res = runChecker(["--pool-root", pool]);
|
|
440
|
+
assert.doesNotMatch(
|
|
441
|
+
res.stderr,
|
|
442
|
+
/unbound variable/,
|
|
443
|
+
`aborted on an unguarded empty-array expansion — ${what}`,
|
|
444
|
+
);
|
|
445
|
+
assert.equal(res.status, expect, `unexpected exit for: ${what}\nstderr: ${res.stderr}`);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("AC-9: the run is read-only against the pool", () => {
|
|
450
|
+
const root = makePool({
|
|
451
|
+
"runner-a": { keys: MANDATED },
|
|
452
|
+
"runner-b": { keys: MANDATED.filter((key) => key !== HOOK) },
|
|
453
|
+
"runner-broken": { noEnv: true },
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const snapshot = () => {
|
|
457
|
+
const state = {};
|
|
458
|
+
for (const name of readdirSync(root).sort()) {
|
|
459
|
+
state[name] = readdirSync(join(root, name)).sort();
|
|
460
|
+
const envPath = join(root, name, ".env");
|
|
461
|
+
state[`${name}/.env`] = existsSync(envPath) ? readFileSync(envPath, "utf8") : null;
|
|
462
|
+
}
|
|
463
|
+
return state;
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const before = snapshot();
|
|
467
|
+
runChecker(["--pool-root", root]);
|
|
468
|
+
|
|
469
|
+
assert.deepEqual(
|
|
470
|
+
snapshot(),
|
|
471
|
+
before,
|
|
472
|
+
"the checker wrote into a runner root — it is pointed at production runners and must only read",
|
|
473
|
+
);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("a commented-out assignment does not count as set", () => {
|
|
477
|
+
// `.env.example` ships every key inside a block of explanatory comments, so a
|
|
478
|
+
// half-applied copy where the operator never uncommented a line is the most
|
|
479
|
+
// likely real drift shape. Matching a key name anywhere in the file would
|
|
480
|
+
// report that runner as provisioned.
|
|
481
|
+
const root = makePool({
|
|
482
|
+
"runner-a": { keys: MANDATED },
|
|
483
|
+
"runner-b": {
|
|
484
|
+
env: `# ${HOOK}=/Users/ci/runners/b/job-cleanup.sh\nRUNNER_TOOL_CACHE=${VALUES.RUNNER_TOOL_CACHE}\nAGENT_TOOLSDIRECTORY=${VALUES.AGENT_TOOLSDIRECTORY}\nLANG=${VALUES.LANG}\n`,
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
const { status, stdout } = runChecker(["--pool-root", root]);
|
|
489
|
+
|
|
490
|
+
assert.notEqual(status, 0);
|
|
491
|
+
assert.match(stdout, /runner-b/);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("a leading-whitespace assignment counts as set", () => {
|
|
495
|
+
const root = makePool({
|
|
496
|
+
"runner-a": { keys: MANDATED },
|
|
497
|
+
"runner-b": {
|
|
498
|
+
env: ` ${HOOK}=/Users/ci/runners/b/job-cleanup.sh\n\tRUNNER_TOOL_CACHE=${VALUES.RUNNER_TOOL_CACHE}\nAGENT_TOOLSDIRECTORY=${VALUES.AGENT_TOOLSDIRECTORY}\nLANG=${VALUES.LANG}\n`,
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const { status } = runChecker(["--pool-root", root]);
|
|
503
|
+
|
|
504
|
+
assert.equal(status, 0, "indentation is not a configuration difference");
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("a longer key that merely starts with a mandated key does not count as set", () => {
|
|
508
|
+
// `LANGUAGE=` must not satisfy `LANG`. A prefix match here would report a
|
|
509
|
+
// runner as provisioned on the strength of an unrelated variable.
|
|
510
|
+
const root = makePool({
|
|
511
|
+
"runner-a": { keys: MANDATED },
|
|
512
|
+
"runner-b": {
|
|
513
|
+
env: `${HOOK}=/Users/ci/runners/b/job-cleanup.sh\nRUNNER_TOOL_CACHE=${VALUES.RUNNER_TOOL_CACHE}\nAGENT_TOOLSDIRECTORY=${VALUES.AGENT_TOOLSDIRECTORY}\nLANGUAGE=en_US\n`,
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
const { status, stdout } = runChecker(["--pool-root", root]);
|
|
518
|
+
|
|
519
|
+
assert.notEqual(status, 0);
|
|
520
|
+
assert.match(stdout, /runner-b/);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("a pool root holding no runner directories is a usage error, not a clean pool", () => {
|
|
524
|
+
// Reporting "no drift" over an empty walk is the worst possible answer: the
|
|
525
|
+
// operator reads a green exit as evidence the fleet is uniform.
|
|
526
|
+
const root = makePool({ "shared-scratch": { isRunner: false, noEnv: true } });
|
|
527
|
+
|
|
528
|
+
const { status } = runChecker(["--pool-root", root]);
|
|
529
|
+
|
|
530
|
+
assert.equal(status, 2, "a pool root with no runners must be distinguishable from a clean pool");
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("an unknown flag is rejected rather than silently ignored", () => {
|
|
534
|
+
const root = makePool({ "runner-a": { keys: MANDATED } });
|
|
535
|
+
|
|
536
|
+
const { status } = runChecker(["--pool-root", root, "--fix"]);
|
|
537
|
+
|
|
538
|
+
assert.equal(status, 2, "this tool never repairs — a flag it does not implement must not appear to work");
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("AC-10: the runbook installs and invokes the checker alongside the hook", () => {
|
|
542
|
+
// `assert.ok` rather than `assert.match`: a failing `match` prints the whole
|
|
543
|
+
// runbook as the actual value, which buries the one line that is wrong.
|
|
544
|
+
const runbook = readFileSync(RUNBOOK, "utf8");
|
|
545
|
+
|
|
546
|
+
assert.ok(
|
|
547
|
+
/cp .*templates\/runner\/check-runner-env-drift\.sh/.test(runbook),
|
|
548
|
+
"an operator following the runbook must end up with the checker on the host",
|
|
549
|
+
);
|
|
550
|
+
assert.ok(
|
|
551
|
+
/\.\/check-runner-env-drift\.sh/.test(runbook),
|
|
552
|
+
"the runbook must show how to invoke it, not just how to copy it",
|
|
553
|
+
);
|
|
554
|
+
});
|
|
@@ -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
|
|
|
@@ -150,16 +152,65 @@ with the runner root's absolute path. The resulting file wires:
|
|
|
150
152
|
|
|
151
153
|
- `ACTIONS_RUNNER_HOOK_JOB_STARTED=<RUNNER_DIR>/job-cleanup.sh` — the
|
|
152
154
|
job-start hook. It reaps orphaned pnpm/node processes parented to **this**
|
|
153
|
-
runner's work tree, clears stale runner-scoped pnpm installs, and
|
|
154
|
-
|
|
155
|
-
(always exits 0) and never touches another runner's state.
|
|
155
|
+
runner's work tree, clears stale runner-scoped pnpm installs, and removes
|
|
156
|
+
leftover tool-download dirs from `<RUNNER_DIR>/_work/_temp`. It never fails
|
|
157
|
+
a job (always exits 0) and never touches another runner's state.
|
|
158
|
+
|
|
159
|
+
**Every path it reads is runner-scoped, and that is load-bearing** (issue
|
|
160
|
+
#343). The hook runs inside the *job's* clock, so its cost is charged to
|
|
161
|
+
`Set up runner` and counts against the job's own `timeout-minutes`. An
|
|
162
|
+
earlier version swept the host-shared OS temp root; on a host where that
|
|
163
|
+
directory had grown to ~840k entries, `Set up runner` reached 5m29s and
|
|
164
|
+
jobs were killed before their first real step — surfacing as `cancelled`
|
|
165
|
+
on unrelated diffs. If you add a sweep to this hook, root it at
|
|
166
|
+
`_work/_temp`, never at `$TMPDIR`.
|
|
156
167
|
- `RUNNER_TOOL_CACHE=<RUNNER_DIR>/_work/_tool` and
|
|
157
168
|
`AGENT_TOOLSDIRECTORY=<RUNNER_DIR>/_work/_tool` — runner-scoped tool cache
|
|
158
169
|
(two env names, one dir; some actions read the legacy name).
|
|
159
170
|
- `LANG=en_US.UTF-8`.
|
|
160
171
|
|
|
161
|
-
|
|
162
|
-
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.
|
|
163
214
|
|
|
164
215
|
## 5. Install as a launchd service (`svc.sh`)
|
|
165
216
|
|
|
@@ -190,10 +241,12 @@ The runner loads `.env` at service start — after any `.env` change, restart:
|
|
|
190
241
|
`svc.sh`.
|
|
191
242
|
- **Kit updates.** The hook and `.env.example` are versioned in
|
|
192
243
|
mandrel-platform. On a platform release that touches `templates/runner/`,
|
|
193
|
-
re-copy `job-cleanup.sh` (verbatim —
|
|
194
|
-
`.env.example` against the live `.env`,
|
|
195
|
-
start`. There is no `mandrel sync`
|
|
196
|
-
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.
|
|
197
250
|
- **Token/registration rotation.** Registration tokens are one-shot at
|
|
198
251
|
config time; nothing persists to rotate. To move a runner between repos or
|
|
199
252
|
rename it: `./svc.sh stop && ./svc.sh uninstall && ./config.sh remove
|
|
@@ -19,8 +19,14 @@ LANG=en_US.UTF-8
|
|
|
19
19
|
|
|
20
20
|
# Job-start hygiene hook. Runs templates/runner/job-cleanup.sh (installed
|
|
21
21
|
# into the runner root) before every job: reaps orphaned pnpm/node processes
|
|
22
|
-
# from THIS runner's work tree and clears stale install/temp artifacts
|
|
23
|
-
# The hook is runner-scoped and never fails the job (always
|
|
22
|
+
# from THIS runner's work tree and clears stale install/temp artifacts from
|
|
23
|
+
# `_work/_temp`. The hook is runner-scoped and never fails the job (always
|
|
24
|
+
# exits 0).
|
|
25
|
+
#
|
|
26
|
+
# Runner-scoped means CHEAP as well as safe: the hook runs inside the job's
|
|
27
|
+
# clock, so anything it reads is billed to `Set up runner` and counts against
|
|
28
|
+
# the job's `timeout-minutes`. It touches only this runner's own paths, so its
|
|
29
|
+
# cost never becomes a function of host-wide temp churn (issue #343).
|
|
24
30
|
ACTIONS_RUNNER_HOOK_JOB_STARTED=<RUNNER_DIR>/job-cleanup.sh
|
|
25
31
|
|
|
26
32
|
# Runner-scoped tool cache. Without this, actions/setup-node & friends
|