mandrel-platform 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Tests for check-workflow-platform-checkout.mjs (Story #415).
3
+ *
4
+ * The lint exists because the defect class it guards is invisible to every
5
+ * other gate: an empty `ref:` is valid YAML, valid actionlint, and green in CI
6
+ * — it goes wrong only at runtime, on a consumer. So these tests assert both
7
+ * directions: each violation shape IS caught, and each legitimate shape is
8
+ * NOT, because a lint that over-fires on a sound pattern gets suppressed and
9
+ * then guards nothing.
10
+ */
11
+ import test from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
14
+ import { join, dirname } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ import { lintWorkflow, parseWorkflow, sparseEntries, stepGuard } from './check-workflow-platform-checkout.mjs';
18
+
19
+ const REPO = join(dirname(fileURLToPath(import.meta.url)), '..');
20
+
21
+ /** A fail-closed resolve step, as the real workflows carry it. */
22
+ const RESOLVE = (id, cond) =>
23
+ ` - name: Resolve platform ref
24
+ ${cond ? ` if: ${cond}\n` : ''} id: ${id}
25
+ shell: bash
26
+ env:
27
+ PLATFORM_SHA: \${{ job.workflow_sha }}
28
+ run: |
29
+ set -euo pipefail
30
+ if [[ ! "\${PLATFORM_SHA}" =~ ^[0-9a-f]{40}$ ]]; then
31
+ echo "::error::unresolved"
32
+ exit 1
33
+ fi
34
+ echo "sha=\${PLATFORM_SHA}" >> "$GITHUB_OUTPUT"
35
+ `;
36
+
37
+ /** A platform checkout consuming a resolve step's output. */
38
+ const CHECKOUT = ({ ref = '${{ steps.platform-ref.outputs.sha }}', cond = null, sparse = ['scripts'] }) =>
39
+ ` - name: Checkout platform
40
+ ${cond ? ` if: ${cond}\n` : ''} uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
41
+ with:
42
+ repository: dsj1984/mandrel-platform
43
+ ref: ${ref}
44
+ sparse-checkout: |
45
+ ${sparse.map((s) => ` ${s}`).join('\n')}
46
+ sparse-checkout-cone-mode: false
47
+ path: _platform
48
+ persist-credentials: false
49
+ `;
50
+
51
+ const workflow = (steps) => `name: t
52
+ on:
53
+ workflow_call:
54
+ jobs:
55
+ build:
56
+ runs-on: ubuntu-latest
57
+ steps:
58
+ ${steps}`;
59
+
60
+ const rules = (findings) => findings.map((f) => f.rule).sort();
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // The clean shape
64
+ // ---------------------------------------------------------------------------
65
+
66
+ test('a pinned, fail-closed, module-graph-complete checkout is clean', () => {
67
+ const src = workflow(RESOLVE('platform-ref', null) + '\n' + CHECKOUT({}));
68
+ assert.deepEqual(lintWorkflow('t.yml', src), []);
69
+ });
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Rule 1 — dead-token
73
+ // ---------------------------------------------------------------------------
74
+
75
+ test('the dead job_workflow_sha token is flagged anywhere in the file', () => {
76
+ const src = workflow(
77
+ RESOLVE('platform-ref', null) + '\n' + CHECKOUT({}),
78
+ ).replace('name: t', '# note: job_workflow_sha used to live here\nname: t');
79
+ const found = lintWorkflow('t.yml', src);
80
+ assert.deepEqual(rules(found), ['dead-token']);
81
+ assert.match(found[0].detail, /OIDC token claim/);
82
+ });
83
+
84
+ test('the dead token is flagged even inside a live ref expression', () => {
85
+ const src = workflow(CHECKOUT({ ref: '${{ fromJSON(toJSON(github)).job_workflow_sha }}' }));
86
+ assert.ok(rules(lintWorkflow('t.yml', src)).includes('dead-token'));
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Rule 2 — resolved-ref
91
+ // ---------------------------------------------------------------------------
92
+
93
+ test('a raw context expression as ref: is rejected', () => {
94
+ // The exact shape that shipped the outage — note it is otherwise valid YAML.
95
+ const src = workflow(CHECKOUT({ ref: '${{ github.sha }}' }));
96
+ const found = lintWorkflow('t.yml', src);
97
+ assert.ok(rules(found).includes('resolved-ref'));
98
+ assert.match(found.find((f) => f.rule === 'resolved-ref').detail, /silently use the default branch/);
99
+ });
100
+
101
+ test('a platform checkout with no ref: at all is rejected', () => {
102
+ const src = workflow(
103
+ ` - name: Checkout platform
104
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
105
+ with:
106
+ repository: dsj1984/mandrel-platform
107
+ path: _platform
108
+ `,
109
+ );
110
+ const found = lintWorkflow('t.yml', src);
111
+ assert.ok(rules(found).includes('resolved-ref'));
112
+ assert.match(found[0].detail, /no `ref:` at all/);
113
+ });
114
+
115
+ test('a checkout of some OTHER repository is not this lint’s business', () => {
116
+ const src = workflow(
117
+ ` - name: Checkout elsewhere
118
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
119
+ with:
120
+ repository: someone/else
121
+ ref: \${{ github.sha }}
122
+ `,
123
+ );
124
+ assert.deepEqual(lintWorkflow('t.yml', src), []);
125
+ });
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Rule 3 — fail-closed
129
+ // ---------------------------------------------------------------------------
130
+
131
+ test('a resolve step that does not assert 40-hex is rejected', () => {
132
+ const weak = ` - name: Resolve platform ref
133
+ id: platform-ref
134
+ shell: bash
135
+ env:
136
+ PLATFORM_SHA: \${{ job.workflow_sha }}
137
+ run: echo "sha=\${PLATFORM_SHA}" >> "$GITHUB_OUTPUT"
138
+ `;
139
+ const found = lintWorkflow('t.yml', workflow(weak + '\n' + CHECKOUT({})));
140
+ assert.ok(rules(found).includes('fail-closed'));
141
+ assert.match(found.find((f) => f.rule === 'fail-closed').detail, /never fall back to the default branch/);
142
+ });
143
+
144
+ test('a ref: pointing at a step id that resolves nothing is rejected', () => {
145
+ const src = workflow(CHECKOUT({ ref: '${{ steps.nope.outputs.sha }}' }));
146
+ const found = lintWorkflow('t.yml', src);
147
+ assert.ok(rules(found).includes('fail-closed'));
148
+ });
149
+
150
+ test('a step reading job.workflow_sha for output but carrying no id is rejected', () => {
151
+ const noId = ` - name: Resolve platform ref
152
+ shell: bash
153
+ env:
154
+ PLATFORM_SHA: \${{ job.workflow_sha }}
155
+ run: |
156
+ if [[ ! "\${PLATFORM_SHA}" =~ ^[0-9a-f]{40}$ ]]; then exit 1; fi
157
+ echo "sha=\${PLATFORM_SHA}" >> "$GITHUB_OUTPUT"
158
+ `;
159
+ const found = lintWorkflow('t.yml', workflow(noId));
160
+ assert.ok(rules(found).includes('fail-closed'));
161
+ assert.match(found[0].detail, /no `id:`/);
162
+ });
163
+
164
+ test('reading job.workflow_sha for a job summary is not a resolve step', () => {
165
+ // deploy-summary echoes the resolved SHA without exporting it; requiring an
166
+ // `id:` there would be noise, so this must NOT fire.
167
+ const summary = ` - name: Emit resolved platform-ref summary
168
+ shell: bash
169
+ run: echo "| SHA | \\\`\${SHA}\\\` |" >> "$GITHUB_STEP_SUMMARY"
170
+ env:
171
+ SHA: \${{ job.workflow_sha }}
172
+ `;
173
+ assert.deepEqual(lintWorkflow('t.yml', workflow(summary)), []);
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Rule 4 — guard-parity
178
+ // ---------------------------------------------------------------------------
179
+
180
+ test('a guarded resolve step paired with a differently-guarded checkout is rejected', () => {
181
+ const src = workflow(
182
+ RESOLVE('platform-ref', "${{ inputs.a }}") + '\n' + CHECKOUT({ cond: '${{ inputs.b }}' }),
183
+ );
184
+ const found = lintWorkflow('t.yml', src);
185
+ assert.ok(rules(found).includes('guard-parity'));
186
+ assert.match(found.find((f) => f.rule === 'guard-parity').detail, /or leave itself unguarded/);
187
+ });
188
+
189
+ test('matching guards on resolve and checkout are clean', () => {
190
+ const src = workflow(
191
+ RESOLVE('platform-ref', '${{ inputs.a }}') + '\n' + CHECKOUT({ cond: '${{ inputs.a }}' }),
192
+ );
193
+ assert.deepEqual(lintWorkflow('t.yml', src), []);
194
+ });
195
+
196
+ test('an UNGUARDED resolve step covers any guarded checkout', () => {
197
+ // The security tier's real shape: one unconditional resolve serving three
198
+ // checkouts with three different `if:` guards. It always runs, so its output
199
+ // is always populated — flagging it would be a false positive.
200
+ const src = workflow(
201
+ RESOLVE('platform-ref', null) +
202
+ '\n' +
203
+ CHECKOUT({ cond: '${{ inputs.enable-sast }}' }) +
204
+ '\n' +
205
+ CHECKOUT({ cond: "${{ inputs.enable-sast && inputs.semgrep-config == 'vendored' }}" }),
206
+ );
207
+ assert.deepEqual(lintWorkflow('t.yml', src), []);
208
+ });
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Rule 5 — module-graph
212
+ // ---------------------------------------------------------------------------
213
+
214
+ test('a sparse list naming a .mjs script but not scripts/lib/ is rejected', () => {
215
+ // Exactly the uptime-apply shape that took every consumer red.
216
+ const src = workflow(
217
+ RESOLVE('platform-ref', null) +
218
+ '\n' +
219
+ CHECKOUT({ sparse: ['scripts/apply-uptime-monitors.mjs', '.nvmrc'] }),
220
+ );
221
+ const found = lintWorkflow('t.yml', src);
222
+ assert.deepEqual(rules(found), ['module-graph']);
223
+ assert.match(found[0].detail, /scripts\/apply-uptime-monitors\.mjs/);
224
+ });
225
+
226
+ test('adding scripts/lib/ to the same list clears it', () => {
227
+ const src = workflow(
228
+ RESOLVE('platform-ref', null) +
229
+ '\n' +
230
+ CHECKOUT({ sparse: ['scripts/apply-uptime-monitors.mjs', 'scripts/lib/', '.nvmrc'] }),
231
+ );
232
+ assert.deepEqual(lintWorkflow('t.yml', src), []);
233
+ });
234
+
235
+ test('a bare `scripts` entry already covers the whole tree', () => {
236
+ const src = workflow(RESOLVE('platform-ref', null) + '\n' + CHECKOUT({ sparse: ['scripts'] }));
237
+ assert.deepEqual(lintWorkflow('t.yml', src), []);
238
+ });
239
+
240
+ test('a non-.mjs sparse list needs no module graph', () => {
241
+ const src = workflow(
242
+ RESOLVE('platform-ref', null) + '\n' + CHECKOUT({ sparse: ['scripts/resolve-diff-range.sh'] }),
243
+ );
244
+ assert.deepEqual(lintWorkflow('t.yml', src), []);
245
+ });
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Parsing seams
249
+ // ---------------------------------------------------------------------------
250
+
251
+ test('YAML aliases are resolved to the step they stand for', () => {
252
+ // pr-quality.yml reuses `&checkout-range` across five jobs; without anchor
253
+ // expansion the aliasing jobs would look like they had no checkout at all
254
+ // and the lint would pass them vacuously.
255
+ const src = `name: t
256
+ on:
257
+ workflow_call:
258
+ jobs:
259
+ a:
260
+ runs-on: ubuntu-latest
261
+ steps:
262
+ - &co
263
+ name: Checkout platform
264
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
265
+ with:
266
+ repository: dsj1984/mandrel-platform
267
+ ref: \${{ github.sha }}
268
+ b:
269
+ runs-on: ubuntu-latest
270
+ steps:
271
+ - *co
272
+ `;
273
+ const { jobs } = parseWorkflow(src);
274
+ assert.equal(jobs.size, 2);
275
+ const found = lintWorkflow('t.yml', src);
276
+ // Both the anchor site and the alias site are reported, at their own lines.
277
+ assert.deepEqual(rules(found), ['resolved-ref', 'resolved-ref']);
278
+ assert.notEqual(found[0].line, found[1].line);
279
+ });
280
+
281
+ test('sparseEntries reads a block scalar and stops at the next key', () => {
282
+ const text = ` - name: x
283
+ with:
284
+ sparse-checkout: |
285
+ scripts/a.mjs
286
+ scripts/lib/
287
+ sparse-checkout-cone-mode: false
288
+ path: _p
289
+ `;
290
+ assert.deepEqual(sparseEntries(text), ['scripts/a.mjs', 'scripts/lib/']);
291
+ });
292
+
293
+ test('stepGuard ignores commented-out if: lines', () => {
294
+ assert.equal(stepGuard(' - name: x\n # if: ${{ never }}\n run: true\n'), null);
295
+ assert.equal(stepGuard(' - name: x\n if: ${{ inputs.a }}\n'), '${{ inputs.a }}');
296
+ });
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // The real tree
300
+ // ---------------------------------------------------------------------------
301
+
302
+ test('every shipped workflow passes the lint', () => {
303
+ const findings = [];
304
+ for (const dir of ['.github/workflows', 'templates/workflows']) {
305
+ const abs = join(REPO, dir);
306
+ if (!existsSync(abs)) continue;
307
+ for (const name of readdirSync(abs)) {
308
+ if (!name.endsWith('.yml') && !name.endsWith('.yaml')) continue;
309
+ findings.push(...lintWorkflow(join(dir, name), readFileSync(join(abs, name), 'utf8')));
310
+ }
311
+ }
312
+ assert.deepEqual(
313
+ findings,
314
+ [],
315
+ `platform-checkout violations:\n${findings.map((f) => `${f.path}:${f.line} [${f.rule}] ${f.detail}`).join('\n')}`,
316
+ );
317
+ });
318
+
319
+ test('every platform checkout in the shipped workflows is actually pinned', () => {
320
+ // Belt-and-braces over the rule engine: assert the resolved shape directly,
321
+ // so a future refactor of the lint cannot quietly stop covering the tree.
322
+ const dir = join(REPO, '.github/workflows');
323
+ let checkouts = 0;
324
+ for (const name of readdirSync(dir)) {
325
+ if (!name.endsWith('.yml')) continue;
326
+ const src = readFileSync(join(dir, name), 'utf8');
327
+ assert.ok(!src.includes('job_workflow_sha'), `${name} still names the dead OIDC claim`);
328
+ const { jobs } = parseWorkflow(src);
329
+ for (const job of jobs.values()) {
330
+ for (const step of job.steps) {
331
+ if (!step.text.includes('repository: dsj1984/mandrel-platform')) continue;
332
+ checkouts += 1;
333
+ assert.match(
334
+ step.text,
335
+ /ref: \$\{\{ steps\.[A-Za-z0-9_-]+\.outputs\.sha \}\}/,
336
+ `${name}:${step.line} platform checkout is not pinned to a resolve step`,
337
+ );
338
+ }
339
+ }
340
+ }
341
+ assert.ok(checkouts >= 10, `expected the known platform checkouts, saw ${checkouts}`);
342
+ });
@@ -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
- * Parse JSON tolerating `//` line comments and block comments (jsonc).
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
- const stripped = text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
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
- stderr.write(`[wrangler-baseline] failed to parse ${configPath}: ${err instanceof Error ? err.message : String(err)}\n`);
493
- return 1;
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 (matches the repo's other scripts/*.mjs entry style).
511
- const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
512
- if (invokedDirectly) {
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
  }