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.
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * release-asset-download-flags.test.mjs — node:test guard over the retry flags
4
+ * on every pinned release-asset download in a first-party composite action
5
+ * (Story #523).
6
+ *
7
+ * WHY A SECOND GUARD
8
+ * ------------------
9
+ * `scripts/check-action-download-retries.mjs` already lints that every asset
10
+ * download carries `--retry`, `--retry-connrefused` and `--max-time`. That
11
+ * contract turned out to be insufficient for the failure the release CDN
12
+ * actually produces: on 2026-09-14 a consumer's `ci / Security` job logged
13
+ *
14
+ * Downloading pinned gitleaks: https://github.com/gitleaks/gitleaks/…
15
+ * curl: (56) The requested URL returned error: 504
16
+ *
17
+ * and the step took **196 ms** — three retries with curl's default backoff
18
+ * take ≥ 7 s, so no retry fired. curl treats HTTP 408/429/5xx as transient
19
+ * only when it can read them as a response; a 504 that arrives as a broken
20
+ * transfer surfaces as `CURLE_RECV_ERROR` (exit 56), outside that set.
21
+ * `--retry-all-errors` (curl ≥ 7.71) is what makes it retryable.
22
+ *
23
+ * So the flag this file pins is deliberately NOT folded into the existing
24
+ * lint's `REQUIRED_FLAGS`: that lint governs *every* asset download in the
25
+ * action surface, whereas the exit-56 case is specific to the GitHub release
26
+ * CDN. This guard scopes itself to `releases/download` URLs and complements
27
+ * the lint rather than restating it — it reuses the lint's exported shell
28
+ * parsing (`collapseContinuations`, `shellWords`) so the two cannot disagree
29
+ * about what a `curl` line even is.
30
+ *
31
+ * WHAT IS ASSERTED
32
+ * ----------------
33
+ * 1. Each of the three action manifests that fetch a pinned release binary
34
+ * still fetches one, and every such line carries `--retry-all-errors`
35
+ * alongside `--retry` (a flag set that retries nothing is worse than no
36
+ * flags, because it reads as covered).
37
+ * 2. No OTHER action manifest has quietly grown a release-asset download
38
+ * that this file's list would miss — a fourth site must join the
39
+ * contract, not escape it.
40
+ * 3. Checksum verification is still fail-closed in each manifest: the retry
41
+ * widens what is attempted, never what is trusted (AC-3).
42
+ * 4. The detector itself is exercised on synthetic fixtures, so a change
43
+ * that makes it match nothing fails here rather than passing vacuously.
44
+ *
45
+ * Run: node --test scripts/release-asset-download-flags.test.mjs
46
+ */
47
+
48
+ import assert from "node:assert/strict";
49
+ import { readFileSync } from "node:fs";
50
+ import { dirname, join } from "node:path";
51
+ import { test } from "node:test";
52
+ import { fileURLToPath } from "node:url";
53
+
54
+ import {
55
+ collapseContinuations,
56
+ findActionManifests,
57
+ shellWords,
58
+ stripShellComment,
59
+ } from "./check-action-download-retries.mjs";
60
+
61
+ const HERE = dirname(fileURLToPath(import.meta.url));
62
+ const REPO_ROOT = join(HERE, "..");
63
+
64
+ /**
65
+ * The action manifests that fetch a pinned release binary, with how many such
66
+ * fetches each one owns. The counts are part of the contract: a site that
67
+ * disappears is as much a regression signal as one that ships uncovered.
68
+ */
69
+ const RELEASE_ASSET_ACTIONS = Object.freeze([
70
+ Object.freeze({ path: ".github/actions/gitleaks-scan/action.yml", downloads: 1 }),
71
+ Object.freeze({ path: ".github/actions/workflow-lint/action.yml", downloads: 2 }),
72
+ Object.freeze({ path: ".github/actions/osv-scan/action.yml", downloads: 1 }),
73
+ ]);
74
+
75
+ /**
76
+ * Flags every release-asset download must carry, with why each one matters.
77
+ * `--retry` without `--retry-all-errors` is the state Story #523 found: the
78
+ * flags read as resilient and covered nothing the CDN produces.
79
+ */
80
+ const REQUIRED_RETRY_FLAGS = Object.freeze(["--retry", "--retry-all-errors"]);
81
+
82
+ /** A GitHub release-asset URL, in any of the spellings the actions use. */
83
+ const RELEASE_ASSET_URL = /releases\/download\//;
84
+ /** `curl` invoked as a command — after start-of-line, whitespace or an operator. */
85
+ const CURL_COMMAND = /(^|[\s;&|(])curl(\s|$)/;
86
+ /** A shell assignment: `name=value`, optionally exported. */
87
+ const ASSIGNMENT = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.+)$/;
88
+ /** Characters that continue a bare `$name` reference. */
89
+ const NAME_CHAR = /[A-Za-z0-9_]/;
90
+
91
+ /**
92
+ * Pure: does `word` reference the shell variable `name`, as `${name}` or as a
93
+ * bare `$name` that ends there? The bare form needs the boundary check or
94
+ * `$url` would match `$urls`.
95
+ *
96
+ * @param {string} word
97
+ * @param {string} name
98
+ * @returns {boolean}
99
+ */
100
+ function referencesVariable(word, name) {
101
+ if (word.includes(`\${${name}}`)) return true;
102
+ const bare = `$${name}`;
103
+ let from = 0;
104
+ for (;;) {
105
+ const at = word.indexOf(bare, from);
106
+ if (at === -1) return false;
107
+ const next = word[at + bare.length];
108
+ if (next === undefined || !NAME_CHAR.test(next)) return true;
109
+ from = at + 1;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Pure: the names of shell variables assigned a GitHub release-asset URL.
115
+ *
116
+ * Every real download in these actions builds the URL one line above the
117
+ * fetch (`url="https://github.com/…/releases/download/v${V}/${asset}"`) and
118
+ * then curls `"$url"`, so a detector that only looks for the literal in the
119
+ * curl line finds nothing at all — which is a guard that passes vacuously.
120
+ *
121
+ * @param {string} source
122
+ * @returns {Set<string>}
123
+ */
124
+ export function releaseAssetUrlVariables(source) {
125
+ const names = new Set();
126
+ for (const { text } of collapseContinuations(source)) {
127
+ const match = ASSIGNMENT.exec(stripShellComment(text).trim());
128
+ if (match === null) continue;
129
+ if (!RELEASE_ASSET_URL.test(match[2])) continue;
130
+ names.add(match[1]);
131
+ }
132
+ return names;
133
+ }
134
+
135
+ /**
136
+ * Pure: every logical line in `source` that fetches a GitHub release asset
137
+ * with curl — whether the URL is written inline or carried in a variable
138
+ * assigned one earlier in the same script. Line numbers are 1-based and name
139
+ * the line the command started on, so a failure points at something a reader
140
+ * can open.
141
+ *
142
+ * @param {string} source
143
+ * @returns {Array<{ line: number, text: string, words: string[] }>}
144
+ */
145
+ export function releaseAssetDownloads(source) {
146
+ const urlVariables = releaseAssetUrlVariables(source);
147
+ const found = [];
148
+ for (const { line, text } of collapseContinuations(source)) {
149
+ const command = stripShellComment(text);
150
+ if (!CURL_COMMAND.test(command)) continue;
151
+ const words = shellWords(text);
152
+ const fetchesRelease = words.some(
153
+ (word) =>
154
+ RELEASE_ASSET_URL.test(word) ||
155
+ [...urlVariables].some((name) => referencesVariable(word, name)),
156
+ );
157
+ if (!fetchesRelease) continue;
158
+ found.push({ line, text, words });
159
+ }
160
+ return found;
161
+ }
162
+
163
+ /**
164
+ * Pure: which of the required retry flags this download is missing, in
165
+ * contract order. Matched as whole shell words (or `--flag=value`), never as
166
+ * substrings — `--retry-all-errors` contains `--retry`, and the reverse
167
+ * containment is exactly the bug this guard exists for.
168
+ *
169
+ * @param {string[]} words
170
+ * @returns {string[]}
171
+ */
172
+ export function missingRetryFlags(words) {
173
+ return REQUIRED_RETRY_FLAGS.filter(
174
+ (flag) => !words.some((word) => word === flag || word.startsWith(`${flag}=`)),
175
+ );
176
+ }
177
+
178
+ /**
179
+ * Read one repo-relative file.
180
+ *
181
+ * @param {string} relative
182
+ * @returns {string}
183
+ */
184
+ function readRepoFile(relative) {
185
+ return readFileSync(join(REPO_ROOT, relative), "utf8");
186
+ }
187
+
188
+ for (const { path, downloads } of RELEASE_ASSET_ACTIONS) {
189
+ test(`${path} — every release-asset download retries an exit-56 failure`, () => {
190
+ const found = releaseAssetDownloads(readRepoFile(path));
191
+
192
+ assert.equal(
193
+ found.length,
194
+ downloads,
195
+ `expected ${downloads} release-asset download(s) in ${path}, found ${found.length} — update RELEASE_ASSET_ACTIONS if a site was deliberately added or removed`,
196
+ );
197
+
198
+ for (const { line, words } of found) {
199
+ const missing = missingRetryFlags(words);
200
+ assert.deepEqual(
201
+ missing,
202
+ [],
203
+ `${path}:${line} is missing ${missing.join(", ")}. A release-CDN 504 reaches curl as exit 56 (CURLE_RECV_ERROR), which --retry alone does not treat as transient; --retry-all-errors is what covers it.`,
204
+ );
205
+ }
206
+ });
207
+
208
+ test(`${path} — a checksum mismatch still fails the download closed`, () => {
209
+ const source = readRepoFile(path);
210
+ assert.match(
211
+ source,
212
+ /checksum mismatch/,
213
+ `${path} no longer fails on a checksum mismatch — the retry may widen what is attempted, never what is trusted`,
214
+ );
215
+ });
216
+ }
217
+
218
+ test("no other action manifest fetches a release asset uncovered", () => {
219
+ const known = new Set(RELEASE_ASSET_ACTIONS.map(({ path }) => path));
220
+ const uncovered = [];
221
+
222
+ for (const manifest of findActionManifests(join(REPO_ROOT, ".github", "actions"))) {
223
+ const relative = manifest.slice(REPO_ROOT.length + 1);
224
+ if (known.has(relative)) continue;
225
+ if (releaseAssetDownloads(readFileSync(manifest, "utf8")).length > 0) {
226
+ uncovered.push(relative);
227
+ }
228
+ }
229
+
230
+ assert.deepEqual(
231
+ uncovered,
232
+ [],
233
+ `these action manifests grew a release-asset download outside the retry contract: ${uncovered.join(", ")} — add them to RELEASE_ASSET_ACTIONS`,
234
+ );
235
+ });
236
+
237
+ test("detects a release-asset download and reports its missing flags", () => {
238
+ const source = [
239
+ " echo 'Downloading pinned thing'",
240
+ ' curl -fsSL --retry 3 --retry-connrefused --max-time 300 "https://github.com/o/r/releases/download/v1/asset.tar.gz" -o "${tmp}/asset.tar.gz"',
241
+ ].join("\n");
242
+
243
+ const found = releaseAssetDownloads(source);
244
+ assert.equal(found.length, 1);
245
+ assert.equal(found[0].line, 2);
246
+ assert.deepEqual(missingRetryFlags(found[0].words), ["--retry-all-errors"]);
247
+ });
248
+
249
+ test("a shell-variable URL split across continuations is still one download", () => {
250
+ const source = [
251
+ ' curl -fsSL --retry 3 --retry-all-errors \\',
252
+ ' --retry-connrefused --retry-delay 2 --max-time 300 \\',
253
+ ' "https://github.com/o/r/releases/download/v1/a" -o "$out"',
254
+ ].join("\n");
255
+
256
+ const found = releaseAssetDownloads(source);
257
+ assert.equal(found.length, 1, "line continuations must fold into one command");
258
+ assert.equal(found[0].line, 1, "the reported line is where the command starts");
259
+ assert.deepEqual(missingRetryFlags(found[0].words), []);
260
+ });
261
+
262
+ test("a URL carried in a shell variable is still a release-asset download", () => {
263
+ const source = [
264
+ ' asset="tool_${V}_linux_amd64.tar.gz"',
265
+ ' url="https://github.com/o/r/releases/download/v${V}/${asset}"',
266
+ ' echo "Downloading pinned tool: ${url}"',
267
+ ' curl -fsSL --retry 3 --retry-connrefused --max-time 300 "$url" -o "${tmp}/${asset}"',
268
+ ].join("\n");
269
+
270
+ const found = releaseAssetDownloads(source);
271
+ assert.equal(found.length, 1, "the fetch resolves through the url variable");
272
+ assert.equal(found[0].line, 4);
273
+ assert.deepEqual(missingRetryFlags(found[0].words), ["--retry-all-errors"]);
274
+ });
275
+
276
+ test("a variable holding a non-release URL does not pull its curl into scope", () => {
277
+ const source = [
278
+ ' api="https://api.github.com/repos/o/r/releases/latest"',
279
+ ' curl -fsSL --max-time 30 "$api" -o meta.json',
280
+ ].join("\n");
281
+
282
+ assert.deepEqual(
283
+ releaseAssetDownloads(source),
284
+ [],
285
+ "an API probe is not a pinned asset fetch and carries no retry contract here",
286
+ );
287
+ });
288
+
289
+ test("a bare $name reference does not match a longer variable name", () => {
290
+ const source = [
291
+ ' url="https://github.com/o/r/releases/download/v1/a"',
292
+ " curl -fsSL $urls -o out",
293
+ ].join("\n");
294
+
295
+ assert.deepEqual(
296
+ releaseAssetDownloads(source),
297
+ [],
298
+ "$urls is a different variable from $url",
299
+ );
300
+ });
301
+
302
+ test("non-release fetches and commented-out lines are out of scope", () => {
303
+ const notRelease = 'curl -fsSL --max-time 5 "https://api.github.com/repos/o/r" -o out.json';
304
+ assert.deepEqual(releaseAssetDownloads(notRelease), []);
305
+
306
+ const commented =
307
+ ' # curl -fsSL "https://github.com/o/r/releases/download/v1/a" -o "$out"';
308
+ assert.deepEqual(
309
+ releaseAssetDownloads(commented),
310
+ [],
311
+ "a curl inside a shell comment is prose, not a download",
312
+ );
313
+
314
+ const trailingComment =
315
+ ' echo hi # see https://github.com/o/r/releases/download/v1/a for the asset';
316
+ assert.deepEqual(releaseAssetDownloads(trailingComment), []);
317
+ });
318
+
319
+ test("--retry-all-errors alone does not satisfy --retry", () => {
320
+ const words = shellWords('curl -fsSL --retry-all-errors "$url" -o out');
321
+ assert.deepEqual(
322
+ missingRetryFlags(words),
323
+ ["--retry"],
324
+ "substring matching would report this as covered; it retries zero times",
325
+ );
326
+ });
327
+
328
+ test("the --flag=value spelling counts", () => {
329
+ const words = shellWords(
330
+ 'curl --retry=3 --retry-all-errors "https://github.com/o/r/releases/download/v1/a" -o out',
331
+ );
332
+ assert.deepEqual(missingRetryFlags(words), []);
333
+ });
@@ -87,13 +87,16 @@ const HERE = dirname(fileURLToPath(import.meta.url));
87
87
  const SCRIPT = join(HERE, "..", "templates", "runner", "check-runner-env-drift.sh");
88
88
  const RUNBOOK = join(HERE, "..", "templates", "runbooks", "runner-provisioning.md");
89
89
 
90
- /** The four keys `templates/runner/.env.example` mandates. */
90
+ /** The five keys `templates/runner/.env.example` mandates. */
91
91
  const HOOK = "ACTIONS_RUNNER_HOOK_JOB_STARTED";
92
- const MANDATED = [HOOK, "RUNNER_TOOL_CACHE", "AGENT_TOOLSDIRECTORY", "LANG"];
92
+ /** The job-END hook (Story #524) — mandated on the same footing as the start one. */
93
+ const HOOK_COMPLETED = "ACTIONS_RUNNER_HOOK_JOB_COMPLETED";
94
+ const MANDATED = [HOOK, HOOK_COMPLETED, "RUNNER_TOOL_CACHE", "AGENT_TOOLSDIRECTORY", "LANG"];
93
95
 
94
96
  /** Representative values — the checker reports PRESENCE, never value. */
95
97
  const VALUES = {
96
98
  ACTIONS_RUNNER_HOOK_JOB_STARTED: "/Users/ci/runners/a/job-cleanup.sh",
99
+ ACTIONS_RUNNER_HOOK_JOB_COMPLETED: "/Users/ci/runners/a/job-completed.sh",
97
100
  RUNNER_TOOL_CACHE: "/Users/ci/runners/a/_work/_tool",
98
101
  AGENT_TOOLSDIRECTORY: "/Users/ci/runners/a/_work/_tool",
99
102
  LANG: "en_US.UTF-8",
@@ -128,7 +131,7 @@ function envWith(keys) {
128
131
  * Build a synthetic pool root.
129
132
  *
130
133
  * Each entry maps a child directory name to its spec:
131
- * `keys` — mandated keys to set in that runner's `.env` (default: all four)
134
+ * `keys` — mandated keys to set in that runner's `.env` (default: all five)
132
135
  * `env` — raw `.env` body, overriding `keys`
133
136
  * `noEnv` — create no `.env` at all
134
137
  * `isRunner` — false to omit `config.sh`, i.e. not a runner directory
@@ -242,6 +245,52 @@ test("AC-2: names every runner missing a key that others have, and exits non-zer
242
245
  assert.match(stdout, new RegExp(HOOK), "the drifting key must be named");
243
246
  });
244
247
 
248
+ test("Story #524: a runner missing only the job-COMPLETED hook is named as drifted", () => {
249
+ // The two hooks close different halves of one gap — started defends the next
250
+ // job against the previous one's orphans, completed makes each job reap its
251
+ // own tree — so a runner carrying only the started hook is half-provisioned,
252
+ // and this checker is the only observer that can see it. Provisioning the
253
+ // completed hook across a fleet is an operator-applied, per-host step, which
254
+ // is exactly the shape that lands on some runners and not others.
255
+ const root = makePool({
256
+ "runner-a": { keys: MANDATED },
257
+ "runner-b": { keys: MANDATED.filter((key) => key !== HOOK_COMPLETED) },
258
+ });
259
+
260
+ const { status, stdout } = runChecker(["--pool-root", root]);
261
+
262
+ assert.notEqual(status, 0, "a missing completed hook must drift like a missing started hook");
263
+ assert.match(stdout, new RegExp(`${HOOK_COMPLETED}: DRIFT`), "the drifting key must be named");
264
+ assert.match(stdout, /runner-b/, "the runner missing it must be named, not just counted");
265
+ });
266
+
267
+ test("Story #524: `.env.example` sets every key the checker mandates", () => {
268
+ // The runbook's provisioning step is `cp .env.example <RUNNER_DIR>/.env`. If
269
+ // the checker mandated a key the example never sets, an operator who
270
+ // followed the runbook exactly could not satisfy it — the alert would fire
271
+ // on a correctly-provisioned fleet, and the operator would learn to ignore
272
+ // the exit code.
273
+ const example = readFileSync(join(HERE, "..", "templates", "runner", ".env.example"), "utf8");
274
+ const mandatedInScript = readFileSync(SCRIPT, "utf8")
275
+ .split("MANDATED_KEYS=(")[1]
276
+ .split(")")[0]
277
+ .split("\n")
278
+ .map((line) => line.trim())
279
+ .filter(Boolean);
280
+
281
+ assert.deepEqual(
282
+ mandatedInScript,
283
+ MANDATED,
284
+ "the checker's mandated keys and this suite's list have diverged",
285
+ );
286
+ for (const key of mandatedInScript) {
287
+ assert.ok(
288
+ new RegExp(`^\\s*${key}=`, "m").test(example),
289
+ `.env.example does not set ${key}, but the checker reports a runner without it`,
290
+ );
291
+ }
292
+ });
293
+
245
294
  test("AC-3: a fully provisioned pool exits 0", () => {
246
295
  const root = makePool({
247
296
  "runner-a": { keys: MANDATED },
@@ -480,7 +529,7 @@ test("a commented-out assignment does not count as set", () => {
480
529
  const root = makePool({
481
530
  "runner-a": { keys: MANDATED },
482
531
  "runner-b": {
483
- 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`,
532
+ env: `# ${HOOK}=/Users/ci/runners/b/job-cleanup.sh\n${HOOK_COMPLETED}=${VALUES[HOOK_COMPLETED]}\nRUNNER_TOOL_CACHE=${VALUES.RUNNER_TOOL_CACHE}\nAGENT_TOOLSDIRECTORY=${VALUES.AGENT_TOOLSDIRECTORY}\nLANG=${VALUES.LANG}\n`,
484
533
  },
485
534
  });
486
535
 
@@ -494,7 +543,7 @@ test("a leading-whitespace assignment counts as set", () => {
494
543
  const root = makePool({
495
544
  "runner-a": { keys: MANDATED },
496
545
  "runner-b": {
497
- 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`,
546
+ env: ` ${HOOK}=/Users/ci/runners/b/job-cleanup.sh\n\t${HOOK_COMPLETED}=${VALUES[HOOK_COMPLETED]}\n\tRUNNER_TOOL_CACHE=${VALUES.RUNNER_TOOL_CACHE}\nAGENT_TOOLSDIRECTORY=${VALUES.AGENT_TOOLSDIRECTORY}\nLANG=${VALUES.LANG}\n`,
498
547
  },
499
548
  });
500
549
 
@@ -509,7 +558,7 @@ test("a longer key that merely starts with a mandated key does not count as set"
509
558
  const root = makePool({
510
559
  "runner-a": { keys: MANDATED },
511
560
  "runner-b": {
512
- 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`,
561
+ env: `${HOOK}=/Users/ci/runners/b/job-cleanup.sh\n${HOOK_COMPLETED}=${VALUES[HOOK_COMPLETED]}\nRUNNER_TOOL_CACHE=${VALUES.RUNNER_TOOL_CACHE}\nAGENT_TOOLSDIRECTORY=${VALUES.AGENT_TOOLSDIRECTORY}\nLANGUAGE=en_US\n`,
513
562
  },
514
563
  });
515
564
 
@@ -4,8 +4,9 @@
4
4
  > this directory, there is no canonical `docs/runbooks/` counterpart to link —
5
5
  > this file IS the process. It provisions a **persistent** (non-ephemeral)
6
6
  > GitHub Actions runner on a macOS host using the mandrel-platform runner kit
7
- > (`templates/runner/`), which ships the job-start hygiene hook
8
- > (`job-cleanup.sh`) and the per-runner `.env` (`.env.example`).
7
+ > (`templates/runner/`), which ships the two job hooks — job-start hygiene
8
+ > (`job-cleanup.sh`) and job-end reaping (`job-completed.sh`) — and the
9
+ > per-runner `.env` (`.env.example`).
9
10
  >
10
11
  > Placeholder convention: `<UPPER_SNAKE>` between angle brackets — search for
11
12
  > `<` after copying to find everything that still needs a value.
@@ -113,13 +114,15 @@ installs the pnpm shim to a **runner-scoped** destination:
113
114
  There is **no runner-side override** for the pnpm `dest` — it is a workflow
114
115
  input — which is why this step is a rollout gate, not an `.env` line.
115
116
 
116
- ## 4. Install the hygiene kit (hook + `.env`)
117
+ ## 4. Install the hygiene kit (hooks + `.env`)
117
118
 
118
119
  Copy the kit from the platform payload into the runner root:
119
120
 
120
121
  ```bash
121
122
  cp node_modules/mandrel-platform/templates/runner/job-cleanup.sh <RUNNER_DIR>/job-cleanup.sh
122
123
  chmod +x <RUNNER_DIR>/job-cleanup.sh
124
+ cp node_modules/mandrel-platform/templates/runner/job-completed.sh <RUNNER_DIR>/job-completed.sh
125
+ chmod +x <RUNNER_DIR>/job-completed.sh
123
126
  cp node_modules/mandrel-platform/templates/runner/check-runner-env-drift.sh <RUNNER_DIR>/check-runner-env-drift.sh
124
127
  chmod +x <RUNNER_DIR>/check-runner-env-drift.sh
125
128
  cp node_modules/mandrel-platform/templates/runner/.env.example <RUNNER_DIR>/.env
@@ -142,19 +145,52 @@ with the runner root's absolute path. The resulting file wires:
142
145
  jobs were killed before their first real step — surfacing as `cancelled`
143
146
  on unrelated diffs. If you add a sweep to this hook, root it at
144
147
  `_work/_temp`, never at `$TMPDIR`.
148
+ - `ACTIONS_RUNNER_HOOK_JOB_COMPLETED=<RUNNER_DIR>/job-completed.sh` — the
149
+ job-end hook. After the last step of every job it terminates whatever of
150
+ **this** job's process tree is still alive — SIGTERM, a bounded grace, then
151
+ SIGKILL — matching processes whose command line resolves inside
152
+ `<RUNNER_DIR>/_work/` plus their descendants. It never signals itself, its
153
+ own ancestors, or the runner's `Runner.Worker`/`Runner.Listener`, never
154
+ fails a job (always exits 0), and does nothing at all when the job left
155
+ nothing behind.
145
156
  - `RUNNER_TOOL_CACHE=<RUNNER_DIR>/_work/_tool` and
146
157
  `AGENT_TOOLSDIRECTORY=<RUNNER_DIR>/_work/_tool` — runner-scoped tool cache
147
158
  (two env names, one dir; some actions read the legacy name).
148
159
  - `LANG=en_US.UTF-8`.
149
160
 
150
- Neither shipped script needs per-runner editing: each derives its paths from
151
- its own location, so the same files work verbatim on every runner.
161
+ None of the shipped scripts needs per-runner editing: each derives its paths
162
+ from its own location, so the same files work verbatim on every runner.
163
+
164
+ ### Why both hooks
165
+
166
+ They cover opposite ends of the same job and neither substitutes for the
167
+ other:
168
+
169
+ | Hook | Runs | Defends against |
170
+ |------|------|-----------------|
171
+ | `job-cleanup.sh` (`..._JOB_STARTED`) | before the first step | the **previous** job's leftovers — orphaned pnpm/node processes and stale `_work/_temp` artifacts already on the runner |
172
+ | `job-completed.sh` (`..._JOB_COMPLETED`) | after the last step | **this** job's own survivors reaching the **next** job |
173
+
174
+ The started hook cannot close the second case: it runs before the new job's
175
+ processes exist, so once a job is minutes in, nothing it did can help. A
176
+ **cancelled** job is where survivors are most likely — the runner terminates
177
+ the step it is executing, not everything that step forked — so a coalesced
178
+ push (`concurrency: cancel-in-progress`) is the routine way a runner ends up
179
+ hosting a previous job's vitest forks or dev server. The observed symptom is
180
+ the next job on that runner exiting 143 (SIGTERM) mid-run, with no
181
+ cancellation request in the runner's `Worker_*.log` and every concurrent job
182
+ on the pool's other runners passing.
183
+
184
+ The completed hook runs on the **job's** clock, like the started one, so it is
185
+ held to the same cost rule: its whole input is one `ps` snapshot, and it
186
+ sleeps only while waiting out the grace period of a tree it actually
187
+ signalled. A job with nothing to reap pays a few milliseconds.
152
188
 
153
189
  ### Confirm the pool is uniform (`check-runner-env-drift.sh`)
154
190
 
155
191
  Run this **after provisioning each runner**, and again whenever two runners
156
192
  behave differently on the same job. It walks the pool and names the runners
157
- missing any of the four mandated keys:
193
+ missing any of the five mandated keys:
158
194
 
159
195
  ```bash
160
196
  cd <RUNNER_DIR>
@@ -200,8 +236,10 @@ cd <RUNNER_DIR>
200
236
 
201
237
  Verify end-to-end: push a trivial workflow run targeting
202
238
  `runs-on: [self-hosted, macOS, ARM64, <REPO>-runner]` and confirm (a) the job
203
- is picked up and (b) the job log shows the `Set up runner` hook phase running
204
- `job-cleanup.sh` before the first step.
239
+ is picked up, (b) the job log shows the `Set up runner` hook phase running
240
+ `job-cleanup.sh` before the first step, and (c) the job log shows
241
+ `job-completed.sh` running after the last step (it prints either what it
242
+ reaped or `nothing to reap`).
205
243
 
206
244
  The runner loads `.env` at service start — after any `.env` change, restart:
207
245
 
@@ -216,10 +254,11 @@ The runner loads `.env` at service start — after any `.env` change, restart:
216
254
  pinned or the self-update wedges, stop the service, download/unpack the new
217
255
  tarball over `<RUNNER_DIR>` (config and `.env` survive), and restart via
218
256
  `svc.sh`.
219
- - **Kit updates.** The hook and `.env.example` are versioned in
257
+ - **Kit updates.** The hooks and `.env.example` are versioned in
220
258
  mandrel-platform. On a platform release that touches `templates/runner/`,
221
- re-copy `job-cleanup.sh` and `check-runner-env-drift.sh` (both verbatim —
222
- they are parameterized) and diff `.env.example` against the live `.env`,
259
+ re-copy `job-cleanup.sh`, `job-completed.sh` and
260
+ `check-runner-env-drift.sh` (all verbatim — they are parameterized) and
261
+ diff `.env.example` against the live `.env`,
223
262
  then `./svc.sh stop && ./svc.sh start`. There is no `mandrel sync`
224
263
  equivalent for a runner host's filesystem — this is an operator-applied
225
264
  step. Re-run `./check-runner-env-drift.sh` afterwards: a kit update applied
@@ -29,6 +29,21 @@ LANG=en_US.UTF-8
29
29
  # cost never becomes a function of host-wide temp churn (issue #343).
30
30
  ACTIONS_RUNNER_HOOK_JOB_STARTED=<RUNNER_DIR>/job-cleanup.sh
31
31
 
32
+ # Job-end reap hook. Runs templates/runner/job-completed.sh (installed into
33
+ # the runner root) after the last step of every job: terminates whatever of
34
+ # THIS job's process tree is still alive — SIGTERM, a bounded grace, then
35
+ # SIGKILL — scoped to processes under this runner's `_work/`.
36
+ #
37
+ # Both hooks are needed, and neither substitutes for the other. The started
38
+ # hook is defence against the PREVIOUS job; it runs before the new job's own
39
+ # processes exist, so it cannot help a job that is already minutes in. The
40
+ # completed hook makes each job clean up after itself while the runner still
41
+ # knows whose processes these are — which is what a CANCELLED job never does
42
+ # on its own (the runner terminates the step it is executing, not everything
43
+ # that step forked). Like the started hook, it is runner-scoped and always
44
+ # exits 0.
45
+ ACTIONS_RUNNER_HOOK_JOB_COMPLETED=<RUNNER_DIR>/job-completed.sh
46
+
32
47
  # Runner-scoped tool cache. Without this, actions/setup-node & friends
33
48
  # default the tool cache to a host-shared location and co-resident runners
34
49
  # race on extraction. `_work/_tool` is inside this runner's own work tree,
@@ -20,10 +20,17 @@
20
20
  #
21
21
  # ── WHAT IT REPORTS ─────────────────────────────────────────────────────────
22
22
  #
23
- # PRESENCE — never value — of the four keys `.env.example` mandates:
24
- # ACTIONS_RUNNER_HOOK_JOB_STARTED, RUNNER_TOOL_CACHE, AGENT_TOOLSDIRECTORY,
25
- # LANG. Values are deliberately not compared: every one of them embeds the
26
- # runner's own absolute root path, so they are SUPPOSED to differ per runner.
23
+ # PRESENCE — never value — of the five keys `.env.example` mandates:
24
+ # ACTIONS_RUNNER_HOOK_JOB_STARTED, ACTIONS_RUNNER_HOOK_JOB_COMPLETED,
25
+ # RUNNER_TOOL_CACHE, AGENT_TOOLSDIRECTORY, LANG. Values are deliberately not
26
+ # compared: every one of them embeds the runner's own absolute root path, so
27
+ # they are SUPPOSED to differ per runner.
28
+ #
29
+ # Both hook keys are mandated, because the two hooks close different halves of
30
+ # the same gap: the started hook defends the NEXT job against the previous
31
+ # one's orphans, the completed hook makes each job reap its own tree before it
32
+ # releases the runner. A runner carrying only one of them is exactly the
33
+ # half-provisioned shape this tool exists to name.
27
34
  #
28
35
  # The drift signal is a key set on SOME runners but not all — the 16-of-19
29
36
  # shape. A key absent from EVERY runner is a uniform gap: reported as such, and
@@ -56,12 +63,12 @@
56
63
  #
57
64
  # READ-ONLY, and never fails soft on a broken runner. It writes nothing into a
58
65
  # runner root and never touches a launchd service. A runner whose `.env` is
59
- # missing or unreadable is recorded as all four keys unset and the walk
66
+ # missing or unreadable is recorded as all five keys unset and the walk
60
67
  # continues — one broken runner must not shrink the sample the verdict is
61
68
  # computed over.
62
69
  #
63
- # This is an OPERATOR-run tool, not a job hook. Do not wire it into
64
- # ACTIONS_RUNNER_HOOK_JOB_STARTED: that hook runs inside the job's clock, where
70
+ # This is an OPERATOR-run tool, not a job hook. Do not wire it into either job
71
+ # hook: both run inside the job's clock, where
65
72
  # every read is billed to `Set up runner` and counts against the job's
66
73
  # `timeout-minutes` (issue #343). A pool-wide walk belongs outside that clock.
67
74
  #
@@ -86,6 +93,7 @@ set -u
86
93
 
87
94
  MANDATED_KEYS=(
88
95
  ACTIONS_RUNNER_HOOK_JOB_STARTED
96
+ ACTIONS_RUNNER_HOOK_JOB_COMPLETED
89
97
  RUNNER_TOOL_CACHE
90
98
  AGENT_TOOLSDIRECTORY
91
99
  LANG