mandrel-platform 1.13.3 → 1.15.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "1.13.3",
3
+ "version": "1.15.0",
4
4
  "description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
+ });