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
package/package.json
CHANGED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-checkout-clean-excludes.test.mjs — guard for the opt-in that lets a
|
|
4
|
+
* caller keep chosen paths across `actions/checkout`'s clean (Story #525).
|
|
5
|
+
*
|
|
6
|
+
* WHAT THIS PINS
|
|
7
|
+
* --------------
|
|
8
|
+
* `actions/checkout` runs `git clean -ffdx && git reset --hard HEAD` before
|
|
9
|
+
* fetching. On a persistent self-hosted fleet that deletes the `node_modules`
|
|
10
|
+
* tree the install step immediately re-creates, once per tier job. The
|
|
11
|
+
* `checkout-clean-excludes` input takes that clean off the action (`clean:
|
|
12
|
+
* false`) and moves it onto a shared hygiene step that discards the same
|
|
13
|
+
* content MINUS the caller's paths.
|
|
14
|
+
*
|
|
15
|
+
* Three ways that can be wrong, none of which a `grep` can see:
|
|
16
|
+
*
|
|
17
|
+
* 1. PARTIAL COVERAGE. `pr-quality.yml` checks the consumer repo out from
|
|
18
|
+
* one anchored step AND from three bespoke ones (migration-guard,
|
|
19
|
+
* security, osv-scan) that cannot alias the anchor because they need
|
|
20
|
+
* `fetch-depth: 0` and Actions has no merge keys. An opt-in wired into
|
|
21
|
+
* the anchor alone reads as done and still pays the full cost in three
|
|
22
|
+
* jobs. So this suite ENUMERATES every consumer-repo checkout in the file
|
|
23
|
+
* and requires each to carry the gate and the hygiene step — a new
|
|
24
|
+
* checkout added without them fails here rather than in a consumer's
|
|
25
|
+
* timing.
|
|
26
|
+
*
|
|
27
|
+
* 2. A GATE THAT DOES NOT MEAN WHAT IT READS. Asserting the spelling of a
|
|
28
|
+
* workflow expression pins the wording, not the behaviour. Every `clean:`
|
|
29
|
+
* gate below is EXTRACTED and EVALUATED under Actions semantics (the same
|
|
30
|
+
* read-then-execute approach as check-runner-runs-on.test.mjs), including
|
|
31
|
+
* the hygiene step's own `if:`, which must be the exact complement — if
|
|
32
|
+
* the two can disagree, a job either cleans twice or not at all.
|
|
33
|
+
*
|
|
34
|
+
* 3. A HYGIENE STEP THAT DOES NOT RESTORE THE GUARANTEE, or that lets the
|
|
35
|
+
* caller's string reach the shell as workflow text. The real `run:` body
|
|
36
|
+
* is extracted and EXECUTED here twice: once against a fixture git repo
|
|
37
|
+
* (does it preserve exactly what it was told to and nothing else?) and
|
|
38
|
+
* once against a stubbed `git` (does a pattern with a leading dash or a
|
|
39
|
+
* shell metacharacter arrive as one literal `-e` operand?).
|
|
40
|
+
*
|
|
41
|
+
* Run: node --test scripts/check-checkout-clean-excludes.test.mjs
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import assert from "node:assert/strict";
|
|
45
|
+
import { test } from "node:test";
|
|
46
|
+
import { execFileSync } from "node:child_process";
|
|
47
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
48
|
+
import { tmpdir } from "node:os";
|
|
49
|
+
import { join } from "node:path";
|
|
50
|
+
|
|
51
|
+
import { evaluate } from "./lib/actions-expression.mjs";
|
|
52
|
+
import { runScript } from "./lib/yaml-step.mjs";
|
|
53
|
+
import { parseWorkflow } from "./check-workflow-platform-checkout.mjs";
|
|
54
|
+
|
|
55
|
+
const WORKFLOW = ".github/workflows/pr-quality.yml";
|
|
56
|
+
const INPUT = "checkout-clean-excludes";
|
|
57
|
+
const HYGIENE_ANCHOR = "checkout-hygiene";
|
|
58
|
+
const PLATFORM_REPO = "dsj1984/mandrel-platform";
|
|
59
|
+
|
|
60
|
+
const SOURCE = readFileSync(WORKFLOW, "utf8");
|
|
61
|
+
const { anchors, jobs } = parseWorkflow(SOURCE);
|
|
62
|
+
|
|
63
|
+
/** Strip comment lines so prose can neither satisfy nor trip an assertion. */
|
|
64
|
+
function withoutComments(text) {
|
|
65
|
+
return text
|
|
66
|
+
.split("\n")
|
|
67
|
+
.filter((l) => !/^\s*#/.test(l))
|
|
68
|
+
.join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True for the shared hygiene step, whether aliased or at its anchor definition. */
|
|
72
|
+
function isHygieneStep(step) {
|
|
73
|
+
if (!step) return false;
|
|
74
|
+
if (step.aliasOf === HYGIENE_ANCHOR) return true;
|
|
75
|
+
return /^\s*-\s+&checkout-hygiene\s*$/m.test(step.text);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Every `actions/checkout` step in the file, split by what it checks out.
|
|
80
|
+
* A platform side-checkout names `repository:`; everything else is the
|
|
81
|
+
* consumer's own repo and is in scope for the opt-in.
|
|
82
|
+
*/
|
|
83
|
+
function checkoutSteps() {
|
|
84
|
+
const consumer = [];
|
|
85
|
+
const platform = [];
|
|
86
|
+
for (const [jobKey, job] of jobs) {
|
|
87
|
+
job.steps.forEach((step, idx) => {
|
|
88
|
+
const body = withoutComments(step.text);
|
|
89
|
+
if (!/uses:\s*actions\/checkout@/.test(body)) return;
|
|
90
|
+
const site = { jobKey, job: jobKey.split("@")[0], line: step.line, body, next: job.steps[idx + 1] };
|
|
91
|
+
if (body.includes(`repository: ${PLATFORM_REPO}`)) platform.push(site);
|
|
92
|
+
else consumer.push(site);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return { consumer, platform };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The `${{ … }}` body of a step's `clean:` value, or null. */
|
|
99
|
+
function cleanExpression(body) {
|
|
100
|
+
const m = body.match(/^\s*clean:\s*\$\{\{(.+)\}\}\s*$/m);
|
|
101
|
+
return m ? m[1].trim() : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const { consumer: CONSUMER_CHECKOUTS, platform: PLATFORM_CHECKOUTS } = checkoutSteps();
|
|
105
|
+
const HYGIENE_BLOCK = anchors.get(HYGIENE_ANCHOR) ?? "";
|
|
106
|
+
const HYGIENE_RUN = runScript(HYGIENE_BLOCK);
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// AC-1 / AC-2 — the input, and the sites it reaches
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
test("the input is declared as an optional string defaulting to empty", () => {
|
|
113
|
+
const lines = SOURCE.split("\n");
|
|
114
|
+
const start = lines.indexOf(` ${INPUT}:`);
|
|
115
|
+
assert.notEqual(start, -1, `no \`${INPUT}:\` workflow_call input found`);
|
|
116
|
+
const block = [];
|
|
117
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
118
|
+
if (lines[i].trim() !== "" && !lines[i].startsWith(" ")) break;
|
|
119
|
+
block.push(lines[i]);
|
|
120
|
+
}
|
|
121
|
+
const text = block.join("\n");
|
|
122
|
+
assert.match(text, /^\s+type:\s*string\s*$/m, "must be declared `type: string`");
|
|
123
|
+
assert.match(text, /^\s+required:\s*false\s*$/m, "must be optional — an existing caller passes nothing");
|
|
124
|
+
assert.match(text, /^\s+default:\s*''\s*$/m, "default must be the empty string (today's behaviour)");
|
|
125
|
+
// Rule 2 of check-workflow-portability: an expression in a workflow_call
|
|
126
|
+
// description is evaluated during interface validation and fails the call.
|
|
127
|
+
assert.ok(!text.includes("${{"), "description/default must be plain text");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("every consumer-repo checkout in the workflow is covered by this guard", () => {
|
|
131
|
+
// The coverage tripwire: a tier that grows its own consumer checkout has to
|
|
132
|
+
// be seen here, because a missed job keeps paying the full clean silently.
|
|
133
|
+
const sites = CONSUMER_CHECKOUTS.map((s) => s.job).sort();
|
|
134
|
+
assert.deepEqual(sites, [
|
|
135
|
+
"contract",
|
|
136
|
+
"coverage-floor",
|
|
137
|
+
"e2e",
|
|
138
|
+
"lint",
|
|
139
|
+
"migration-guard",
|
|
140
|
+
"osv-scan",
|
|
141
|
+
"security",
|
|
142
|
+
"typecheck",
|
|
143
|
+
"unit",
|
|
144
|
+
"workflow-lint",
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a caller that sets nothing gets the full clean at every consumer checkout", () => {
|
|
149
|
+
for (const site of CONSUMER_CHECKOUTS) {
|
|
150
|
+
const expr = cleanExpression(site.body);
|
|
151
|
+
assert.ok(
|
|
152
|
+
expr,
|
|
153
|
+
`${WORKFLOW}:${site.line} (${site.job}) — consumer checkout does not gate \`clean:\` on an expression`,
|
|
154
|
+
);
|
|
155
|
+
assert.ok(
|
|
156
|
+
expr.includes(INPUT),
|
|
157
|
+
`${WORKFLOW}:${site.line} (${site.job}) — \`clean:\` does not read \`${INPUT}\`: ${expr}`,
|
|
158
|
+
);
|
|
159
|
+
assert.equal(
|
|
160
|
+
evaluate(expr, { [INPUT]: "" }),
|
|
161
|
+
true,
|
|
162
|
+
`${WORKFLOW}:${site.line} (${site.job}) — an empty input must still resolve to the full clean`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a non-empty input hands the clean to the hygiene step at every consumer checkout", () => {
|
|
168
|
+
for (const site of CONSUMER_CHECKOUTS) {
|
|
169
|
+
const expr = cleanExpression(site.body);
|
|
170
|
+
assert.equal(
|
|
171
|
+
evaluate(expr, { [INPUT]: "node_modules" }),
|
|
172
|
+
false,
|
|
173
|
+
`${WORKFLOW}:${site.line} (${site.job}) — a non-empty input must take the clean off actions/checkout`,
|
|
174
|
+
);
|
|
175
|
+
assert.ok(
|
|
176
|
+
isHygieneStep(site.next),
|
|
177
|
+
`${WORKFLOW}:${site.line} (${site.job}) — consumer checkout is not immediately followed by ` +
|
|
178
|
+
`\`*${HYGIENE_ANCHOR}\`, so with \`clean: false\` this job would keep the previous job's untracked tree`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("the platform side-checkouts are left alone", () => {
|
|
184
|
+
assert.ok(PLATFORM_CHECKOUTS.length > 0, "expected at least one platform side-checkout to exist");
|
|
185
|
+
for (const site of PLATFORM_CHECKOUTS) {
|
|
186
|
+
assert.match(
|
|
187
|
+
site.body,
|
|
188
|
+
/^\s*path:\s*_mandrel-platform/m,
|
|
189
|
+
`${WORKFLOW}:${site.line} — a platform checkout must stay path-scoped`,
|
|
190
|
+
);
|
|
191
|
+
assert.equal(
|
|
192
|
+
cleanExpression(site.body),
|
|
193
|
+
null,
|
|
194
|
+
`${WORKFLOW}:${site.line} — the side-checkouts are out of scope for ${INPUT} (path-scoped and small)`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("the hygiene step runs exactly when the checkouts stop cleaning", () => {
|
|
200
|
+
const guard = HYGIENE_BLOCK.match(/^\s*if:\s*\$\{\{(.+)\}\}\s*$/m);
|
|
201
|
+
assert.ok(guard, "the hygiene step must be guarded by an `if:` expression");
|
|
202
|
+
const expr = guard[1].trim();
|
|
203
|
+
// The complement of the `clean:` gate, evaluated rather than eyeballed: a
|
|
204
|
+
// gate and a guard that can disagree either clean twice or not at all.
|
|
205
|
+
assert.equal(evaluate(expr, { [INPUT]: "" }), false, "empty input must skip the hygiene step");
|
|
206
|
+
assert.equal(evaluate(expr, { [INPUT]: "node_modules" }), true, "a non-empty input must run the hygiene step");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// AC-4 — the caller's string never reaches the shell as workflow text
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
test("the caller value reaches the hygiene step through env, never interpolation", () => {
|
|
214
|
+
assert.ok(
|
|
215
|
+
!HYGIENE_RUN.includes("${{"),
|
|
216
|
+
"the hygiene `run:` body must contain no `${{ }}` — an interpolated caller string is spliced into the " +
|
|
217
|
+
"script before bash parses it (rules/security-baseline.md)",
|
|
218
|
+
);
|
|
219
|
+
assert.match(
|
|
220
|
+
withoutComments(HYGIENE_BLOCK),
|
|
221
|
+
/^\s*CHECKOUT_CLEAN_EXCLUDES:\s*\$\{\{\s*inputs\.checkout-clean-excludes\s*\}\}\s*$/m,
|
|
222
|
+
"the value must arrive through `env:`",
|
|
223
|
+
);
|
|
224
|
+
assert.match(HYGIENE_RUN, /\$\{CHECKOUT_CLEAN_EXCLUDES\}/, "the body must read the environment variable");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("each pattern is passed to git clean as one literal -e operand", () => {
|
|
228
|
+
const dir = mkdtempSync(join(tmpdir(), "clean-excludes-argv-"));
|
|
229
|
+
const log = join(dir, "argv.log");
|
|
230
|
+
const bin = join(dir, "bin");
|
|
231
|
+
mkdirSync(bin);
|
|
232
|
+
// A stub `git` that records argv verbatim, one argument per line, so an
|
|
233
|
+
// argument that was split by the shell is visible as two lines.
|
|
234
|
+
const stub = join(bin, "git");
|
|
235
|
+
writeFileSync(stub, `#!/bin/sh\nprintf '%s\\n' "$@" >> "${log}"\nprintf -- '--\\n' >> "${log}"\nexit 0\n`);
|
|
236
|
+
chmodSync(stub, 0o755);
|
|
237
|
+
|
|
238
|
+
// Deliberately hostile: a leading dash (could be read as a git flag), a
|
|
239
|
+
// command substitution and a semicolon (could be executed), an embedded
|
|
240
|
+
// space (could be split into two operands), and a blank line.
|
|
241
|
+
const patterns = ["-rf", "$(touch pwned)", "two words;echo hi", "", "node_modules"];
|
|
242
|
+
execFileSync("bash", ["-c", HYGIENE_RUN], {
|
|
243
|
+
cwd: dir,
|
|
244
|
+
env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, CHECKOUT_CLEAN_EXCLUDES: patterns.join("\n") },
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const invocations = readFileSync(log, "utf8")
|
|
248
|
+
.split("--\n")
|
|
249
|
+
.filter((chunk) => chunk.trim() !== "")
|
|
250
|
+
.map((chunk) => chunk.split("\n").filter((line) => line !== ""));
|
|
251
|
+
|
|
252
|
+
assert.deepEqual(invocations, [
|
|
253
|
+
["clean", "-ffdx", "-e", "-rf", "-e", "$(touch pwned)", "-e", "two words;echo hi", "-e", "node_modules"],
|
|
254
|
+
["reset", "--hard", "HEAD"],
|
|
255
|
+
]);
|
|
256
|
+
assert.ok(!existsSync(join(dir, "pwned")), "a command substitution in a pattern must never be evaluated");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// AC-3 — the hygiene step preserves exactly what it is told to
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
test("the hygiene step preserves the named paths and discards everything else", () => {
|
|
264
|
+
const dir = mkdtempSync(join(tmpdir(), "clean-excludes-repo-"));
|
|
265
|
+
const git = (...args) => execFileSync("git", args, { cwd: dir, encoding: "utf8" });
|
|
266
|
+
|
|
267
|
+
git("init", "-q", "-b", "main", ".");
|
|
268
|
+
writeFileSync(join(dir, ".gitignore"), "node_modules/\nbuild-cache/\nstray.log\n");
|
|
269
|
+
writeFileSync(join(dir, "tracked.txt"), "committed\n");
|
|
270
|
+
git("add", "-A");
|
|
271
|
+
git("-c", "user.email=guard@example.test", "-c", "user.name=guard", "commit", "-qm", "fixture");
|
|
272
|
+
|
|
273
|
+
// What a reused self-hosted workspace looks like at the start of the next job.
|
|
274
|
+
mkdirSync(join(dir, "node_modules/.pnpm"), { recursive: true });
|
|
275
|
+
writeFileSync(join(dir, "node_modules/.pnpm/lock.yaml"), "warm\n");
|
|
276
|
+
mkdirSync(join(dir, "build-cache"), { recursive: true });
|
|
277
|
+
writeFileSync(join(dir, "build-cache/out.bin"), "cold\n");
|
|
278
|
+
writeFileSync(join(dir, "stray.log"), "ignored\n");
|
|
279
|
+
writeFileSync(join(dir, "leftover.txt"), "untracked\n");
|
|
280
|
+
writeFileSync(join(dir, "tracked.txt"), "committed\nlocally modified\n");
|
|
281
|
+
|
|
282
|
+
execFileSync("bash", ["-c", HYGIENE_RUN], {
|
|
283
|
+
cwd: dir,
|
|
284
|
+
env: { ...process.env, CHECKOUT_CLEAN_EXCLUDES: "node_modules/\n" },
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
assert.ok(
|
|
288
|
+
existsSync(join(dir, "node_modules/.pnpm/lock.yaml")),
|
|
289
|
+
"the named path must survive, nested content included — that is the whole point of the input",
|
|
290
|
+
);
|
|
291
|
+
assert.ok(!existsSync(join(dir, "build-cache")), "an ignored directory that was NOT named must be removed");
|
|
292
|
+
assert.ok(!existsSync(join(dir, "stray.log")), "an ignored file that was NOT named must be removed");
|
|
293
|
+
assert.ok(!existsSync(join(dir, "leftover.txt")), "an untracked file must be removed");
|
|
294
|
+
assert.equal(
|
|
295
|
+
readFileSync(join(dir, "tracked.txt"), "utf8"),
|
|
296
|
+
"committed\n",
|
|
297
|
+
"a modified tracked file must be restored",
|
|
298
|
+
);
|
|
299
|
+
assert.equal(git("status", "--porcelain").trim(), "", "the tree must be clean afterwards");
|
|
300
|
+
});
|