mandrel-platform 1.5.0 → 1.6.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.
@@ -39,6 +39,20 @@
39
39
  * job". Requires git history (run CI checkout with fetch-depth: 0); the
40
40
  * check degrades to a skipped NOTE when the pinned blob is unreachable.
41
41
  *
42
+ * 4. `runs-on: ${{ inputs.runner }}` — consuming a `runner` input RAW is
43
+ * PROHIBITED. GitHub does not parse a JSON-array *string* in that
44
+ * position: it takes the entire text as ONE label name, so the
45
+ * documented `'["self-hosted","my-runner"]'` form matches no runner and
46
+ * every job sits `queued` until the 24-hour timeout. This one is worse
47
+ * than the others because it is not even loud — no job starts, so there
48
+ * are no logs, and `gh pr checks` reports `pending 0`, indistinguishable
49
+ * from a busy fleet. Normalize to
50
+ * `fromJSON(startsWith(inputs.runner, '[') && inputs.runner ||
51
+ * format('"{0}"', inputs.runner))`, which accepts both documented
52
+ * shapes. (Caused #419 / v1.5.0; behaviour is covered by
53
+ * check-runner-runs-on.test.mjs, which evaluates the real expression
54
+ * rather than matching its spelling.)
55
+ *
42
56
  * What this lint deliberately does NOT flag: `${{ }}` in `runs.steps[].with`
43
57
  * (e.g. `dest: ${{ inputs['pnpm-dest'] || format('{0}/pnpm', runner.temp) }}`)
44
58
  * is a VALID runtime expression. The lint only inspects `description` and
@@ -266,6 +280,31 @@ export function checkWorkflowContent(content) {
266
280
  }
267
281
  }
268
282
 
283
+
284
+ // Rule 4: no `runs-on` that consumes a `runner` input raw. GitHub does not
285
+ // parse a JSON-array STRING in that position — it takes the whole text as one
286
+ // label name — so the documented `'["self-hosted","my-runner"]'` form matches
287
+ // no runner and every job sits `queued` until the 24-hour timeout. The failure
288
+ // is silent (no job starts, so no logs, and `gh pr checks` shows `pending 0`),
289
+ // which is why it needs a static tripwire as well as the behavioural guard in
290
+ // check-runner-runs-on.test.mjs.
291
+ content.split("\n").forEach((raw, idx) => {
292
+ const trimmed = raw.trim();
293
+ if (!trimmed.startsWith("runs-on:")) return;
294
+ const m = trimmed.match(/^runs-on:\s*\$\{\{(.+)\}\}\s*$/);
295
+ if (!m) return;
296
+ if (m[1].trim() !== "inputs.runner") return;
297
+ violations.push({
298
+ line: idx + 1,
299
+ message:
300
+ `\`runs-on: \${{ inputs.runner }}\` consumes the input raw — a ` +
301
+ `JSON-encoded label-array string resolves to ONE unmatchable label ` +
302
+ `name and the job queues until the 24-hour timeout, silently. ` +
303
+ `Normalize it: \`fromJSON(startsWith(inputs.runner, '[') && ` +
304
+ `inputs.runner || format('"{0}"', inputs.runner))\`.`,
305
+ });
306
+ });
307
+
269
308
  return violations;
270
309
  }
271
310
 
@@ -197,3 +197,69 @@ test("parseArgs: --no-pin-check, dir overrides, and --help are parsed", () => {
197
197
  help: true,
198
198
  });
199
199
  });
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Rule 4 — `runs-on` must not consume a `runner` input raw (Story #421)
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /** A minimal reusable workflow whose single job's `runs-on` is `value`. */
206
+ function runnerWorkflow(value) {
207
+ return [
208
+ "on:",
209
+ " workflow_call:",
210
+ " inputs:",
211
+ " runner:",
212
+ " required: false",
213
+ " type: string",
214
+ " default: 'ubuntu-latest'",
215
+ "jobs:",
216
+ " build:",
217
+ ` runs-on: ${value}`,
218
+ " steps:",
219
+ " - run: echo hi",
220
+ "",
221
+ ].join("\n");
222
+ }
223
+
224
+ const NORMALIZED =
225
+ "${{ fromJSON(startsWith(inputs.runner, '[') && inputs.runner " +
226
+ "|| format('\"{0}\"', inputs.runner)) }}";
227
+
228
+ test("Rule 4: a raw `runs-on: ${{ inputs.runner }}` is a violation", () => {
229
+ const violations = checkWorkflowContent(runnerWorkflow("${{ inputs.runner }}"));
230
+ assert.equal(violations.length, 1);
231
+ assert.equal(violations[0].line, 10);
232
+ assert.match(violations[0].message, /consumes the input raw/);
233
+ // The message must name the failure mode, not just the rule — a silent
234
+ // 24-hour queue is not something a reader infers from "normalize this".
235
+ assert.match(violations[0].message, /queues until the 24-hour timeout/);
236
+ });
237
+
238
+ test("Rule 4: the normalized form is clean", () => {
239
+ assert.deepEqual(checkWorkflowContent(runnerWorkflow(NORMALIZED)), []);
240
+ });
241
+
242
+ test("Rule 4: a hardcoded `runs-on` is untouched", () => {
243
+ assert.deepEqual(checkWorkflowContent(runnerWorkflow("ubuntu-latest")), []);
244
+ });
245
+
246
+ test("Rule 4: an unrelated expression in `runs-on` is untouched", () => {
247
+ // Only the exact raw-input read is flagged; a caller doing its own
248
+ // normalization or reading a different context is none of this rule's business.
249
+ assert.deepEqual(checkWorkflowContent(runnerWorkflow("${{ fromJSON(vars.CI_RUNNER) }}")), []);
250
+ assert.deepEqual(checkWorkflowContent(runnerWorkflow("${{ inputs.runner-label }}")), []);
251
+ });
252
+
253
+ test("Rule 4: only reusable workflows are checked", () => {
254
+ // A `push`-triggered workflow has no workflow_call interface to break.
255
+ const plain = [
256
+ "on:",
257
+ " push:",
258
+ " branches: [main]",
259
+ "jobs:",
260
+ " build:",
261
+ " runs-on: ${{ inputs.runner }}",
262
+ "",
263
+ ].join("\n");
264
+ assert.deepEqual(checkWorkflowContent(plain), []);
265
+ });
@@ -0,0 +1,271 @@
1
+ /**
2
+ * actions-expression.mjs — a minimal GitHub Actions expression evaluator.
3
+ *
4
+ * Extracted from check-toolchain-cache-default.test.mjs (Story #364) when a
5
+ * second guard needed it (Story #421). It exists because **a `grep` is not an
6
+ * acceptance criterion for a workflow expression**: string-matching pins the
7
+ * spelling, not the behaviour, and both expression defects this repo has
8
+ * shipped read correctly and evaluated wrong. Extract the real expression from
9
+ * the YAML and RUN it.
10
+ *
11
+ * Supported surface — only what the guarded expressions use: string literals,
12
+ * `inputs.<name>` / `inputs['<name>']` reads, `==`/`!=`, `&&`/`||`,
13
+ * parentheses, and `contains()`, `startsWith()`, `format()`, `fromJSON()`.
14
+ *
15
+ * The semantics that matter, and that a hand-read gets wrong:
16
+ *
17
+ * - `a && b` yields `b` when `a` is truthy, else `a`.
18
+ * - `a || b` yields `a` when `a` is truthy, else `b`.
19
+ * Both yield OPERANDS, not booleans.
20
+ * - EVERY non-empty string is truthy — including the string `'false'`.
21
+ * - Arrays and objects are truthy. GitHub documents the falsy set as exactly
22
+ * `false`, `0`, `-0`, `''`, `null`; an array is not in it.
23
+ * - String comparison is case-insensitive.
24
+ *
25
+ * ## On short-circuiting
26
+ *
27
+ * This evaluator parses to an AST first and evaluates `&&`/`||` lazily, which
28
+ * is the standard semantic. GitHub does NOT document whether its own evaluator
29
+ * short-circuits, so a guarded expression **must not depend on it** — never
30
+ * place a throwing call (`fromJSON` on caller-controlled text) in a branch that
31
+ * is only conditionally reached. Wrap the conditional instead:
32
+ *
33
+ * fromJSON(cond && '<json>' || '<json>') ← safe under either semantic
34
+ * cond && fromJSON(x) || x ← safe ONLY if lazy
35
+ *
36
+ * Evaluating lazily here while requiring the safe shape in the workflows means
37
+ * the guard cannot bless an expression whose correctness rests on an
38
+ * unverified assumption about GitHub's evaluator.
39
+ *
40
+ * Errors are thrown as plain `Error`s; nothing here depends on a test framework.
41
+ */
42
+
43
+ /** Actions truthiness — NOT JavaScript's. */
44
+ export function truthy(v) {
45
+ if (typeof v === "boolean") return v;
46
+ if (typeof v === "number") return v !== 0;
47
+ if (typeof v === "string") return v !== "";
48
+ return v !== null && v !== undefined;
49
+ }
50
+
51
+ /** Actions comparison: case-insensitive for strings. */
52
+ export function looseEqual(a, b) {
53
+ if (typeof a === "string" && typeof b === "string") {
54
+ return a.toLowerCase() === b.toLowerCase();
55
+ }
56
+ return a === b;
57
+ }
58
+
59
+ function fail(message) {
60
+ throw new Error(`[actions-expression] ${message}`);
61
+ }
62
+
63
+ const FUNCTIONS = new Map([
64
+ ["contains", 2],
65
+ ["startsWith", 2],
66
+ ["endsWith", 2],
67
+ ["fromJSON", 1],
68
+ ]);
69
+
70
+ /** Parse `expr` into an AST. Exported for tests that assert on shape. */
71
+ export function parse(expr) {
72
+ let i = 0;
73
+
74
+ const ws = () => {
75
+ while (i < expr.length && /\s/.test(expr[i])) i++;
76
+ };
77
+ const eat = (token) => {
78
+ ws();
79
+ if (expr.startsWith(token, i)) {
80
+ i += token.length;
81
+ return true;
82
+ }
83
+ return false;
84
+ };
85
+
86
+ function parseOr() {
87
+ let left = parseAnd();
88
+ for (;;) {
89
+ ws();
90
+ if (!eat("||")) return left;
91
+ left = { kind: "or", left, right: parseAnd() };
92
+ }
93
+ }
94
+
95
+ function parseAnd() {
96
+ let left = parseCompare();
97
+ for (;;) {
98
+ ws();
99
+ if (!eat("&&")) return left;
100
+ left = { kind: "and", left, right: parseCompare() };
101
+ }
102
+ }
103
+
104
+ function parseCompare() {
105
+ const left = parsePrimary();
106
+ ws();
107
+ if (eat("!=")) return { kind: "ne", left, right: parsePrimary() };
108
+ if (eat("==")) return { kind: "eq", left, right: parsePrimary() };
109
+ return left;
110
+ }
111
+
112
+ function parsePrimary() {
113
+ ws();
114
+ if (eat("(")) {
115
+ const node = parseOr();
116
+ ws();
117
+ if (!eat(")")) fail(`unbalanced parenthesis at ${i} in: ${expr}`);
118
+ return node;
119
+ }
120
+ if (expr[i] === "'") {
121
+ i++;
122
+ let out = "";
123
+ while (i < expr.length) {
124
+ if (expr[i] === "'" && expr[i + 1] === "'") {
125
+ out += "'";
126
+ i += 2;
127
+ continue;
128
+ }
129
+ if (expr[i] === "'") {
130
+ i++;
131
+ return { kind: "literal", value: out };
132
+ }
133
+ out += expr[i++];
134
+ }
135
+ fail(`unterminated string literal in: ${expr}`);
136
+ }
137
+ for (const [name, arity] of FUNCTIONS) {
138
+ if (!expr.startsWith(`${name}(`, i)) continue;
139
+ i += `${name}(`.length;
140
+ const args = [parseOr()];
141
+ while (args.length < arity) {
142
+ ws();
143
+ if (!eat(",")) fail(`${name}() expects ${arity} arguments in: ${expr}`);
144
+ args.push(parseOr());
145
+ }
146
+ ws();
147
+ if (!eat(")")) fail(`unclosed ${name}() in: ${expr}`);
148
+ return { kind: "call", name, args };
149
+ }
150
+ // `format()` is variadic, so it is parsed separately from the fixed-arity table.
151
+ if (expr.startsWith("format(", i)) {
152
+ i += "format(".length;
153
+ const args = [parseOr()];
154
+ for (;;) {
155
+ ws();
156
+ if (!eat(",")) break;
157
+ args.push(parseOr());
158
+ }
159
+ ws();
160
+ if (!eat(")")) fail(`unclosed format() in: ${expr}`);
161
+ return { kind: "call", name: "format", args };
162
+ }
163
+ const ident = expr.slice(i).match(/^[A-Za-z_][A-Za-z0-9_.\-]*(\['[^']*'\])?/);
164
+ if (!ident) fail(`unparseable token at ${i} in: ${expr}`);
165
+ i += ident[0].length;
166
+ const raw = ident[0];
167
+ if (raw === "true") return { kind: "literal", value: true };
168
+ if (raw === "false") return { kind: "literal", value: false };
169
+ const bracket = raw.match(/^inputs\['([^']*)'\]$/);
170
+ const dotted = raw.match(/^inputs\.(.+)$/);
171
+ const key = bracket ? bracket[1] : dotted ? dotted[1] : null;
172
+ if (key === null) fail(`unsupported context read \`${raw}\` in: ${expr}`);
173
+ return { kind: "input", key };
174
+ }
175
+
176
+ const ast = parseOr();
177
+ ws();
178
+ if (i !== expr.length) fail(`trailing tokens at ${i} in: ${expr}`);
179
+ return ast;
180
+ }
181
+
182
+ function applyFormat(args) {
183
+ const template = String(args[0]);
184
+ const rest = args.slice(1);
185
+ let out = "";
186
+ for (let k = 0; k < template.length; k++) {
187
+ const ch = template[k];
188
+ if (ch === "{" && template[k + 1] === "{") {
189
+ out += "{";
190
+ k++;
191
+ } else if (ch === "}" && template[k + 1] === "}") {
192
+ out += "}";
193
+ k++;
194
+ } else if (ch === "{") {
195
+ const close = template.indexOf("}", k);
196
+ if (close === -1) fail(`unclosed format placeholder in: ${template}`);
197
+ const idx = Number(template.slice(k + 1, close));
198
+ if (!Number.isInteger(idx)) fail(`non-numeric format placeholder in: ${template}`);
199
+ out += rest[idx] === undefined ? "" : String(rest[idx]);
200
+ k = close;
201
+ } else {
202
+ out += ch;
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+
208
+ function evalNode(node, inputs) {
209
+ switch (node.kind) {
210
+ case "literal":
211
+ return node.value;
212
+ case "input":
213
+ if (!(node.key in inputs)) {
214
+ fail(`expression reads \`inputs.${node.key}\`, not provided by the caller`);
215
+ }
216
+ return inputs[node.key];
217
+ case "and": {
218
+ // Lazy on purpose — see the module header's note on short-circuiting.
219
+ const left = evalNode(node.left, inputs);
220
+ return truthy(left) ? evalNode(node.right, inputs) : left;
221
+ }
222
+ case "or": {
223
+ const left = evalNode(node.left, inputs);
224
+ return truthy(left) ? left : evalNode(node.right, inputs);
225
+ }
226
+ case "eq":
227
+ return looseEqual(evalNode(node.left, inputs), evalNode(node.right, inputs));
228
+ case "ne":
229
+ return !looseEqual(evalNode(node.left, inputs), evalNode(node.right, inputs));
230
+ case "call": {
231
+ const args = node.args.map((a) => evalNode(a, inputs));
232
+ if (node.name === "format") return applyFormat(args);
233
+ const [a, b] = args;
234
+ if (node.name === "contains") {
235
+ // Substring on a string haystack; exact-item match on an array one.
236
+ if (Array.isArray(a)) return a.some((item) => looseEqual(item, b));
237
+ return String(a).toLowerCase().includes(String(b).toLowerCase());
238
+ }
239
+ if (node.name === "startsWith") {
240
+ return String(a).toLowerCase().startsWith(String(b).toLowerCase());
241
+ }
242
+ if (node.name === "endsWith") {
243
+ return String(a).toLowerCase().endsWith(String(b).toLowerCase());
244
+ }
245
+ if (node.name === "fromJSON") {
246
+ try {
247
+ return JSON.parse(String(a));
248
+ } catch {
249
+ // A parse failure is a HARD expression error in Actions — the run
250
+ // fails rather than yielding null, which is what makes a malformed
251
+ // `runner` value loud instead of silently unmatchable.
252
+ fail(`fromJSON() could not parse ${JSON.stringify(String(a))} in this expression`);
253
+ }
254
+ }
255
+ return fail(`unsupported function \`${node.name}\``);
256
+ }
257
+ default:
258
+ return fail(`unsupported node kind \`${node.kind}\``);
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Evaluate `expr` (the body BETWEEN `${{` and `}}`) against `inputs`.
264
+ *
265
+ * @param {string} expr
266
+ * @param {Record<string, unknown>} inputs values for `inputs.*` reads
267
+ * @returns {unknown} the resulting operand — a string, boolean, or array
268
+ */
269
+ export function evaluate(expr, inputs) {
270
+ return evalNode(parse(expr), inputs);
271
+ }
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * actions-expression.test.mjs — the evaluator must be trustworthy before it
4
+ * can judge a workflow (Story #421).
5
+ *
6
+ * These assertions are the reason the evaluator exists: every one of them is a
7
+ * place where Actions semantics diverge from JavaScript's, and where a
8
+ * hand-read of an expression gets the wrong answer.
9
+ *
10
+ * Run: node --test scripts/lib/actions-expression.test.mjs
11
+ */
12
+
13
+ import assert from "node:assert/strict";
14
+ import { test } from "node:test";
15
+ import { evaluate, truthy } from "./actions-expression.mjs";
16
+
17
+ test("`&&` and `||` yield OPERANDS, not booleans", () => {
18
+ assert.equal(evaluate("true && 'kept'", {}), "kept");
19
+ assert.equal(evaluate("'' || 'fallback'", {}), "fallback");
20
+ assert.equal(evaluate("'a' || 'b'", {}), "a");
21
+ });
22
+
23
+ test("every non-empty string is truthy — including the string 'false'", () => {
24
+ // The trap the toolchain-cache expression depends on: an explicitly-pinned
25
+ // 'false' must survive the first arm of the ternary rather than falling
26
+ // through to the derivation.
27
+ assert.equal(evaluate("'false' && 'yes' || 'no'", {}), "yes");
28
+ assert.equal(truthy("false"), true);
29
+ assert.equal(truthy(""), false);
30
+ });
31
+
32
+ test("arrays are truthy — the property the runs-on normalization rests on", () => {
33
+ // GitHub documents the falsy set as exactly false, 0, -0, '', null. An array
34
+ // is not in it, so a parsed label array carries through `&&` / `||`.
35
+ assert.equal(truthy([]), true);
36
+ assert.equal(truthy(["self-hosted"]), true);
37
+ });
38
+
39
+ test("comparison is case-insensitive", () => {
40
+ assert.equal(evaluate("'AUTO' == 'auto'", {}), true);
41
+ assert.equal(evaluate("'AUTO' != 'auto'", {}), false);
42
+ });
43
+
44
+ test("contains() is substring on a string and exact-item on an array", () => {
45
+ assert.equal(evaluate("contains('[\"self-hosted\",\"x\"]', 'self-hosted')", {}), true);
46
+ assert.equal(evaluate("contains(inputs.r, 'self-hosted')", { r: ["self-hosted", "x"] }), true);
47
+ // Exact-item, NOT substring, once the haystack is an array.
48
+ assert.equal(evaluate("contains(inputs.r, 'self')", { r: ["self-hosted"] }), false);
49
+ });
50
+
51
+ test("startsWith() casts to string and is case-insensitive", () => {
52
+ assert.equal(evaluate("startsWith('[\"a\"]', '[')", {}), true);
53
+ assert.equal(evaluate("startsWith('ubuntu-latest', '[')", {}), false);
54
+ assert.equal(evaluate("startsWith('UBUNTU', 'ub')", {}), true);
55
+ });
56
+
57
+ test("format() substitutes positionally and unescapes doubled braces", () => {
58
+ assert.equal(evaluate("format('\"{0}\"', inputs.r)", { r: "my-runner" }), '"my-runner"');
59
+ assert.equal(evaluate("format('{{{0}}}', inputs.r)", { r: "x" }), "{x}");
60
+ });
61
+
62
+ test("fromJSON() parses arrays and scalars", () => {
63
+ assert.deepEqual(evaluate("fromJSON('[\"self-hosted\",\"x\"]')", {}), ["self-hosted", "x"]);
64
+ assert.equal(evaluate("fromJSON('\"bare-label\"')", {}), "bare-label");
65
+ });
66
+
67
+ test("fromJSON() on malformed input THROWS — it does not yield null", () => {
68
+ // Load-bearing: in Actions a fromJSON parse failure fails the run. If it
69
+ // degraded to null here, a malformed `runner` would look like a clean
70
+ // fallback in the guard while hanging forever in production.
71
+ assert.throws(() => evaluate("fromJSON('[\"unterminated')", {}), /could not parse/);
72
+ });
73
+
74
+ test("`&&` / `||` short-circuit, so an unreached branch is never evaluated", () => {
75
+ // The evaluator is lazy. Guarded workflow expressions must NOT rely on that
76
+ // (GitHub does not document its own behaviour here) — but modelling it
77
+ // faithfully keeps the guard honest about what it is asserting.
78
+ assert.equal(evaluate("false && fromJSON('nonsense') || 'safe'", {}), "safe");
79
+ });
80
+
81
+ test("an unknown input read is an error, not a silent undefined", () => {
82
+ assert.throws(() => evaluate("inputs.missing", {}), /not provided by the caller/);
83
+ });