mandrel-platform 1.13.2 → 1.14.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/check-checkout-clean-excludes.test.mjs +300 -0
- package/scripts/job-completed-hook.test.mjs +398 -0
- package/scripts/release-asset-download-flags.test.mjs +333 -0
- package/scripts/runner-env-drift.test.mjs +55 -6
- package/templates/runbooks/runner-provisioning.md +50 -11
- package/templates/runner/.env.example +15 -0
- package/templates/runner/check-runner-env-drift.sh +15 -7
- package/templates/runner/job-completed.sh +224 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* job-completed-hook.test.mjs — node:test suite for the
|
|
4
|
+
* ACTIONS_RUNNER_HOOK_JOB_COMPLETED hook shipped at
|
|
5
|
+
* `templates/runner/job-completed.sh` (Story #524).
|
|
6
|
+
*
|
|
7
|
+
* WHAT THE HOOK IS FOR
|
|
8
|
+
*
|
|
9
|
+
* On a persistent self-hosted runner, a job's process tree can outlive the
|
|
10
|
+
* job — most reliably when the job is CANCELLED, because the runner
|
|
11
|
+
* terminates the step it is executing, not everything that step forked. The
|
|
12
|
+
* job-started hook (`job-cleanup.sh`) reaps a previous job's orphans before
|
|
13
|
+
* the next job begins, which cannot help a job that is already minutes in:
|
|
14
|
+
* consumer run 34854313590 saw a `Unit` job die of an unexplained SIGTERM two
|
|
15
|
+
* minutes into a 32-minute budget on a runner that had hosted a cancelled job
|
|
16
|
+
* four minutes earlier. This hook closes that gap from the other end.
|
|
17
|
+
*
|
|
18
|
+
* WHAT THIS SUITE PINS, AND WHY EACH HALF IS NECESSARY
|
|
19
|
+
*
|
|
20
|
+
* 1. It reaps — including the leaves. A job's `sleep`/worker fork carries no
|
|
21
|
+
* runner path in its own argv, so a hook that matched only on the path
|
|
22
|
+
* would leave exactly the long-lived children that cause the incident.
|
|
23
|
+
* The in-tree fixture therefore spawns a grandchild whose command line
|
|
24
|
+
* names nothing runner-scoped.
|
|
25
|
+
* 2. It reaps NOTHING ELSE. Three controls stand in for the three ways a
|
|
26
|
+
* too-broad reap takes down a co-resident runner: a sibling runner's job,
|
|
27
|
+
* a process outside any runner tree, and a `$HOME`-shared
|
|
28
|
+
* `setup-pnpm` process (the shape issue #343 warned about). A fourth
|
|
29
|
+
* control is a process named `Runner.Worker` sitting INSIDE this runner's
|
|
30
|
+
* own work tree — it matches the seed pattern and must still survive,
|
|
31
|
+
* because signalling it takes the runner offline.
|
|
32
|
+
* 3. It cannot kill itself. The hook is executed from a copy INSIDE the
|
|
33
|
+
* runner's `_work` tree, so its own command line matches the seed
|
|
34
|
+
* pattern. Without the self/ancestor guard it would signal itself, and
|
|
35
|
+
* the job would see a hook that died rather than exited 0.
|
|
36
|
+
* 4. Idle is free. The invariant is that the hook finishes in well under a
|
|
37
|
+
* second when there is nothing to reap. That is asserted as wall clock
|
|
38
|
+
* *because the regression it guards against is temporal*: a grace period
|
|
39
|
+
* slept unconditionally rather than polled would put the hook's full
|
|
40
|
+
* 3-second budget on the job's clock on every job of every runner. The
|
|
41
|
+
* threshold sits far below that budget and far above the ~100ms a bash
|
|
42
|
+
* start plus two `ps` calls costs, so it separates the two without
|
|
43
|
+
* timing anything finer.
|
|
44
|
+
*
|
|
45
|
+
* The suite executes the real script, following the
|
|
46
|
+
* `scripts/job-cleanup-hook.test.mjs` precedent for shell-under-test, and
|
|
47
|
+
* resolves its interpreter the way `scripts/runner-env-drift.test.mjs` does:
|
|
48
|
+
* the fleet is macOS, where `/bin/bash` is 3.2, and a suite that inherits a
|
|
49
|
+
* PATH `bash` cannot say what its passes prove.
|
|
50
|
+
*
|
|
51
|
+
* Run: node --test scripts/job-completed-hook.test.mjs
|
|
52
|
+
* RUNNER_KIT_BASH=/bin/bash node --test scripts/job-completed-hook.test.mjs
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import assert from "node:assert/strict";
|
|
56
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
57
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
58
|
+
import { tmpdir } from "node:os";
|
|
59
|
+
import { dirname, join } from "node:path";
|
|
60
|
+
import { fileURLToPath } from "node:url";
|
|
61
|
+
import { after, test } from "node:test";
|
|
62
|
+
|
|
63
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
64
|
+
const SCRIPT = join(HERE, "..", "templates", "runner", "job-completed.sh");
|
|
65
|
+
const ENV_EXAMPLE = join(HERE, "..", "templates", "runner", ".env.example");
|
|
66
|
+
const RUNBOOK = join(HERE, "..", "templates", "runbooks", "runner-provisioning.md");
|
|
67
|
+
|
|
68
|
+
/** Sandboxes created by the suite, torn down in `after`. */
|
|
69
|
+
const SANDBOXES = [];
|
|
70
|
+
/** Every process the suite started, so a failed assertion never leaks one. */
|
|
71
|
+
const SPAWNED = [];
|
|
72
|
+
|
|
73
|
+
after(() => {
|
|
74
|
+
for (const pid of SPAWNED) {
|
|
75
|
+
try {
|
|
76
|
+
process.kill(pid, "SIGKILL");
|
|
77
|
+
} catch {
|
|
78
|
+
/* already gone — the point of most of these tests */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const dir of SANDBOXES) {
|
|
82
|
+
rmSync(dir, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve the interpreter the execution tests run under. `/bin/bash` is
|
|
88
|
+
* preferred because the fleet's shell is the stricter 3.2; `RUNNER_KIT_BASH`
|
|
89
|
+
* overrides for a deliberate cross-check (the `runner-kit-bash32` CI job).
|
|
90
|
+
*
|
|
91
|
+
* @returns {{ cmd: string, banner: string }}
|
|
92
|
+
*/
|
|
93
|
+
function resolveBash() {
|
|
94
|
+
const candidates = process.env.RUNNER_KIT_BASH ? [process.env.RUNNER_KIT_BASH] : ["/bin/bash", "bash"];
|
|
95
|
+
for (const cmd of candidates) {
|
|
96
|
+
try {
|
|
97
|
+
const banner = execFileSync(cmd, ["--version"], { encoding: "utf8" }).split("\n")[0].trim();
|
|
98
|
+
return { cmd, banner };
|
|
99
|
+
} catch {
|
|
100
|
+
/* try the next candidate */
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`no usable bash interpreter (tried ${candidates.join(", ")}) — this suite executes a shell script`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const BASH = resolveBash();
|
|
107
|
+
|
|
108
|
+
/** A fixture that forks a child whose OWN argv names nothing runner-scoped. */
|
|
109
|
+
const TREE_FIXTURE = ['#!/usr/bin/env bash', 'sleep 300 &', 'printf "%s\\n" "$!" > "$1"', "wait", ""].join("\n");
|
|
110
|
+
|
|
111
|
+
/** A fixture that ignores SIGTERM, so only the SIGKILL escalation ends it. */
|
|
112
|
+
const STUBBORN_FIXTURE = [
|
|
113
|
+
"process.on('SIGTERM', () => {});",
|
|
114
|
+
"setTimeout(() => {}, 300_000);",
|
|
115
|
+
"",
|
|
116
|
+
].join("\n");
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build a sandbox holding this runner, a co-resident runner, an out-of-tree
|
|
120
|
+
* directory and a `$HOME`-shared pnpm shim location.
|
|
121
|
+
*
|
|
122
|
+
* @returns {{ root: string, runnerDir: string, runnerWork: string }}
|
|
123
|
+
*/
|
|
124
|
+
function makeSandbox() {
|
|
125
|
+
const root = mkdtempSync(join(tmpdir(), "job-completed-test-"));
|
|
126
|
+
SANDBOXES.push(root);
|
|
127
|
+
|
|
128
|
+
const runnerDir = join(root, "runner-a");
|
|
129
|
+
const runnerWork = join(runnerDir, "_work");
|
|
130
|
+
for (const dir of [
|
|
131
|
+
join(runnerWork, "repo"),
|
|
132
|
+
join(runnerWork, "_temp"),
|
|
133
|
+
join(root, "runner-b", "_work", "repo"),
|
|
134
|
+
join(root, "outside"),
|
|
135
|
+
join(root, "home", "setup-pnpm", "node_modules"),
|
|
136
|
+
]) {
|
|
137
|
+
mkdirSync(dir, { recursive: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { root, runnerDir, runnerWork };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Write a fixture script and start it, returning its pid plus a promise that
|
|
145
|
+
* settles when the process actually exits.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} scriptPath — where to write the fixture
|
|
148
|
+
* @param {{ body?: string, node?: boolean, pidFile?: string }} [opts]
|
|
149
|
+
* @returns {{ pid: number, exited: Promise<void> }}
|
|
150
|
+
*/
|
|
151
|
+
function startFixture(scriptPath, { body = TREE_FIXTURE, node = false, pidFile } = {}) {
|
|
152
|
+
writeFileSync(scriptPath, body);
|
|
153
|
+
const child = node
|
|
154
|
+
? spawn(process.execPath, [scriptPath], { stdio: "ignore" })
|
|
155
|
+
: spawn(BASH.cmd, [scriptPath, pidFile ?? `${scriptPath}.pid`], { stdio: "ignore" });
|
|
156
|
+
SPAWNED.push(child.pid);
|
|
157
|
+
return {
|
|
158
|
+
pid: child.pid,
|
|
159
|
+
exited: new Promise((resolve) => child.once("exit", () => resolve())),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Poll until a predicate holds or the budget expires.
|
|
165
|
+
*
|
|
166
|
+
* @param {() => boolean} predicate
|
|
167
|
+
* @param {number} [budgetMs]
|
|
168
|
+
* @returns {Promise<boolean>}
|
|
169
|
+
*/
|
|
170
|
+
async function waitFor(predicate, budgetMs = 5000) {
|
|
171
|
+
const deadline = Date.now() + budgetMs;
|
|
172
|
+
while (Date.now() < deadline) {
|
|
173
|
+
if (predicate()) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
177
|
+
}
|
|
178
|
+
return predicate();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* True while a pid exists. A zombie still answers signal 0, so this is only
|
|
183
|
+
* ever used for processes the suite did not parent (grandchildren, reaped by
|
|
184
|
+
* init) or alongside a child's own `exit` event.
|
|
185
|
+
*
|
|
186
|
+
* @param {number} pid
|
|
187
|
+
* @returns {boolean}
|
|
188
|
+
*/
|
|
189
|
+
function isAlive(pid) {
|
|
190
|
+
try {
|
|
191
|
+
process.kill(pid, 0);
|
|
192
|
+
return true;
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Read the pid a tree fixture recorded for its forked grandchild. */
|
|
199
|
+
async function readGrandchildPid(pidFile) {
|
|
200
|
+
await waitFor(() => existsSync(pidFile));
|
|
201
|
+
const pid = Number.parseInt(readFileSync(pidFile, "utf8").trim(), 10);
|
|
202
|
+
assert.ok(Number.isInteger(pid) && pid > 0, `fixture never recorded its child pid at ${pidFile}`);
|
|
203
|
+
SPAWNED.push(pid);
|
|
204
|
+
return pid;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Run the hook against a runner root.
|
|
209
|
+
*
|
|
210
|
+
* @param {{ runnerDir: string, scriptPath?: string }} opts
|
|
211
|
+
* @returns {{ status: number|null, signal: string|null, stdout: string, stderr: string, elapsedMs: number }}
|
|
212
|
+
*/
|
|
213
|
+
function runHook({ runnerDir, scriptPath = SCRIPT }) {
|
|
214
|
+
const started = Date.now();
|
|
215
|
+
const res = spawnSync(BASH.cmd, [scriptPath], {
|
|
216
|
+
encoding: "utf8",
|
|
217
|
+
env: { ...process.env, RUNNER_DIR: runnerDir },
|
|
218
|
+
timeout: 60_000,
|
|
219
|
+
});
|
|
220
|
+
return {
|
|
221
|
+
status: res.status,
|
|
222
|
+
signal: res.signal,
|
|
223
|
+
stdout: res.stdout ?? "",
|
|
224
|
+
stderr: res.stderr ?? "",
|
|
225
|
+
elapsedMs: Date.now() - started,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
test("AC-1: the shipped hook is executable", () => {
|
|
230
|
+
// The runbook's kit-copy step chmods it, but a template that ships 100644 is
|
|
231
|
+
// a live defect class in this repo (scripts/check-husky-hook-modes.test.mjs)
|
|
232
|
+
// and an operator who copies with `cp -p` inherits the bad mode.
|
|
233
|
+
assert.equal((statSync(SCRIPT).mode & 0o100) !== 0, true, "templates/runner/job-completed.sh must ship executable");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("AC-1: reaps this job's surviving tree, including a child whose argv names no runner path", async () => {
|
|
237
|
+
const { runnerWork, runnerDir } = makeSandbox();
|
|
238
|
+
const fixture = join(runnerWork, "repo", "job-tree.sh");
|
|
239
|
+
const pidFile = join(runnerWork, "repo", "child.pid");
|
|
240
|
+
const parent = startFixture(fixture, { pidFile });
|
|
241
|
+
const grandchild = await readGrandchildPid(pidFile);
|
|
242
|
+
|
|
243
|
+
const { status, signal, stdout } = runHook({ runnerDir });
|
|
244
|
+
|
|
245
|
+
assert.equal(status, 0, `the hook must exit 0 (signal=${signal}) — it must never fail a job`);
|
|
246
|
+
await parent.exited;
|
|
247
|
+
assert.equal(
|
|
248
|
+
await waitFor(() => !isAlive(grandchild)),
|
|
249
|
+
true,
|
|
250
|
+
"the forked grandchild survived — matching only on the runner path leaves the leaves of the tree behind",
|
|
251
|
+
);
|
|
252
|
+
assert.match(stdout, /reaping processes that outlived this job/, "a reap must be attributable in the job log");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("AC-4: leaves a co-resident runner, an out-of-tree process and a $HOME-shared process alone", async () => {
|
|
256
|
+
const { root, runnerDir, runnerWork } = makeSandbox();
|
|
257
|
+
|
|
258
|
+
// The victim: without one, an inert hook would pass this test vacuously.
|
|
259
|
+
const victimPidFile = join(runnerWork, "repo", "victim.pid");
|
|
260
|
+
const victim = startFixture(join(runnerWork, "repo", "victim.sh"), { pidFile: victimPidFile });
|
|
261
|
+
const victimChild = await readGrandchildPid(victimPidFile);
|
|
262
|
+
|
|
263
|
+
const controls = {
|
|
264
|
+
"co-resident runner": startFixture(join(root, "runner-b", "_work", "repo", "sibling.sh")),
|
|
265
|
+
"out of any runner tree": startFixture(join(root, "outside", "unrelated.sh")),
|
|
266
|
+
// `~/setup-pnpm` is pnpm/action-setup's DEFAULT dest and is shared by every
|
|
267
|
+
// runner on the host — the exact process a pattern kill would destroy
|
|
268
|
+
// mid-install on a concurrent runner (issue #343).
|
|
269
|
+
"$HOME-shared setup-pnpm": startFixture(join(root, "home", "setup-pnpm", "node_modules", "pnpm.sh")),
|
|
270
|
+
// Inside THIS runner's work tree, so it matches the seed pattern — and
|
|
271
|
+
// must still survive, because signalling the runner ends the job's own
|
|
272
|
+
// bookkeeping.
|
|
273
|
+
"the runner's own Runner.Worker": startFixture(join(runnerWork, "_temp", "Runner.Worker")),
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const { status } = runHook({ runnerDir });
|
|
277
|
+
assert.equal(status, 0);
|
|
278
|
+
|
|
279
|
+
await victim.exited;
|
|
280
|
+
assert.equal(await waitFor(() => !isAlive(victimChild)), true, "the in-tree victim was not reaped");
|
|
281
|
+
|
|
282
|
+
for (const [what, control] of Object.entries(controls)) {
|
|
283
|
+
assert.equal(isAlive(control.pid), true, `the hook killed a process it must never touch: ${what}`);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("AC-1: exits 0 and returns fast when nothing survived the job", () => {
|
|
288
|
+
const { runnerDir } = makeSandbox();
|
|
289
|
+
|
|
290
|
+
const { status, stdout, elapsedMs } = runHook({ runnerDir });
|
|
291
|
+
|
|
292
|
+
assert.equal(status, 0);
|
|
293
|
+
assert.match(stdout, /nothing to reap/, "the no-op case must still say so in the job log");
|
|
294
|
+
assert.ok(
|
|
295
|
+
elapsedMs < 1500,
|
|
296
|
+
`an idle run took ${elapsedMs}ms — it runs on the job's clock, so the grace period must be polled, never slept unconditionally`,
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("escalates to SIGKILL when the tree ignores SIGTERM", async () => {
|
|
301
|
+
const { runnerWork, runnerDir } = makeSandbox();
|
|
302
|
+
const stubborn = startFixture(join(runnerWork, "repo", "stubborn.mjs"), { body: STUBBORN_FIXTURE, node: true });
|
|
303
|
+
// Let node install its SIGTERM handler before the hook fires, or the test
|
|
304
|
+
// would pass through the SIGTERM path and prove nothing about escalation.
|
|
305
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
306
|
+
|
|
307
|
+
const { status, stdout } = runHook({ runnerDir });
|
|
308
|
+
|
|
309
|
+
assert.equal(status, 0);
|
|
310
|
+
await stubborn.exited;
|
|
311
|
+
assert.match(stdout, /escalating to SIGKILL/, "a tree that ignores SIGTERM must be escalated, and the log must say so");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("AC-4: cannot signal itself even when its own command line sits inside the work tree", async () => {
|
|
315
|
+
const { runnerWork, runnerDir } = makeSandbox();
|
|
316
|
+
const pidFile = join(runnerWork, "repo", "bystander.pid");
|
|
317
|
+
const victim = startFixture(join(runnerWork, "repo", "bystander.sh"), { pidFile });
|
|
318
|
+
await readGrandchildPid(pidFile);
|
|
319
|
+
|
|
320
|
+
// Installed INSIDE the work tree, so the hook's own argv matches its seed
|
|
321
|
+
// pattern. The self/ancestor guard is the only thing between that and a hook
|
|
322
|
+
// that SIGKILLs itself mid-run — which the job sees as a hook that failed.
|
|
323
|
+
const installed = join(runnerWork, "hook-copy-job-completed.sh");
|
|
324
|
+
mkdirSync(dirname(installed), { recursive: true });
|
|
325
|
+
copyFileSync(SCRIPT, installed);
|
|
326
|
+
|
|
327
|
+
const { status, signal, stdout } = runHook({ runnerDir, scriptPath: installed });
|
|
328
|
+
|
|
329
|
+
assert.equal(signal, null, "the hook signalled itself");
|
|
330
|
+
assert.equal(status, 0, "the hook must exit 0 even when its own path matches the reap pattern");
|
|
331
|
+
await victim.exited;
|
|
332
|
+
assert.match(stdout, /reaping processes that outlived this job/, "self-protection must not disable the reap");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("AC-4: the header states the concurrency-safety constraints it inherits", () => {
|
|
336
|
+
// AC-4 requires the constraints to be STATED, not only honoured: the next
|
|
337
|
+
// maintainer's reason not to reach for `pkill -f ~/setup-pnpm` lives here,
|
|
338
|
+
// and the behavioural controls above cannot carry that rationale.
|
|
339
|
+
const source = readFileSync(SCRIPT, "utf8");
|
|
340
|
+
const header = source.split("\nset -u")[0];
|
|
341
|
+
|
|
342
|
+
assert.match(header, /setup-pnpm/, "the header must name the $HOME-shared path it refuses to touch");
|
|
343
|
+
assert.match(header, /TMPDIR/, "the header must name the shared temp scan it refuses to do");
|
|
344
|
+
assert.match(header, /RUNNER_DIR/, "the header must state that every path derives from RUNNER_DIR");
|
|
345
|
+
assert.match(header, /Runner\.(Worker|Listener)/, "the header must state that the runner's own processes are excluded");
|
|
346
|
+
assert.match(header, /#343/, "the header must cite the incident the constraints came from");
|
|
347
|
+
assert.match(header, /job-cleanup\.sh/, "the header must say why a second hook exists beside the job-started one");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("AC-4: the code honours those constraints structurally", () => {
|
|
351
|
+
const source = readFileSync(SCRIPT, "utf8");
|
|
352
|
+
const code = source
|
|
353
|
+
.split("\n")
|
|
354
|
+
.filter((line) => !/^\s*#/.test(line))
|
|
355
|
+
.join("\n");
|
|
356
|
+
|
|
357
|
+
// Same property job-cleanup.sh pins: the hook has no legitimate use for the
|
|
358
|
+
// shared temp root, and naming it is necessary to reach it.
|
|
359
|
+
assert.equal(/TMPDIR/.test(code), false, "the hook references the shared temp root");
|
|
360
|
+
assert.equal(/setup-pnpm/.test(code), false, "the hook targets a $HOME-shared pnpm path");
|
|
361
|
+
assert.equal(/\bfind\b/.test(code), false, "the hook enumerates a directory — its only input must be one ps snapshot");
|
|
362
|
+
// `pkill -f` cannot exclude this script, its ancestors, or a co-resident
|
|
363
|
+
// runner's lookalike, and its matching differs across macOS and Linux.
|
|
364
|
+
assert.equal(/\bpkill\b/.test(code), false, "the hook uses a pattern kill instead of explicit, filtered pids");
|
|
365
|
+
// bash 3.2 is the fleet's interpreter; each of these fails as a SYNTAX error
|
|
366
|
+
// at the operator's first real job, not as a wrong answer in CI.
|
|
367
|
+
assert.equal(/declare\s+-A/.test(code), false, "associative arrays are bash 4+");
|
|
368
|
+
assert.equal(/\bmapfile\b|\breadarray\b/.test(code), false, "mapfile/readarray are 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("AC-3: the kit and the runbook wire the completed hook beside the started one", () => {
|
|
373
|
+
// `assert.ok` rather than `assert.match`: a failing `match` would print the
|
|
374
|
+
// whole runbook and bury the one line that is wrong.
|
|
375
|
+
const runbook = readFileSync(RUNBOOK, "utf8");
|
|
376
|
+
const envExample = readFileSync(ENV_EXAMPLE, "utf8");
|
|
377
|
+
|
|
378
|
+
assert.ok(
|
|
379
|
+
/^\s*ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/m.test(envExample),
|
|
380
|
+
"`.env.example` is the file the runbook copies onto a runner — the wiring has to be in it",
|
|
381
|
+
);
|
|
382
|
+
assert.ok(
|
|
383
|
+
/cp .*templates\/runner\/job-completed\.sh/.test(runbook),
|
|
384
|
+
"an operator following the runbook must end up with the hook on the host",
|
|
385
|
+
);
|
|
386
|
+
assert.ok(
|
|
387
|
+
/chmod \+x .*job-completed\.sh/.test(runbook),
|
|
388
|
+
"the runbook must chmod the hook — a non-executable hook is silently skipped by the runner",
|
|
389
|
+
);
|
|
390
|
+
assert.ok(
|
|
391
|
+
/ACTIONS_RUNNER_HOOK_JOB_COMPLETED=<RUNNER_DIR>\/job-completed\.sh/.test(runbook),
|
|
392
|
+
"the runbook must show the `.env` line, not just the copy",
|
|
393
|
+
);
|
|
394
|
+
assert.ok(
|
|
395
|
+
/why both hooks/i.test(runbook),
|
|
396
|
+
"the runbook must explain why a runner needs both hooks, or the next operator installs one of them",
|
|
397
|
+
);
|
|
398
|
+
});
|