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.

740 lines · 30.7 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 "merge_group":
295 exclusive(c, o, path, "branches", "branches-ignore");
296 on.merge_group = pick({ types: list("types"), branches: list("branches"), branchesIgnore: list("branches-ignore") });
297 return;
298 case "workflow_run":
299 exclusive(c, o, path, "branches", "branches-ignore");
300 on.workflow_run = pick({
301 workflows: list("workflows"),
302 types: list("types"),
303 branches: list("branches"),
304 branchesIgnore: list("branches-ignore"),
305 });
306 return;
307 default: {
308 const out: Obj = { ...o };
309 if (o.types !== undefined) out.types = list("types");
310 on[ev] = out;
311 }
312 }
313}
314
315function normalizeSchedule(c: Context, cfg: unknown, path: Seg[]): { cron: string }[] {
316 if (!Array.isArray(cfg)) {
317 c.error(path, "on.schedule must be a list of { cron: '...' } entries");
318 return [];
319 }
320 const out: { cron: string }[] = [];
321 cfg.forEach((entry, i) => {
322 const p = [...path, i];
323 if (!isObj(entry) || typeof entry.cron !== "string") {
324 c.error(p, "on.schedule entries must have a 'cron' string");
325 return;
326 }
327 const err = validateCron(entry.cron);
328 if (err) c.error([...p, "cron"], `Invalid cron: ${err}`);
329 else out.push({ cron: entry.cron });
330 });
331 return out;
332}
333
334function normalizeInputs(
335 c: Context,
336 raw: unknown,
337 path: Seg[],
338 types: Set<string>,
339 typeRequired: boolean,
340): Record<string, DispatchInput> | undefined {
341 if (raw === undefined || raw === null) return undefined;
342 if (!isObj(raw)) {
343 c.error(path, `${formatPath(path)} must be a mapping`);
344 return undefined;
345 }
346 const out: Record<string, DispatchInput> = {};
347 for (const [name, def] of Object.entries(raw)) {
348 const p = [...path, name];
349 const d = isObj(def) ? def : {};
350 if (def !== null && !isObj(def)) c.error(p, `input '${name}' must be a mapping`);
351 const input: DispatchInput = {};
352 const description = c.str(d.description, [...p, "description"]);
353 if (description !== undefined) input.description = description;
354 if (d.required !== undefined) {
355 if (typeof d.required === "boolean") input.required = d.required;
356 else c.error([...p, "required"], `input '${name}': 'required' must be a boolean`);
357 }
358 if (d.default !== undefined) input.default = d.default;
359 if (d.type !== undefined) {
360 const t = String(d.type);
361 if (!types.has(t)) c.error([...p, "type"], `input '${name}': invalid type '${t}' (expected one of ${[...types].join(", ")})`);
362 input.type = t;
363 } else if (typeRequired) {
364 c.error(p, `input '${name}' must declare a 'type'`, { key: true });
365 }
366 if (d.options !== undefined) input.options = c.strList(d.options, [...p, "options"]) ?? [];
367 if (input.type === "choice" && (!input.options || input.options.length === 0)) {
368 c.error(p, `input '${name}' of type 'choice' must define 'options'`, { key: true });
369 }
370 if (typeof d.deprecationMessage === "string") input.deprecationMessage = d.deprecationMessage;
371 out[name] = input;
372 }
373 return out;
374}
375
376// ---- shared job/workflow pieces --------------------------------------------------
377
378function normalizeDefaults(c: Context, raw: unknown, path: Seg[]): Defaults {
379 if (raw === undefined || raw === null) return {};
380 if (!isObj(raw)) {
381 c.error(path, `${formatPath(path)} must be a mapping`);
382 return {};
383 }
384 const out: Defaults = {};
385 if (raw.run !== undefined && raw.run !== null) {
386 if (!isObj(raw.run)) c.error([...path, "run"], `${formatPath([...path, "run"])} must be a mapping`);
387 else {
388 out.run = {};
389 const shell = c.str(raw.run.shell, [...path, "run", "shell"]);
390 const wd = c.str(raw.run["working-directory"], [...path, "run", "working-directory"]);
391 if (shell !== undefined) out.run.shell = shell;
392 if (wd !== undefined) out.run["working-directory"] = wd;
393 }
394 }
395 return out;
396}
397
398function normalizePermissions(c: Context, raw: unknown, path: Seg[]): Permissions | undefined {
399 if (raw === undefined) return undefined;
400 if (raw === null) return {};
401 if (raw === "read-all" || raw === "write-all") return raw;
402 if (!isObj(raw)) {
403 c.error(path, "permissions must be 'read-all', 'write-all' or a mapping of scope: read|write|none");
404 return undefined;
405 }
406 const out: Record<string, string> = {};
407 for (const [scope, level] of Object.entries(raw)) {
408 if (!PERMISSION_SCOPES.has(scope)) c.warn([...path, scope], `unknown permission scope '${scope}'`);
409 if (level !== "read" && level !== "write" && level !== "none") {
410 c.error([...path, scope], `permission '${scope}' must be 'read', 'write' or 'none'`);
411 continue;
412 }
413 out[scope] = level;
414 }
415 return out;
416}
417
418function normalizeConcurrency(c: Context, raw: unknown, path: Seg[]): Concurrency | undefined {
419 if (raw === undefined || raw === null) return undefined;
420 if (isScalarValue(raw)) return { group: String(raw), cancelInProgress: false };
421 if (!isObj(raw) || !isScalarValue(raw.group)) {
422 c.error(path, "concurrency must be a group string or a mapping with 'group'");
423 return undefined;
424 }
425 return { group: String(raw.group), cancelInProgress: c.boolOrExpr(raw["cancel-in-progress"], [...path, "cancel-in-progress"], false) };
426}
427
428function normalizeContainer(c: Context, raw: unknown, path: Seg[]): ContainerSpec | undefined {
429 if (raw === undefined || raw === null) return undefined;
430 if (typeof raw === "string") return { image: raw };
431 if (!isObj(raw) || typeof raw.image !== "string") {
432 c.error(path, `${formatPath(path)} must be an image string or a mapping with 'image'`);
433 return undefined;
434 }
435 const out: ContainerSpec = { image: raw.image };
436 if (isObj(raw.credentials)) {
437 out.credentials = {};
438 const u = c.str(raw.credentials.username, [...path, "credentials", "username"]);
439 const p = c.str(raw.credentials.password, [...path, "credentials", "password"]);
440 if (u !== undefined) out.credentials.username = u;
441 if (p !== undefined) out.credentials.password = p;
442 }
443 if (raw.env !== undefined) out.env = c.strMap(raw.env, [...path, "env"]);
444 if (raw.ports !== undefined) {
445 if (Array.isArray(raw.ports) && raw.ports.every((p) => typeof p === "string" || typeof p === "number")) {
446 out.ports = raw.ports as Array<string | number>;
447 } else c.error([...path, "ports"], `${formatPath([...path, "ports"])} must be a list of ports`);
448 }
449 if (raw.volumes !== undefined) out.volumes = c.strList(raw.volumes, [...path, "volumes"]) ?? [];
450 const options = c.str(raw.options, [...path, "options"]);
451 if (options !== undefined) out.options = options;
452 return out;
453}
454
455function normalizeEnvironment(c: Context, raw: unknown, path: Seg[]): EnvironmentSpec | undefined {
456 if (raw === undefined || raw === null) return undefined;
457 if (isScalarValue(raw)) return { name: String(raw) };
458 if (!isObj(raw) || !isScalarValue(raw.name)) {
459 c.error(path, "environment must be a name or a mapping with 'name'");
460 return undefined;
461 }
462 const env: EnvironmentSpec = { name: String(raw.name) };
463 const url = c.str(raw.url, [...path, "url"]);
464 if (url !== undefined) env.url = url;
465 return env;
466}
467
468// ---- steps and jobs ----------------------------------------------------------------
469
470function normalizeStep(c: Context, raw: unknown, path: Seg[], ids: Set<string>): StepSpec | undefined {
471 if (!isObj(raw)) {
472 c.error(path, "step must be a mapping");
473 return undefined;
474 }
475 c.unknownKeys(raw, STEP_KEYS, path, "step");
476 const step: StepSpec = {};
477 const hasRun = raw.run !== undefined && raw.run !== null;
478 const hasUses = raw.uses !== undefined && raw.uses !== null;
479 if (hasRun && hasUses) c.error(path, "step cannot have both 'run' and 'uses'");
480 if (!hasRun && !hasUses) c.error(path, "step must have either 'run' or 'uses'");
481
482 const id = c.str(raw.id, [...path, "id"]);
483 if (id !== undefined) {
484 if (!ID_RE.test(id) && soleExpression(id) === undefined) {
485 c.error([...path, "id"], `step id '${id}' must start with a letter or '_' and contain only alphanumerics, '-' or '_'`);
486 }
487 if (ids.has(id)) c.error([...path, "id"], `duplicate step id '${id}'`);
488 ids.add(id);
489 step.id = id;
490 }
491 const name = c.str(raw.name, [...path, "name"]);
492 if (name !== undefined) step.name = name;
493 const cond = c.str(raw.if, [...path, "if"]);
494 if (cond !== undefined) step.if = cond;
495 const run = c.str(raw.run, [...path, "run"]);
496 if (run !== undefined) step.run = run;
497 const uses = c.str(raw.uses, [...path, "uses"]);
498 if (uses !== undefined) {
499 if (uses.trim() === "") c.error([...path, "uses"], "'uses' must not be empty");
500 step.uses = uses;
501 }
502 if (raw.with !== undefined && raw.with !== null) step.with = c.strMap(raw.with, [...path, "with"]);
503 if (raw.env !== undefined && raw.env !== null) step.env = c.strMap(raw.env, [...path, "env"]);
504 const shell = c.str(raw.shell, [...path, "shell"]);
505 if (shell !== undefined) step.shell = shell;
506 const wd = c.str(raw["working-directory"], [...path, "working-directory"]);
507 if (wd !== undefined) step["working-directory"] = wd;
508 if (raw["continue-on-error"] !== undefined) {
509 step["continue-on-error"] = c.boolOrExpr(raw["continue-on-error"], [...path, "continue-on-error"], false);
510 }
511 const timeout = c.numOrExpr(raw["timeout-minutes"], [...path, "timeout-minutes"]);
512 if (timeout !== undefined) step["timeout-minutes"] = timeout;
513 return step;
514}
515
516function normalizeStrategy(c: Context, raw: unknown, path: Seg[]): Strategy | undefined {
517 if (raw === undefined || raw === null) return undefined;
518 if (!isObj(raw)) {
519 c.error(path, "strategy must be a mapping");
520 return undefined;
521 }
522 const strategy: Strategy = { failFast: c.boolOrExpr(raw["fail-fast"], [...path, "fail-fast"], true) };
523 const maxParallel = c.numOrExpr(raw["max-parallel"], [...path, "max-parallel"]);
524 if (maxParallel !== undefined) strategy.maxParallel = maxParallel;
525 if (raw.matrix !== undefined) {
526 strategy.matrix = raw.matrix;
527 if (!JSON.stringify(raw.matrix).includes("${{")) {
528 try {
529 if (expandMatrix(raw.matrix).length === 0) c.error([...path, "matrix"], "matrix produces no job combinations");
530 } catch (e) {
531 c.error([...path, "matrix"], (e as Error).message);
532 }
533 } else if (!isObj(raw.matrix) && (typeof raw.matrix !== "string" || soleExpression(raw.matrix) === undefined)) {
534 c.error([...path, "matrix"], "matrix must be a mapping or a single ${{ }} expression");
535 }
536 }
537 return strategy;
538}
539
540function normalizeRunsOn(c: Context, raw: unknown, path: Seg[], job: Job): void {
541 if (isScalarValue(raw)) job.runsOn = [String(raw)];
542 else if (Array.isArray(raw)) job.runsOn = c.strList(raw, path) ?? [];
543 else if (isObj(raw)) {
544 if (raw.group !== undefined) {
545 const g = c.str(raw.group, [...path, "group"]);
546 if (g !== undefined) job.runsOnGroup = g;
547 }
548 job.runsOn = c.strList(raw.labels, [...path, "labels"]) ?? [];
549 if (raw.group === undefined && raw.labels === undefined) c.error(path, "runs-on mapping must define 'group' and/or 'labels'");
550 } else c.error(path, "runs-on must be a label, a list of labels or a mapping with group/labels");
551}
552
553function normalizeJob(c: Context, key: string, raw: unknown): Job | undefined {
554 const path: Seg[] = ["jobs", key];
555 if (!ID_RE.test(key)) {
556 c.error(path, `job id '${key}' must start with a letter or '_' and contain only alphanumerics, '-' or '_'`, { key: true });
557 }
558 if (!isObj(raw)) {
559 c.error(path, `job '${key}' must be a mapping`, { key: true });
560 return undefined;
561 }
562 const isReusable = raw.uses !== undefined && raw.uses !== null;
563 c.unknownKeys(raw, isReusable ? REUSABLE_JOB_KEYS : JOB_KEYS, path, isReusable ? "reusable-workflow job" : "job");
564
565 const job: Job = {
566 key,
567 runsOn: [],
568 needs: c.strList(raw.needs, [...path, "needs"]) ?? [],
569 env: c.strMap(raw.env, [...path, "env"]),
570 defaults: normalizeDefaults(c, raw.defaults, [...path, "defaults"]),
571 outputs: c.strMap(raw.outputs, [...path, "outputs"]),
572 steps: [],
573 timeoutMinutes: c.numOrExpr(raw["timeout-minutes"], [...path, "timeout-minutes"]) ?? 360,
574 continueOnError: c.boolOrExpr(raw["continue-on-error"], [...path, "continue-on-error"], false),
575 };
576 const name = c.str(raw.name, [...path, "name"]);
577 if (name !== undefined) job.name = name;
578 const cond = c.str(raw.if, [...path, "if"]);
579 if (cond !== undefined) job.if = cond;
580
581 if (isReusable) {
582 const uses = c.str(raw.uses, [...path, "uses"]);
583 if (uses !== undefined) {
584 if (!/^\.\/.+\.ya?ml$/.test(uses) && !/^[^/\s]+\/[^/\s]+\/.+\.ya?ml@\S+$/.test(uses)) {
585 c.error([...path, "uses"], `reusable workflow reference '${uses}' must be './path/to/workflow.yml' or 'owner/repo/path/to/workflow.yml@ref'`);
586 }
587 job.uses = uses;
588 }
589 if (raw.steps !== undefined) c.error([...path, "steps"], "a job that calls a reusable workflow ('uses') cannot have 'steps'", { key: true });
590 if (raw["runs-on"] !== undefined) c.error([...path, "runs-on"], "a job that calls a reusable workflow ('uses') cannot have 'runs-on'", { key: true });
591 if (raw.with !== undefined && raw.with !== null) {
592 if (!isObj(raw.with)) c.error([...path, "with"], "with must be a mapping");
593 else job.with = { ...raw.with };
594 }
595 if (raw.secrets !== undefined && raw.secrets !== null) {
596 job.secrets = raw.secrets === "inherit" ? "inherit" : c.strMap(raw.secrets, [...path, "secrets"]);
597 }
598 } else {
599 if (raw["runs-on"] === undefined || raw["runs-on"] === null) {
600 c.error(path, `job '${key}' is missing 'runs-on'`, { key: true });
601 } else {
602 normalizeRunsOn(c, raw["runs-on"], [...path, "runs-on"], job);
603 }
604 if (!Array.isArray(raw.steps)) {
605 c.error(raw.steps === undefined ? path : [...path, "steps"], `job '${key}' must have a list of 'steps'`, { key: true });
606 } else {
607 if (raw.steps.length === 0) c.error([...path, "steps"], `job '${key}' must have at least one step`, { key: true });
608 const ids = new Set<string>();
609 raw.steps.forEach((s, i) => {
610 const step = normalizeStep(c, s, [...path, "steps", i], ids);
611 if (step) job.steps.push(step);
612 });
613 }
614 const container = normalizeContainer(c, raw.container, [...path, "container"]);
615 if (container) job.container = container;
616 if (raw.services !== undefined && raw.services !== null) {
617 if (!isObj(raw.services)) c.error([...path, "services"], "services must be a mapping");
618 else {
619 job.services = {};
620 for (const [name, svc] of Object.entries(raw.services)) {
621 const spec = normalizeContainer(c, svc, [...path, "services", name]);
622 if (spec) job.services[name] = spec;
623 }
624 }
625 }
626 const environment = normalizeEnvironment(c, raw.environment, [...path, "environment"]);
627 if (environment) job.environment = environment;
628 }
629
630 const strategy = normalizeStrategy(c, raw.strategy, [...path, "strategy"]);
631 if (strategy) job.strategy = strategy;
632 const permissions = normalizePermissions(c, raw.permissions, [...path, "permissions"]);
633 if (permissions) job.permissions = permissions;
634 const concurrency = normalizeConcurrency(c, raw.concurrency, [...path, "concurrency"]);
635 if (concurrency) job.concurrency = concurrency;
636 return job;
637}
638
639// ---- expression validation ---------------------------------------------------------
640
641/** Checks every string for unclosed `${{` and expression syntax errors. */
642function checkExpressions(c: Context, value: unknown, path: Seg[]): void {
643 if (typeof value === "string") {
644 const isIf = path[0] === "jobs" && path[path.length - 1] === "if" && (path.length === 3 || (path.length === 5 && path[2] === "steps"));
645 if (isIf && !value.includes("${{")) {
646 if (value.trim() !== "") {
647 const err = checkExpressionSyntax(value);
648 if (err) c.error(path, `Invalid expression: ${err}`);
649 }
650 return;
651 }
652 if (!value.includes("${{")) return;
653 try {
654 for (const part of splitTemplate(value)) {
655 if (part.kind !== "expr") continue;
656 const err = checkExpressionSyntax(part.expr);
657 if (err) c.error(path, `Invalid expression \${{ ${part.expr} }}: ${err}`);
658 }
659 } catch (e) {
660 if (e instanceof TemplateError) c.error(path, e.message);
661 else throw e;
662 }
663 } else if (Array.isArray(value)) {
664 value.forEach((v, i) => checkExpressions(c, v, [...path, i]));
665 } else if (isObj(value)) {
666 for (const [k, v] of Object.entries(value)) checkExpressions(c, v, [...path, k]);
667 }
668}
669
670// ---- entry point -------------------------------------------------------------------
671
672/**
673 * Parses and validates a workflow file. Errors carry YAML line/column; entries
674 * whose message starts with `warning:` are non-fatal. `workflow` is returned
675 * only when there are no (non-warning) errors.
676 */
677export function parseWorkflow(source: string, filePath: string): { workflow?: Workflow; errors: WorkflowError[] } {
678 const lc = new LineCounter();
679 const doc = parseDocument(source, { lineCounter: lc, merge: true, prettyErrors: false });
680 if (doc.errors.length > 0) {
681 return {
682 errors: doc.errors.map((e) => {
683 const pos = e.linePos?.[0] ?? lc.linePos(e.pos[0]);
684 return { path: "", ...(pos ? { line: pos.line, col: pos.col } : {}), message: `YAML syntax error: ${e.message.split("\n")[0]}` };
685 }),
686 };
687 }
688 const c = new Context(doc, lc);
689 let raw: unknown;
690 try {
691 raw = doc.toJS({ maxAliasCount: 10000 });
692 } catch (e) {
693 return { errors: [{ path: "", message: `YAML error: ${(e as Error).message}` }] };
694 }
695 if (!isObj(raw)) {
696 c.error([], raw === null || raw === undefined ? "workflow file is empty" : "workflow must be a mapping");
697 return { errors: c.errors };
698 }
699
700 c.unknownKeys(raw, TOP_LEVEL_KEYS, [], "top-level");
701 const wf: Workflow = {
702 path: filePath,
703 on: {},
704 env: c.strMap(raw.env, ["env"]),
705 defaults: normalizeDefaults(c, raw.defaults, ["defaults"]),
706 jobs: {},
707 raw,
708 };
709 const name = c.str(raw.name, ["name"]);
710 if (name !== undefined) wf.name = name;
711 const runName = c.str(raw["run-name"], ["run-name"]);
712 if (runName !== undefined) wf.runName = runName;
713
714 if (raw.on === undefined || raw.on === null) c.error(["on"], "workflow is missing the 'on' trigger section");
715 else wf.on = normalizeOn(c, raw.on);
716
717 const permissions = normalizePermissions(c, raw.permissions, ["permissions"]);
718 if (permissions) wf.permissions = permissions;
719 const concurrency = normalizeConcurrency(c, raw.concurrency, ["concurrency"]);
720 if (concurrency) wf.concurrency = concurrency;
721
722 if (raw.jobs === undefined || raw.jobs === null) {
723 c.error(["jobs"], "workflow is missing the 'jobs' section");
724 } else if (!isObj(raw.jobs)) {
725 c.error(["jobs"], "'jobs' must be a mapping of job id to job");
726 } else {
727 if (Object.keys(raw.jobs).length === 0) c.error(["jobs"], "'jobs' must define at least one job", { key: true });
728 for (const [key, rawJob] of Object.entries(raw.jobs)) {
729 const job = normalizeJob(c, key, rawJob);
730 if (job) wf.jobs[key] = job;
731 }
732 for (const e of jobOrder(wf).errors) c.errorDotted(e.path, e.message);
733 }
734
735 checkExpressions(c, raw, []);
736
737 c.errors.sort((a, b) => (a.line ?? 0) - (b.line ?? 0) || (a.col ?? 0) - (b.col ?? 0));
738 const fatal = c.errors.some((e) => !isWarning(e));
739 return fatal ? { errors: c.errors } : { workflow: wf, errors: c.errors };
740}