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.

WIP: workflow parser and runner agent in progress

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
Claude authored signed1 parent e0ffa64commit 5842b9dc29de8bef630d3b208acc3f4d7d22326bBrowse files
2 files changed+871−0
addedcontainers/runner/src/log.ts+135−0View file
@@ -1,0 +1,135 @@
1+import type { LogLine } from "@diffinery/shared";
2+import type { LogBatcher } from "./api.js";
3+import { parseCommand } from "./commands.js";
4+import type { Masker } from "./mask.js";
5+import { nowIso } from "./util.js";
6+
7+/** Masks and ships log lines; keeps a short tail per step for failure summaries. */
8+export class JobLogger {
9+ private tails = new Map<number, string[]>();
10+
11+ constructor(
12+ private readonly masker: Masker,
13+ private readonly batcher: LogBatcher | null,
14+ private readonly echo: boolean,
15+ ) {}
16+
17+ line(step: number, text: string): void {
18+ for (const raw of text.split(/\r?\n/)) {
19+ const masked = this.masker.mask(raw);
20+ const l: LogLine = { t: nowIso(), step, text: masked };
21+ this.batcher?.push(l);
22+ if (this.echo) process.stdout.write(`[${step}] ${masked}\n`);
23+ let tail = this.tails.get(step);
24+ if (!tail) this.tails.set(step, (tail = []));
25+ tail.push(masked);
26+ if (tail.length > 40) tail.splice(0, tail.length - 40);
27+ }
28+ }
29+
30+ tail(step: number, n = 20): string[] {
31+ // skip runner chrome (##[group] etc.) in summaries
32+ return (this.tails.get(step) ?? []).filter((l) => !/^##\[(group|endgroup)\]/.test(l)).slice(-n);
33+ }
34+}
35+
36+export interface Annotation {
37+ level: "error" | "warning" | "notice";
38+ message: string;
39+ file?: string;
40+ line?: string;
41+ title?: string;
42+}
43+
44+export interface OutputSink {
45+ logIndex: number;
46+ outputs: Record<string, string>;
47+ state: Record<string, string>;
48+}
49+
50+/**
51+ * Interprets workflow commands on a step's stdout/stderr and forwards the
52+ * remaining text to the logger.
53+ */
54+export class OutputProcessor {
55+ private stopToken: string | null = null;
56+ private echoCommands = false;
57+
58+ constructor(
59+ private readonly logger: JobLogger,
60+ private readonly masker: Masker,
61+ private readonly sink: OutputSink,
62+ private readonly hooks: {
63+ debug: boolean;
64+ onAnnotation: (a: Annotation) => void;
65+ },
66+ ) {}
67+
68+ private log(text: string): void {
69+ this.logger.line(this.sink.logIndex, text);
70+ }
71+
72+ handle(line: string): void {
73+ if (this.stopToken !== null) {
74+ if (line.trim() === `::${this.stopToken}::`) {
75+ this.stopToken = null;
76+ return;
77+ }
78+ this.log(line);
79+ return;
80+ }
81+ const cmd = parseCommand(line);
82+ if (!cmd) {
83+ this.log(line);
84+ return;
85+ }
86+ if (this.echoCommands && cmd.command !== "add-mask") this.log(line);
87+ const p = cmd.properties;
88+ switch (cmd.command) {
89+ case "add-mask":
90+ this.masker.add(cmd.message);
91+ return;
92+ case "set-output":
93+ if (p.name) this.sink.outputs[p.name] = cmd.message;
94+ return;
95+ case "save-state":
96+ if (p.name) this.sink.state[p.name] = cmd.message;
97+ return;
98+ case "group":
99+ this.log(`##[group]${cmd.message}`);
100+ return;
101+ case "endgroup":
102+ this.log("##[endgroup]");
103+ return;
104+ case "debug":
105+ if (this.hooks.debug) this.log(`##[debug]${cmd.message}`);
106+ return;
107+ case "error":
108+ case "warning":
109+ case "notice": {
110+ const a: Annotation = { level: cmd.command, message: cmd.message };
111+ if (p.file) a.file = p.file;
112+ if (p.line) a.line = p.line;
113+ if (p.title) a.title = p.title;
114+ const loc = a.file ? `${a.file}${a.line ? `:${a.line}` : ""}: ` : "";
115+ const title = a.title ? `${a.title}: ` : "";
116+ this.log(`##[${cmd.command}]${loc}${title}${cmd.message}`);
117+ this.hooks.onAnnotation(a);
118+ return;
119+ }
120+ case "stop-commands":
121+ if (cmd.message) this.stopToken = cmd.message;
122+ return;
123+ case "echo":
124+ this.echoCommands = cmd.message.trim() === "on";
125+ return;
126+ case "set-env":
127+ case "add-path":
128+ this.log(`##[error]The \`${cmd.command}\` command is disabled. Please upgrade to using Environment Files.`);
129+ this.hooks.onAnnotation({ level: "error", message: `The \`${cmd.command}\` command is disabled.` });
130+ return;
131+ default:
132+ this.log(line);
133+ }
134+ }
135+}
addedpackages/workflow-yaml/src/parse.ts+736−0View file
@@ -1,0 +1,736 @@
1+/**
2+ * Parsing, normalization and validation of GitHub Actions workflow files.
3+ */
4+import { LineCounter, isAlias, isMap, isScalar, isSeq, parseDocument, type Document, type Node as YamlNode } from "yaml";
5+import { validateCron } from "./cron.js";
6+import { checkExpressionSyntax } from "./expr/evaluate.js";
7+import { soleExpression, splitTemplate, TemplateError } from "./expr/template.js";
8+import { jobOrder } from "./graph.js";
9+import { expandMatrix } from "./matrix.js";
10+import 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+
26+type Seg = string | number;
27+type Obj = Record<string, unknown>;
28+
29+export const WARNING_PREFIX = "warning:";
30+export const isWarning = (e: WorkflowError): boolean => e.message.startsWith(WARNING_PREFIX);
31+
32+const TOP_LEVEL_KEYS = new Set(["name", "run-name", "on", "env", "defaults", "permissions", "concurrency", "jobs"]);
33+const 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+]);
38+const REUSABLE_JOB_KEYS = new Set(["name", "uses", "with", "secrets", "needs", "if", "permissions", "concurrency", "strategy"]);
39+const STEP_KEYS = new Set([
40+ "id", "if", "name", "uses", "run", "working-directory", "shell", "with", "env", "continue-on-error", "timeout-minutes",
41+]);
42+export 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+]);
49+const 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+]);
53+const INPUT_TYPES_DISPATCH = new Set(["string", "boolean", "number", "choice", "environment"]);
54+const INPUT_TYPES_CALL = new Set(["string", "boolean", "number"]);
55+const ID_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
56+
57+const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v);
58+const isScalarValue = (v: unknown): v is string | number | boolean =>
59+ typeof v === "string" || typeof v === "number" || typeof v === "boolean";
60+
61+export 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+
67+class 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+
190+function 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+
209+function 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+
213+function 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+
311+function 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+
330+function 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+
374+function 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+
394+function 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+
414+function 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+
424+function 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+
451+function 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+
466+function 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+
512+function 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+
536+function 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+
549+function 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. */
638+function checkExpressions(c: Context, value: unknown, path: Seg[]): void {
639+ if (typeof value === "string") {
640+ const isIf = 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+ */
673+export 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];
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([], "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([], "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+}