Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

workflow-parser-ext.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

workflow-parser-ext.tsBlame683 lines · 1 contributor
5ff9cc2Claude1/**
2 * Extended workflow parser — adds matrix / if / needs / uses / with / env /
3 * outputs / workflow_dispatch inputs on top of the locked base parser in
4 * `workflow-parser.ts`.
5 *
6 * The base parser is shipped and locked (BUILD_BIBLE §4.3). It silently
7 * ignores the extended fields. We never modify it. Instead:
8 * 1. Call `parseWorkflow()` for the validated base shape.
9 * 2. Walk the same YAML with a line-based indent-aware scanner that
10 * specifically looks for the extended keys under known job/step
11 * blocks.
12 * 3. Merge the extensions onto the base jobs/steps by name.
13 *
14 * Any extension-field failure is captured as a `warnings[]` entry without
15 * failing the whole parse — the base workflow is still usable.
16 *
17 * Pure: no DB, no I/O, no external calls. Never throws.
18 */
19
20import { parseWorkflow, type ParsedWorkflow } from "./workflow-parser";
21
22// ---------------------------------------------------------------------------
23// Public types
24// ---------------------------------------------------------------------------
25
26export type MatrixSpec = {
27 axes: Record<string, unknown[]>;
28 include?: Record<string, unknown>[];
29 exclude?: Record<string, unknown>[];
30 failFast?: boolean;
31 maxParallel?: number;
32};
33
34export type ExtendedStep = {
35 name?: string;
36 id?: string;
37 if?: string;
38 run?: string;
39 uses?: string;
40 with?: Record<string, unknown>;
41 env?: Record<string, string>;
42 parallel?: boolean;
43 continueOnError?: boolean;
44};
45
46export type ExtendedJob = {
47 name: string;
48 runsOn: string;
49 if?: string;
50 needs?: string[];
51 strategy?: { matrix?: MatrixSpec };
52 env?: Record<string, string>;
53 outputs?: Record<string, string>;
54 steps: ExtendedStep[];
55};
56
57export type DispatchInput = {
58 type: "string" | "boolean" | "choice" | "number";
59 required?: boolean;
60 default?: string | boolean | number;
61 options?: string[];
62 description?: string;
63};
64
65export type ExtendedWorkflow = {
66 name: string;
67 on: string[];
68 /** Keyed by input name — shape mirrors GitHub Actions' `on.workflow_dispatch.inputs`. */
69 dispatchInputs?: Record<string, DispatchInput>;
70 env?: Record<string, string>;
71 jobs: ExtendedJob[];
72 warnings: string[];
73};
74
75export type ExtendedParseResult =
76 | { ok: true; workflow: ExtendedWorkflow }
77 | { ok: false; error: string };
78
79// ---------------------------------------------------------------------------
80// Line-based YAML walker (tolerant — only finds blocks we care about)
81// ---------------------------------------------------------------------------
82
83type Line = { indent: number; text: string; raw: string };
84
85function lexLines(source: string): Line[] {
86 const out: Line[] = [];
87 const raw = source.replace(/\r\n?/g, "\n").split("\n");
88 for (const r of raw) {
89 let indent = 0;
90 while (indent < r.length && (r[indent] === " " || r[indent] === "\t")) indent++;
91 const body = r.slice(indent);
92 if (body.length === 0 || body.startsWith("#")) continue;
93 out.push({ indent, text: body, raw: r });
94 }
95 return out;
96}
97
98/** Parse a scalar that may be quoted, true/false, numeric, or plain. */
99function parseScalar(raw: string): string | number | boolean | null {
100 const s = raw.trim();
101 if (s.length === 0) return null;
102 if (s === "true") return true;
103 if (s === "false") return false;
104 if (s === "null" || s === "~") return null;
105 if (/^-?\d+$/.test(s)) return parseInt(s, 10);
106 if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s);
107 if (
108 (s.startsWith('"') && s.endsWith('"')) ||
109 (s.startsWith("'") && s.endsWith("'"))
110 ) {
111 return s.slice(1, -1);
112 }
113 return s;
114}
115
116/** Parse a flow-style array literal like `[a, b, "c"]` or `[16, 18, 20]`. */
117function parseFlowArray(raw: string): unknown[] {
118 const trimmed = raw.trim();
119 if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return [];
120 const body = trimmed.slice(1, -1).trim();
121 if (body.length === 0) return [];
122 const items: string[] = [];
123 let depth = 0;
124 let cur = "";
125 let inQuote: string | null = null;
126 for (const ch of body) {
127 if (inQuote) {
128 cur += ch;
129 if (ch === inQuote) inQuote = null;
130 continue;
131 }
132 if (ch === '"' || ch === "'") {
133 inQuote = ch;
134 cur += ch;
135 continue;
136 }
137 if (ch === "[" || ch === "{") depth++;
138 else if (ch === "]" || ch === "}") depth--;
139 if (ch === "," && depth === 0) {
140 items.push(cur);
141 cur = "";
142 } else {
143 cur += ch;
144 }
145 }
146 if (cur.trim().length > 0) items.push(cur);
147 return items.map((s) => parseScalar(s));
148}
149
150/** Given the full line list + start index pointing at "key:", collect every
151 * child line (indent > startIndent). Returns the child sub-array and the
152 * index of the line immediately after the block. */
153function collectBlock(
154 lines: Line[],
155 startIdx: number
156): { children: Line[]; nextIdx: number } {
157 const startIndent = lines[startIdx].indent;
158 const children: Line[] = [];
159 let i = startIdx + 1;
160 while (i < lines.length && lines[i].indent > startIndent) {
161 children.push(lines[i]);
162 i++;
163 }
164 return { children, nextIdx: i };
165}
166
167/** Split "key: value" at the first unquoted colon. Returns [key, value] or
168 * null if the line has no colon (value-only — used for list items). */
169function splitKV(text: string): [string, string] | null {
170 let inQuote: string | null = null;
171 for (let i = 0; i < text.length; i++) {
172 const ch = text[i];
173 if (inQuote) {
174 if (ch === inQuote) inQuote = null;
175 continue;
176 }
177 if (ch === '"' || ch === "'") inQuote = ch;
178 else if (ch === ":" && (i + 1 === text.length || /\s|$/.test(text[i + 1] ?? ""))) {
179 return [text.slice(0, i).trim(), text.slice(i + 1).trim()];
180 }
181 }
182 return null;
183}
184
185/** Parse a block-style mapping (each line is `key: value` at the same
186 * indent). Returns a flat Record<string, string>. */
187function parseBlockMap(children: Line[]): Record<string, string> {
188 const out: Record<string, string> = {};
189 if (children.length === 0) return out;
190 const baseIndent = children[0].indent;
191 for (const ln of children) {
192 if (ln.indent !== baseIndent) continue;
193 const kv = splitKV(ln.text);
194 if (!kv) continue;
195 const scalar = parseScalar(kv[1]);
196 out[kv[0]] = String(scalar ?? "");
197 }
198 return out;
199}
200
201/** Parse `needs:` which can be a scalar, flow-array, or block list. */
202function parseNeeds(valueAfterColon: string, children: Line[]): string[] | undefined {
203 const v = valueAfterColon.trim();
204 if (v.length > 0) {
205 if (v.startsWith("[")) {
206 return parseFlowArray(v).map(String);
207 }
208 return [String(parseScalar(v))];
209 }
210 const items: string[] = [];
211 if (children.length === 0) return undefined;
212 const baseIndent = children[0].indent;
213 for (const ln of children) {
214 if (ln.indent !== baseIndent) continue;
215 if (ln.text.startsWith("- ")) {
216 items.push(String(parseScalar(ln.text.slice(2))));
217 }
218 }
219 return items.length > 0 ? items : undefined;
220}
221
222/** Parse a matrix block. `axes` are any key/value where value is a flow
223 * array or block list. `include`/`exclude` are lists of combo maps. */
224function parseMatrix(children: Line[], warnings: string[]): MatrixSpec | undefined {
225 if (children.length === 0) return undefined;
226 const baseIndent = children[0].indent;
227 const spec: MatrixSpec = { axes: {} };
228 let i = 0;
229 while (i < children.length) {
230 const ln = children[i];
231 if (ln.indent !== baseIndent) {
232 i++;
233 continue;
234 }
235 const kv = splitKV(ln.text);
236 if (!kv) {
237 i++;
238 continue;
239 }
240 const [key, val] = kv;
241 if (key === "include" || key === "exclude") {
242 // Collect child list-of-map entries
243 const slice: Record<string, unknown>[] = [];
244 let j = i + 1;
245 let current: Record<string, unknown> | null = null;
246 while (j < children.length && children[j].indent > baseIndent) {
247 const c = children[j];
248 if (c.text.startsWith("- ")) {
249 if (current) slice.push(current);
250 current = {};
251 const inner = c.text.slice(2);
252 const innerKv = splitKV(inner);
253 if (innerKv) current[innerKv[0]] = parseScalar(innerKv[1]);
254 } else if (current) {
255 const innerKv = splitKV(c.text);
256 if (innerKv) current[innerKv[0]] = parseScalar(innerKv[1]);
257 }
258 j++;
259 }
260 if (current) slice.push(current);
261 if (key === "include") spec.include = slice;
262 else spec.exclude = slice;
263 i = j;
264 continue;
265 }
266 if (key === "fail-fast") {
267 spec.failFast = val.trim() === "true";
268 i++;
269 continue;
270 }
271 if (key === "max-parallel") {
272 const n = parseInt(val.trim(), 10);
273 if (Number.isFinite(n)) spec.maxParallel = n;
274 i++;
275 continue;
276 }
277 // Treat as axis
278 if (val.trim().length > 0 && val.trim().startsWith("[")) {
279 spec.axes[key] = parseFlowArray(val);
280 i++;
281 } else if (val.trim().length === 0) {
282 // Block list under this axis
283 const items: unknown[] = [];
284 let j = i + 1;
285 while (j < children.length && children[j].indent > baseIndent) {
286 const c = children[j];
287 if (c.text.startsWith("- ")) items.push(parseScalar(c.text.slice(2)));
288 j++;
289 }
290 spec.axes[key] = items;
291 i = j;
292 } else {
293 warnings.push(`matrix axis '${key}' has unsupported shape`);
294 i++;
295 }
296 }
297 return spec;
298}
299
300/** Parse `on:` block for workflow_dispatch inputs. */
301function extractDispatchInputs(
302 lines: Line[],
303 warnings: string[]
304): { onArray: string[]; dispatchInputs?: Record<string, DispatchInput> } {
305 const onArray: string[] = [];
306 let dispatchInputs: Record<string, DispatchInput> | undefined;
307 for (let i = 0; i < lines.length; i++) {
308 const ln = lines[i];
309 if (ln.indent !== 0) continue;
310 const kv = splitKV(ln.text);
311 if (!kv || kv[0] !== "on") continue;
312 const val = kv[1].trim();
313 // Scalar/flow form — base parser already handles these; just populate
314 if (val.length > 0) {
315 if (val.startsWith("[")) {
316 onArray.push(...parseFlowArray(val).map(String));
317 } else {
318 onArray.push(String(parseScalar(val)));
319 }
320 break;
321 }
322 // Block form — children can be scalar events or mappings
323 const { children } = collectBlock(lines, i);
324 if (children.length === 0) break;
325 const baseIndent = children[0].indent;
326 for (let j = 0; j < children.length; j++) {
327 const c = children[j];
328 if (c.indent !== baseIndent) continue;
329 if (c.text.startsWith("- ")) {
330 onArray.push(String(parseScalar(c.text.slice(2))));
331 continue;
332 }
333 const eventKv = splitKV(c.text);
334 if (!eventKv) continue;
335 const event = eventKv[0];
336 onArray.push(event);
337 if (event === "workflow_dispatch" && eventKv[1].trim().length === 0) {
338 // Look for nested `inputs:` block
339 let k = j + 1;
340 while (k < children.length && children[k].indent > baseIndent) {
341 const inputsLine = children[k];
342 const inputsKv = splitKV(inputsLine.text);
343 if (inputsKv && inputsKv[0] === "inputs") {
344 dispatchInputs = {};
345 let m = k + 1;
346 const inputBaseIndent = inputsLine.indent + 2;
347 while (m < children.length && children[m].indent >= inputBaseIndent) {
348 const nameLine = children[m];
349 if (nameLine.indent === inputBaseIndent) {
350 const nameKv = splitKV(nameLine.text);
351 if (nameKv) {
352 const inp: DispatchInput = { type: "string" };
353 let n = m + 1;
354 while (n < children.length && children[n].indent > nameLine.indent) {
355 const fieldKv = splitKV(children[n].text);
356 if (fieldKv) {
357 const [fk, fv] = fieldKv;
358 if (fk === "type") {
359 const t = String(parseScalar(fv));
360 if (t === "string" || t === "boolean" || t === "choice" || t === "number") {
361 inp.type = t;
362 }
363 } else if (fk === "required") {
364 inp.required = parseScalar(fv) === true;
365 } else if (fk === "default") {
366 const s = parseScalar(fv);
367 if (s !== null) inp.default = s as string | boolean | number;
368 } else if (fk === "description") {
369 inp.description = String(parseScalar(fv));
370 } else if (fk === "options" && fv.trim().startsWith("[")) {
371 inp.options = parseFlowArray(fv).map(String);
372 }
373 }
374 n++;
375 }
376 dispatchInputs[nameKv[0]] = inp;
377 m = n;
378 continue;
379 }
380 }
381 m++;
382 }
383 k = m;
384 } else {
385 k++;
386 }
387 }
388 }
389 }
390 break;
391 }
392 return { onArray, dispatchInputs };
393}
394
395/** Walk job + step blocks and enrich them. Returns a map of jobName →
396 * extension data, which callers merge onto the base workflow. */
397function extractJobExtensions(
398 lines: Line[],
399 warnings: string[]
400): {
401 workflowEnv?: Record<string, string>;
402 jobExts: Map<string, Partial<ExtendedJob>>;
403 stepExts: Map<string, ExtendedStep[]>; // jobName → step[] aligned by order
404} {
405 const jobExts = new Map<string, Partial<ExtendedJob>>();
406 const stepExts = new Map<string, ExtendedStep[]>();
407 let workflowEnv: Record<string, string> | undefined;
408
409 for (let i = 0; i < lines.length; i++) {
410 const ln = lines[i];
411 if (ln.indent !== 0) continue;
412 const kv = splitKV(ln.text);
413 if (!kv) continue;
414
415 if (kv[0] === "env" && kv[1].trim().length === 0) {
416 const { children, nextIdx } = collectBlock(lines, i);
417 workflowEnv = parseBlockMap(children);
418 i = nextIdx - 1;
419 continue;
420 }
421
422 if (kv[0] !== "jobs" || kv[1].trim().length > 0) continue;
423
424 // Walk each job block
425 const { children: jobChildren } = collectBlock(lines, i);
426 if (jobChildren.length === 0) continue;
427 const jobIndent = jobChildren[0].indent;
428 let j = 0;
429 while (j < jobChildren.length) {
430 const jline = jobChildren[j];
431 if (jline.indent !== jobIndent) {
432 j++;
433 continue;
434 }
435 const jkv = splitKV(jline.text);
436 if (!jkv) {
437 j++;
438 continue;
439 }
440 const jobName = jkv[0];
441 // Absolute index in lines[] for this job header
442 const absIdx = lines.indexOf(jline);
443 const { children: jobBody, nextIdx: jobNextIdx } = collectBlock(lines, absIdx);
444 const ext: Partial<ExtendedJob> = {};
445 const steps: ExtendedStep[] = [];
446 const jobBodyIndent = jobBody.length > 0 ? jobBody[0].indent : 0;
447
448 let k = 0;
449 while (k < jobBody.length) {
450 const bline = jobBody[k];
451 if (bline.indent !== jobBodyIndent) {
452 k++;
453 continue;
454 }
455 const bkv = splitKV(bline.text);
456 if (!bkv) {
457 k++;
458 continue;
459 }
460 const [bkey, bval] = bkv;
461 const bodyAbsIdx = lines.indexOf(bline);
462 const { children: bodyChildren, nextIdx: bodyNextIdx } = collectBlock(
463 lines,
464 bodyAbsIdx
465 );
466
467 if (bkey === "if") {
468 ext.if = bval.trim().length > 0 ? bval.trim() : undefined;
469 k++;
470 } else if (bkey === "needs") {
471 ext.needs = parseNeeds(bval, bodyChildren);
472 k = bodyNextIdx - bodyAbsIdx - 1 + k + 1;
473 } else if (bkey === "env" && bval.trim().length === 0) {
474 ext.env = parseBlockMap(bodyChildren);
475 k += bodyChildren.length + 1;
476 } else if (bkey === "outputs" && bval.trim().length === 0) {
477 ext.outputs = parseBlockMap(bodyChildren);
478 k += bodyChildren.length + 1;
479 } else if (bkey === "strategy" && bval.trim().length === 0) {
480 for (let s = 0; s < bodyChildren.length; s++) {
481 const sline = bodyChildren[s];
482 if (sline.indent !== bodyChildren[0].indent) continue;
483 const skv = splitKV(sline.text);
484 if (skv && skv[0] === "matrix" && skv[1].trim().length === 0) {
485 const sAbsIdx = lines.indexOf(sline);
486 const { children: matrixChildren } = collectBlock(lines, sAbsIdx);
487 const matrix = parseMatrix(matrixChildren, warnings);
488 if (matrix) ext.strategy = { matrix };
489 }
490 }
491 k += bodyChildren.length + 1;
492 } else if (bkey === "steps" && bval.trim().length === 0) {
493 // Walk each step
494 let stepIdx = 0;
495 let s = 0;
496 while (s < bodyChildren.length) {
497 const sline = bodyChildren[s];
498 if (sline.text.startsWith("- ")) {
499 const stepBody: Line[] = [];
500 const stepIndent = sline.indent;
501 const stepFirst: Line = {
502 indent: stepIndent + 2,
503 text: sline.text.slice(2),
504 raw: sline.raw,
505 };
506 stepBody.push(stepFirst);
507 let t = s + 1;
508 while (t < bodyChildren.length && bodyChildren[t].indent > stepIndent) {
509 stepBody.push(bodyChildren[t]);
510 t++;
511 }
512 const stepExt: ExtendedStep = {};
513 for (let u = 0; u < stepBody.length; u++) {
514 const stepLine = stepBody[u];
515 const sKv = splitKV(stepLine.text);
516 if (!sKv) continue;
517 const [sk, sv] = sKv;
518 if (sk === "id") stepExt.id = String(parseScalar(sv));
519 else if (sk === "name") stepExt.name = String(parseScalar(sv));
520 else if (sk === "if") stepExt.if = sv.trim();
521 else if (sk === "run") stepExt.run = String(parseScalar(sv));
522 else if (sk === "uses") stepExt.uses = String(parseScalar(sv));
523 else if (sk === "parallel") stepExt.parallel = parseScalar(sv) === true;
524 else if (sk === "continue-on-error")
525 stepExt.continueOnError = parseScalar(sv) === true;
526 else if (sk === "with" && sv.trim().length === 0) {
527 // Sub-block at deeper indent
528 const withBody: Line[] = [];
529 let v = u + 1;
530 while (v < stepBody.length && stepBody[v].indent > stepLine.indent) {
531 withBody.push(stepBody[v]);
532 v++;
533 }
534 stepExt.with = parseBlockMap(withBody);
535 u = v - 1;
536 } else if (sk === "env" && sv.trim().length === 0) {
537 const envBody: Line[] = [];
538 let v = u + 1;
539 while (v < stepBody.length && stepBody[v].indent > stepLine.indent) {
540 envBody.push(stepBody[v]);
541 v++;
542 }
543 stepExt.env = parseBlockMap(envBody);
544 u = v - 1;
545 }
546 }
547 steps.push(stepExt);
548 stepIdx++;
549 s = t;
550 } else {
551 s++;
552 }
553 }
554 k += bodyChildren.length + 1;
555 } else {
556 k++;
557 }
558 }
559
560 if (Object.keys(ext).length > 0) jobExts.set(jobName, ext);
561 if (steps.length > 0) stepExts.set(jobName, steps);
562 j = jobNextIdx - absIdx - 1 + j + 1;
563 }
564 break;
565 }
566
567 return { workflowEnv, jobExts, stepExts };
568}
569
570// ---------------------------------------------------------------------------
571// Public API
572// ---------------------------------------------------------------------------
573
574/** The base parser requires every step to have a `run:` field. For `uses:`-only
575 * steps (which are legal in GitHub Actions) we inject a no-op `run` so base
576 * parse succeeds; the extended parser then picks up the real `uses:` value. */
577function preprocessForBaseParser(yaml: string): string {
578 const lines = yaml.replace(/\r\n?/g, "\n").split("\n");
579 const out: string[] = [];
580 let inStepsBlock = false;
581 let stepsIndent = -1;
582 for (let i = 0; i < lines.length; i++) {
583 const line = lines[i];
584 const trimmed = line.replace(/^\s+/, "");
585 const indent = line.length - trimmed.length;
586
587 if (/^steps\s*:\s*$/.test(trimmed)) {
588 inStepsBlock = true;
589 stepsIndent = indent;
590 out.push(line);
591 continue;
592 }
593 if (inStepsBlock && trimmed.length > 0 && indent <= stepsIndent && !trimmed.startsWith("-")) {
594 inStepsBlock = false;
595 }
596
597 out.push(line);
598
599 if (inStepsBlock && /^-\s+uses\s*:/.test(trimmed)) {
600 // Look ahead to see if this step block already has a run: line
601 const stepItemIndent = indent;
602 let hasRun = false;
603 for (let j = i + 1; j < lines.length; j++) {
604 const jline = lines[j];
605 const jtrim = jline.replace(/^\s+/, "");
606 const jindent = jline.length - jtrim.length;
607 if (jtrim.length === 0) continue;
608 if (jtrim.startsWith("-") && jindent === stepItemIndent) break;
609 if (jindent <= stepItemIndent) break;
610 if (/^run\s*:/.test(jtrim)) {
611 hasRun = true;
612 break;
613 }
614 }
615 if (!hasRun) {
616 out.push(`${" ".repeat(indent + 2)}run: ':'`);
617 }
618 }
619 }
620 return out.join("\n");
621}
622
623export function parseExtended(yaml: string): ExtendedParseResult {
624 const base = parseWorkflow(preprocessForBaseParser(yaml));
625 if (!base.ok) return base;
626
627 const warnings: string[] = [];
628 let extended: ExtendedWorkflow;
629 try {
630 const lines = lexLines(yaml);
631 const { onArray, dispatchInputs } = extractDispatchInputs(lines, warnings);
632 const { workflowEnv, jobExts, stepExts } = extractJobExtensions(lines, warnings);
633
634 // Merge onto base — keep base structure but enrich each job with extras
635 const mergedOn = onArray.length > 0 ? Array.from(new Set(onArray)) : base.workflow.on;
636
637 const jobs: ExtendedJob[] = base.workflow.jobs.map((baseJob) => {
638 const ext = jobExts.get(baseJob.name) ?? {};
639 const extendedStepsForJob = stepExts.get(baseJob.name) ?? [];
640 const steps: ExtendedStep[] = baseJob.steps.map((bs, idx) => {
641 const ext = extendedStepsForJob[idx] ?? {};
642 return { name: bs.name, run: bs.run, ...ext };
643 });
644 return {
645 name: baseJob.name,
646 runsOn: baseJob.runsOn,
647 steps,
648 if: ext.if,
649 needs: ext.needs,
650 strategy: ext.strategy,
651 env: ext.env,
652 outputs: ext.outputs,
653 };
654 });
655
656 extended = {
657 name: base.workflow.name,
658 on: mergedOn,
659 dispatchInputs,
660 env: workflowEnv,
661 jobs,
662 warnings,
663 };
664 } catch (err) {
665 // Defensive: if enrichment throws for any reason, return the base as
666 // an extended workflow with a single warning — never fail the parse.
667 warnings.push(
668 `extension-pass error: ${err instanceof Error ? err.message : String(err)}`
669 );
670 extended = {
671 name: base.workflow.name,
672 on: base.workflow.on,
673 jobs: base.workflow.jobs.map((j) => ({
674 name: j.name,
675 runsOn: j.runsOn,
676 steps: j.steps.map((s) => ({ name: s.name, run: s.run })),
677 })),
678 warnings,
679 };
680 }
681
682 return { ok: true, workflow: extended };
683}