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,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
|
|
90
|
+
/** The five keys `templates/runner/.env.example` mandates. */
|
|
91
91
|
const HOOK = "ACTIONS_RUNNER_HOOK_JOB_STARTED";
|
|
92
|
-
|
|
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
|
|
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
|
|
8
|
-
> (`job-cleanup.sh`) and
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
222
|
-
they are parameterized) and
|
|
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
|
|
24
|
-
# ACTIONS_RUNNER_HOOK_JOB_STARTED,
|
|
25
|
-
# LANG. Values are deliberately not
|
|
26
|
-
# runner's own absolute root path, so
|
|
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
|
|
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
|
-
#
|
|
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
|