@patronage/factory-ci 0.2.1 → 1.0.0-alpha.13

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,462 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ /**
4
+ * Parse-check the shell embedded in generated workflow YAML (#376).
5
+ *
6
+ * The generated-workflow lint validates YAML shape. It never parses the shell
7
+ * inside a `run:` block, so a script that cannot execute at all — a stray
8
+ * `fi`, an unclosed quote, a `then` with no `if` — passes every local check
9
+ * and only fails when the runner reaches it. That is not hypothetical:
10
+ * paitronage#1090 shipped a stray `fi` into a generated workflow and made
11
+ * every automated preview destroy a parse-time no-op for five days.
12
+ *
13
+ * `bash -n` is the whole control: it parses without executing. It lives here,
14
+ * once, rather than as a contract test in each consumer, because a guard
15
+ * copied per repository is a guard that exists in some of them.
16
+ */
17
+
18
+ /** One `run:` block that bash refuses to parse. */
19
+ export interface WorkflowShellParseFailure {
20
+ /** The step's `name:` when the YAML carried one. */
21
+ readonly step?: string;
22
+ /** The script as bash saw it, expressions already neutralized. */
23
+ readonly script: string;
24
+ /** What bash said. */
25
+ readonly stderr: string;
26
+ }
27
+
28
+ /** A `run:` block lifted out of generated YAML, with its step's `shell:`. */
29
+ interface RunBlock {
30
+ readonly script: string;
31
+ readonly shell?: string;
32
+ readonly step?: string;
33
+ }
34
+
35
+ const RUN_KEY = /^(?<indent>\s*)(?:-\s+)?run:(?<inline>.*)$/u;
36
+
37
+ /**
38
+ * GitHub evaluates `${{ }}` before bash ever sees the script, and what it
39
+ * substitutes is not knowable here. Neutralizing each expression to one plain
40
+ * word is what the runner's *shape* looks like: a value in argument position.
41
+ * Leaving them in would make every workflow fail to parse; expanding them to
42
+ * nothing would silently change quoting.
43
+ *
44
+ * Scanned rather than matched with a lazy regex, because `}}` occurs inside
45
+ * Actions string literals: `format('refs/{{0}}', github.ref_name)` escapes a
46
+ * literal brace pair that way, and stopping there would leave half an
47
+ * expression in the script and report a parse error the runner never sees.
48
+ */
49
+ const neutralizeExpressions = (script: string): string => {
50
+ let out = "";
51
+ let cursor = 0;
52
+
53
+ while (cursor < script.length) {
54
+ const start = script.indexOf("${{", cursor);
55
+ if (start === -1) {
56
+ out += script.slice(cursor);
57
+ break;
58
+ }
59
+ out += script.slice(cursor, start);
60
+
61
+ let scan = start + 3;
62
+ let quote: string | undefined;
63
+ let end = -1;
64
+ while (scan < script.length) {
65
+ const char = script[scan];
66
+ if (quote === undefined) {
67
+ if (char === "'" || char === '"') {
68
+ quote = char;
69
+ } else if (char === "}" && script[scan + 1] === "}") {
70
+ end = scan + 2;
71
+ break;
72
+ }
73
+ } else if (char === quote) {
74
+ // Doubling is how both Actions string literals escape their quote.
75
+ if (script[scan + 1] === quote) {
76
+ scan += 1;
77
+ } else {
78
+ quote = undefined;
79
+ }
80
+ }
81
+ scan += 1;
82
+ }
83
+
84
+ if (end === -1) {
85
+ // Unterminated: leave it verbatim so bash reports the truth about a
86
+ // script GitHub would also refuse to substitute.
87
+ out += script.slice(start);
88
+ break;
89
+ }
90
+ out += "FACTORY_ACTIONS_EXPRESSION";
91
+ cursor = end;
92
+ }
93
+
94
+ return out;
95
+ };
96
+
97
+ /** Strip one layer of YAML single quoting from a scalar value. */
98
+ const unquote = (raw: string): string => {
99
+ const value = raw.trim();
100
+ if (value.startsWith("'") && value.endsWith("'") && value.length > 1) {
101
+ return value.slice(1, -1).replaceAll("''", "'");
102
+ }
103
+ return value;
104
+ };
105
+
106
+ const indentOf = (line: string): number =>
107
+ line.length - line.trimStart().length;
108
+
109
+ /**
110
+ * Where a mapping key sits, counting the `- ` sequence marker as indentation:
111
+ * `- name:` and the `run:` below it are siblings in the same step even though
112
+ * their raw columns differ by two.
113
+ */
114
+ const keyIndentOf = (line: string): number =>
115
+ indentOf(line) + (line.trimStart().startsWith("- ") ? 2 : 0);
116
+
117
+ /** A sibling scalar of the `run:` key under inspection, when the line is one. */
118
+ const keyValueAt = (
119
+ line: string,
120
+ indent: number,
121
+ key: string
122
+ ): string | undefined => {
123
+ if (keyIndentOf(line) !== indent) {
124
+ return undefined;
125
+ }
126
+ const rest = line.trimStart().replace(/^-\s+/u, "");
127
+ return rest.startsWith(`${key}:`) ? rest.slice(key.length + 1) : undefined;
128
+ };
129
+
130
+ /** A `defaults.run.shell` and the span of workflow it governs. */
131
+ interface DefaultShellScope {
132
+ readonly depth: number;
133
+ readonly end: number;
134
+ readonly shell: string;
135
+ readonly start: number;
136
+ }
137
+
138
+ /**
139
+ * Every `defaults: { run: { shell } }` in the document, with the span it
140
+ * governs: the mapping that declares it, which is the whole workflow at the
141
+ * top level and one job under `jobs:`.
142
+ *
143
+ * GitHub resolves a step's interpreter as step `shell:`, then the job's
144
+ * default, then the workflow's, then bash. A scanner that only looked at the
145
+ * step would call every step in a `defaults.run.shell: sh` workflow bash and
146
+ * report a pass for scripts `sh` cannot parse — the very false negative this
147
+ * control exists to close.
148
+ */
149
+ const defaultShellScopes = (lines: string[]): DefaultShellScope[] => {
150
+ const scopes: DefaultShellScope[] = [];
151
+
152
+ for (const [index, line] of lines.entries()) {
153
+ if (line.trim() !== "defaults:") {
154
+ continue;
155
+ }
156
+ const depth = keyIndentOf(line);
157
+
158
+ const shell = (() => {
159
+ let inRun = false;
160
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
161
+ const candidate = lines[cursor];
162
+ if (candidate.trim().length === 0) {
163
+ continue;
164
+ }
165
+ if (keyIndentOf(candidate) <= depth) {
166
+ return;
167
+ }
168
+ if (keyValueAt(candidate, depth + 2, "run") !== undefined) {
169
+ inRun = true;
170
+ continue;
171
+ }
172
+ if (keyIndentOf(candidate) <= depth + 2) {
173
+ inRun = false;
174
+ continue;
175
+ }
176
+ const value = inRun
177
+ ? keyValueAt(candidate, depth + 4, "shell")
178
+ : undefined;
179
+ if (value !== undefined) {
180
+ return unquote(value);
181
+ }
182
+ }
183
+ })();
184
+ if (shell === undefined) {
185
+ continue;
186
+ }
187
+
188
+ // The governed span is the mapping `defaults:` belongs to: back to its
189
+ // parent key, forward to the next sibling of that parent.
190
+ let start = 0;
191
+ for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
192
+ if (
193
+ lines[cursor].trim().length > 0 &&
194
+ keyIndentOf(lines[cursor]) < depth
195
+ ) {
196
+ start = cursor;
197
+ break;
198
+ }
199
+ }
200
+ let end = lines.length;
201
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
202
+ if (
203
+ lines[cursor].trim().length > 0 &&
204
+ keyIndentOf(lines[cursor]) < depth
205
+ ) {
206
+ end = cursor;
207
+ break;
208
+ }
209
+ }
210
+
211
+ scopes.push({ depth, end, shell, start });
212
+ }
213
+
214
+ return scopes;
215
+ };
216
+
217
+ /** The innermost `defaults.run.shell` governing a line, if any. */
218
+ const inheritedShell = (
219
+ scopes: readonly DefaultShellScope[],
220
+ index: number
221
+ ): string | undefined =>
222
+ scopes
223
+ .filter((scope) => index >= scope.start && index < scope.end)
224
+ .toSorted((left, right) => right.depth - left.depth)
225
+ .at(0)?.shell;
226
+
227
+ /**
228
+ * Every `run:` block in a generated workflow, paired with the `shell:` its
229
+ * step declares.
230
+ *
231
+ * Deliberately a scanner over the emitted text and not a YAML parse: this
232
+ * package takes no dependency it does not need, and the emitted shape is one
233
+ * generator's output, not arbitrary YAML. It reads both block scalars
234
+ * (`run: |-`) and inline scripts.
235
+ */
236
+ export const workflowRunBlocks = (yaml: string): RunBlock[] => {
237
+ const lines = yaml.split("\n");
238
+ const scopes = defaultShellScopes(lines);
239
+ const blocks: RunBlock[] = [];
240
+
241
+ for (const [index, line] of lines.entries()) {
242
+ const match = RUN_KEY.exec(line);
243
+ if (match?.groups === undefined) {
244
+ continue;
245
+ }
246
+ const keyIndent = keyIndentOf(line);
247
+ const inline = match.groups.inline.trim();
248
+
249
+ let script: string;
250
+ let end = index;
251
+ // The style indicator is the first character of a block header, whatever
252
+ // follows it: YAML allows the indentation and chomping indicators in
253
+ // either order (`>2-` and `>-2` are both folded). Keying on that one
254
+ // character is why no header spelling can slip past as an inline script.
255
+ if (inline.startsWith(">")) {
256
+ // A folded scalar joins ordinary line breaks into spaces, so the script
257
+ // bash would see is not the text on the page: `if true; then` / `echo
258
+ // ok` / `fi` folds to one line the runner rejects. Refusing is loud;
259
+ // checking the unfolded text would quietly pass it. No generator in this
260
+ // fleet emits one.
261
+ throw new Error(
262
+ `folded (\`run: >\`) scripts are not supported: YAML folds their line breaks into spaces, so what bash parses is not what is written. Use a literal block (\`run: |\`).`
263
+ );
264
+ }
265
+ if (inline.trimStart().startsWith('"')) {
266
+ // Double-quoted YAML has its own escape grammar (\", \\, \n, \x41 ...),
267
+ // and decoding it half-way is how an unterminated bash quote survives a
268
+ // parse check. Refusing is loud; guessing is not. No generator in this
269
+ // fleet emits one.
270
+ throw new Error(
271
+ `double-quoted \`run:\` scalars are not supported: their YAML escapes would have to be decoded before bash sees them. Use a literal block (\`run: |\`) or an unquoted scalar.`
272
+ );
273
+ }
274
+ if (inline.startsWith("|")) {
275
+ const body: string[] = [];
276
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
277
+ const candidate = lines[cursor];
278
+ if (candidate.trim().length > 0 && indentOf(candidate) <= keyIndent) {
279
+ break;
280
+ }
281
+ body.push(candidate);
282
+ end = cursor;
283
+ }
284
+ const strip = Math.min(
285
+ ...body
286
+ .filter((entry) => entry.trim().length > 0)
287
+ .map((entry) => indentOf(entry))
288
+ );
289
+ script = body.map((entry) => entry.slice(strip)).join("\n");
290
+ } else if (inline.length > 0) {
291
+ script = unquote(inline);
292
+ } else {
293
+ continue;
294
+ }
295
+
296
+ // The step's own keys: the siblings of this `run:`, scanning back to the
297
+ // step's `- ` marker and forward to the next one.
298
+ let shell: string | undefined;
299
+ let step: string | undefined;
300
+ const readSibling = (line_: string) => {
301
+ shell ??= keyValueAt(line_, keyIndent, "shell");
302
+ step ??= keyValueAt(line_, keyIndent, "name");
303
+ };
304
+ for (
305
+ let cursor = index;
306
+ cursor >= 0 && keyIndentOf(lines[cursor]) >= keyIndent;
307
+ cursor -= 1
308
+ ) {
309
+ readSibling(lines[cursor]);
310
+ if (lines[cursor].trimStart().startsWith("- ")) {
311
+ break;
312
+ }
313
+ }
314
+ for (let cursor = end + 1; cursor < lines.length; cursor += 1) {
315
+ if (
316
+ keyIndentOf(lines[cursor]) < keyIndent ||
317
+ lines[cursor].trimStart().startsWith("- ")
318
+ ) {
319
+ break;
320
+ }
321
+ readSibling(lines[cursor]);
322
+ }
323
+
324
+ const effectiveShell =
325
+ shell === undefined ? inheritedShell(scopes, index) : unquote(shell);
326
+
327
+ blocks.push({
328
+ script,
329
+ ...(effectiveShell === undefined ? {} : { shell: effectiveShell }),
330
+ ...(step === undefined ? {} : { step: unquote(step) }),
331
+ });
332
+ }
333
+
334
+ return blocks;
335
+ };
336
+
337
+ /** `NAME=value` in a shell command template, as `env` takes them. */
338
+ const ASSIGNMENT = /^[A-Za-z_]\w*=/u;
339
+
340
+ /**
341
+ * Interpreters GitHub supports that this control deliberately leaves alone.
342
+ * Named rather than inferred, so an unfamiliar command fails loudly instead of
343
+ * being skipped as though it had been considered.
344
+ */
345
+ const NON_SHELL_INTERPRETERS = new Set([
346
+ "cmd",
347
+ "powershell",
348
+ "pwsh",
349
+ "python",
350
+ "python3",
351
+ ]);
352
+
353
+ /**
354
+ * The interpreter that will parse a step's script, or `undefined` for one this
355
+ * control leaves alone.
356
+ *
357
+ * A step declaring no shell gets bash: that is GitHub's default for `run:` on
358
+ * Linux runners. A step declaring `sh` gets `sh`, because the runner runs it
359
+ * with `sh` — whose grammar is narrower than bash's, so parsing it with bash
360
+ * would report a pass for a script the runner cannot run. Anything else
361
+ * (pwsh, python, cmd) is not this control's business.
362
+ */
363
+ const parserFor = (shell: string | undefined): string[] | undefined => {
364
+ if (shell === undefined) {
365
+ return ["bash"];
366
+ }
367
+
368
+ const argv: string[] = [];
369
+ let interpreter: string | undefined;
370
+ for (const token of shell.trim().split(/\s+/u)) {
371
+ if (token === "{0}") {
372
+ break;
373
+ }
374
+ if (interpreter !== undefined) {
375
+ // The interpreter's own options change its grammar — `bash -O extglob`
376
+ // parses scripts plain bash rejects — so they are kept and passed on.
377
+ argv.push(token);
378
+ continue;
379
+ }
380
+ const executable = token.slice(token.lastIndexOf("/") + 1);
381
+ if (executable === "bash" || executable === "sh") {
382
+ interpreter = token;
383
+ } else if (
384
+ !(executable === "env" || token.startsWith("-") || ASSIGNMENT.test(token))
385
+ ) {
386
+ if (NON_SHELL_INTERPRETERS.has(executable)) {
387
+ return undefined;
388
+ }
389
+ throw new Error(
390
+ `unrecognized \`shell:\` command: ${shell}. This control parses bash and sh scripts and knowingly skips pwsh, powershell, python, and cmd; it refuses rather than guess at anything else.`
391
+ );
392
+ }
393
+ argv.push(token);
394
+ }
395
+
396
+ if (interpreter === undefined) {
397
+ throw new Error(
398
+ `unrecognized \`shell:\` command: ${shell}. No interpreter to parse the script with.`
399
+ );
400
+ }
401
+ return argv;
402
+ };
403
+
404
+ /** Every `run:` block its interpreter refuses to parse. Empty means sound. */
405
+ export const workflowShellParseFailures = (
406
+ yaml: string
407
+ ): WorkflowShellParseFailure[] => {
408
+ const failures: WorkflowShellParseFailure[] = [];
409
+
410
+ for (const block of workflowRunBlocks(yaml)) {
411
+ const parser = parserFor(block.shell);
412
+ if (parser === undefined) {
413
+ continue;
414
+ }
415
+ const [executable, ...parserArgs] = parser;
416
+ const script = neutralizeExpressions(block.script);
417
+ try {
418
+ execFileSync(executable, [...parserArgs, "-n"], {
419
+ input: script,
420
+ stdio: ["pipe", "ignore", "pipe"],
421
+ });
422
+ } catch (error) {
423
+ failures.push({
424
+ script,
425
+ ...(block.step === undefined ? {} : { step: block.step }),
426
+ stderr: String(
427
+ (error as { stderr?: Buffer | string }).stderr ?? error
428
+ ).trim(),
429
+ });
430
+ }
431
+ }
432
+
433
+ return failures;
434
+ };
435
+
436
+ /**
437
+ * Fail the generated-workflow lint when any embedded `run:` block is not
438
+ * parseable bash. Call it on the YAML a generator is about to write, so the
439
+ * defect is caught at generation rather than by the runner.
440
+ */
441
+ export const assertWorkflowShellParses = (
442
+ yaml: string,
443
+ options: { readonly source: string }
444
+ ): void => {
445
+ const failures = workflowShellParseFailures(yaml);
446
+ if (failures.length === 0) {
447
+ return;
448
+ }
449
+
450
+ const detail = failures
451
+ .map(
452
+ (failure) =>
453
+ ` - ${failure.step ?? "unnamed step"}: ${failure.stderr.replaceAll(
454
+ "\n",
455
+ "\n "
456
+ )}`
457
+ )
458
+ .join("\n");
459
+ throw new Error(
460
+ `${options.source} emits shell bash cannot parse; the runner would treat it as a no-op:\n${detail}`
461
+ );
462
+ };