Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

736 lines · 30.5 KBRaw History
1/**
2 * Parsing, normalization and validation of GitHub Actions workflow files.
3 */
4import { LineCounter, isAlias, isMap, isScalar, isSeq, parseDocument, type Document, type Node as YamlNode } from "yaml";
5import { validateCron } from "./cron.js";
6import { checkExpressionSyntax } from "./expr/evaluate.js";
7import { soleExpression, splitTemplate, TemplateError } from "./expr/template.js";
8import { jobOrder } from "./graph.js";
9import { expandMatrix } from "./matrix.js";
10import type {
11 Concurrency,
12 ContainerSpec,
13 Defaults,
14 DispatchInput,
15 EnvironmentSpec,
16 Job,
17 Permissions,
18 StepSpec,
19 Strategy,
20 Workflow,
21 WorkflowCallTrigger,
22 WorkflowError,
23 WorkflowOn,
24} from "./model.js";
25
26type Seg = string | number;
27type Obj = Record<string, unknown>;
28
29export const WARNING_PREFIX = "warning:";
30export const isWarning = (e: WorkflowError): boolean => e.message.startsWith(WARNING_PREFIX);
31
32const TOP_LEVEL_KEYS = new Set(["name", "run-name", "on", "env", "defaults", "permissions", "concurrency", "jobs"]);
33const JOB_KEYS = new Set([
34 "name", "permissions", "needs", "if", "runs-on", "snapshot", "environment", "concurrency", "outputs", "env",
35 "defaults", "steps", "timeout-minutes", "strategy", "continue-on-error", "container", "services", "uses", "with",
36 "secrets",
37]);
38const REUSABLE_JOB_KEYS = new Set(["name", "uses", "with", "secrets", "needs", "if", "permissions", "concurrency", "strategy"]);
39const STEP_KEYS = new Set([
40 "id", "if", "name", "uses", "run", "working-directory", "shell", "with", "env", "continue-on-error", "timeout-minutes",
41]);
42export const KNOWN_EVENTS = new Set([
43 "branch_protection_rule", "check_run", "check_suite", "create", "delete", "deployment", "deployment_status",
44 "discussion", "discussion_comment", "fork", "gollum", "issue_comment", "issues", "label", "merge_group",
45 "milestone", "page_build", "project", "project_card", "project_column", "public", "pull_request",
46 "pull_request_review", "pull_request_review_comment", "pull_request_target", "push", "registry_package", "release",
47 "repository_dispatch", "schedule", "status", "watch", "workflow_call", "workflow_dispatch", "workflow_run",
48]);
49const PERMISSION_SCOPES = new Set([
50 "actions", "attestations", "checks", "contents", "deployments", "discussions", "id-token", "issues", "models",
51 "packages", "pages", "pull-requests", "repository-projects", "security-events", "statuses",
52]);
53const INPUT_TYPES_DISPATCH = new Set(["string", "boolean", "number", "choice", "environment"]);
54const INPUT_TYPES_CALL = new Set(["string", "boolean", "number"]);
55const ID_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
56
57const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v);
58const isScalarValue = (v: unknown): v is string | number | boolean =>
59 typeof v === "string" || typeof v === "number" || typeof v === "boolean";
60
61export function formatPath(path: readonly Seg[]): string {
62 let out = "";
63 for (const s of path) out += typeof s === "number" ? `[${s}]` : out === "" ? s : `.${s}`;
64 return out;
65}
66
67class Context {
68 readonly errors: WorkflowError[] = [];
69 constructor(
70 private readonly doc: Document,
71 private readonly lc: LineCounter,
72 ) {}
73
74 /** Finds the YAML node at `path` (the key node when `key` is set), falling back to the deepest existing ancestor. */
75 private locate(path: readonly Seg[], key: boolean): { line?: number; col?: number } {
76 let node: unknown = this.doc.contents;
77 let best: unknown = node;
78 for (let i = 0; i < path.length; i++) {
79 const seg = path[i]!;
80 if (isAlias(node)) node = node.resolve(this.doc);
81 if (isMap(node)) {
82 const pair = node.items.find((p) => (isScalar(p.key) ? String(p.key.value) : String(p.key)) === String(seg));
83 if (!pair) break;
84 const last = i === path.length - 1;
85 best = last && key ? pair.key : (pair.value ?? pair.key);
86 node = pair.value;
87 } else if (isSeq(node) && typeof seg === "number") {
88 const item = node.items[seg];
89 if (!item) break;
90 best = item;
91 node = item;
92 } else {
93 break;
94 }
95 }
96 const range = (best as YamlNode | undefined)?.range;
97 if (!range) return {};
98 const pos = this.lc.linePos(range[0]);
99 return { line: pos.line, col: pos.col };
100 }
101
102 error(path: readonly Seg[], message: string, opts: { key?: boolean } = {}): void {
103 this.errors.push({ path: formatPath(path), ...this.locate(path, opts.key ?? false), message });
104 }
105
106 warn(path: readonly Seg[], message: string): void {
107 this.error(path, `${WARNING_PREFIX} ${message}`, { key: true });
108 }
109
110 /** Re-anchors an error whose path is a dotted string (from jobOrder). */
111 errorDotted(dotted: string, message: string): void {
112 this.error(dotted.split("."), message, { key: true });
113 }
114
115 // ---- value helpers -------------------------------------------------------
116
117 str(v: unknown, path: Seg[], what = "a string"): string | undefined {
118 if (v === undefined || v === null) return undefined;
119 if (isScalarValue(v)) return String(v);
120 this.error(path, `${formatPath(path)} must be ${what}`);
121 return undefined;
122 }
123
124 strList(v: unknown, path: Seg[]): string[] | undefined {
125 if (v === undefined || v === null) return undefined;
126 if (isScalarValue(v)) return [String(v)];
127 if (Array.isArray(v)) {
128 const out: string[] = [];
129 v.forEach((item, i) => {
130 if (isScalarValue(item)) out.push(String(item));
131 else this.error([...path, i], `${formatPath([...path, i])} must be a string`);
132 });
133 return out;
134 }
135 this.error(path, `${formatPath(path)} must be a string or a list of strings`);
136 return undefined;
137 }
138
139 strMap(v: unknown, path: Seg[]): Record<string, string> {
140 const out: Record<string, string> = {};
141 if (v === undefined || v === null) return out;
142 if (typeof v === "string" && soleExpression(v) !== undefined) {
143 // e.g. `env: ${{ fromJSON(...) }}` — cannot be normalized statically.
144 this.error(path, `${formatPath(path)} must be a mapping (expressions producing whole mappings are not supported here)`);
145 return out;
146 }
147 if (!isObj(v)) {
148 this.error(path, `${formatPath(path)} must be a mapping`);
149 return out;
150 }
151 for (const [k, val] of Object.entries(v)) {
152 if (val === null || val === undefined) out[k] = "";
153 else if (isScalarValue(val)) out[k] = String(val);
154 else this.error([...path, k], `${formatPath([...path, k])} must be a string, number or boolean`);
155 }
156 return out;
157 }
158
159 boolOrExpr(v: unknown, path: Seg[], dflt: boolean): boolean | string {
160 if (v === undefined || v === null) return dflt;
161 if (typeof v === "boolean") return v;
162 if (typeof v === "string") {
163 if (v === "true" || v === "false") return v === "true";
164 if (soleExpression(v) !== undefined) return v;
165 }
166 this.error(path, `${formatPath(path)} must be a boolean or an expression`);
167 return dflt;
168 }
169
170 numOrExpr(v: unknown, path: Seg[]): number | string | undefined {
171 if (v === undefined || v === null) return undefined;
172 if (typeof v === "number" && Number.isFinite(v)) return v;
173 if (typeof v === "string") {
174 if (/^\d+(\.\d+)?$/.test(v.trim())) return Number(v);
175 if (soleExpression(v) !== undefined) return v;
176 }
177 this.error(path, `${formatPath(path)} must be a number or an expression`);
178 return undefined;
179 }
180
181 unknownKeys(obj: Obj, allowed: Set<string>, path: Seg[], what: string): void {
182 for (const k of Object.keys(obj)) {
183 if (!allowed.has(k)) this.warn([...path, k], `unknown ${what} key '${k}'`);
184 }
185 }
186}
187
188// ---- `on:` ---------------------------------------------------------------------
189
190function normalizeOn(c: Context, raw: unknown): WorkflowOn {
191 const on: WorkflowOn = {};
192 const path: Seg[] = ["on"];
193 if (typeof raw === "string") {
194 addEvent(c, on, raw, null, path);
195 } else if (Array.isArray(raw)) {
196 raw.forEach((ev, i) => {
197 if (typeof ev === "string") addEvent(c, on, ev, null, [...path, i]);
198 else c.error([...path, i], "on[] entries must be event names");
199 });
200 } else if (isObj(raw)) {
201 for (const [ev, cfg] of Object.entries(raw)) addEvent(c, on, ev, cfg, [...path, ev]);
202 } else {
203 c.error(path, "'on' must be an event name, a list of event names or a mapping");
204 }
205 if (Object.keys(on).length === 0 && (Array.isArray(raw) || isObj(raw))) c.error(path, "'on' must define at least one event");
206 return on;
207}
208
209function exclusive(c: Context, cfg: Obj, path: Seg[], a: string, b: string): void {
210 if (cfg[a] !== undefined && cfg[b] !== undefined) c.error(path, `'${a}' and '${b}' cannot be used together for the same event`);
211}
212
213function addEvent(c: Context, on: WorkflowOn, ev: string, cfg: unknown, path: Seg[]): void {
214 if (!KNOWN_EVENTS.has(ev)) c.warn(path, `unknown event '${ev}'`);
215 if (ev === "schedule") {
216 on.schedule = normalizeSchedule(c, cfg, path);
217 return;
218 }
219 if (cfg !== null && cfg !== undefined && !isObj(cfg)) {
220 c.error(path, `on.${ev} must be a mapping`);
221 on[ev] = {};
222 return;
223 }
224 const o: Obj = cfg ?? {};
225 const list = (k: string) => c.strList(o[k], [...path, k]);
226 const pick = <T extends Obj>(entries: Record<string, unknown>): T =>
227 Object.fromEntries(Object.entries(entries).filter(([, v]) => v !== undefined)) as T;
228
229 switch (ev) {
230 case "push":
231 exclusive(c, o, path, "branches", "branches-ignore");
232 exclusive(c, o, path, "tags", "tags-ignore");
233 exclusive(c, o, path, "paths", "paths-ignore");
234 on.push = pick({
235 branches: list("branches"),
236 branchesIgnore: list("branches-ignore"),
237 tags: list("tags"),
238 tagsIgnore: list("tags-ignore"),
239 paths: list("paths"),
240 pathsIgnore: list("paths-ignore"),
241 });
242 return;
243 case "pull_request":
244 case "pull_request_target":
245 exclusive(c, o, path, "branches", "branches-ignore");
246 exclusive(c, o, path, "paths", "paths-ignore");
247 on[ev] = pick({
248 types: list("types"),
249 branches: list("branches"),
250 branchesIgnore: list("branches-ignore"),
251 paths: list("paths"),
252 pathsIgnore: list("paths-ignore"),
253 });
254 return;
255 case "workflow_dispatch": {
256 const inputs = normalizeInputs(c, o.inputs, [...path, "inputs"], INPUT_TYPES_DISPATCH, false);
257 on.workflow_dispatch = inputs ? { inputs } : {};
258 return;
259 }
260 case "workflow_call": {
261 const call: WorkflowCallTrigger = {};
262 const inputs = normalizeInputs(c, o.inputs, [...path, "inputs"], INPUT_TYPES_CALL, true);
263 if (inputs) call.inputs = inputs;
264 if (o.outputs !== undefined && o.outputs !== null) {
265 if (!isObj(o.outputs)) c.error([...path, "outputs"], "on.workflow_call.outputs must be a mapping");
266 else {
267 call.outputs = {};
268 for (const [name, def] of Object.entries(o.outputs)) {
269 const p = [...path, "outputs", name];
270 if (!isObj(def) || typeof def.value !== "string") {
271 c.error(p, `workflow_call output '${name}' must be a mapping with a 'value'`);
272 continue;
273 }
274 call.outputs[name] = pick({ description: c.str(def.description, [...p, "description"]), value: def.value });
275 }
276 }
277 }
278 if (o.secrets !== undefined && o.secrets !== null) {
279 if (!isObj(o.secrets)) c.error([...path, "secrets"], "on.workflow_call.secrets must be a mapping");
280 else {
281 call.secrets = {};
282 for (const [name, def] of Object.entries(o.secrets)) {
283 const d = isObj(def) ? def : {};
284 call.secrets[name] = pick({
285 description: c.str(d.description, [...path, "secrets", name, "description"]),
286 required: typeof d.required === "boolean" ? d.required : undefined,
287 });
288 }
289 }
290 }
291 on.workflow_call = call;
292 return;
293 }
294 case "workflow_run":
295 exclusive(c, o, path, "branches", "branches-ignore");
296 on.workflow_run = pick({
297 workflows: list("workflows"),
298 types: list("types"),
299 branches: list("branches"),
300 branchesIgnore: list("branches-ignore"),
301 });
302 return;
303 default: {
304 const out: Obj = { ...o };
305 if (o.types !== undefined) out.types = list("types");
306 on[ev] = out;
307 }
308 }
309}
310
311function normalizeSchedule(c: Context, cfg: unknown, path: Seg[]): { cron: string }[] {
312 if (!Array.isArray(cfg)) {
313 c.error(path, "on.schedule must be a list of { cron: '...' } entries");
314 return [];
315 }
316 const out: { cron: string }[] = [];
317 cfg.forEach((entry, i) => {
318 const p = [...path, i];
319 if (!isObj(entry) || typeof entry.cron !== "string") {
320 c.error(p, "on.schedule entries must have a 'cron' string");
321 return;
322 }
323 const err = validateCron(entry.cron);
324 if (err) c.error([...p, "cron"], `Invalid cron: ${err}`);
325 else out.push({ cron: entry.cron });
326 });
327 return out;
328}
329
330function normalizeInputs(
331 c: Context,
332 raw: unknown,
333 path: Seg[],
334 types: Set<string>,
335 typeRequired: boolean,
336): Record<string, DispatchInput> | undefined {
337 if (raw === undefined || raw === null) return undefined;
338 if (!isObj(raw)) {
339 c.error(path, `${formatPath(path)} must be a mapping`);
340 return undefined;
341 }
342 const out: Record<string, DispatchInput> = {};
343 for (const [name, def] of Object.entries(raw)) {
344 const p = [...path, name];
345 const d = isObj(def) ? def : {};
346 if (def !== null && !isObj(def)) c.error(p, `input '${name}' must be a mapping`);
347 const input: DispatchInput = {};
348 const description = c.str(d.description, [...p, "description"]);
349 if (description !== undefined) input.description = description;
350 if (d.required !== undefined) {
351 if (typeof d.required === "boolean") input.required = d.required;
352 else c.error([...p, "required"], `input '${name}': 'required' must be a boolean`);
353 }
354 if (d.default !== undefined) input.default = d.default;
355 if (d.type !== undefined) {
356 const t = String(d.type);
357 if (!types.has(t)) c.error([...p, "type"], `input '${name}': invalid type '${t}' (expected one of ${[...types].join(", ")})`);
358 input.type = t;
359 } else if (typeRequired) {
360 c.error(p, `input '${name}' must declare a 'type'`, { key: true });
361 }
362 if (d.options !== undefined) input.options = c.strList(d.options, [...p, "options"]) ?? [];
363 if (input.type === "choice" && (!input.options || input.options.length === 0)) {
364 c.error(p, `input '${name}' of type 'choice' must define 'options'`, { key: true });
365 }
366 if (typeof d.deprecationMessage === "string") input.deprecationMessage = d.deprecationMessage;
367 out[name] = input;
368 }
369 return out;
370}
371
372// ---- shared job/workflow pieces --------------------------------------------------
373
374function normalizeDefaults(c: Context, raw: unknown, path: Seg[]): Defaults {
375 if (raw === undefined || raw === null) return {};
376 if (!isObj(raw)) {
377 c.error(path, `${formatPath(path)} must be a mapping`);
378 return {};
379 }
380 const out: Defaults = {};
381 if (raw.run !== undefined && raw.run !== null) {
382 if (!isObj(raw.run)) c.error([...path, "run"], `${formatPath([...path, "run"])} must be a mapping`);
383 else {
384 out.run = {};
385 const shell = c.str(raw.run.shell, [...path, "run", "shell"]);
386 const wd = c.str(raw.run["working-directory"], [...path, "run", "working-directory"]);
387 if (shell !== undefined) out.run.shell = shell;
388 if (wd !== undefined) out.run["working-directory"] = wd;
389 }
390 }
391 return out;
392}
393
394function normalizePermissions(c: Context, raw: unknown, path: Seg[]): Permissions | undefined {
395 if (raw === undefined) return undefined;
396 if (raw === null) return {};
397 if (raw === "read-all" || raw === "write-all") return raw;
398 if (!isObj(raw)) {
399 c.error(path, "permissions must be 'read-all', 'write-all' or a mapping of scope: read|write|none");
400 return undefined;
401 }
402 const out: Record<string, string> = {};
403 for (const [scope, level] of Object.entries(raw)) {
404 if (!PERMISSION_SCOPES.has(scope)) c.warn([...path, scope], `unknown permission scope '${scope}'`);
405 if (level !== "read" && level !== "write" && level !== "none") {
406 c.error([...path, scope], `permission '${scope}' must be 'read', 'write' or 'none'`);
407 continue;
408 }
409 out[scope] = level;
410 }
411 return out;
412}
413
414function normalizeConcurrency(c: Context, raw: unknown, path: Seg[]): Concurrency | undefined {
415 if (raw === undefined || raw === null) return undefined;
416 if (isScalarValue(raw)) return { group: String(raw), cancelInProgress: false };
417 if (!isObj(raw) || !isScalarValue(raw.group)) {
418 c.error(path, "concurrency must be a group string or a mapping with 'group'");
419 return undefined;
420 }
421 return { group: String(raw.group), cancelInProgress: c.boolOrExpr(raw["cancel-in-progress"], [...path, "cancel-in-progress"], false) };
422}
423
424function normalizeContainer(c: Context, raw: unknown, path: Seg[]): ContainerSpec | undefined {
425 if (raw === undefined || raw === null) return undefined;
426 if (typeof raw === "string") return { image: raw };
427 if (!isObj(raw) || typeof raw.image !== "string") {
428 c.error(path, `${formatPath(path)} must be an image string or a mapping with 'image'`);
429 return undefined;
430 }
431 const out: ContainerSpec = { image: raw.image };
432 if (isObj(raw.credentials)) {
433 out.credentials = {};
434 const u = c.str(raw.credentials.username, [...path, "credentials", "username"]);
435 const p = c.str(raw.credentials.password, [...path, "credentials", "password"]);
436 if (u !== undefined) out.credentials.username = u;
437 if (p !== undefined) out.credentials.password = p;
438 }
439 if (raw.env !== undefined) out.env = c.strMap(raw.env, [...path, "env"]);
440 if (raw.ports !== undefined) {
441 if (Array.isArray(raw.ports) && raw.ports.every((p) => typeof p === "string" || typeof p === "number")) {
442 out.ports = raw.ports as Array<string | number>;
443 } else c.error([...path, "ports"], `${formatPath([...path, "ports"])} must be a list of ports`);
444 }
445 if (raw.volumes !== undefined) out.volumes = c.strList(raw.volumes, [...path, "volumes"]) ?? [];
446 const options = c.str(raw.options, [...path, "options"]);
447 if (options !== undefined) out.options = options;
448 return out;
449}
450
451function normalizeEnvironment(c: Context, raw: unknown, path: Seg[]): EnvironmentSpec | undefined {
452 if (raw === undefined || raw === null) return undefined;
453 if (isScalarValue(raw)) return { name: String(raw) };
454 if (!isObj(raw) || !isScalarValue(raw.name)) {
455 c.error(path, "environment must be a name or a mapping with 'name'");
456 return undefined;
457 }
458 const env: EnvironmentSpec = { name: String(raw.name) };
459 const url = c.str(raw.url, [...path, "url"]);
460 if (url !== undefined) env.url = url;
461 return env;
462}
463
464// ---- steps and jobs ----------------------------------------------------------------
465
466function normalizeStep(c: Context, raw: unknown, path: Seg[], ids: Set<string>): StepSpec | undefined {
467 if (!isObj(raw)) {
468 c.error(path, "step must be a mapping");
469 return undefined;
470 }
471 c.unknownKeys(raw, STEP_KEYS, path, "step");
472 const step: StepSpec = {};
473 const hasRun = raw.run !== undefined && raw.run !== null;
474 const hasUses = raw.uses !== undefined && raw.uses !== null;
475 if (hasRun && hasUses) c.error(path, "step cannot have both 'run' and 'uses'");
476 if (!hasRun && !hasUses) c.error(path, "step must have either 'run' or 'uses'");
477
478 const id = c.str(raw.id, [...path, "id"]);
479 if (id !== undefined) {
480 if (!ID_RE.test(id) && soleExpression(id) === undefined) {
481 c.error([...path, "id"], `step id '${id}' must start with a letter or '_' and contain only alphanumerics, '-' or '_'`);
482 }
483 if (ids.has(id)) c.error([...path, "id"], `duplicate step id '${id}'`);
484 ids.add(id);
485 step.id = id;
486 }
487 const name = c.str(raw.name, [...path, "name"]);
488 if (name !== undefined) step.name = name;
489 const cond = c.str(raw.if, [...path, "if"]);
490 if (cond !== undefined) step.if = cond;
491 const run = c.str(raw.run, [...path, "run"]);
492 if (run !== undefined) step.run = run;
493 const uses = c.str(raw.uses, [...path, "uses"]);
494 if (uses !== undefined) {
495 if (uses.trim() === "") c.error([...path, "uses"], "'uses' must not be empty");
496 step.uses = uses;
497 }
498 if (raw.with !== undefined && raw.with !== null) step.with = c.strMap(raw.with, [...path, "with"]);
499 if (raw.env !== undefined && raw.env !== null) step.env = c.strMap(raw.env, [...path, "env"]);
500 const shell = c.str(raw.shell, [...path, "shell"]);
501 if (shell !== undefined) step.shell = shell;
502 const wd = c.str(raw["working-directory"], [...path, "working-directory"]);
503 if (wd !== undefined) step["working-directory"] = wd;
504 if (raw["continue-on-error"] !== undefined) {
505 step["continue-on-error"] = c.boolOrExpr(raw["continue-on-error"], [...path, "continue-on-error"], false);
506 }
507 const timeout = c.numOrExpr(raw["timeout-minutes"], [...path, "timeout-minutes"]);
508 if (timeout !== undefined) step["timeout-minutes"] = timeout;
509 return step;
510}
511
512function normalizeStrategy(c: Context, raw: unknown, path: Seg[]): Strategy | undefined {
513 if (raw === undefined || raw === null) return undefined;
514 if (!isObj(raw)) {
515 c.error(path, "strategy must be a mapping");
516 return undefined;
517 }
518 const strategy: Strategy = { failFast: c.boolOrExpr(raw["fail-fast"], [...path, "fail-fast"], true) };
519 const maxParallel = c.numOrExpr(raw["max-parallel"], [...path, "max-parallel"]);
520 if (maxParallel !== undefined) strategy.maxParallel = maxParallel;
521 if (raw.matrix !== undefined) {
522 strategy.matrix = raw.matrix;
523 if (!JSON.stringify(raw.matrix).includes("${{")) {
524 try {
525 if (expandMatrix(raw.matrix).length === 0) c.error([...path, "matrix"], "matrix produces no job combinations");
526 } catch (e) {
527 c.error([...path, "matrix"], (e as Error).message);
528 }
529 } else if (!isObj(raw.matrix) && (typeof raw.matrix !== "string" || soleExpression(raw.matrix) === undefined)) {
530 c.error([...path, "matrix"], "matrix must be a mapping or a single ${{ }} expression");
531 }
532 }
533 return strategy;
534}
535
536function normalizeRunsOn(c: Context, raw: unknown, path: Seg[], job: Job): void {
537 if (isScalarValue(raw)) job.runsOn = [String(raw)];
538 else if (Array.isArray(raw)) job.runsOn = c.strList(raw, path) ?? [];
539 else if (isObj(raw)) {
540 if (raw.group !== undefined) {
541 const g = c.str(raw.group, [...path, "group"]);
542 if (g !== undefined) job.runsOnGroup = g;
543 }
544 job.runsOn = c.strList(raw.labels, [...path, "labels"]) ?? [];
545 if (raw.group === undefined && raw.labels === undefined) c.error(path, "runs-on mapping must define 'group' and/or 'labels'");
546 } else c.error(path, "runs-on must be a label, a list of labels or a mapping with group/labels");
547}
548
549function normalizeJob(c: Context, key: string, raw: unknown): Job | undefined {
550 const path: Seg[] = ["jobs", key];
551 if (!ID_RE.test(key)) {
552 c.error(path, `job id '${key}' must start with a letter or '_' and contain only alphanumerics, '-' or '_'`, { key: true });
553 }
554 if (!isObj(raw)) {
555 c.error(path, `job '${key}' must be a mapping`, { key: true });
556 return undefined;
557 }
558 const isReusable = raw.uses !== undefined && raw.uses !== null;
559 c.unknownKeys(raw, isReusable ? REUSABLE_JOB_KEYS : JOB_KEYS, path, isReusable ? "reusable-workflow job" : "job");
560
561 const job: Job = {
562 key,
563 runsOn: [],
564 needs: c.strList(raw.needs, [...path, "needs"]) ?? [],
565 env: c.strMap(raw.env, [...path, "env"]),
566 defaults: normalizeDefaults(c, raw.defaults, [...path, "defaults"]),
567 outputs: c.strMap(raw.outputs, [...path, "outputs"]),
568 steps: [],
569 timeoutMinutes: c.numOrExpr(raw["timeout-minutes"], [...path, "timeout-minutes"]) ?? 360,
570 continueOnError: c.boolOrExpr(raw["continue-on-error"], [...path, "continue-on-error"], false),
571 };
572 const name = c.str(raw.name, [...path, "name"]);
573 if (name !== undefined) job.name = name;
574 const cond = c.str(raw.if, [...path, "if"]);
575 if (cond !== undefined) job.if = cond;
576
577 if (isReusable) {
578 const uses = c.str(raw.uses, [...path, "uses"]);
579 if (uses !== undefined) {
580 if (!/^\.\/.+\.ya?ml$/.test(uses) && !/^[^/\s]+\/[^/\s]+\/.+\.ya?ml@\S+$/.test(uses)) {
581 c.error([...path, "uses"], `reusable workflow reference '${uses}' must be './path/to/workflow.yml' or 'owner/repo/path/to/workflow.yml@ref'`);
582 }
583 job.uses = uses;
584 }
585 if (raw.steps !== undefined) c.error([...path, "steps"], "a job that calls a reusable workflow ('uses') cannot have 'steps'", { key: true });
586 if (raw["runs-on"] !== undefined) c.error([...path, "runs-on"], "a job that calls a reusable workflow ('uses') cannot have 'runs-on'", { key: true });
587 if (raw.with !== undefined && raw.with !== null) {
588 if (!isObj(raw.with)) c.error([...path, "with"], "with must be a mapping");
589 else job.with = { ...raw.with };
590 }
591 if (raw.secrets !== undefined && raw.secrets !== null) {
592 job.secrets = raw.secrets === "inherit" ? "inherit" : c.strMap(raw.secrets, [...path, "secrets"]);
593 }
594 } else {
595 if (raw["runs-on"] === undefined || raw["runs-on"] === null) {
596 c.error(path, `job '${key}' is missing 'runs-on'`, { key: true });
597 } else {
598 normalizeRunsOn(c, raw["runs-on"], [...path, "runs-on"], job);
599 }
600 if (!Array.isArray(raw.steps)) {
601 c.error(raw.steps === undefined ? path : [...path, "steps"], `job '${key}' must have a list of 'steps'`, { key: true });
602 } else {
603 if (raw.steps.length === 0) c.error([...path, "steps"], `job '${key}' must have at least one step`, { key: true });
604 const ids = new Set<string>();
605 raw.steps.forEach((s, i) => {
606 const step = normalizeStep(c, s, [...path, "steps", i], ids);
607 if (step) job.steps.push(step);
608 });
609 }
610 const container = normalizeContainer(c, raw.container, [...path, "container"]);
611 if (container) job.container = container;
612 if (raw.services !== undefined && raw.services !== null) {
613 if (!isObj(raw.services)) c.error([...path, "services"], "services must be a mapping");
614 else {
615 job.services = {};
616 for (const [name, svc] of Object.entries(raw.services)) {
617 const spec = normalizeContainer(c, svc, [...path, "services", name]);
618 if (spec) job.services[name] = spec;
619 }
620 }
621 }
622 const environment = normalizeEnvironment(c, raw.environment, [...path, "environment"]);
623 if (environment) job.environment = environment;
624 }
625
626 const strategy = normalizeStrategy(c, raw.strategy, [...path, "strategy"]);
627 if (strategy) job.strategy = strategy;
628 const permissions = normalizePermissions(c, raw.permissions, [...path, "permissions"]);
629 if (permissions) job.permissions = permissions;
630 const concurrency = normalizeConcurrency(c, raw.concurrency, [...path, "concurrency"]);
631 if (concurrency) job.concurrency = concurrency;
632 return job;
633}
634
635// ---- expression validation ---------------------------------------------------------
636
637/** Checks every string for unclosed `${{` and expression syntax errors. */
638function checkExpressions(c: Context, value: unknown, path: Seg[]): void {
639 if (typeof value === "string") {
640 const isIf = path[0] === "jobs" && path[path.length - 1] === "if" && (path.length === 3 || (path.length === 5 && path[2] === "steps"));
641 if (isIf && !value.includes("${{")) {
642 if (value.trim() !== "") {
643 const err = checkExpressionSyntax(value);
644 if (err) c.error(path, `Invalid expression: ${err}`);
645 }
646 return;
647 }
648 if (!value.includes("${{")) return;
649 try {
650 for (const part of splitTemplate(value)) {
651 if (part.kind !== "expr") continue;
652 const err = checkExpressionSyntax(part.expr);
653 if (err) c.error(path, `Invalid expression \${{ ${part.expr} }}: ${err}`);
654 }
655 } catch (e) {
656 if (e instanceof TemplateError) c.error(path, e.message);
657 else throw e;
658 }
659 } else if (Array.isArray(value)) {
660 value.forEach((v, i) => checkExpressions(c, v, [...path, i]));
661 } else if (isObj(value)) {
662 for (const [k, v] of Object.entries(value)) checkExpressions(c, v, [...path, k]);
663 }
664}
665
666// ---- entry point -------------------------------------------------------------------
667
668/**
669 * Parses and validates a workflow file. Errors carry YAML line/column; entries
670 * whose message starts with `warning:` are non-fatal. `workflow` is returned
671 * only when there are no (non-warning) errors.
672 */
673export function parseWorkflow(source: string, filePath: string): { workflow?: Workflow; errors: WorkflowError[] } {
674 const lc = new LineCounter();
675 const doc = parseDocument(source, { lineCounter: lc, merge: true, prettyErrors: false });
676 if (doc.errors.length > 0) {
677 return {
678 errors: doc.errors.map((e) => {
679 const pos = e.linePos?.[0] ?? lc.linePos(e.pos[0]);
680 return { path: "", ...(pos ? { line: pos.line, col: pos.col } : {}), message: `YAML syntax error: ${e.message.split("\n")[0]}` };
681 }),
682 };
683 }
684 const c = new Context(doc, lc);
685 let raw: unknown;
686 try {
687 raw = doc.toJS({ maxAliasCount: 10000 });
688 } catch (e) {
689 return { errors: [{ path: "", message: `YAML error: ${(e as Error).message}` }] };
690 }
691 if (!isObj(raw)) {
692 c.error([], raw === null || raw === undefined ? "workflow file is empty" : "workflow must be a mapping");
693 return { errors: c.errors };
694 }
695
696 c.unknownKeys(raw, TOP_LEVEL_KEYS, [], "top-level");
697 const wf: Workflow = {
698 path: filePath,
699 on: {},
700 env: c.strMap(raw.env, ["env"]),
701 defaults: normalizeDefaults(c, raw.defaults, ["defaults"]),
702 jobs: {},
703 raw,
704 };
705 const name = c.str(raw.name, ["name"]);
706 if (name !== undefined) wf.name = name;
707 const runName = c.str(raw["run-name"], ["run-name"]);
708 if (runName !== undefined) wf.runName = runName;
709
710 if (raw.on === undefined || raw.on === null) c.error(["on"], "workflow is missing the 'on' trigger section");
711 else wf.on = normalizeOn(c, raw.on);
712
713 const permissions = normalizePermissions(c, raw.permissions, ["permissions"]);
714 if (permissions) wf.permissions = permissions;
715 const concurrency = normalizeConcurrency(c, raw.concurrency, ["concurrency"]);
716 if (concurrency) wf.concurrency = concurrency;
717
718 if (raw.jobs === undefined || raw.jobs === null) {
719 c.error(["jobs"], "workflow is missing the 'jobs' section");
720 } else if (!isObj(raw.jobs)) {
721 c.error(["jobs"], "'jobs' must be a mapping of job id to job");
722 } else {
723 if (Object.keys(raw.jobs).length === 0) c.error(["jobs"], "'jobs' must define at least one job", { key: true });
724 for (const [key, rawJob] of Object.entries(raw.jobs)) {
725 const job = normalizeJob(c, key, rawJob);
726 if (job) wf.jobs[key] = job;
727 }
728 for (const e of jobOrder(wf).errors) c.errorDotted(e.path, e.message);
729 }
730
731 checkExpressions(c, raw, []);
732
733 c.errors.sort((a, b) => (a.line ?? 0) - (b.line ?? 0) || (a.col ?? 0) - (b.col ?? 0));
734 const fatal = c.errors.some((e) => !isWarning(e));
735 return fatal ? { errors: c.errors } : { workflow: wf, errors: c.errors };
736}