mandrel-platform 1.4.2 → 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/apply-uptime-monitors.mjs +6 -2
- package/scripts/check-affected-mode.test.mjs +54 -1
- package/scripts/check-coverage-threshold.test.mjs +1 -1
- package/scripts/check-destructive-migration.mjs +6 -2
- package/scripts/check-osv-scan-mode.test.mjs +2 -2
- package/scripts/check-pin-drift.mjs +7 -5
- package/scripts/check-repo-settings.mjs +6 -4
- package/scripts/check-ruleset.mjs +6 -4
- package/scripts/check-runner-health.mjs +6 -4
- package/scripts/check-runner-runs-on.test.mjs +153 -0
- package/scripts/check-toolchain-cache-default.test.mjs +6 -138
- package/scripts/check-workflow-gh-flags.mjs +6 -2
- package/scripts/check-workflow-platform-checkout.mjs +319 -0
- package/scripts/check-workflow-platform-checkout.test.mjs +342 -0
- package/scripts/check-workflow-portability.mjs +39 -0
- package/scripts/check-workflow-portability.test.mjs +66 -0
- package/scripts/check-wrangler-baseline.mjs +126 -8
- package/scripts/check-wrangler-baseline.test.mjs +258 -1
- package/scripts/deploy-boot-smoke.mjs +1 -1
- package/scripts/deploy-worker-secrets.mjs +1 -1
- package/scripts/lib/actions-expression.mjs +271 -0
- package/scripts/lib/actions-expression.test.mjs +83 -0
- package/scripts/lib/entry-guard.mjs +111 -0
- package/scripts/lib/entry-guard.test.mjs +179 -0
- package/scripts/platform-repair.mjs +6 -4
- package/scripts/track-issue.test.mjs +280 -0
- package/scripts/update-semgrep-rules.mjs +6 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mandrel-platform",
|
|
3
|
-
"version": "1.
|
|
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)'",
|
|
@@ -61,6 +61,8 @@
|
|
|
61
61
|
import { readFileSync } from "node:fs";
|
|
62
62
|
import { resolve } from "node:path";
|
|
63
63
|
|
|
64
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
65
|
+
|
|
64
66
|
// ---------------------------------------------------------------------------
|
|
65
67
|
// Monitor config schema (pure validation — no I/O)
|
|
66
68
|
// ---------------------------------------------------------------------------
|
|
@@ -436,7 +438,9 @@ async function main() {
|
|
|
436
438
|
}
|
|
437
439
|
}
|
|
438
440
|
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
+
// Direct-invocation guard — symlink-safe via the shared seam (Story #407):
|
|
442
|
+
// comparing an unresolved argv[1] against a realpath-resolved
|
|
443
|
+
// import.meta.url silently never matches under pnpm's symlinked node_modules.
|
|
444
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
441
445
|
main();
|
|
442
446
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `scripts/resolve-diff-range.sh` has solid unit coverage
|
|
8
8
|
* (`resolve-diff-range.test.mjs`), but the affected-mode *wiring* inside the
|
|
9
9
|
* reusable workflow was only covered indirectly by anchor-expansion checks.
|
|
10
|
-
* This suite pins the
|
|
10
|
+
* This suite pins the four invariants that keep the affected-mode design
|
|
11
11
|
* correct, modelled on the sibling `check-ci-required-aggregator.test.mjs`
|
|
12
12
|
* (read the real workflow, extract blocks by indentation, assert):
|
|
13
13
|
*
|
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
* `affected-base` override is rejected before the `$GITHUB_ENV` write, so
|
|
25
25
|
* it can never inject a second env line; a single-line override exports
|
|
26
26
|
* exactly `TURBO_SCM_BASE` / `TURBO_SCM_HEAD` and nothing else.
|
|
27
|
+
* 4. DOCUMENTED PRECONDITION (Story #413) — neither `.github/workflows/
|
|
28
|
+
* pr-quality.yml` nor `docs/reusable-workflows.md` claims affected mode
|
|
29
|
+
* can never miss a task (true only of an UNRESOLVABLE base), and both
|
|
30
|
+
* give the conditional consumer shape `turbo run <tier>
|
|
31
|
+
* ${TURBO_SCM_BASE:+--affected}` — the gate that stops a baked-in
|
|
32
|
+
* `--affected` from scheduling zero tasks on a push-to-`main` run.
|
|
27
33
|
*
|
|
28
34
|
* The `run:` script is executed against real bash with a stubbed
|
|
29
35
|
* `resolve-diff-range.sh` (the sourced derivation), so no git repo is needed;
|
|
@@ -180,3 +186,50 @@ test("single-line affected-base override exports exactly TURBO_SCM_BASE and TURB
|
|
|
180
186
|
const nonEmpty = r.envLines.split("\n").filter((l) => l.trim() !== "");
|
|
181
187
|
assert.equal(nonEmpty.length, 2, `expected exactly two env lines, got: ${JSON.stringify(nonEmpty)}`);
|
|
182
188
|
});
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// 4. DOCUMENTED PRECONDITION (Story #413) — the `affected` input's safety claim
|
|
192
|
+
//
|
|
193
|
+
// The old prose promised affected mode "can never miss a task", which is true
|
|
194
|
+
// only of the UNRESOLVABLE-base case. A `--affected` baked unconditionally into
|
|
195
|
+
// a consumer's package script hits the resolvable-but-EMPTY case instead: with
|
|
196
|
+
// `affected: false` the TURBO_SCM_* vars are never exported, turbo falls back
|
|
197
|
+
// to its own default base of `main`, and on a push to `main` that range is
|
|
198
|
+
// empty — zero tasks scheduled, exit 0, a silent no-op that reads as a pass.
|
|
199
|
+
// These assertions pin the correction in both consumer-facing surfaces so the
|
|
200
|
+
// absolute claim cannot return and the conditional shape cannot be dropped.
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
const DOCS = "docs/reusable-workflows.md";
|
|
204
|
+
|
|
205
|
+
/** The safe consumer shape: `--affected` only when a base was exported. */
|
|
206
|
+
const CONDITIONAL_FORM = "${TURBO_SCM_BASE:+--affected}";
|
|
207
|
+
|
|
208
|
+
/** Absolute claims that over-promise the fallback. Retired — must not return. */
|
|
209
|
+
const ABSOLUTE_CLAIMS = ["so this can never miss a task", "never a missed task"];
|
|
210
|
+
|
|
211
|
+
for (const [label, relPath] of [
|
|
212
|
+
["the reusable workflow", WORKFLOW],
|
|
213
|
+
["the consumer documentation", DOCS],
|
|
214
|
+
]) {
|
|
215
|
+
test(`${label} states the affected-mode precondition conditionally, not absolutely`, () => {
|
|
216
|
+
const text = readFileSync(join(repoRoot, relPath), "utf8");
|
|
217
|
+
// Case-insensitive: the claim is retired as a claim, not as a casing.
|
|
218
|
+
const haystack = text.toLowerCase();
|
|
219
|
+
|
|
220
|
+
for (const claim of ABSOLUTE_CLAIMS) {
|
|
221
|
+
assert.ok(
|
|
222
|
+
!haystack.includes(claim),
|
|
223
|
+
`${relPath} still claims "${claim}" — the full-run fallback covers an ` +
|
|
224
|
+
"unresolvable base only, not a base that resolves to an empty range"
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
assert.ok(
|
|
229
|
+
text.includes(CONDITIONAL_FORM),
|
|
230
|
+
`${relPath} must recommend \`turbo run <tier> ${CONDITIONAL_FORM}\` — gating ` +
|
|
231
|
+
"the flag on the exported base is what keeps a push-to-`main` run from " +
|
|
232
|
+
"silently scheduling zero tasks"
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
@@ -485,7 +485,7 @@ test("formatVerdict renders a skip line for the disabled gate", () => {
|
|
|
485
485
|
//
|
|
486
486
|
// The workflow's "Coverage threshold gate" step no longer embeds a copy of
|
|
487
487
|
// this script (Story #230): it sparse-side-checkouts mandrel-platform at
|
|
488
|
-
// `
|
|
488
|
+
// `job.workflow_sha` into `_mandrel-platform-scripts/` and runs
|
|
489
489
|
// `scripts/check-coverage-threshold.mjs` directly. These tests exercise that
|
|
490
490
|
// exact invocation shape — the real script, run from the side-checkout path,
|
|
491
491
|
// with the workflow's `--threshold` / `--metric` args and the consumer
|
|
@@ -84,6 +84,8 @@
|
|
|
84
84
|
import { appendFileSync, readFileSync } from "node:fs";
|
|
85
85
|
import { resolve } from "node:path";
|
|
86
86
|
|
|
87
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
88
|
+
|
|
87
89
|
// The override acknowledgement label. Documented in docs/reusable-workflows.md.
|
|
88
90
|
export const DEFAULT_OVERRIDE_LABEL = "migration:destructive-ok";
|
|
89
91
|
|
|
@@ -635,7 +637,9 @@ function main() {
|
|
|
635
637
|
process.exit(1);
|
|
636
638
|
}
|
|
637
639
|
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
+
// Direct-invocation guard — symlink-safe via the shared seam (Story #407):
|
|
641
|
+
// comparing an unresolved argv[1] against a realpath-resolved
|
|
642
|
+
// import.meta.url silently never matches under pnpm's symlinked node_modules.
|
|
643
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
640
644
|
main();
|
|
641
645
|
}
|
|
@@ -191,8 +191,8 @@ test("the diff-range resolver is side-checked-out from the pinned platform SHA",
|
|
|
191
191
|
assert.match(block, /repository:\s*dsj1984\/mandrel-platform/);
|
|
192
192
|
assert.match(
|
|
193
193
|
block,
|
|
194
|
-
/
|
|
195
|
-
"must resolve THIS repo at the SHA the caller pinned, not a floating ref",
|
|
194
|
+
/ref: \$\{\{ steps\.[A-Za-z0-9_-]+\.outputs\.sha \}\}/,
|
|
195
|
+
"must resolve THIS repo at the SHA the caller pinned, not a floating ref — and via a fail-closed resolve step, so an unresolvable ref stops the job instead of silently checking out the default branch (Story #415)",
|
|
196
196
|
);
|
|
197
197
|
assert.match(
|
|
198
198
|
block,
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
* that has no canonical pin to track at all (`orphan`). A stale literal is
|
|
59
59
|
* a real configuration error and is never suppressed by the
|
|
60
60
|
* `minimumReleaseAge` hold. The fix is to adopt the resolved-ref step
|
|
61
|
-
* summary `deploy-cloudflare.yml` now emits (its `
|
|
61
|
+
* summary `deploy-cloudflare.yml` now emits (its `job.workflow_sha`
|
|
62
62
|
* single source of truth) rather than maintaining the literal by hand.
|
|
63
63
|
*
|
|
64
64
|
* Data-driven: a new consumer is one object in pin-drift-consumers.json.
|
|
@@ -97,6 +97,8 @@ import {
|
|
|
97
97
|
parseSemver,
|
|
98
98
|
} from "./lib/semver-duration.mjs";
|
|
99
99
|
|
|
100
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
101
|
+
|
|
100
102
|
// Re-export the extracted seams so existing importers (platform-repair.mjs,
|
|
101
103
|
// the test suite) keep their `check-pin-drift.mjs` import paths. The canonical
|
|
102
104
|
// homes are scripts/lib/gh-json.mjs and scripts/lib/semver-duration.mjs
|
|
@@ -1114,9 +1116,9 @@ export function runCli({
|
|
|
1114
1116
|
return 0;
|
|
1115
1117
|
}
|
|
1116
1118
|
|
|
1117
|
-
// Direct-invocation guard
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
if (
|
|
1119
|
+
// Direct-invocation guard — symlink-safe via the shared seam (Story #407):
|
|
1120
|
+
// comparing an unresolved argv[1] against a realpath-resolved
|
|
1121
|
+
// import.meta.url silently never matches under pnpm's symlinked node_modules.
|
|
1122
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
1121
1123
|
process.exit(runCli());
|
|
1122
1124
|
}
|
|
@@ -42,6 +42,8 @@ import { readFileSync, appendFileSync } from "node:fs";
|
|
|
42
42
|
import { resolve } from "node:path";
|
|
43
43
|
import { execFileSync } from "node:child_process";
|
|
44
44
|
|
|
45
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
46
|
+
|
|
45
47
|
// ---------------------------------------------------------------------------
|
|
46
48
|
// Arg parsing
|
|
47
49
|
// ---------------------------------------------------------------------------
|
|
@@ -355,9 +357,9 @@ export function runCli({
|
|
|
355
357
|
return 0;
|
|
356
358
|
}
|
|
357
359
|
|
|
358
|
-
// Direct-invocation guard
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if (
|
|
360
|
+
// Direct-invocation guard — symlink-safe via the shared seam (Story #407):
|
|
361
|
+
// comparing an unresolved argv[1] against a realpath-resolved
|
|
362
|
+
// import.meta.url silently never matches under pnpm's symlinked node_modules.
|
|
363
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
362
364
|
process.exit(runCli());
|
|
363
365
|
}
|
|
@@ -64,6 +64,8 @@ import { readFileSync, appendFileSync } from "node:fs";
|
|
|
64
64
|
import { resolve } from "node:path";
|
|
65
65
|
import { execFileSync } from "node:child_process";
|
|
66
66
|
|
|
67
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
68
|
+
|
|
67
69
|
// ---------------------------------------------------------------------------
|
|
68
70
|
// Arg parsing
|
|
69
71
|
// ---------------------------------------------------------------------------
|
|
@@ -427,9 +429,9 @@ export function runCli({
|
|
|
427
429
|
return 0;
|
|
428
430
|
}
|
|
429
431
|
|
|
430
|
-
// Direct-invocation guard
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
if (
|
|
432
|
+
// Direct-invocation guard — symlink-safe via the shared seam (Story #407):
|
|
433
|
+
// comparing an unresolved argv[1] against a realpath-resolved
|
|
434
|
+
// import.meta.url silently never matches under pnpm's symlinked node_modules.
|
|
435
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
434
436
|
process.exit(runCli());
|
|
435
437
|
}
|
|
@@ -55,6 +55,8 @@ import { resolve } from "node:path";
|
|
|
55
55
|
|
|
56
56
|
import { defaultGhRunner, ghApiJson, isNotFound } from "./lib/gh-json.mjs";
|
|
57
57
|
|
|
58
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
59
|
+
|
|
58
60
|
// ---------------------------------------------------------------------------
|
|
59
61
|
// Arg parsing
|
|
60
62
|
// ---------------------------------------------------------------------------
|
|
@@ -461,9 +463,9 @@ export function runCli({
|
|
|
461
463
|
return 0;
|
|
462
464
|
}
|
|
463
465
|
|
|
464
|
-
// Direct-invocation guard
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
if (
|
|
466
|
+
// Direct-invocation guard — symlink-safe via the shared seam (Story #407):
|
|
467
|
+
// comparing an unresolved argv[1] against a realpath-resolved
|
|
468
|
+
// import.meta.url silently never matches under pnpm's symlinked node_modules.
|
|
469
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
468
470
|
process.exit(runCli());
|
|
469
471
|
}
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
35
35
|
import { join } from 'node:path';
|
|
36
36
|
|
|
37
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
38
|
+
|
|
37
39
|
const WORKFLOW_DIRS = ['.github/workflows', 'templates/workflows'];
|
|
38
40
|
|
|
39
41
|
/**
|
|
@@ -166,7 +168,9 @@ function main() {
|
|
|
166
168
|
return 1;
|
|
167
169
|
}
|
|
168
170
|
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
+
// Direct-invocation guard — symlink-safe via the shared seam (Story #407):
|
|
172
|
+
// comparing an unresolved argv[1] against a realpath-resolved
|
|
173
|
+
// import.meta.url silently never matches under pnpm's symlinked node_modules.
|
|
174
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
171
175
|
process.exit(main());
|
|
172
176
|
}
|