mandrel-platform 1.5.0 → 1.5.1
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 +2 -2
- package/scripts/check-runner-runs-on.test.mjs +153 -0
- package/scripts/check-toolchain-cache-default.test.mjs +6 -138
- package/scripts/check-workflow-portability.mjs +39 -0
- package/scripts/check-workflow-portability.test.mjs +66 -0
- package/scripts/lib/actions-expression.mjs +271 -0
- package/scripts/lib/actions-expression.test.mjs +83 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mandrel-platform",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.1",
|
|
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": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"provenance": true
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"mandrel": "^2.
|
|
47
|
+
"mandrel": "^2.38.0"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
50
|
"typecheck": "node --input-type=module --eval 'process.exit(0)'",
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-runner-runs-on.test.mjs — regression guard for the `runner` input's
|
|
4
|
+
* two documented shapes (Story #421).
|
|
5
|
+
*
|
|
6
|
+
* The bug this pins: every `runs-on:` site consumed the input raw as
|
|
7
|
+
* `${{ inputs.runner }}`. GitHub does not parse a JSON-array *string* in that
|
|
8
|
+
* position — it takes the entire text as ONE label name. A caller passing the
|
|
9
|
+
* documented `'["self-hosted","my-runner"]'` therefore targeted a label no
|
|
10
|
+
* runner carries, and every tier sat `queued` until the 24-hour timeout.
|
|
11
|
+
*
|
|
12
|
+
* It was silent. Nothing went red, no job started, so there were no logs, and
|
|
13
|
+
* `gh pr checks` reported `pending 0` — indistinguishable from a busy fleet.
|
|
14
|
+
* The tell was that caller-owned jobs went green while every reusable-workflow
|
|
15
|
+
* tier reported `pending 0`.
|
|
16
|
+
*
|
|
17
|
+
* Why this is not a grep. A `grep` cannot tell a resolvable `runs-on`
|
|
18
|
+
* expression from an unresolvable one — that is precisely how this shipped,
|
|
19
|
+
* past static checks that all passed. Asserting the expression's spelling
|
|
20
|
+
* would pin the wording and not the behaviour. So this EXTRACTS each real
|
|
21
|
+
* expression from the workflow and EVALUATES it under Actions semantics, the
|
|
22
|
+
* same read-then-execute approach as check-toolchain-cache-default.test.mjs.
|
|
23
|
+
*
|
|
24
|
+
* Run: node --test scripts/check-runner-runs-on.test.mjs
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import assert from "node:assert/strict";
|
|
28
|
+
import { test } from "node:test";
|
|
29
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
30
|
+
import { evaluate } from "./lib/actions-expression.mjs";
|
|
31
|
+
|
|
32
|
+
const WORKFLOW_DIR = ".github/workflows";
|
|
33
|
+
|
|
34
|
+
/** Every workflow that declares a `runner` workflow_call input. */
|
|
35
|
+
function runnerWorkflows() {
|
|
36
|
+
return readdirSync(WORKFLOW_DIR)
|
|
37
|
+
.filter((f) => f.endsWith(".yml"))
|
|
38
|
+
.map((f) => ({ file: `${WORKFLOW_DIR}/${f}`, text: readFileSync(`${WORKFLOW_DIR}/${f}`, "utf8") }))
|
|
39
|
+
.filter(({ text }) => /^\s{6}runner:$/m.test(text));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The `${{ … }}` bodies of every `runs-on:` value that reads `inputs.runner`.
|
|
44
|
+
* Scans lines rather than building a regex around the file — the block
|
|
45
|
+
* boundary is a single line, and a dynamically-constructed regex is a SAST
|
|
46
|
+
* finding that buys nothing here.
|
|
47
|
+
*/
|
|
48
|
+
function runsOnExpressions(text) {
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const [idx, line] of text.split("\n").entries()) {
|
|
51
|
+
const trimmed = line.trim();
|
|
52
|
+
if (!trimmed.startsWith("runs-on:")) continue;
|
|
53
|
+
if (!trimmed.includes("inputs.runner")) continue;
|
|
54
|
+
const m = trimmed.match(/^runs-on:\s*\$\{\{(.+)\}\}\s*$/);
|
|
55
|
+
assert.ok(m, `line ${idx + 1}: runs-on reads inputs.runner but is not a single expression: ${trimmed}`);
|
|
56
|
+
out.push({ line: idx + 1, expr: m[1].trim() });
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const WORKFLOWS = runnerWorkflows();
|
|
62
|
+
|
|
63
|
+
test("every workflow taking a `runner` input is covered by this guard", () => {
|
|
64
|
+
// A new reusable workflow with a `runner` input must not slip past unseen.
|
|
65
|
+
const names = WORKFLOWS.map(({ file }) => file.split("/").pop()).sort();
|
|
66
|
+
assert.deepEqual(names, [
|
|
67
|
+
"advisory-scan.yml",
|
|
68
|
+
"deploy-cloudflare.yml",
|
|
69
|
+
"pr-quality.yml",
|
|
70
|
+
"release-automation.yml",
|
|
71
|
+
"secret-scan-push.yml",
|
|
72
|
+
"uptime-apply.yml",
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("no `runs-on:` consumes `inputs.runner` raw", () => {
|
|
77
|
+
// The exact shape that shipped the bug. Kept as a cheap, legible tripwire
|
|
78
|
+
// alongside the behavioural assertions below.
|
|
79
|
+
for (const { file, text } of WORKFLOWS) {
|
|
80
|
+
for (const { line, expr } of runsOnExpressions(text)) {
|
|
81
|
+
assert.notEqual(
|
|
82
|
+
expr,
|
|
83
|
+
"inputs.runner",
|
|
84
|
+
`${file}:${line}: \`runs-on\` consumes the input raw — a JSON-array ` +
|
|
85
|
+
`string resolves to one unmatchable label name and the job queues forever`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
for (const { file, text } of WORKFLOWS) {
|
|
92
|
+
const sites = runsOnExpressions(text);
|
|
93
|
+
|
|
94
|
+
test(`${file}: has at least one runner-driven runs-on site`, () => {
|
|
95
|
+
assert.ok(sites.length > 0, "expected this workflow to drive runs-on from inputs.runner");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test(`${file}: a bare label resolves to that same string`, () => {
|
|
99
|
+
for (const { line, expr } of sites) {
|
|
100
|
+
assert.equal(evaluate(expr, { runner: "ubuntu-latest" }), "ubuntu-latest", `${file}:${line}`);
|
|
101
|
+
assert.equal(evaluate(expr, { runner: "beestera-runner" }), "beestera-runner", `${file}:${line}`);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test(`${file}: the documented JSON-array string resolves to a label ARRAY`, () => {
|
|
106
|
+
for (const { line, expr } of sites) {
|
|
107
|
+
assert.deepEqual(
|
|
108
|
+
evaluate(expr, { runner: '["self-hosted","beestera-runner"]' }),
|
|
109
|
+
["self-hosted", "beestera-runner"],
|
|
110
|
+
`${file}:${line}: the documented array form must yield real labels, ` +
|
|
111
|
+
`not one label named after the whole JSON text`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test(`${file}: a malformed array-shaped value fails loudly, not into the label branch`, () => {
|
|
117
|
+
// A silent fallback to the raw string would rebuild the original failure
|
|
118
|
+
// mode — an unmatchable label, queued until the 24-hour timeout — behind a
|
|
119
|
+
// fix that claims to have removed it.
|
|
120
|
+
for (const { line, expr } of sites) {
|
|
121
|
+
assert.throws(
|
|
122
|
+
() => evaluate(expr, { runner: '["unterminated' }),
|
|
123
|
+
/could not parse/,
|
|
124
|
+
`${file}:${line}: a '['-leading value that is not valid JSON must be a hard error`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test(`${file}: every runs-on site resolves identically`, () => {
|
|
130
|
+
// One workflow must not drift into two dialects of the same decision.
|
|
131
|
+
const rendered = sites.map(({ expr }) =>
|
|
132
|
+
JSON.stringify(evaluate(expr, { runner: '["self-hosted","x"]' })),
|
|
133
|
+
);
|
|
134
|
+
assert.equal(new Set(rendered).size, 1, `${file}: runs-on sites disagree: ${rendered.join(" | ")}`);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
test("the documented array form resolves AND derives toolchain-cache 'false'", () => {
|
|
139
|
+
// The coupling the gap report found: before this fix the only `runner` value
|
|
140
|
+
// that derived the correct cache posture was the one that never reached a
|
|
141
|
+
// runner, and the only value that reached a runner derived the wrong posture.
|
|
142
|
+
// A self-hosted caller could not get both right from the documented
|
|
143
|
+
// interface. Assert the pairing is now reachable in a single value.
|
|
144
|
+
const quality = readFileSync(`${WORKFLOW_DIR}/pr-quality.yml`, "utf8");
|
|
145
|
+
const runner = '["self-hosted","beestera-runner"]';
|
|
146
|
+
|
|
147
|
+
const [runsOn] = runsOnExpressions(quality);
|
|
148
|
+
assert.deepEqual(evaluate(runsOn.expr, { runner }), ["self-hosted", "beestera-runner"]);
|
|
149
|
+
|
|
150
|
+
const cache = quality.match(/^\s*cache:\s*\$\{\{(.+)\}\}\s*$/m);
|
|
151
|
+
assert.ok(cache, "no `cache: ${{ … }}` value found at the setup-toolchain call site");
|
|
152
|
+
assert.equal(evaluate(cache[1].trim(), { runner, "toolchain-cache": "auto" }), "false");
|
|
153
|
+
});
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import assert from "node:assert/strict";
|
|
32
32
|
import { test } from "node:test";
|
|
33
33
|
import { readFileSync } from "node:fs";
|
|
34
|
+
import { evaluate } from "./lib/actions-expression.mjs";
|
|
34
35
|
|
|
35
36
|
const QUALITY = ".github/workflows/pr-quality.yml";
|
|
36
37
|
const ADVISORY = ".github/workflows/advisory-scan.yml";
|
|
@@ -68,146 +69,13 @@ function inputDefault(text, name, file) {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
// ---------------------------------------------------------------------------
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// - `a && b` yields `b` when `a` is truthy, else `a`.
|
|
78
|
-
// - `a || b` yields `a` when `a` is truthy, else `b`.
|
|
79
|
-
// - EVERY non-empty string is truthy — including the string `'false'`.
|
|
80
|
-
// - String comparison is case-insensitive.
|
|
72
|
+
// The Actions expression evaluator now lives in scripts/lib/actions-expression.mjs
|
|
73
|
+
// (extracted by Story #421, when check-runner-runs-on.test.mjs needed the same
|
|
74
|
+
// read-then-execute approach). Its own semantics are covered by
|
|
75
|
+
// scripts/lib/actions-expression.test.mjs, so this file asserts only the
|
|
76
|
+
// toolchain-cache contract.
|
|
81
77
|
// ---------------------------------------------------------------------------
|
|
82
78
|
|
|
83
|
-
function truthy(v) {
|
|
84
|
-
if (typeof v === "boolean") return v;
|
|
85
|
-
if (typeof v === "number") return v !== 0;
|
|
86
|
-
if (typeof v === "string") return v !== "";
|
|
87
|
-
return v !== null && v !== undefined;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function looseEqual(a, b) {
|
|
91
|
-
if (typeof a === "string" && typeof b === "string") {
|
|
92
|
-
return a.toLowerCase() === b.toLowerCase();
|
|
93
|
-
}
|
|
94
|
-
return a === b;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function evaluate(expr, inputs) {
|
|
98
|
-
let i = 0;
|
|
99
|
-
|
|
100
|
-
const ws = () => {
|
|
101
|
-
while (i < expr.length && /\s/.test(expr[i])) i++;
|
|
102
|
-
};
|
|
103
|
-
const eat = (token) => {
|
|
104
|
-
ws();
|
|
105
|
-
if (expr.startsWith(token, i)) {
|
|
106
|
-
i += token.length;
|
|
107
|
-
return true;
|
|
108
|
-
}
|
|
109
|
-
return false;
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
function parseOr() {
|
|
113
|
-
let left = parseAnd();
|
|
114
|
-
for (;;) {
|
|
115
|
-
ws();
|
|
116
|
-
if (!eat("||")) return left;
|
|
117
|
-
const right = parseAnd();
|
|
118
|
-
left = truthy(left) ? left : right;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function parseAnd() {
|
|
123
|
-
let left = parseCompare();
|
|
124
|
-
for (;;) {
|
|
125
|
-
ws();
|
|
126
|
-
if (!eat("&&")) return left;
|
|
127
|
-
const right = parseCompare();
|
|
128
|
-
left = truthy(left) ? right : left;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function parseCompare() {
|
|
133
|
-
const left = parsePrimary();
|
|
134
|
-
ws();
|
|
135
|
-
if (eat("!=")) return !looseEqual(left, parsePrimary());
|
|
136
|
-
if (eat("==")) return looseEqual(left, parsePrimary());
|
|
137
|
-
return left;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function parsePrimary() {
|
|
141
|
-
ws();
|
|
142
|
-
if (eat("(")) {
|
|
143
|
-
const v = parseOr();
|
|
144
|
-
ws();
|
|
145
|
-
assert.ok(eat(")"), `unbalanced parenthesis at ${i} in: ${expr}`);
|
|
146
|
-
return v;
|
|
147
|
-
}
|
|
148
|
-
if (expr[i] === "'") {
|
|
149
|
-
i++;
|
|
150
|
-
let out = "";
|
|
151
|
-
while (i < expr.length) {
|
|
152
|
-
if (expr[i] === "'" && expr[i + 1] === "'") {
|
|
153
|
-
out += "'";
|
|
154
|
-
i += 2;
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
if (expr[i] === "'") {
|
|
158
|
-
i++;
|
|
159
|
-
return out;
|
|
160
|
-
}
|
|
161
|
-
out += expr[i++];
|
|
162
|
-
}
|
|
163
|
-
assert.fail(`unterminated string literal in: ${expr}`);
|
|
164
|
-
}
|
|
165
|
-
if (expr.startsWith("contains(", i)) {
|
|
166
|
-
i += "contains(".length;
|
|
167
|
-
const hay = parseOr();
|
|
168
|
-
ws();
|
|
169
|
-
assert.ok(eat(","), `contains() expects two arguments in: ${expr}`);
|
|
170
|
-
const needle = parseOr();
|
|
171
|
-
ws();
|
|
172
|
-
assert.ok(eat(")"), `unclosed contains() in: ${expr}`);
|
|
173
|
-
return String(hay).toLowerCase().includes(String(needle).toLowerCase());
|
|
174
|
-
}
|
|
175
|
-
const ident = expr.slice(i).match(/^[A-Za-z_][A-Za-z0-9_.\-]*(\['[^']*'\])?/);
|
|
176
|
-
assert.ok(ident, `unparseable token at ${i} in: ${expr}`);
|
|
177
|
-
i += ident[0].length;
|
|
178
|
-
const raw = ident[0];
|
|
179
|
-
if (raw === "true") return true;
|
|
180
|
-
if (raw === "false") return false;
|
|
181
|
-
const bracket = raw.match(/^inputs\['([^']*)'\]$/);
|
|
182
|
-
const dotted = raw.match(/^inputs\.(.+)$/);
|
|
183
|
-
const key = bracket ? bracket[1] : dotted ? dotted[1] : null;
|
|
184
|
-
assert.ok(key !== null, `unsupported context read \`${raw}\` in: ${expr}`);
|
|
185
|
-
assert.ok(key in inputs, `expression reads \`inputs.${key}\`, not provided by the test case`);
|
|
186
|
-
return inputs[key];
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const value = parseOr();
|
|
190
|
-
ws();
|
|
191
|
-
assert.equal(i, expr.length, `trailing tokens at ${i} in: ${expr}`);
|
|
192
|
-
return value;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// ---------------------------------------------------------------------------
|
|
196
|
-
// The evaluator itself must be trustworthy before it can judge the workflow.
|
|
197
|
-
// ---------------------------------------------------------------------------
|
|
198
|
-
|
|
199
|
-
test("the expression evaluator models Actions truthiness, not JavaScript's", () => {
|
|
200
|
-
// The trap the real expression depends on: the STRING 'false' is truthy, so
|
|
201
|
-
// an explicitly-pinned 'false' survives the first arm of the ternary.
|
|
202
|
-
assert.equal(evaluate("'false' && 'yes' || 'no'", {}), "yes");
|
|
203
|
-
// `&&`/`||` yield operands, not booleans.
|
|
204
|
-
assert.equal(evaluate("true && 'kept'", {}), "kept");
|
|
205
|
-
assert.equal(evaluate("'' || 'fallback'", {}), "fallback");
|
|
206
|
-
// Comparison is case-insensitive.
|
|
207
|
-
assert.equal(evaluate("'AUTO' == 'auto'", {}), true);
|
|
208
|
-
assert.equal(evaluate("contains('[\"self-hosted\",\"x\"]', 'self-hosted')", {}), true);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
79
|
// ---------------------------------------------------------------------------
|
|
212
80
|
// The contract
|
|
213
81
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|
+
});
|