/** * A small evaluator for GitHub-Actions-style `${{ }}` expressions: literals, context paths, * comparison/logical operators and the common built-in functions. */ export type ExprValue = string | number | boolean | null | ExprValue[] | { [key: string]: ExprValue }; export type ExprContext = { contexts: Record; jobStatus: "success" | "failure"; }; type Token = | { type: "number"; value: number } | { type: "string"; value: string } | { type: "ident"; value: string } | { type: "op"; value: string } | { type: "punct"; value: string }; const OPERATORS = ["==", "!=", "<=", ">=", "&&", "||", "<", ">", "!"]; const PUNCTUATION = ["(", ")", "[", "]", ",", "."]; const IDENT_START = /[A-Za-z_]/; const IDENT_PART = /[A-Za-z0-9_-]/; const DIGIT = /[0-9]/; const MAX_EXPRESSION_DEPTH = 64; const MAX_EXPRESSION_LENGTH = 8 * 1024; function tokenize(source: string): Token[] { const tokens: Token[] = []; let index = 0; while (index < source.length) { const char = source[index]; if (/\s/.test(char)) { index++; continue; } if (char === "'") { let value = ""; index++; while (index < source.length) { if (source[index] === "'" && source[index + 1] === "'") { value += "'"; index += 2; continue; } if (source[index] === "'") break; value += source[index++]; } index++; tokens.push({ type: "string", value }); continue; } if (DIGIT.test(char) || (char === "-" && DIGIT.test(source[index + 1] ?? ""))) { let raw = char; index++; while (index < source.length && /[0-9.]/.test(source[index])) raw += source[index++]; tokens.push({ type: "number", value: Number(raw) }); continue; } if (IDENT_START.test(char)) { let value = char; index++; while (index < source.length && IDENT_PART.test(source[index])) value += source[index++]; tokens.push({ type: "ident", value }); continue; } const operator = OPERATORS.find((op) => source.startsWith(op, index)); if (operator) { tokens.push({ type: "op", value: operator }); index += operator.length; continue; } if (PUNCTUATION.includes(char)) { tokens.push({ type: "punct", value: char }); index++; continue; } throw new Error(`Unexpected character '${char}' in expression`); } return tokens; } function truthy(value: ExprValue): boolean { if (value === null || value === false || value === 0 || value === "") return false; return true; } function coerceForCompare(value: ExprValue): string | number | boolean | null { if (Array.isArray(value) || (typeof value === "object" && value !== null)) return JSON.stringify(value); return value; } function looselyEqual(left: ExprValue, right: ExprValue): boolean { const a = coerceForCompare(left); const b = coerceForCompare(right); if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase(); if (typeof a === typeof b) return a === b; return Number(a) === Number(b); } function asString(value: ExprValue): string { if (value === null) return ""; if (typeof value === "object") return JSON.stringify(value); return String(value); } function property(target: ExprValue, key: string): ExprValue { if (Array.isArray(target)) { if (key === "*") return target; const index = Number(key); return Number.isInteger(index) ? (target[index] ?? null) : null; } if (target && typeof target === "object") { const match = Object.keys(target).find((candidate) => candidate.toLowerCase() === key.toLowerCase()); return match === undefined ? null : (target[match] ?? null); } return null; } class Parser { private position = 0; constructor( private readonly tokens: Token[], private readonly context: ExprContext, ) {} parse(): ExprValue { const value = this.or(); if (this.position < this.tokens.length) throw new Error("Unexpected trailing tokens in expression"); return value; } private peek(): Token | undefined { return this.tokens[this.position]; } private accept(type: Token["type"], value?: string): boolean { const token = this.peek(); if (token && token.type === type && (value === undefined || token.value === value)) { this.position++; return true; } return false; } private expect(type: Token["type"], value: string): void { if (!this.accept(type, value)) throw new Error(`Expected '${value}' in expression`); } private depth = 0; private nested(parse: () => T): T { if (++this.depth > MAX_EXPRESSION_DEPTH) throw new Error("Expression is nested too deeply"); try { return parse(); } finally { this.depth--; } } private or(): ExprValue { return this.nested(() => { let left = this.and(); while (this.accept("op", "||")) { const right = this.and(); left = truthy(left) ? left : right; } return left; }); } private and(): ExprValue { let left = this.comparison(); while (this.accept("op", "&&")) { const right = this.comparison(); left = truthy(left) ? right : left; } return left; } private comparison(): ExprValue { let left = this.unary(); while (true) { const token = this.peek(); if (token?.type !== "op" || !["==", "!=", "<", ">", "<=", ">="].includes(token.value)) return left; this.position++; const right = this.unary(); switch (token.value) { case "==": left = looselyEqual(left, right); break; case "!=": left = !looselyEqual(left, right); break; case "<": left = Number(left) < Number(right); break; case ">": left = Number(left) > Number(right); break; case "<=": left = Number(left) <= Number(right); break; default: left = Number(left) >= Number(right); } } } private unary(): ExprValue { if (this.accept("op", "!")) return this.nested(() => !truthy(this.unary())); return this.postfix(); } private postfix(): ExprValue { let value = this.primary(); while (true) { if (this.accept("punct", ".")) { const token = this.peek(); if (token?.type === "ident") { this.position++; value = property(value, token.value); continue; } if (token?.type === "op" || token?.type === "punct") throw new Error("Expected property name"); this.position++; value = property(value, String(token?.value ?? "")); continue; } if (this.accept("punct", "[")) { const key = this.or(); this.expect("punct", "]"); value = property(value, asString(key)); continue; } return value; } } private primary(): ExprValue { const token = this.peek(); if (!token) throw new Error("Unexpected end of expression"); if (this.accept("punct", "(")) { const value = this.or(); this.expect("punct", ")"); return value; } if (token.type === "number" || token.type === "string") { this.position++; return token.value; } if (token.type === "ident") { this.position++; if (token.value === "true") return true; if (token.value === "false") return false; if (token.value === "null") return null; if (this.accept("punct", "(")) { const args: ExprValue[] = []; if (!this.accept("punct", ")")) { do { args.push(this.or()); } while (this.accept("punct", ",")); this.expect("punct", ")"); } return this.call(token.value, args); } return this.context.contexts[token.value] ?? null; } throw new Error(`Unexpected token '${String(token.value)}'`); } private call(name: string, args: ExprValue[]): ExprValue { const [first, second] = args; switch (name.toLowerCase()) { case "success": return this.context.jobStatus === "success"; case "failure": return this.context.jobStatus === "failure"; case "always": return true; case "cancelled": return false; case "contains": if (Array.isArray(first)) return first.some((item) => looselyEqual(item, second ?? null)); return asString(first ?? null) .toLowerCase() .includes(asString(second ?? null).toLowerCase()); case "startswith": return asString(first ?? null) .toLowerCase() .startsWith(asString(second ?? null).toLowerCase()); case "endswith": return asString(first ?? null) .toLowerCase() .endsWith(asString(second ?? null).toLowerCase()); case "format": return asString(first ?? null).replace(/\{(\d+)\}/g, (_match, index: string) => asString(args[Number(index) + 1] ?? null), ); case "join": return Array.isArray(first) ? first.map(asString).join(second === undefined ? "," : asString(second)) : asString(first ?? null); case "tojson": return JSON.stringify(first ?? null, null, 2); case "fromjson": return JSON.parse(asString(first ?? null)); case "hashfiles": throw new Error("hashFiles() must be the only thing inside its ${{ }} expression"); default: throw new Error(`Unknown function ${name}()`); } } } export function evaluate(source: string, context: ExprContext): ExprValue { if (source.length > MAX_EXPRESSION_LENGTH) throw new Error("Expression is too long"); return new Parser(tokenize(source), context).parse(); } export function evaluateCondition(source: string | boolean | undefined, context: ExprContext): boolean { if (source === undefined) return context.jobStatus === "success"; if (typeof source === "boolean") return source && context.jobStatus === "success"; const trimmed = source.trim().replace(/^\$\{\{([\s\S]*)\}\}$/, "$1"); const usesStatus = /\b(success|failure|always|cancelled)\s*\(/i.test(trimmed); const result = truthy(evaluate(trimmed, context)); return usesStatus ? result : result && context.jobStatus === "success"; } export function stringify(value: ExprValue): string { return asString(value); }