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

workflow-conditionals.ts

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

workflow-conditionals.tsBlame564 lines · 1 contributor
abfa9adClaude1/**
2 * workflow-conditionals.ts
3 *
4 * Restricted expression evaluator for workflow `if:` clauses.
5 *
6 * NO eval(), NO Function constructor. Hand-written tokenizer + recursive-
7 * descent / precedence-climbing parser. Never throws on any input.
8 *
9 * Grammar (lowest precedence first):
10 * or := and ( '||' and )*
11 * and := eq ( '&&' eq )*
12 * eq := cmp ( ('=='|'!=') cmp )*
13 * cmp := unary ( ('<'|'<='|'>'|'>=') unary )*
14 * unary := '!' unary | primary
15 * primary := literal | callOrPath | '(' or ')'
16 * path := IDENT ( '.' IDENT )*
17 * call := IDENT '(' [ arg (',' arg)* ] ')'
18 */
19
20export type ConditionalContext = {
21 env?: Record<string, string>;
22 matrix?: Record<string, unknown>;
23 steps?: Record<
24 string,
25 { outcome?: string; conclusion?: string; outputs?: Record<string, string> }
26 >;
27 needs?: Record<string, { result?: string; outputs?: Record<string, string> }>;
28 secrets?: Record<string, string>;
29 inputs?: Record<string, unknown>;
30 github?: {
31 event_name?: string;
32 ref?: string;
33 sha?: string;
34 actor?: string;
35 repository?: string;
36 };
37 job?: { status?: string };
38 runner?: { os?: string; arch?: string };
39};
40
41export type EvalResult = { ok: true; value: boolean } | { ok: false; error: string };
42
43// ---------------------------------------------------------------------------
44// Tokenizer
45// ---------------------------------------------------------------------------
46
47type Tok =
48 | { k: "num"; v: number }
49 | { k: "str"; v: string }
50 | { k: "ident"; v: string }
51 | { k: "bool"; v: boolean }
52 | { k: "null" }
53 | { k: "op"; v: string }
54 | { k: "lparen" }
55 | { k: "rparen" }
56 | { k: "dot" }
57 | { k: "comma" }
58 | { k: "eof" };
59
60function tokenize(src: string): Tok[] | { error: string } {
61 const toks: Tok[] = [];
62 let i = 0;
63 const n = src.length;
64 while (i < n) {
65 const c = src[i]!;
66 // whitespace
67 if (c === " " || c === "\t" || c === "\n" || c === "\r") {
68 i++;
69 continue;
70 }
71 // strings
72 if (c === "'" || c === '"') {
73 const quote = c;
74 let j = i + 1;
75 let s = "";
76 let closed = false;
77 while (j < n) {
78 const ch = src[j]!;
79 if (ch === quote) {
80 // GitHub Actions uses doubled-quote escape inside single-quoted strings.
81 if (quote === "'" && src[j + 1] === "'") {
82 s += "'";
83 j += 2;
84 continue;
85 }
86 closed = true;
87 j++;
88 break;
89 }
90 if (ch === "\\" && quote === '"' && j + 1 < n) {
91 const nx = src[j + 1]!;
92 if (nx === "n") s += "\n";
93 else if (nx === "t") s += "\t";
94 else if (nx === "r") s += "\r";
95 else if (nx === "\\") s += "\\";
96 else if (nx === '"') s += '"';
97 else s += nx;
98 j += 2;
99 continue;
100 }
101 s += ch;
102 j++;
103 }
104 if (!closed) return { error: `unterminated string at ${i}` };
105 toks.push({ k: "str", v: s });
106 i = j;
107 continue;
108 }
109 // numbers (integers and simple decimals)
110 if ((c >= "0" && c <= "9") || (c === "-" && src[i + 1] && src[i + 1]! >= "0" && src[i + 1]! <= "9")) {
111 // Only treat as number if preceded by nothing, an operator, comma, or lparen.
112 const prev = toks[toks.length - 1];
113 const allowNeg =
114 c !== "-" ||
115 !prev ||
116 prev.k === "op" ||
117 prev.k === "comma" ||
118 prev.k === "lparen";
119 if (c === "-" && !allowNeg) {
120 // fall through to operator handling
121 } else {
122 let j = i;
123 if (c === "-") j++;
124 while (j < n && src[j]! >= "0" && src[j]! <= "9") j++;
125 if (src[j] === "." && src[j + 1] && src[j + 1]! >= "0" && src[j + 1]! <= "9") {
126 j++;
127 while (j < n && src[j]! >= "0" && src[j]! <= "9") j++;
128 }
129 const num = Number(src.slice(i, j));
130 if (!Number.isFinite(num)) return { error: `bad number at ${i}` };
131 toks.push({ k: "num", v: num });
132 i = j;
133 continue;
134 }
135 }
136 // identifiers / keywords
137 if ((c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_") {
138 let j = i + 1;
139 while (
140 j < n &&
141 ((src[j]! >= "a" && src[j]! <= "z") ||
142 (src[j]! >= "A" && src[j]! <= "Z") ||
143 (src[j]! >= "0" && src[j]! <= "9") ||
144 src[j] === "_" ||
145 src[j] === "-")
146 ) {
147 j++;
148 }
149 const word = src.slice(i, j);
150 if (word === "true") toks.push({ k: "bool", v: true });
151 else if (word === "false") toks.push({ k: "bool", v: false });
152 else if (word === "null") toks.push({ k: "null" });
153 else toks.push({ k: "ident", v: word });
154 i = j;
155 continue;
156 }
157 // operators / punctuation
158 const two = src.slice(i, i + 2);
159 if (two === "==" || two === "!=" || two === "&&" || two === "||" || two === "<=" || two === ">=") {
160 toks.push({ k: "op", v: two });
161 i += 2;
162 continue;
163 }
164 if (c === "<" || c === ">") {
165 toks.push({ k: "op", v: c });
166 i++;
167 continue;
168 }
169 if (c === "!") {
170 toks.push({ k: "op", v: "!" });
171 i++;
172 continue;
173 }
174 if (c === "(") {
175 toks.push({ k: "lparen" });
176 i++;
177 continue;
178 }
179 if (c === ")") {
180 toks.push({ k: "rparen" });
181 i++;
182 continue;
183 }
184 if (c === ".") {
185 toks.push({ k: "dot" });
186 i++;
187 continue;
188 }
189 if (c === ",") {
190 toks.push({ k: "comma" });
191 i++;
192 continue;
193 }
194 return { error: `unexpected character "${c}" at ${i}` };
195 }
196 toks.push({ k: "eof" });
197 return toks;
198}
199
200// ---------------------------------------------------------------------------
201// AST nodes — represented as tagged unions of plain objects.
202// ---------------------------------------------------------------------------
203
204type Node =
205 | { t: "lit"; v: unknown }
206 | { t: "path"; segs: string[] }
207 | { t: "call"; name: string; args: Node[] }
208 | { t: "unary"; op: "!"; x: Node }
209 | { t: "bin"; op: string; a: Node; b: Node };
210
211// ---------------------------------------------------------------------------
212// Parser
213// ---------------------------------------------------------------------------
214
215class Parser {
216 pos = 0;
217 constructor(private toks: Tok[]) {}
218
219 peek(): Tok {
220 return this.toks[this.pos]!;
221 }
222 eat(): Tok {
223 return this.toks[this.pos++]!;
224 }
225 expect(k: Tok["k"]): Tok {
226 const t = this.eat();
227 if (t.k !== k) throw new Error(`expected ${k} got ${t.k}`);
228 return t;
229 }
230
231 parseOr(): Node {
232 let a = this.parseAnd();
233 while (this.peek().k === "op" && (this.peek() as { v: string }).v === "||") {
234 this.eat();
235 const b = this.parseAnd();
236 a = { t: "bin", op: "||", a, b };
237 }
238 return a;
239 }
240 parseAnd(): Node {
241 let a = this.parseEq();
242 while (this.peek().k === "op" && (this.peek() as { v: string }).v === "&&") {
243 this.eat();
244 const b = this.parseEq();
245 a = { t: "bin", op: "&&", a, b };
246 }
247 return a;
248 }
249 parseEq(): Node {
250 let a = this.parseCmp();
251 while (this.peek().k === "op") {
252 const op = (this.peek() as { v: string }).v;
253 if (op !== "==" && op !== "!=") break;
254 this.eat();
255 const b = this.parseCmp();
256 a = { t: "bin", op, a, b };
257 }
258 return a;
259 }
260 parseCmp(): Node {
261 let a = this.parseUnary();
262 while (this.peek().k === "op") {
263 const op = (this.peek() as { v: string }).v;
264 if (op !== "<" && op !== "<=" && op !== ">" && op !== ">=") break;
265 this.eat();
266 const b = this.parseUnary();
267 a = { t: "bin", op, a, b };
268 }
269 return a;
270 }
271 parseUnary(): Node {
272 if (this.peek().k === "op" && (this.peek() as { v: string }).v === "!") {
273 this.eat();
274 return { t: "unary", op: "!", x: this.parseUnary() };
275 }
276 return this.parsePrimary();
277 }
278 parsePrimary(): Node {
279 const t = this.peek();
280 if (t.k === "lparen") {
281 this.eat();
282 const inner = this.parseOr();
283 this.expect("rparen");
284 return inner;
285 }
286 if (t.k === "num") {
287 this.eat();
288 return { t: "lit", v: t.v };
289 }
290 if (t.k === "str") {
291 this.eat();
292 return { t: "lit", v: t.v };
293 }
294 if (t.k === "bool") {
295 this.eat();
296 return { t: "lit", v: t.v };
297 }
298 if (t.k === "null") {
299 this.eat();
300 return { t: "lit", v: null };
301 }
302 if (t.k === "ident") {
303 this.eat();
304 // function call?
305 if (this.peek().k === "lparen") {
306 this.eat();
307 const args: Node[] = [];
308 if (this.peek().k !== "rparen") {
309 args.push(this.parseOr());
310 while (this.peek().k === "comma") {
311 this.eat();
312 args.push(this.parseOr());
313 }
314 }
315 this.expect("rparen");
316 return { t: "call", name: t.v, args };
317 }
318 // path
319 const segs: string[] = [t.v];
320 while (this.peek().k === "dot") {
321 this.eat();
322 const nx = this.eat();
323 if (nx.k !== "ident") throw new Error("expected identifier after '.'");
324 segs.push(nx.v);
325 }
326 return { t: "path", segs };
327 }
328 throw new Error(`unexpected token ${t.k}`);
329 }
330}
331
332// ---------------------------------------------------------------------------
333// Evaluator
334// ---------------------------------------------------------------------------
335
336function resolvePath(segs: string[], ctx: ConditionalContext): unknown {
337 if (segs.length === 0) return undefined;
338 const root = segs[0]!;
339 let cur: unknown;
340 switch (root) {
341 case "env":
342 cur = ctx.env ?? {};
343 break;
344 case "matrix":
345 cur = ctx.matrix ?? {};
346 break;
347 case "steps":
348 cur = ctx.steps ?? {};
349 break;
350 case "needs":
351 cur = ctx.needs ?? {};
352 break;
353 case "secrets":
354 cur = ctx.secrets ?? {};
355 break;
356 case "inputs":
357 cur = ctx.inputs ?? {};
358 break;
359 case "github":
360 cur = ctx.github ?? {};
361 break;
362 case "job":
363 cur = ctx.job ?? {};
364 break;
365 case "runner":
366 cur = ctx.runner ?? {};
367 break;
368 default:
369 return undefined;
370 }
371 for (let i = 1; i < segs.length; i++) {
372 if (cur == null) return undefined;
373 if (typeof cur !== "object") return undefined;
374 cur = (cur as Record<string, unknown>)[segs[i]!];
375 }
376 return cur;
377}
378
379function toBool(v: unknown): boolean {
380 if (v === null || v === undefined) return false;
381 if (typeof v === "boolean") return v;
382 if (typeof v === "number") return v !== 0 && !Number.isNaN(v);
383 if (typeof v === "string") return v.length > 0;
384 if (Array.isArray(v)) return true;
385 if (typeof v === "object") return true;
386 return false;
387}
388
389function looseEq(a: unknown, b: unknown): boolean {
390 if (a === b) return true;
391 if (a == null && b == null) return true;
392 if (a == null || b == null) return false;
393 // If one side is number and the other is string, compare numerically when possible.
394 if (typeof a === "number" && typeof b === "string") {
395 const nb = Number(b);
396 if (!Number.isNaN(nb)) return a === nb;
397 return false;
398 }
399 if (typeof a === "string" && typeof b === "number") {
400 const na = Number(a);
401 if (!Number.isNaN(na)) return na === b;
402 return false;
403 }
404 // Case-insensitive string comparison (GitHub Actions semantic).
405 if (typeof a === "string" && typeof b === "string") {
406 return a.toLowerCase() === b.toLowerCase();
407 }
408 if (typeof a === "boolean" && typeof b === "boolean") return a === b;
409 return false;
410}
411
412function toNum(v: unknown): number {
413 if (typeof v === "number") return v;
414 if (typeof v === "string") {
415 const n = Number(v);
416 return Number.isNaN(n) ? NaN : n;
417 }
418 if (typeof v === "boolean") return v ? 1 : 0;
419 if (v == null) return 0;
420 return NaN;
421}
422
423function toStr(v: unknown): string {
424 if (v == null) return "";
425 if (typeof v === "string") return v;
426 if (typeof v === "number" || typeof v === "boolean") return String(v);
427 try {
428 return JSON.stringify(v);
429 } catch {
430 return "";
431 }
432}
433
434function evalNode(node: Node, ctx: ConditionalContext): unknown {
435 switch (node.t) {
436 case "lit":
437 return node.v;
438 case "path":
439 return resolvePath(node.segs, ctx);
440 case "unary":
441 return !toBool(evalNode(node.x, ctx));
442 case "bin": {
443 if (node.op === "&&") {
444 const av = evalNode(node.a, ctx);
445 if (!toBool(av)) return false;
446 return toBool(evalNode(node.b, ctx));
447 }
448 if (node.op === "||") {
449 const av = evalNode(node.a, ctx);
450 if (toBool(av)) return true;
451 return toBool(evalNode(node.b, ctx));
452 }
453 const a = evalNode(node.a, ctx);
454 const b = evalNode(node.b, ctx);
455 if (node.op === "==") return looseEq(a, b);
456 if (node.op === "!=") return !looseEq(a, b);
457 const na = toNum(a);
458 const nb = toNum(b);
459 if (Number.isNaN(na) || Number.isNaN(nb)) return false;
460 if (node.op === "<") return na < nb;
461 if (node.op === "<=") return na <= nb;
462 if (node.op === ">") return na > nb;
463 if (node.op === ">=") return na >= nb;
464 return false;
465 }
466 case "call":
467 return evalCall(node, ctx);
468 }
469}
470
471function evalCall(node: Extract<Node, { t: "call" }>, ctx: ConditionalContext): unknown {
472 const name = node.name.toLowerCase();
473 const argVals = node.args.map((a) => evalNode(a, ctx));
474 switch (name) {
475 case "success": {
476 const s = ctx.job?.status;
477 return s !== "failure" && s !== "cancelled";
478 }
479 case "failure":
480 return ctx.job?.status === "failure";
481 case "cancelled":
482 return ctx.job?.status === "cancelled";
483 case "always":
484 return true;
485 case "contains": {
486 const haystack = argVals[0];
487 const needle = argVals[1];
488 if (Array.isArray(haystack)) {
489 for (const it of haystack) {
490 if (looseEq(it, needle)) return true;
491 }
492 return false;
493 }
494 const hs = toStr(haystack);
495 const nd = toStr(needle);
496 if (nd === "") return true;
497 return hs.toLowerCase().includes(nd.toLowerCase());
498 }
499 case "startswith": {
500 const s = toStr(argVals[0]).toLowerCase();
501 const p = toStr(argVals[1]).toLowerCase();
502 return s.startsWith(p);
503 }
504 case "endswith": {
505 const s = toStr(argVals[0]).toLowerCase();
506 const p = toStr(argVals[1]).toLowerCase();
507 return s.endsWith(p);
508 }
509 case "format": {
510 const fmt = toStr(argVals[0]);
511 const rest = argVals.slice(1);
512 return fmt.replace(/\{(\d+)\}/g, (_m, idx) => {
513 const n = Number(idx);
514 if (!Number.isFinite(n) || n < 0 || n >= rest.length) return "";
515 return toStr(rest[n]);
516 });
517 }
518 default:
519 return undefined;
520 }
521}
522
523// ---------------------------------------------------------------------------
524// Public entry point
525// ---------------------------------------------------------------------------
526
527export function evaluateIf(
528 expr: string | undefined | null,
529 ctx: ConditionalContext,
530): EvalResult {
531 if (expr === undefined || expr === null) return { ok: true, value: true };
532 let s = String(expr).trim();
533 if (s.length === 0) return { ok: true, value: true };
534
535 // Strip optional ${{ ... }} wrapper if the whole expression is wrapped.
536 const m = s.match(/^\$\{\{\s*([\s\S]*?)\s*\}\}$/);
537 if (m && m[1] !== undefined) s = m[1].trim();
538 if (s.length === 0) return { ok: true, value: true };
539
540 const toks = tokenize(s);
541 if (!Array.isArray(toks)) {
542 return { ok: false, error: `parse: ${toks.error}` };
543 }
544
545 let ast: Node;
546 try {
547 const p = new Parser(toks);
548 ast = p.parseOr();
549 if (p.peek().k !== "eof") {
550 return { ok: false, error: `parse: unexpected trailing token ${p.peek().k}` };
551 }
552 } catch (e) {
553 const msg = e instanceof Error ? e.message : String(e);
554 return { ok: false, error: `parse: ${msg}` };
555 }
556
557 try {
558 const v = evalNode(ast, ctx);
559 return { ok: true, value: toBool(v) };
560 } catch (e) {
561 const msg = e instanceof Error ? e.message : String(e);
562 return { ok: false, error: `eval: ${msg}` };
563 }
564}