@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.
- package/README.md +183 -3
- package/dist/index.d.ts +522 -2
- package/dist/index.js +1530 -35
- package/package.json +6 -6
- package/src/bundle-alchemy-entry.ts +94 -1
- package/src/candidate-lifecycle.ts +29 -0
- package/src/factory-workflow.ts +27 -28
- package/src/github-app-token.ts +162 -0
- package/src/index.ts +80 -0
- package/src/pinned-action.ts +30 -0
- package/src/production-impact-workflow.ts +109 -0
- package/src/proof-reuse-gate.ts +141 -10
- package/src/proof-reuse-presentation.ts +125 -0
- package/src/push-identity-workflow.ts +448 -0
- package/src/vitest-profile-reader.test.ts +208 -0
- package/src/vitest-profile-reader.ts +220 -0
- package/src/vitest-profile.ts +631 -0
- package/src/workflow-shell-lint.ts +462 -0
|
@@ -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
|
+
};
|