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
|
@@ -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
|
+
});
|
|
@@ -63,6 +63,8 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
63
63
|
import { resolve } from 'node:path';
|
|
64
64
|
import { parseArgs } from 'node:util';
|
|
65
65
|
|
|
66
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
67
|
+
|
|
66
68
|
// ---------------------------------------------------------------------------
|
|
67
69
|
// CLI argument parsing
|
|
68
70
|
// ---------------------------------------------------------------------------
|
|
@@ -153,13 +155,114 @@ export function resolveConfigPath(explicitFile, cwd = process.cwd()) {
|
|
|
153
155
|
// ---------------------------------------------------------------------------
|
|
154
156
|
|
|
155
157
|
/**
|
|
156
|
-
*
|
|
158
|
+
* Skip past a JSON string literal, honouring backslash escapes.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} text Full document.
|
|
161
|
+
* @param {number} start Index of the opening quote.
|
|
162
|
+
* @returns {number} Index just past the closing quote (or `text.length`).
|
|
163
|
+
*/
|
|
164
|
+
function skipString(text, start) {
|
|
165
|
+
let i = start + 1;
|
|
166
|
+
while (i < text.length) {
|
|
167
|
+
if (text[i] === '\\') {
|
|
168
|
+
i += 2;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (text[i] === '"') return i + 1;
|
|
172
|
+
i += 1;
|
|
173
|
+
}
|
|
174
|
+
return i;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Blank out `//` line comments and block comments, leaving every other byte —
|
|
179
|
+
* string-literal contents included — untouched.
|
|
180
|
+
*
|
|
181
|
+
* Character-scanned rather than regex-replaced. A regex stripping `//` to
|
|
182
|
+
* end-of-line cannot tell a comment from the `//` inside a string: the
|
|
183
|
+
* previous `(^|[^:])//` guard special-cased the scheme colon in
|
|
184
|
+
* `"https://…"`, but `"a//b"` still lost its tail. Comment bytes are replaced
|
|
185
|
+
* with spaces (newlines preserved) rather than deleted, so every offset is
|
|
186
|
+
* unchanged and a `JSON.parse` error still reports the position it actually
|
|
187
|
+
* occupies in the file the operator is looking at.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} text
|
|
190
|
+
* @returns {string} Same length as `text`, comments blanked.
|
|
191
|
+
*/
|
|
192
|
+
function blankComments(text) {
|
|
193
|
+
const out = text.split('');
|
|
194
|
+
let i = 0;
|
|
195
|
+
while (i < text.length) {
|
|
196
|
+
if (text[i] === '"') {
|
|
197
|
+
i = skipString(text, i);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (text[i] === '/' && text[i + 1] === '/') {
|
|
201
|
+
while (i < text.length && text[i] !== '\n') {
|
|
202
|
+
out[i] = ' ';
|
|
203
|
+
i += 1;
|
|
204
|
+
}
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (text[i] === '/' && text[i + 1] === '*') {
|
|
208
|
+
out[i] = ' ';
|
|
209
|
+
out[i + 1] = ' ';
|
|
210
|
+
i += 2;
|
|
211
|
+
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) {
|
|
212
|
+
if (text[i] !== '\n') out[i] = ' ';
|
|
213
|
+
i += 1;
|
|
214
|
+
}
|
|
215
|
+
if (i < text.length) {
|
|
216
|
+
out[i] = ' ';
|
|
217
|
+
out[i + 1] = ' ';
|
|
218
|
+
i += 2;
|
|
219
|
+
}
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
i += 1;
|
|
223
|
+
}
|
|
224
|
+
return out.join('');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Blank out trailing commas — a `,` whose next significant character closes
|
|
229
|
+
* its object or array. Legal JSONC, rejected by `JSON.parse`.
|
|
230
|
+
*
|
|
231
|
+
* Runs AFTER {@link blankComments} so a comment sitting between the comma and
|
|
232
|
+
* its closing brace (`[1, /* note *\/ ]`) cannot hide the trailing comma. Like
|
|
233
|
+
* that pass it substitutes spaces rather than deleting, preserving offsets.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} text Comment-blanked document.
|
|
236
|
+
* @returns {string} Same length as `text`, trailing commas blanked.
|
|
237
|
+
*/
|
|
238
|
+
function blankTrailingCommas(text) {
|
|
239
|
+
const out = text.split('');
|
|
240
|
+
let i = 0;
|
|
241
|
+
while (i < text.length) {
|
|
242
|
+
if (text[i] === '"') {
|
|
243
|
+
i = skipString(text, i);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (text[i] === ',') {
|
|
247
|
+
let j = i + 1;
|
|
248
|
+
while (j < text.length && /\s/.test(text[j])) j += 1;
|
|
249
|
+
if (text[j] === '}' || text[j] === ']') out[i] = ' ';
|
|
250
|
+
}
|
|
251
|
+
i += 1;
|
|
252
|
+
}
|
|
253
|
+
return out.join('');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Parse JSON tolerating what the `.jsonc` extension names: `//` line comments,
|
|
258
|
+
* block comments, and trailing commas in objects and arrays. String-literal
|
|
259
|
+
* contents pass through byte-identical.
|
|
260
|
+
*
|
|
157
261
|
* @param {string} text
|
|
158
262
|
* @returns {any}
|
|
159
263
|
*/
|
|
160
264
|
export function parseJsonc(text) {
|
|
161
|
-
|
|
162
|
-
return JSON.parse(stripped);
|
|
265
|
+
return JSON.parse(blankTrailingCommas(blankComments(text)));
|
|
163
266
|
}
|
|
164
267
|
|
|
165
268
|
/**
|
|
@@ -489,8 +592,21 @@ export function runCli({
|
|
|
489
592
|
const text = readFileSync(configPath, 'utf-8');
|
|
490
593
|
config = parseWranglerConfig(configPath, text);
|
|
491
594
|
} catch (err) {
|
|
492
|
-
|
|
493
|
-
|
|
595
|
+
// Advisory mode is a promise about EXIT CODES, not about which findings
|
|
596
|
+
// are reachable: under --warn-only nothing in the consumer's config may
|
|
597
|
+
// turn the gate red. A parse failure used to return 1 here, ahead of the
|
|
598
|
+
// warnOnly branch below, so an advisory-mode consumer with a legal
|
|
599
|
+
// trailing-comma .jsonc got a hard red on a gate that had opted out of
|
|
600
|
+
// blocking (Story #407). Route it through the same policy as violations.
|
|
601
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
602
|
+
const marker = warnOnly ? '⚠️ ' : '❌';
|
|
603
|
+
stderr.write(`[wrangler-baseline] ${marker} failed to parse ${configPath}: ${detail}\n`);
|
|
604
|
+
if (json) {
|
|
605
|
+
stdout.write(
|
|
606
|
+
`${JSON.stringify({ kind: 'wrangler-baseline-report', found: true, file: configPath, parseError: detail, violations: [] })}\n`,
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
return warnOnly ? 0 : 1;
|
|
494
610
|
}
|
|
495
611
|
|
|
496
612
|
const report = evaluateBaseline(config, maxAgeDays, now);
|
|
@@ -507,8 +623,10 @@ export function runCli({
|
|
|
507
623
|
return 0;
|
|
508
624
|
}
|
|
509
625
|
|
|
510
|
-
// Direct-invocation guard
|
|
511
|
-
|
|
512
|
-
|
|
626
|
+
// Direct-invocation guard — symlink-safe via the shared seam. The previous
|
|
627
|
+
// spelling compared a realpath-resolved `import.meta.url` against an
|
|
628
|
+
// unresolved `process.argv[1]`, which never matched under pnpm's symlinked
|
|
629
|
+
// node_modules: the CLI silently never ran (Story #407).
|
|
630
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
513
631
|
process.exit(runCli());
|
|
514
632
|
}
|
|
@@ -11,9 +11,17 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import assert from 'node:assert/strict';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
mkdtempSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
symlinkSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
} from 'node:fs';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
15
22
|
import { tmpdir } from 'node:os';
|
|
16
23
|
import { join } from 'node:path';
|
|
24
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
25
|
import { afterEach, beforeEach, test } from 'node:test';
|
|
18
26
|
import {
|
|
19
27
|
parseArgv,
|
|
@@ -452,3 +460,252 @@ test('runCli --help prints usage and exits 0 without touching the filesystem', (
|
|
|
452
460
|
assert.equal(exit, 0);
|
|
453
461
|
assert.match(streams.out, /check-wrangler-baseline\.mjs/);
|
|
454
462
|
});
|
|
463
|
+
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
// JSONC tolerance — trailing commas, and string contents left alone (#407)
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
test('parseJsonc tolerates trailing commas in objects and arrays', () => {
|
|
469
|
+
const text = `{
|
|
470
|
+
"name": "web",
|
|
471
|
+
"analytics_engine_datasets": [
|
|
472
|
+
{ "binding": "AE", "dataset": "events", },
|
|
473
|
+
],
|
|
474
|
+
}`;
|
|
475
|
+
assert.deepEqual(parseJsonc(text), {
|
|
476
|
+
name: 'web',
|
|
477
|
+
analytics_engine_datasets: [{ binding: 'AE', dataset: 'events' }],
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test('parseJsonc tolerates a trailing comma separated by a comment', () => {
|
|
482
|
+
const text = '{ "a": [1, 2, /* done */ ], }';
|
|
483
|
+
assert.deepEqual(parseJsonc(text), { a: [1, 2] });
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test('parseJsonc leaves string contents byte-identical', () => {
|
|
487
|
+
// Each of these would be corrupted by a regex-based comment/comma stripper:
|
|
488
|
+
// `,}` and `,]` look like trailing commas, and `//` looks like a comment
|
|
489
|
+
// even when it is not preceded by a scheme colon.
|
|
490
|
+
const text = JSON.stringify({
|
|
491
|
+
braces: 'a,} b,] c',
|
|
492
|
+
slashes: 'a//b',
|
|
493
|
+
url: 'https://example.com/x',
|
|
494
|
+
block: 'a/*b*/c',
|
|
495
|
+
});
|
|
496
|
+
assert.deepEqual(parseJsonc(text), {
|
|
497
|
+
braces: 'a,} b,] c',
|
|
498
|
+
slashes: 'a//b',
|
|
499
|
+
url: 'https://example.com/x',
|
|
500
|
+
block: 'a/*b*/c',
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('parseJsonc still strips real comments outside strings', () => {
|
|
505
|
+
const text = `{
|
|
506
|
+
// line comment
|
|
507
|
+
"logpush": true, /* block */
|
|
508
|
+
"name": "x"
|
|
509
|
+
}`;
|
|
510
|
+
assert.deepEqual(parseJsonc(text), { logpush: true, name: 'x' });
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('parseJsonc reports parse-error positions against the original offsets', () => {
|
|
514
|
+
// Comments and trailing commas are blanked in place, never deleted, so a
|
|
515
|
+
// position in the error message still points at the operator's file.
|
|
516
|
+
const text = '{\n // a comment\n "a": 1\n "b": 2\n}';
|
|
517
|
+
assert.throws(
|
|
518
|
+
() => parseJsonc(text),
|
|
519
|
+
(err) => /position (2[0-9]|3[0-9])/.test(err.message),
|
|
520
|
+
);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
// Advisory mode is a promise about exit codes, not about findings (#407)
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
test('runCli exits 0 on an unparseable config with --warn-only', () => {
|
|
528
|
+
writeFileSync(join(tmpDir, 'wrangler.jsonc'), '{ not valid json');
|
|
529
|
+
const streams = noopStreams();
|
|
530
|
+
const exit = runCli({
|
|
531
|
+
argv: ['--warn-only'],
|
|
532
|
+
cwd: tmpDir,
|
|
533
|
+
stdout: streams.stdout,
|
|
534
|
+
stderr: streams.stderr,
|
|
535
|
+
});
|
|
536
|
+
assert.equal(exit, 0, 'advisory mode must never hard-fail on config content');
|
|
537
|
+
assert.match(streams.err, /failed to parse/);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test('runCli --json emits a parseError envelope instead of dying silently', () => {
|
|
541
|
+
writeFileSync(join(tmpDir, 'wrangler.jsonc'), '{ not valid json');
|
|
542
|
+
const streams = noopStreams();
|
|
543
|
+
const exit = runCli({
|
|
544
|
+
argv: ['--json', '--warn-only'],
|
|
545
|
+
cwd: tmpDir,
|
|
546
|
+
stdout: streams.stdout,
|
|
547
|
+
stderr: streams.stderr,
|
|
548
|
+
});
|
|
549
|
+
assert.equal(exit, 0);
|
|
550
|
+
const envelope = JSON.parse(streams.out);
|
|
551
|
+
assert.equal(envelope.kind, 'wrangler-baseline-report');
|
|
552
|
+
assert.equal(envelope.found, true);
|
|
553
|
+
assert.ok(envelope.parseError, 'the envelope names why the config was unusable');
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test('runCli reads a trailing-comma wrangler.jsonc end to end', () => {
|
|
557
|
+
writeFileSync(
|
|
558
|
+
join(tmpDir, 'wrangler.jsonc'),
|
|
559
|
+
`{
|
|
560
|
+
"compatibility_date": "2026-06-15",
|
|
561
|
+
"logpush": true,
|
|
562
|
+
"env": { "production": { "logpush": true, }, },
|
|
563
|
+
"analytics_engine_datasets": [{ "binding": "AE", "dataset": "events", },],
|
|
564
|
+
}`,
|
|
565
|
+
);
|
|
566
|
+
const streams = noopStreams();
|
|
567
|
+
const exit = runCli({
|
|
568
|
+
argv: [],
|
|
569
|
+
cwd: tmpDir,
|
|
570
|
+
stdout: streams.stdout,
|
|
571
|
+
stderr: streams.stderr,
|
|
572
|
+
now: new Date('2026-07-01T00:00:00Z'),
|
|
573
|
+
});
|
|
574
|
+
assert.equal(exit, 0, streams.out + streams.err);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
// The consumer end state reported in #406 (Turborepo + declared exception)
|
|
579
|
+
// ---------------------------------------------------------------------------
|
|
580
|
+
|
|
581
|
+
test('runCli renders a declared exception as EXCEPTED while still reporting a real violation', () => {
|
|
582
|
+
// domio's shape: the Worker config lives outside the repo root, uses
|
|
583
|
+
// trailing commas throughout, declares an analytics-engine opt-out, and has
|
|
584
|
+
// a genuinely stale compatibility_date.
|
|
585
|
+
mkdirSync(join(tmpDir, 'apps', 'web'), { recursive: true });
|
|
586
|
+
writeFileSync(
|
|
587
|
+
join(tmpDir, 'apps', 'web', 'wrangler.jsonc'),
|
|
588
|
+
`{
|
|
589
|
+
// Worker config for the web app
|
|
590
|
+
"name": "web",
|
|
591
|
+
"compatibility_date": "2025-01-01",
|
|
592
|
+
"logpush": true,
|
|
593
|
+
"env": { "production": { "logpush": true, }, },
|
|
594
|
+
"mandrel": {
|
|
595
|
+
"wranglerBaselineExceptions": {
|
|
596
|
+
"analytics-engine": "no telemetry sink for this static-asset Worker",
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
}`,
|
|
600
|
+
);
|
|
601
|
+
const streams = noopStreams();
|
|
602
|
+
const exit = runCli({
|
|
603
|
+
argv: ['--file', 'apps/web/wrangler.jsonc', '--json'],
|
|
604
|
+
cwd: tmpDir,
|
|
605
|
+
stdout: streams.stdout,
|
|
606
|
+
stderr: streams.stderr,
|
|
607
|
+
now: new Date('2026-08-29T00:00:00Z'),
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const envelope = JSON.parse(streams.out);
|
|
611
|
+
const analytics = envelope.findings.find((f) => f.id === 'analytics-engine');
|
|
612
|
+
assert.equal(analytics.excepted, true, 'the declared opt-out is honoured');
|
|
613
|
+
assert.ok(
|
|
614
|
+
!envelope.violations.some((v) => v.id === 'analytics-engine'),
|
|
615
|
+
'an excepted rule is absent from violations',
|
|
616
|
+
);
|
|
617
|
+
assert.ok(
|
|
618
|
+
envelope.violations.some((v) => v.id === 'compat-date-stale'),
|
|
619
|
+
'a genuine staleness violation is still reported',
|
|
620
|
+
);
|
|
621
|
+
assert.equal(exit, 1, 'a real un-excepted violation still fails in blocking mode');
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
// Direct-invocation guard — spawned as a SUBPROCESS through a symlink (#407)
|
|
626
|
+
//
|
|
627
|
+
// Every other test here imports runCli, which is exactly why the guard bug
|
|
628
|
+
// survived: the guard line only runs when the file is the process entry point.
|
|
629
|
+
// These cases spawn it the way a pnpm consumer's CI does.
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Spawn the CLI at `scriptPath` with `cwd`, returning the child result.
|
|
636
|
+
*
|
|
637
|
+
* @param {string} scriptPath Path to invoke (possibly through a symlink).
|
|
638
|
+
* @param {string[]} args CLI args.
|
|
639
|
+
* @param {string} cwd Working directory for the child.
|
|
640
|
+
*/
|
|
641
|
+
function runScript(scriptPath, args, cwd) {
|
|
642
|
+
return spawnSync(process.execPath, [scriptPath, ...args], {
|
|
643
|
+
cwd,
|
|
644
|
+
encoding: 'utf-8',
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
test('the CLI runs when invoked through a pnpm-style symlinked node_modules', () => {
|
|
649
|
+
// node_modules/mandrel-platform -> <repo root>, the shape pnpm installs.
|
|
650
|
+
const nodeModules = join(tmpDir, 'node_modules');
|
|
651
|
+
mkdirSync(nodeModules, { recursive: true });
|
|
652
|
+
symlinkSync(REPO_ROOT, join(nodeModules, 'mandrel-platform'), 'dir');
|
|
653
|
+
writeFileSync(join(tmpDir, 'wrangler.json'), '{"compatibility_date": "2020-01-01"}');
|
|
654
|
+
|
|
655
|
+
const linked = join(
|
|
656
|
+
nodeModules,
|
|
657
|
+
'mandrel-platform',
|
|
658
|
+
'scripts',
|
|
659
|
+
'check-wrangler-baseline.mjs',
|
|
660
|
+
);
|
|
661
|
+
const result = runScript(linked, ['--max-age-days', '90'], tmpDir);
|
|
662
|
+
|
|
663
|
+
assert.notEqual(
|
|
664
|
+
result.stdout.trim(),
|
|
665
|
+
'',
|
|
666
|
+
'a symlinked invocation must produce output, not a silent pass',
|
|
667
|
+
);
|
|
668
|
+
assert.match(result.stdout, /wrangler-baseline/);
|
|
669
|
+
assert.equal(result.status, 1, 'a violating config exits non-zero in blocking mode');
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test('a symlinked invocation honours --warn-only rather than exiting silently', () => {
|
|
673
|
+
const nodeModules = join(tmpDir, 'node_modules');
|
|
674
|
+
mkdirSync(nodeModules, { recursive: true });
|
|
675
|
+
symlinkSync(REPO_ROOT, join(nodeModules, 'mandrel-platform'), 'dir');
|
|
676
|
+
writeFileSync(join(tmpDir, 'wrangler.json'), '{"compatibility_date": "2020-01-01"}');
|
|
677
|
+
|
|
678
|
+
const linked = join(
|
|
679
|
+
nodeModules,
|
|
680
|
+
'mandrel-platform',
|
|
681
|
+
'scripts',
|
|
682
|
+
'check-wrangler-baseline.mjs',
|
|
683
|
+
);
|
|
684
|
+
const result = runScript(linked, ['--max-age-days', '90', '--warn-only'], tmpDir);
|
|
685
|
+
|
|
686
|
+
assert.equal(result.status, 0);
|
|
687
|
+
assert.match(result.stdout, /violation/i, 'advisory mode still reports the findings');
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test('the CLI still runs when invoked through its real path', () => {
|
|
691
|
+
writeFileSync(join(tmpDir, 'wrangler.json'), '{"compatibility_date": "2020-01-01"}');
|
|
692
|
+
const direct = join(REPO_ROOT, 'scripts', 'check-wrangler-baseline.mjs');
|
|
693
|
+
const result = runScript(direct, ['--max-age-days', '90'], tmpDir);
|
|
694
|
+
assert.match(result.stdout, /wrangler-baseline/);
|
|
695
|
+
assert.equal(result.status, 1);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test('importing the module does not execute the CLI', () => {
|
|
699
|
+
// The guard must be false when the entry point is some other script.
|
|
700
|
+
const probe = join(tmpDir, 'probe.mjs');
|
|
701
|
+
const target = join(REPO_ROOT, 'scripts', 'check-wrangler-baseline.mjs');
|
|
702
|
+
writeFileSync(
|
|
703
|
+
probe,
|
|
704
|
+
`await import(${JSON.stringify(pathToFileURL(target).href)});\nconsole.log('IMPORT-OK');\n`,
|
|
705
|
+
);
|
|
706
|
+
writeFileSync(join(tmpDir, 'wrangler.json'), '{"compatibility_date": "2020-01-01"}');
|
|
707
|
+
const result = runScript(probe, [], tmpDir);
|
|
708
|
+
assert.equal(result.status, 0, 'an import must not exit(1) via the CLI');
|
|
709
|
+
assert.match(result.stdout, /IMPORT-OK/);
|
|
710
|
+
assert.doesNotMatch(result.stdout, /wrangler-baseline/);
|
|
711
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* (Story #231). Extracted from the workflow's former ~140-line inline bash so
|
|
7
7
|
* the probe is unit-testable and reviewed as code, not as a YAML diff. The
|
|
8
8
|
* workflow sparse-checks this script out of dsj1984/mandrel-platform at
|
|
9
|
-
* `
|
|
9
|
+
* `job.workflow_sha` — the exact commit the caller's
|
|
10
10
|
* `deploy-cloudflare.yml@<ref>` pin resolved to — so the script version
|
|
11
11
|
* always travels in lockstep with the workflow pin (same model as
|
|
12
12
|
* `uptime-apply.yml` → `apply-uptime-monitors.mjs`).
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* In-pipeline worker-secrets provisioning for the shared
|
|
6
6
|
* `deploy-cloudflare.yml` workflow (Story #170; extracted from inline bash in
|
|
7
7
|
* Story #231). The workflow sparse-checks this script out of
|
|
8
|
-
* dsj1984/mandrel-platform at `
|
|
8
|
+
* dsj1984/mandrel-platform at `job.workflow_sha` — the exact commit
|
|
9
9
|
* the caller's `deploy-cloudflare.yml@<ref>` pin resolved to — so the script
|
|
10
10
|
* version always travels in lockstep with the workflow pin (same model as
|
|
11
11
|
* `uptime-apply.yml` → `apply-uptime-monitors.mjs`).
|