flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/actions/src/expr.rs

1,842 lines64,489 bytesCodeBlame
1//! The GitHub Actions expression language: the `${{ }}` language.
2//!
3//! This follows GitHub's "Evaluate expressions in workflows and actions"
4//! precisely, so a real GitHub workflow evaluates here the way it
5//! does on GitHub: the same literals, the same operator precedence, the same
6//! loose equality (with its coercions to number), the same case-insensitive
7//! string handling, the same object filters (`labels.*.name`) and the same
8//! functions. Parse errors are found before anything is evaluated, so an
9//! unknown context or function is an error even in a branch that would never
10//! run, as on GitHub.
11
12use serde_json::{Map, Value};
13use std::borrow::Cow;
14use std::cmp::Ordering;
15
16/// How the job is going, for success(), failure(), cancelled(), always().
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum Status {
19 /// Everything before succeeded: success() is true.
20 Success,
21 /// Something before failed (a step; for a job's `if:`, a job it needs
22 /// or any job before those): failure() is true.
23 Failure,
24 /// The run was cancelled: cancelled() is true.
25 Cancelled,
26 /// For a job's `if:`: nothing before it failed and the run was not
27 /// cancelled, but a job it needs did not succeed (it was skipped, or
28 /// cancelled by itself). success(), failure() and cancelled() are all
29 /// false, so the job is skipped unless its `if:` says otherwise, such
30 /// as `always()` or `!failure() && !cancelled()`.
31 Incomplete,
32}
33
34/// The status a job's `if:` is decided with, as on GitHub, from the
35/// results of the jobs it needs (`needs.<id>.result`), whether any job
36/// before those failed (`ancestor_failed`: failure() looks through every
37/// ancestor, so a job after a skipped one still sees a failure before
38/// it), and whether the run was cancelled.
39pub fn job_status<'a>(needs: impl IntoIterator<Item = &'a str>, ancestor_failed: bool, run_cancelled: bool) -> Status {
40 if run_cancelled {
41 return Status::Cancelled;
42 }
43 let mut all_succeeded = true;
44 for result in needs {
45 match result {
46 "success" => {}
47 "failure" => return Status::Failure,
48 _ => all_succeeded = false,
49 }
50 }
51 if ancestor_failed {
52 Status::Failure
53 } else if all_succeeded {
54 Status::Success
55 } else {
56 Status::Incomplete
57 }
58}
59
60pub struct Scope<'a> {
61 /// Top-level contexts by lower-case name: github, env, vars, secrets, inputs, matrix, strategy, needs, steps, job, jobs, runner.
62 pub contexts: &'a Map<String, Value>,
63 pub status: Status,
64 /// hashFiles(...) when the caller can compute it (the sandbox); None means hashFiles evaluates to "".
65 #[allow(clippy::type_complexity)]
66 pub hash_files: Option<&'a dyn Fn(&[String]) -> String>,
67}
68
69/// The contexts a workflow may name, whether or not the caller supplied them.
70const NAMED_VALUES: &[&str] = &[
71 "github", "env", "vars", "secrets", "inputs", "matrix", "strategy", "needs", "steps", "job",
72 "jobs", "runner",
73];
74
75/// Evaluates one expression (the text between `${{` and `}}`, or a bare `if:`).
76pub fn evaluate(expression: &str, scope: &Scope) -> Result<Value, String> {
77 let ast = parse(expression, scope.contexts)?;
78 Ok(eval(&ast, scope)?.into_value())
79}
80
81/// An `if:` value: with or without `${{ }}` around it; when the expression calls none of success/failure/cancelled/always, it is implicitly `success() && (expr)`. An empty condition is `success()`.
82pub fn condition(text: &str, scope: &Scope) -> Result<bool, String> {
83 let success = scope.status == Status::Success;
84 let trimmed = text.trim();
85 let source = match single_expression(trimmed) {
86 Some(inner) => inner,
87 // Text around or between expressions makes the whole thing a string,
88 // as on GitHub: it is true whenever it interpolates to anything.
89 None if has_expression(trimmed) => {
90 let text = interpolate(trimmed, scope)?;
91 return Ok(success && !text.is_empty());
92 }
93 None => trimmed,
94 };
95 if source.trim().is_empty() {
96 return Ok(success);
97 }
98 let ast = parse(source, scope.contexts)?;
99 if uses_status(&ast) {
100 Ok(eval(&ast, scope)?.truthy())
101 } else {
102 Ok(success && eval(&ast, scope)?.truthy())
103 }
104}
105
106/// Replaces each `${{ expr }}` in text with the value converted to a string. Text without `${{` is returned unchanged.
107pub fn interpolate(text: &str, scope: &Scope) -> Result<String, String> {
108 if !has_expression(text) {
109 return Ok(text.to_string());
110 }
111 let mut out = String::with_capacity(text.len());
112 let mut from = 0;
113 while let Some(rel) = text[from..].find("${{") {
114 let open = from + rel;
115 out.push_str(&text[from..open]);
116 let body = open + 3;
117 let close = find_close(text, body).ok_or_else(|| {
118 format!(
119 "The expression is not closed. An unescaped ${{{{ sequence was found, but the closing }}}} sequence was not found: {text}"
120 )
121 })?;
122 let value = evaluate(&text[body..close], scope)?;
123 out.push_str(&to_text(&value));
124 from = close + 2;
125 }
126 out.push_str(&text[from..]);
127 Ok(out)
128}
129
130/// Interpolates every string inside a JSON value (keys too). If a string is exactly one `${{ expr }}` and nothing else, the result keeps the expression's type (as GitHub does for e.g. `strategy.matrix: ${{ fromJSON(...) }}`, `continue-on-error: ${{ ... }}`).
131pub fn interpolate_value(value: &Value, scope: &Scope) -> Result<Value, String> {
132 Ok(match value {
133 Value::String(text) => match single_expression(text) {
134 Some(inner) => evaluate(inner, scope)?,
135 None => Value::String(interpolate(text, scope)?),
136 },
137 Value::Array(items) => Value::Array(
138 items
139 .iter()
140 .map(|item| interpolate_value(item, scope))
141 .collect::<Result<_, _>>()?,
142 ),
143 Value::Object(map) => {
144 let mut out = Map::new();
145 for (key, item) in map {
146 out.insert(interpolate(key, scope)?, interpolate_value(item, scope)?);
147 }
148 Value::Object(out)
149 }
150 other => other.clone(),
151 })
152}
153
154/// false, 0, -0, NaN, "" and null are falsy; everything else is truthy.
155pub fn truthy(value: &Value) -> bool {
156 match value {
157 Value::Null => false,
158 Value::Bool(b) => *b,
159 Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0 && !f.is_nan()),
160 Value::String(s) => !s.is_empty(),
161 Value::Array(_) | Value::Object(_) => true,
162 }
163}
164
165/// A value as GitHub writes it into a string: null → "", bools "true"/"false", numbers as GitHub formats them (integers without ".0"), strings as-is, and `Array` or `Object` for collections, as GitHub's runner does (`toJSON` gives their contents).
166pub fn to_text(value: &Value) -> String {
167 match value {
168 Value::Null => String::new(),
169 Value::Bool(b) => b.to_string(),
170 Value::Number(n) => format_number(n.as_f64().unwrap_or(f64::NAN)),
171 Value::String(s) => s.clone(),
172 Value::Array(_) => "Array".to_owned(),
173 Value::Object(_) => "Object".to_owned(),
174 }
175}
176
177/// Whether text contains `${{`.
178pub fn has_expression(text: &str) -> bool {
179 text.contains("${{")
180}
181
182// ---------------------------------------------------------------------------
183// Template scanning
184
185/// Finds the `}}` closing an expression whose body starts at byte `from`,
186/// skipping over string literals (which may themselves contain `}}`).
187fn find_close(text: &str, from: usize) -> Option<usize> {
188 let bytes = text.as_bytes();
189 let mut in_string = false;
190 let mut i = from;
191 while i < bytes.len() {
192 match bytes[i] {
193 b'\'' => in_string = !in_string,
194 b'}' if !in_string && bytes.get(i + 1) == Some(&b'}') => return Some(i),
195 _ => {}
196 }
197 i += 1;
198 }
199 None
200}
201
202/// The body of text when text is exactly one `${{ expr }}` and nothing else.
203fn single_expression(text: &str) -> Option<&str> {
204 let text = text.trim();
205 if !text.starts_with("${{") {
206 return None;
207 }
208 let close = find_close(text, 3)?;
209 (close + 2 == text.len()).then(|| &text[3..close])
210}
211
212// ---------------------------------------------------------------------------
213// Lexing
214
215#[derive(Clone, Debug, PartialEq)]
216enum Tok {
217 Null,
218 True,
219 False,
220 Number(f64),
221 Str(String),
222 Ident(String),
223 Dot,
224 Star,
225 LBracket,
226 RBracket,
227 LParen,
228 RParen,
229 Comma,
230 Not,
231 Lt,
232 Le,
233 Gt,
234 Ge,
235 Eq,
236 Ne,
237 And,
238 Or,
239}
240
241#[derive(Clone, Debug)]
242struct Token {
243 tok: Tok,
244 /// 1-based character position within the expression.
245 pos: usize,
246 /// The token as written, for error messages.
247 text: String,
248}
249
250fn located(message: &str, pos: usize, src: &str) -> String {
251 format!("{message}. Located at position {pos} within expression: {src}")
252}
253
254fn lex(src: &str) -> Result<Vec<Token>, String> {
255 let chars: Vec<char> = src.chars().collect();
256 let mut out: Vec<Token> = Vec::new();
257 let mut i = 0;
258 while i < chars.len() {
259 let c = chars[i];
260 if c.is_whitespace() {
261 i += 1;
262 continue;
263 }
264 let start = i;
265 let next = chars.get(i + 1).copied();
266 // Whether a value (rather than an operator) may come next, which
267 // decides whether `.5` is a number or a dereference.
268 let value_may_start = out.last().is_none_or(|t| {
269 !matches!(
270 t.tok,
271 Tok::Ident(_)
272 | Tok::Number(_)
273 | Tok::Str(_)
274 | Tok::Null
275 | Tok::True
276 | Tok::False
277 | Tok::RParen
278 | Tok::RBracket
279 | Tok::Star
280 )
281 });
282 let starts_number = c.is_ascii_digit()
283 || ((c == '-' || c == '+') && next.is_some_and(|n| n.is_ascii_digit() || n == '.'))
284 || (c == '.' && value_may_start && next.is_some_and(|n| n.is_ascii_digit()));
285 let tok = if starts_number {
286 i += 1;
287 while i < chars.len() {
288 let d = chars[i];
289 let so_far: String = chars[start..i].iter().collect();
290 let hex = so_far
291 .trim_start_matches(['-', '+'])
292 .to_ascii_lowercase()
293 .starts_with("0x");
294 let exponent_sign =
295 (d == '+' || d == '-') && matches!(chars[i - 1], 'e' | 'E') && !hex;
296 if d.is_ascii_alphanumeric() || d == '.' || d == '_' || exponent_sign {
297 i += 1;
298 } else {
299 break;
300 }
301 }
302 let text: String = chars[start..i].iter().collect();
303 match parse_number(&text, false) {
304 Some(n) => Tok::Number(n),
305 None => {
306 return Err(located(
307 &format!("Unexpected symbol: '{text}'"),
308 start + 1,
309 src,
310 ));
311 }
312 }
313 } else if c.is_alphabetic() || c == '_' {
314 i += 1;
315 while i < chars.len()
316 && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '-')
317 {
318 i += 1;
319 }
320 let word: String = chars[start..i].iter().collect();
321 match word.as_str() {
322 "null" => Tok::Null,
323 "true" => Tok::True,
324 "false" => Tok::False,
325 _ => Tok::Ident(word),
326 }
327 } else if c == '\'' {
328 i += 1;
329 let mut s = String::new();
330 loop {
331 match chars.get(i) {
332 None => {
333 let text: String = chars[start..].iter().collect();
334 return Err(located(
335 &format!("Unexpected symbol: '{text}'"),
336 start + 1,
337 src,
338 ));
339 }
340 Some('\'') if chars.get(i + 1) == Some(&'\'') => {
341 s.push('\'');
342 i += 2;
343 }
344 Some('\'') => {
345 i += 1;
346 break;
347 }
348 Some(&ch) => {
349 s.push(ch);
350 i += 1;
351 }
352 }
353 }
354 Tok::Str(s)
355 } else {
356 let two = |a: char, b: char| c == a && next == Some(b);
357 let (tok, len) = if two('=', '=') {
358 (Tok::Eq, 2)
359 } else if two('!', '=') {
360 (Tok::Ne, 2)
361 } else if two('<', '=') {
362 (Tok::Le, 2)
363 } else if two('>', '=') {
364 (Tok::Ge, 2)
365 } else if two('&', '&') {
366 (Tok::And, 2)
367 } else if two('|', '|') {
368 (Tok::Or, 2)
369 } else {
370 let tok = match c {
371 '.' => Tok::Dot,
372 '*' => Tok::Star,
373 '[' => Tok::LBracket,
374 ']' => Tok::RBracket,
375 '(' => Tok::LParen,
376 ')' => Tok::RParen,
377 ',' => Tok::Comma,
378 '!' => Tok::Not,
379 '<' => Tok::Lt,
380 '>' => Tok::Gt,
381 _ => {
382 return Err(located(
383 &format!("Unexpected symbol: '{c}'"),
384 start + 1,
385 src,
386 ));
387 }
388 };
389 (tok, 1)
390 };
391 i += len;
392 tok
393 };
394 out.push(Token {
395 tok,
396 pos: start + 1,
397 text: chars[start..i].iter().collect(),
398 });
399 }
400 Ok(out)
401}
402
403/// Parses a number. Literals (`lenient == false`) must be exactly a number;
404/// strings being coerced (`lenient == true`) may be padded with whitespace,
405/// and the empty string is 0.
406fn parse_number(text: &str, lenient: bool) -> Option<f64> {
407 let s = if lenient { text.trim() } else { text };
408 if s.is_empty() {
409 return lenient.then_some(0.0);
410 }
411 let (negative, body) = match s.as_bytes()[0] {
412 b'-' => (true, &s[1..]),
413 b'+' => (false, &s[1..]),
414 _ => (false, s),
415 };
416 let sign = if negative { -1.0 } else { 1.0 };
417 let lower = body.to_ascii_lowercase();
418 if let Some(hex) = lower.strip_prefix("0x") {
419 return u64::from_str_radix(hex, 16).ok().map(|n| sign * n as f64);
420 }
421 if let Some(oct) = lower.strip_prefix("0o") {
422 return u64::from_str_radix(oct, 8).ok().map(|n| sign * n as f64);
423 }
424 if lenient && body == "Infinity" {
425 return Some(sign * f64::INFINITY);
426 }
427 // digits [. digits] [e [+-] digits], with at least one mantissa digit.
428 let bytes = body.as_bytes();
429 let mut i = 0;
430 let mut mantissa_digits = 0;
431 while i < bytes.len() && bytes[i].is_ascii_digit() {
432 i += 1;
433 mantissa_digits += 1;
434 }
435 if i < bytes.len() && bytes[i] == b'.' {
436 i += 1;
437 while i < bytes.len() && bytes[i].is_ascii_digit() {
438 i += 1;
439 mantissa_digits += 1;
440 }
441 }
442 if mantissa_digits == 0 {
443 return None;
444 }
445 if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
446 i += 1;
447 if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
448 i += 1;
449 }
450 let digits_start = i;
451 while i < bytes.len() && bytes[i].is_ascii_digit() {
452 i += 1;
453 }
454 if i == digits_start {
455 return None;
456 }
457 }
458 if i != bytes.len() {
459 return None;
460 }
461 let normalized = if body.starts_with('.') {
462 format!("0{body}")
463 } else {
464 body.to_string()
465 };
466 normalized.parse::<f64>().ok().map(|n| sign * n)
467}
468
469// ---------------------------------------------------------------------------
470// Parsing
471
472#[derive(Clone, Copy, Debug, PartialEq, Eq)]
473enum Func {
474 Contains,
475 StartsWith,
476 EndsWith,
477 Format,
478 Join,
479 ToJson,
480 FromJson,
481 HashFiles,
482 Success,
483 Always,
484 Cancelled,
485 Failure,
486}
487
488/// Name, function, fewest and most arguments.
489const FUNCTIONS: &[(&str, Func, usize, usize)] = &[
490 ("contains", Func::Contains, 2, 2),
491 ("startsWith", Func::StartsWith, 2, 2),
492 ("endsWith", Func::EndsWith, 2, 2),
493 ("format", Func::Format, 1, usize::MAX),
494 ("join", Func::Join, 1, 2),
495 ("toJSON", Func::ToJson, 1, 1),
496 ("fromJSON", Func::FromJson, 1, 1),
497 ("hashFiles", Func::HashFiles, 1, usize::MAX),
498 ("success", Func::Success, 0, 0),
499 ("always", Func::Always, 0, 0),
500 ("cancelled", Func::Cancelled, 0, 0),
501 ("failure", Func::Failure, 0, 0),
502];
503
504#[derive(Clone, Copy, Debug)]
505enum CmpOp {
506 Lt,
507 Le,
508 Gt,
509 Ge,
510 Eq,
511 Ne,
512}
513
514#[derive(Debug)]
515enum Expr {
516 Literal(Value),
517 Named(String),
518 Property(Box<Expr>, String),
519 Index(Box<Expr>, Box<Expr>),
520 Wildcard(Box<Expr>),
521 Not(Box<Expr>),
522 Compare(CmpOp, Box<Expr>, Box<Expr>),
523 And(Box<Expr>, Box<Expr>),
524 Or(Box<Expr>, Box<Expr>),
525 Call(Func, Vec<Expr>),
526}
527
528fn parse(src: &str, contexts: &Map<String, Value>) -> Result<Expr, String> {
529 let toks = lex(src)?;
530 if toks.is_empty() {
531 return Err(format!("An expression was expected: '{src}'"));
532 }
533 let mut parser = Parser {
534 toks,
535 i: 0,
536 src,
537 contexts,
538 };
539 let expr = parser.or()?;
540 if parser.i < parser.toks.len() {
541 return Err(parser.unexpected());
542 }
543 Ok(expr)
544}
545
546struct Parser<'s> {
547 toks: Vec<Token>,
548 i: usize,
549 src: &'s str,
550 contexts: &'s Map<String, Value>,
551}
552
553impl Parser<'_> {
554 fn peek(&self) -> Option<&Tok> {
555 self.toks.get(self.i).map(|t| &t.tok)
556 }
557
558 fn eat(&mut self, tok: &Tok) -> bool {
559 if self.peek() == Some(tok) {
560 self.i += 1;
561 true
562 } else {
563 false
564 }
565 }
566
567 fn unexpected(&self) -> String {
568 match self.toks.get(self.i) {
569 Some(t) => located(&format!("Unexpected symbol: '{}'", t.text), t.pos, self.src),
570 None => match self.toks.last() {
571 Some(t) => located(
572 &format!("Unexpected end of expression: '{}'", t.text),
573 t.pos,
574 self.src,
575 ),
576 None => format!("An expression was expected: '{}'", self.src),
577 },
578 }
579 }
580
581 fn or(&mut self) -> Result<Expr, String> {
582 let mut left = self.and()?;
583 while self.eat(&Tok::Or) {
584 let right = self.and()?;
585 left = Expr::Or(Box::new(left), Box::new(right));
586 }
587 Ok(left)
588 }
589
590 fn and(&mut self) -> Result<Expr, String> {
591 let mut left = self.equality()?;
592 while self.eat(&Tok::And) {
593 let right = self.equality()?;
594 left = Expr::And(Box::new(left), Box::new(right));
595 }
596 Ok(left)
597 }
598
599 fn equality(&mut self) -> Result<Expr, String> {
600 let mut left = self.comparison()?;
601 loop {
602 let op = match self.peek() {
603 Some(Tok::Eq) => CmpOp::Eq,
604 Some(Tok::Ne) => CmpOp::Ne,
605 _ => return Ok(left),
606 };
607 self.i += 1;
608 let right = self.comparison()?;
609 left = Expr::Compare(op, Box::new(left), Box::new(right));
610 }
611 }
612
613 fn comparison(&mut self) -> Result<Expr, String> {
614 let mut left = self.unary()?;
615 loop {
616 let op = match self.peek() {
617 Some(Tok::Lt) => CmpOp::Lt,
618 Some(Tok::Le) => CmpOp::Le,
619 Some(Tok::Gt) => CmpOp::Gt,
620 Some(Tok::Ge) => CmpOp::Ge,
621 _ => return Ok(left),
622 };
623 self.i += 1;
624 let right = self.unary()?;
625 left = Expr::Compare(op, Box::new(left), Box::new(right));
626 }
627 }
628
629 fn unary(&mut self) -> Result<Expr, String> {
630 if self.eat(&Tok::Not) {
631 return Ok(Expr::Not(Box::new(self.unary()?)));
632 }
633 self.postfix()
634 }
635
636 fn postfix(&mut self) -> Result<Expr, String> {
637 let mut expr = self.primary()?;
638 loop {
639 if self.eat(&Tok::Dot) {
640 let token = self.toks.get(self.i).cloned();
641 expr = match token.map(|t| (t.tok, t.text)) {
642 Some((Tok::Star, _)) => Expr::Wildcard(Box::new(expr)),
643 Some((Tok::Ident(name), _)) => Expr::Property(Box::new(expr), name),
644 Some((Tok::True | Tok::False | Tok::Null, text)) => {
645 Expr::Property(Box::new(expr), text)
646 }
647 _ => return Err(self.unexpected()),
648 };
649 self.i += 1;
650 } else if self.eat(&Tok::LBracket) {
651 if self.peek() == Some(&Tok::Star)
652 && self.toks.get(self.i + 1).map(|t| &t.tok) == Some(&Tok::RBracket)
653 {
654 self.i += 2;
655 expr = Expr::Wildcard(Box::new(expr));
656 } else {
657 let index = self.or()?;
658 if !self.eat(&Tok::RBracket) {
659 return Err(self.unexpected());
660 }
661 expr = Expr::Index(Box::new(expr), Box::new(index));
662 }
663 } else {
664 return Ok(expr);
665 }
666 }
667 }
668
669 fn primary(&mut self) -> Result<Expr, String> {
670 let Some(token) = self.toks.get(self.i).cloned() else {
671 return Err(self.unexpected());
672 };
673 self.i += 1;
674 match token.tok {
675 Tok::Null => Ok(Expr::Literal(Value::Null)),
676 Tok::True => Ok(Expr::Literal(Value::Bool(true))),
677 Tok::False => Ok(Expr::Literal(Value::Bool(false))),
678 Tok::Number(n) => Ok(Expr::Literal(number(n))),
679 Tok::Str(s) => Ok(Expr::Literal(Value::String(s))),
680 Tok::LParen => {
681 let inner = self.or()?;
682 if !self.eat(&Tok::RParen) {
683 return Err(self.unexpected());
684 }
685 Ok(inner)
686 }
687 Tok::Ident(name) if self.peek() == Some(&Tok::LParen) => {
688 self.i += 1;
689 self.call(&name, token.pos)
690 }
691 Tok::Ident(name) => {
692 let known = NAMED_VALUES.iter().any(|n| n.eq_ignore_ascii_case(&name))
693 || self.contexts.keys().any(|k| k.eq_ignore_ascii_case(&name));
694 if !known {
695 return Err(located(
696 &format!("Unrecognized named-value: '{name}'"),
697 token.pos,
698 self.src,
699 ));
700 }
701 Ok(Expr::Named(name))
702 }
703 _ => {
704 self.i -= 1;
705 Err(self.unexpected())
706 }
707 }
708 }
709
710 fn call(&mut self, name: &str, pos: usize) -> Result<Expr, String> {
711 let Some(&(canonical, func, min, max)) = FUNCTIONS
712 .iter()
713 .find(|(n, ..)| n.eq_ignore_ascii_case(name))
714 else {
715 return Err(located(
716 &format!("Unrecognized function: '{name}'"),
717 pos,
718 self.src,
719 ));
720 };
721 let mut args = Vec::new();
722 if !self.eat(&Tok::RParen) {
723 loop {
724 args.push(self.or()?);
725 if self.eat(&Tok::Comma) {
726 continue;
727 }
728 if self.eat(&Tok::RParen) {
729 break;
730 }
731 return Err(self.unexpected());
732 }
733 }
734 if args.len() < min {
735 return Err(located(
736 &format!("Too few parameters supplied: '{canonical}'"),
737 pos,
738 self.src,
739 ));
740 }
741 if args.len() > max {
742 return Err(located(
743 &format!("Too many parameters supplied: '{canonical}'"),
744 pos,
745 self.src,
746 ));
747 }
748 Ok(Expr::Call(func, args))
749 }
750}
751
752/// Whether the expression calls success(), failure(), cancelled() or always().
753fn uses_status(expr: &Expr) -> bool {
754 match expr {
755 Expr::Literal(_) | Expr::Named(_) => false,
756 Expr::Property(base, _) | Expr::Wildcard(base) | Expr::Not(base) => uses_status(base),
757 Expr::Index(a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
758 uses_status(a) || uses_status(b)
759 }
760 Expr::Call(func, args) => {
761 matches!(
762 func,
763 Func::Success | Func::Failure | Func::Cancelled | Func::Always
764 ) || args.iter().any(uses_status)
765 }
766 }
767}
768
769// ---------------------------------------------------------------------------
770// Evaluation
771
772/// A value while evaluating: borrowed from the contexts where possible, and
773/// a filtered array (the result of a `*`) kept apart, since property access on
774/// it applies to every item.
775enum Ev<'c> {
776 One(Cow<'c, Value>),
777 Filtered(Vec<Cow<'c, Value>>),
778}
779
780impl Ev<'_> {
781 fn into_value(self) -> Value {
782 match self {
783 Ev::One(v) => v.into_owned(),
784 Ev::Filtered(items) => Value::Array(items.into_iter().map(Cow::into_owned).collect()),
785 }
786 }
787
788 fn truthy(&self) -> bool {
789 match self {
790 Ev::One(v) => truthy(v),
791 Ev::Filtered(_) => true,
792 }
793 }
794}
795
796fn owned<'c>(value: Value) -> Ev<'c> {
797 Ev::One(Cow::Owned(value))
798}
799
800/// A key on an object: the exact key if present, otherwise ignoring ASCII case.
801fn find_key<'m>(map: &'m Map<String, Value>, key: &str) -> Option<&'m String> {
802 if let Some((k, _)) = map.get_key_value(key) {
803 return Some(k);
804 }
805 map.keys().find(|k| k.eq_ignore_ascii_case(key))
806}
807
808enum Key {
809 Name(String),
810 Index(usize),
811}
812
813fn child<'c>(value: Cow<'c, Value>, key: &Key) -> Option<Cow<'c, Value>> {
814 match (value, key) {
815 (Cow::Borrowed(Value::Object(map)), Key::Name(name)) => find_key(map, name)
816 .and_then(|k| map.get(k))
817 .map(Cow::Borrowed),
818 (Cow::Owned(Value::Object(mut map)), Key::Name(name)) => {
819 let k = find_key(&map, name)?.clone();
820 map.remove(&k).map(Cow::Owned)
821 }
822 (Cow::Borrowed(Value::Array(items)), Key::Index(i)) => items.get(*i).map(Cow::Borrowed),
823 (Cow::Owned(Value::Array(items)), Key::Index(i)) => {
824 items.into_iter().nth(*i).map(Cow::Owned)
825 }
826 _ => None,
827 }
828}
829
830fn children(value: Cow<'_, Value>) -> Vec<Cow<'_, Value>> {
831 match value {
832 Cow::Borrowed(Value::Array(items)) => items.iter().map(Cow::Borrowed).collect(),
833 Cow::Borrowed(Value::Object(map)) => map.values().map(Cow::Borrowed).collect(),
834 Cow::Owned(Value::Array(items)) => items.into_iter().map(Cow::Owned).collect(),
835 Cow::Owned(Value::Object(map)) => map.into_iter().map(|(_, v)| Cow::Owned(v)).collect(),
836 _ => Vec::new(),
837 }
838}
839
840/// The key `index` selects on `target`: a position on an array, a name on an object.
841fn key_for(target: &Value, index: &Value) -> Option<Key> {
842 match target {
843 Value::Array(_) => {
844 let n = to_number(index);
845 (n.is_finite() && n >= 0.0).then(|| Key::Index(n.trunc() as usize))
846 }
847 Value::Object(_) => Some(Key::Name(to_text(index))),
848 _ => None,
849 }
850}
851
852fn eval<'c>(expr: &Expr, scope: &Scope<'c>) -> Result<Ev<'c>, String> {
853 Ok(match expr {
854 Expr::Literal(v) => owned(v.clone()),
855 Expr::Named(name) => {
856 let contexts: &'c Map<String, Value> = scope.contexts;
857 match find_key(contexts, name).and_then(|k| contexts.get(k)) {
858 Some(v) => Ev::One(Cow::Borrowed(v)),
859 None => owned(Value::Null),
860 }
861 }
862 Expr::Property(base, name) => {
863 let key = Key::Name(name.clone());
864 match eval(base, scope)? {
865 Ev::One(v) => Ev::One(child(v, &key).unwrap_or(Cow::Owned(Value::Null))),
866 Ev::Filtered(items) => {
867 Ev::Filtered(items.into_iter().filter_map(|it| child(it, &key)).collect())
868 }
869 }
870 }
871 Expr::Index(base, index) => {
872 let base = eval(base, scope)?;
873 let index = eval(index, scope)?.into_value();
874 match base {
875 Ev::One(v) => {
876 let found = key_for(&v, &index).and_then(|key| child(v, &key));
877 Ev::One(found.unwrap_or(Cow::Owned(Value::Null)))
878 }
879 Ev::Filtered(items) => Ev::Filtered(
880 items
881 .into_iter()
882 .filter_map(|it| key_for(&it, &index).and_then(|key| child(it, &key)))
883 .collect(),
884 ),
885 }
886 }
887 Expr::Wildcard(base) => match eval(base, scope)? {
888 Ev::One(v) => Ev::Filtered(children(v)),
889 Ev::Filtered(items) => Ev::Filtered(items.into_iter().flat_map(children).collect()),
890 },
891 Expr::Not(inner) => owned(Value::Bool(!eval(inner, scope)?.truthy())),
892 Expr::And(a, b) => {
893 let left = eval(a, scope)?;
894 if !left.truthy() {
895 return Ok(left);
896 }
897 eval(b, scope)?
898 }
899 Expr::Or(a, b) => {
900 let left = eval(a, scope)?;
901 if left.truthy() {
902 return Ok(left);
903 }
904 eval(b, scope)?
905 }
906 Expr::Compare(op, a, b) => {
907 let left = eval(a, scope)?.into_value();
908 let right = eval(b, scope)?.into_value();
909 let result = match op {
910 CmpOp::Eq => loose_eq(&left, &right),
911 CmpOp::Ne => !loose_eq(&left, &right),
912 CmpOp::Lt => compare(&left, &right) == Some(Ordering::Less),
913 CmpOp::Le => matches!(
914 compare(&left, &right),
915 Some(Ordering::Less | Ordering::Equal)
916 ),
917 CmpOp::Gt => compare(&left, &right) == Some(Ordering::Greater),
918 CmpOp::Ge => {
919 matches!(
920 compare(&left, &right),
921 Some(Ordering::Greater | Ordering::Equal)
922 )
923 }
924 };
925 owned(Value::Bool(result))
926 }
927 Expr::Call(func, args) => owned(call(*func, args, scope)?),
928 })
929}
930
931fn call(func: Func, args: &[Expr], scope: &Scope) -> Result<Value, String> {
932 let status = scope.status;
933 match func {
934 Func::Success => return Ok(Value::Bool(status == Status::Success)),
935 Func::Failure => return Ok(Value::Bool(status == Status::Failure)),
936 Func::Cancelled => return Ok(Value::Bool(status == Status::Cancelled)),
937 Func::Always => return Ok(Value::Bool(true)),
938 _ => {}
939 }
940 let values: Vec<Value> = args
941 .iter()
942 .map(|a| eval(a, scope).map(Ev::into_value))
943 .collect::<Result<_, _>>()?;
944 Ok(match func {
945 Func::Contains => Value::Bool(match &values[0] {
946 Value::Array(items) => items.iter().any(|item| loose_eq(item, &values[1])),
947 search => upper(&to_text(search)).contains(&upper(&to_text(&values[1]))),
948 }),
949 Func::StartsWith => {
950 Value::Bool(upper(&to_text(&values[0])).starts_with(&upper(&to_text(&values[1]))))
951 }
952 Func::EndsWith => {
953 Value::Bool(upper(&to_text(&values[0])).ends_with(&upper(&to_text(&values[1]))))
954 }
955 Func::Format => Value::String(format_string(&values)?),
956 Func::Join => {
957 let separator = values.get(1).map_or_else(|| ",".to_string(), to_text);
958 Value::String(match &values[0] {
959 Value::Array(items) => items
960 .iter()
961 .map(to_text)
962 .collect::<Vec<_>>()
963 .join(&separator),
964 other => to_text(other),
965 })
966 }
967 Func::ToJson => Value::String(to_json(&values[0])),
968 Func::FromJson => {
969 let text = to_text(&values[0]);
970 let parsed: Value = serde_json::from_str(&text)
971 .map_err(|e| format!("Error from function 'fromJSON': {e}. Input: '{text}'"))?;
972 normalize(parsed)
973 }
974 Func::HashFiles => {
975 let patterns: Vec<String> = values.iter().map(to_text).collect();
976 Value::String(scope.hash_files.map(|f| f(&patterns)).unwrap_or_default())
977 }
978 Func::Success | Func::Failure | Func::Cancelled | Func::Always => unreachable!(),
979 })
980}
981
982/// format('{0} {1}', ...): `{N}` is the Nth argument after the format string,
983/// `{{` and `}}` are literal braces, anything else with a brace is an error.
984fn format_string(values: &[Value]) -> Result<String, String> {
985 let template = to_text(&values[0]);
986 let args: Vec<String> = values[1..].iter().map(to_text).collect();
987 let invalid = || format!("The following format string is invalid: '{template}'");
988 let chars: Vec<char> = template.chars().collect();
989 let mut out = String::new();
990 let mut i = 0;
991 while i < chars.len() {
992 match chars[i] {
993 '{' if chars.get(i + 1) == Some(&'{') => {
994 out.push('{');
995 i += 2;
996 }
997 '}' if chars.get(i + 1) == Some(&'}') => {
998 out.push('}');
999 i += 2;
1000 }
1001 '{' => {
1002 let start = i + 1;
1003 let mut end = start;
1004 while end < chars.len() && chars[end].is_ascii_digit() {
1005 end += 1;
1006 }
1007 if end == start || chars.get(end) != Some(&'}') {
1008 return Err(invalid());
1009 }
1010 let digits: String = chars[start..end].iter().collect();
1011 let index: usize = digits.parse().map_err(|_| invalid())?;
1012 let arg = args.get(index).ok_or_else(|| {
1013 format!(
1014 "The following format string references more arguments than were supplied: '{template}'"
1015 )
1016 })?;
1017 out.push_str(arg);
1018 i = end + 1;
1019 }
1020 '}' => return Err(invalid()),
1021 c => {
1022 out.push(c);
1023 i += 1;
1024 }
1025 }
1026 }
1027 Ok(out)
1028}
1029
1030fn upper(s: &str) -> String {
1031 s.to_uppercase()
1032}
1033
1034/// A value coerced to a number, as GitHub does when operand types differ.
1035fn to_number(value: &Value) -> f64 {
1036 match value {
1037 Value::Null => 0.0,
1038 Value::Bool(b) => f64::from(u8::from(*b)),
1039 Value::Number(n) => n.as_f64().unwrap_or(f64::NAN),
1040 Value::String(s) => parse_number(s, true).unwrap_or(f64::NAN),
1041 Value::Array(_) | Value::Object(_) => f64::NAN,
1042 }
1043}
1044
1045/// GitHub's `==`: same types compare directly (strings ignoring case; arrays
1046/// and objects are never equal, since they cannot be the same instance here);
1047/// different types are both coerced to numbers, and NaN equals nothing.
1048fn loose_eq(a: &Value, b: &Value) -> bool {
1049 match (a, b) {
1050 (Value::Null, Value::Null) => true,
1051 (Value::Bool(x), Value::Bool(y)) => x == y,
1052 (Value::Number(_), Value::Number(_)) => to_number(a) == to_number(b),
1053 (Value::String(x), Value::String(y)) => upper(x) == upper(y),
1054 (Value::Array(_), Value::Array(_)) | (Value::Object(_), Value::Object(_)) => false,
1055 _ => to_number(a) == to_number(b),
1056 }
1057}
1058
1059/// GitHub's ordering for `<`, `<=`, `>`, `>=`; None when they do not compare.
1060fn compare(a: &Value, b: &Value) -> Option<Ordering> {
1061 match (a, b) {
1062 (Value::Null, Value::Null) => Some(Ordering::Equal),
1063 (Value::String(x), Value::String(y)) => Some(upper(x).cmp(&upper(y))),
1064 (Value::Array(_) | Value::Object(_), _) | (_, Value::Array(_) | Value::Object(_)) => None,
1065 _ => to_number(a).partial_cmp(&to_number(b)),
1066 }
1067}
1068
1069/// A number as a JSON value, integral numbers as integers so they print and
1070/// serialize without a trailing ".0".
1071fn number(n: f64) -> Value {
1072 if n.fract() == 0.0 && n.abs() < 9_007_199_254_740_992.0 {
1073 Value::from(n as i64)
1074 } else {
1075 serde_json::Number::from_f64(n).map_or(Value::Null, Value::Number)
1076 }
1077}
1078
1079/// Integral floats as integers, all the way down.
1080fn normalize(value: Value) -> Value {
1081 match value {
1082 Value::Number(n) if n.is_f64() => number(n.as_f64().unwrap_or(f64::NAN)),
1083 Value::Array(items) => Value::Array(items.into_iter().map(normalize).collect()),
1084 Value::Object(map) => {
1085 Value::Object(map.into_iter().map(|(k, v)| (k, normalize(v))).collect())
1086 }
1087 other => other,
1088 }
1089}
1090
1091fn to_json(value: &Value) -> String {
1092 serde_json::to_string_pretty(&normalize(value.clone())).unwrap_or_default()
1093}
1094
1095/// A number the way GitHub (.NET's "G15") writes it: up to 15 significant
1096/// digits, no trailing zeros, and scientific notation (`1E+15`, `1E-07`) for
1097/// very large or very small magnitudes.
1098fn format_number(n: f64) -> String {
1099 if n.is_nan() {
1100 return "NaN".to_string();
1101 }
1102 if n.is_infinite() {
1103 return if n > 0.0 { "Infinity" } else { "-Infinity" }.to_string();
1104 }
1105 if n == 0.0 {
1106 return "0".to_string();
1107 }
1108 let scientific = format!("{:.14e}", n.abs());
1109 let (mantissa, exponent) = scientific.split_once('e').unwrap_or((&scientific, "0"));
1110 let exponent: i32 = exponent.parse().unwrap_or(0);
1111 let mut digits: String = mantissa.chars().filter(char::is_ascii_digit).collect();
1112 while digits.len() > 1 && digits.ends_with('0') {
1113 digits.pop();
1114 }
1115 let mut out = String::new();
1116 if n < 0.0 {
1117 out.push('-');
1118 }
1119 if !(-5..15).contains(&exponent) {
1120 out.push_str(&digits[..1]);
1121 if digits.len() > 1 {
1122 out.push('.');
1123 out.push_str(&digits[1..]);
1124 }
1125 out.push('E');
1126 out.push(if exponent < 0 { '-' } else { '+' });
1127 out.push_str(&format!("{:02}", exponent.abs()));
1128 } else if exponent >= 0 {
1129 let whole = exponent as usize + 1;
1130 if digits.len() > whole {
1131 out.push_str(&digits[..whole]);
1132 out.push('.');
1133 out.push_str(&digits[whole..]);
1134 } else {
1135 out.push_str(&digits);
1136 out.push_str(&"0".repeat(whole - digits.len()));
1137 }
1138 } else {
1139 out.push_str("0.");
1140 out.push_str(&"0".repeat((-exponent - 1) as usize));
1141 out.push_str(&digits);
1142 }
1143 out
1144}
1145
1146// ---------------------------------------------------------------------------
1147
1148#[cfg(test)]
1149mod tests {
1150 use super::*;
1151 use serde_json::json;
1152
1153 fn contexts() -> Map<String, Value> {
1154 let value = json!({
1155 "github": {
1156 "ref": "refs/heads/main",
1157 "ref_name": "main",
1158 "event_name": "push",
1159 "repository": "flagon-io/g1t",
1160 "actor": "dependabot[bot]",
1161 "event": {
1162 "pull_request": {
1163 "number": 42,
1164 "draft": false,
1165 "title": "Fix the thing",
1166 "head": { "ref": "feature/x" },
1167 "labels": [{ "name": "bug" }, { "name": "Enhancement" }]
1168 },
1169 "issues": [
1170 { "labels": [{ "name": "a" }, { "name": "b" }] },
1171 { "labels": [{ "name": "c" }] }
1172 ],
1173 "head_commit": { "message": "fix: x [skip ci]" }
1174 }
1175 },
1176 "env": { "NODE_VERSION": "18", "EMPTY": "" },
1177 "vars": { "DEPLOY": "yes" },
1178 "secrets": { "TOKEN": "s3cret" },
1179 "inputs": { "debug": "true", "flag": true, "count": 3, "environment": "staging" },
1180 "matrix": { "os": "ubuntu-latest", "node": 18, "experimental": false },
1181 "strategy": { "fail-fast": true, "job-index": 0 },
1182 "steps": {
1183 "build": { "outputs": { "version": "1.2.3" }, "outcome": "success", "conclusion": "success" },
1184 "test": { "outputs": {}, "outcome": "failure", "conclusion": "success" },
1185 "my-step": { "outputs": { "cache-hit": "true" } }
1186 },
1187 "needs": {
1188 "setup": {
1189 "result": "success",
1190 "outputs": { "matrix": "{\"os\":[\"ubuntu-latest\",\"windows-latest\"],\"node\":[18,20]}" }
1191 }
1192 },
1193 "runner": { "os": "Linux", "arch": "X64" },
1194 "job": { "status": "success" }
1195 });
1196 match value {
1197 Value::Object(map) => map,
1198 _ => unreachable!(),
1199 }
1200 }
1201
1202 fn with<T>(status: Status, f: impl FnOnce(&Scope) -> T) -> T {
1203 let contexts = contexts();
1204 let hash = |patterns: &[String]| format!("hash({})", patterns.join("|"));
1205 let scope = Scope {
1206 contexts: &contexts,
1207 status,
1208 hash_files: Some(&hash),
1209 };
1210 f(&scope)
1211 }
1212
1213 fn ev(expression: &str) -> Value {
1214 with(Status::Success, |s| evaluate(expression, s))
1215 .unwrap_or_else(|e| panic!("{expression}: {e}"))
1216 }
1217
1218 fn err(expression: &str) -> String {
1219 with(Status::Success, |s| evaluate(expression, s)).unwrap_err()
1220 }
1221
1222 fn cond(status: Status, text: &str) -> bool {
1223 with(status, |s| condition(text, s)).unwrap_or_else(|e| panic!("{text}: {e}"))
1224 }
1225
1226 #[test]
1227 fn literals() {
1228 assert_eq!(ev("null"), Value::Null);
1229 assert_eq!(ev("true"), json!(true));
1230 assert_eq!(ev("false"), json!(false));
1231 assert_eq!(ev("711"), json!(711));
1232 assert_eq!(ev("-9.2"), json!(-9.2));
1233 assert_eq!(ev("0xff"), json!(255));
1234 assert_eq!(ev("-2.99e-2"), json!(-0.0299));
1235 assert_eq!(ev("1e3"), json!(1000));
1236 assert_eq!(ev("'Mona the Octocat'"), json!("Mona the Octocat"));
1237 assert_eq!(ev("'It''s open source!'"), json!("It's open source!"));
1238 }
1239
1240 #[test]
1241 fn double_quotes_are_an_error() {
1242 let e = err("github.ref == \"main\"");
1243 assert!(
1244 e.starts_with("Unexpected symbol: '\"'. Located at position 15 within expression:"),
1245 "{e}"
1246 );
1247 }
1248
1249 #[test]
1250 fn ref_is_main() {
1251 assert_eq!(ev("github.ref == 'refs/heads/main'"), json!(true));
1252 assert_eq!(ev("github.ref != 'refs/heads/main'"), json!(false));
1253 }
1254
1255 #[test]
1256 fn starts_with_tag() {
1257 assert_eq!(ev("startsWith(github.ref, 'refs/tags/v')"), json!(false));
1258 assert_eq!(ev("startsWith(github.ref, 'REFS/heads/')"), json!(true));
1259 assert_eq!(ev("endsWith(github.repository, '/G1T')"), json!(true));
1260 }
1261
1262 #[test]
1263 fn contains_labels_filter() {
1264 assert_eq!(
1265 ev("contains(github.event.pull_request.labels.*.name, 'bug')"),
1266 json!(true)
1267 );
1268 assert_eq!(
1269 ev("contains(github.event.pull_request.labels.*.name, 'enhancement')"),
1270 json!(true)
1271 );
1272 assert_eq!(
1273 ev("contains(github.event.pull_request.labels.*.name, 'docs')"),
1274 json!(false)
1275 );
1276 }
1277
1278 #[test]
1279 fn nested_object_filters_flatten() {
1280 assert_eq!(
1281 ev("github.event.issues.*.labels.*.name"),
1282 json!(["a", "b", "c"])
1283 );
1284 assert_eq!(
1285 ev("github.event.pull_request.labels[*].name"),
1286 json!(["bug", "Enhancement"])
1287 );
1288 assert_eq!(
1289 ev("github.event.pull_request.*"),
1290 ev("github.event.pull_request.*")
1291 );
1292 assert_eq!(ev("matrix.nothing.*"), json!([]));
1293 }
1294
1295 #[test]
1296 fn matrix_and() {
1297 assert_eq!(
1298 ev("matrix.os == 'ubuntu-latest' && matrix.node >= 18"),
1299 json!(true)
1300 );
1301 assert_eq!(
1302 ev("matrix.os == 'windows-latest' && matrix.node >= 18"),
1303 json!(false)
1304 );
1305 }
1306
1307 #[test]
1308 fn step_outputs() {
1309 assert_eq!(ev("steps.build.outputs.version"), json!("1.2.3"));
1310 assert_eq!(
1311 ev("steps.my-step.outputs.cache-hit != 'true'"),
1312 json!(false)
1313 );
1314 assert_eq!(ev("steps.missing.outputs.version"), Value::Null);
1315 }
1316
1317 #[test]
1318 fn format_with_hash_files() {
1319 assert_eq!(
1320 ev("format('{0}-{1}', runner.os, hashFiles('**/package-lock.json'))"),
1321 json!("Linux-hash(**/package-lock.json)")
1322 );
1323 assert_eq!(ev("hashFiles('a', 'b')"), json!("hash(a|b)"));
1324 }
1325
1326 #[test]
1327 fn hash_files_without_sandbox_is_empty() {
1328 let contexts = contexts();
1329 let scope = Scope {
1330 contexts: &contexts,
1331 status: Status::Success,
1332 hash_files: None,
1333 };
1334 assert_eq!(
1335 evaluate("hashFiles('**/*.lock')", &scope).unwrap(),
1336 json!("")
1337 );
1338 }
1339
1340 #[test]
1341 fn format_escapes_and_errors() {
1342 assert_eq!(
1343 ev("format('{{Hello {0} {1} {2}!}}', 'Mona', 'the', 'Octocat')"),
1344 json!("{Hello Mona the Octocat!}")
1345 );
1346 assert_eq!(ev("format('{0}{0}', 1)"), json!("11"));
1347 assert!(err("format('{1}', 'a')").contains("more arguments than were supplied"));
1348 assert!(err("format('{0', 'a')").contains("invalid"));
1349 }
1350
1351 #[test]
1352 fn from_json_matrix() {
1353 assert_eq!(
1354 ev("fromJSON(needs.setup.outputs.matrix)"),
1355 json!({ "os": ["ubuntu-latest", "windows-latest"], "node": [18, 20] })
1356 );
1357 assert_eq!(
1358 ev("fromJSON(needs.setup.outputs.matrix).node[1]"),
1359 json!(20)
1360 );
1361 assert_eq!(ev("fromJSON('true')"), json!(true));
1362 assert_eq!(ev("fromJSON('3.0')"), json!(3));
1363 assert!(err("fromJSON('{nope')").contains("fromJSON"));
1364 }
1365
1366 #[test]
1367 fn event_name_or() {
1368 assert_eq!(
1369 ev("github.event_name == 'push' || github.event_name == 'workflow_dispatch'"),
1370 json!(true)
1371 );
1372 }
1373
1374 #[test]
1375 fn and_or_return_operands() {
1376 assert_eq!(ev("matrix.os && 'yes'"), json!("yes"));
1377 assert_eq!(ev("env.EMPTY && 'yes'"), json!(""));
1378 assert_eq!(ev("env.EMPTY || 'fallback'"), json!("fallback"));
1379 assert_eq!(ev("inputs.environment || 'production'"), json!("staging"));
1380 assert_eq!(ev("github.event.pull_request.draft || null"), Value::Null);
1381 }
1382
1383 #[test]
1384 fn short_circuit_skips_errors() {
1385 assert_eq!(ev("false && fromJSON('{bad')"), json!(false));
1386 assert_eq!(ev("true || fromJSON('{bad')"), json!(true));
1387 }
1388
1389 #[test]
1390 fn not_cancelled() {
1391 assert!(cond(Status::Success, "!cancelled()"));
1392 assert!(cond(Status::Failure, "!cancelled()"));
1393 assert!(!cond(Status::Cancelled, "!cancelled()"));
1394 }
1395
1396 #[test]
1397 fn failure_and_outcome() {
1398 assert!(cond(
1399 Status::Failure,
1400 "failure() && steps.test.outcome == 'failure'"
1401 ));
1402 assert!(!cond(
1403 Status::Success,
1404 "failure() && steps.test.outcome == 'failure'"
1405 ));
1406 assert!(!cond(
1407 Status::Failure,
1408 "failure() && steps.build.outcome == 'failure'"
1409 ));
1410 }
1411
1412 #[test]
1413 fn string_input_is_not_true() {
1414 // The famous gotcha: 'true' coerces to NaN when compared with a bool.
1415 assert_eq!(ev("inputs.debug == true"), json!(false));
1416 assert_eq!(ev("inputs.debug == 'true'"), json!(true));
1417 assert_eq!(ev("inputs.flag == true"), json!(true));
1418 }
1419
1420 #[test]
1421 fn coercions() {
1422 assert_eq!(ev("'' == 0"), json!(true));
1423 assert_eq!(ev("null == false"), json!(true));
1424 assert_eq!(ev("null == 0"), json!(true));
1425 assert_eq!(ev("1 == '1'"), json!(true));
1426 assert_eq!(ev("'1.0' == 1"), json!(true));
1427 assert_eq!(ev("' 2 ' == 2"), json!(true));
1428 assert_eq!(ev("'0x10' == 16"), json!(true));
1429 assert_eq!(ev("true == 1"), json!(true));
1430 assert_eq!(ev("'abc' == 0"), json!(false));
1431 assert_eq!(ev("'abc' != 0"), json!(true));
1432 assert_eq!(ev("null == ''"), json!(true));
1433 }
1434
1435 #[test]
1436 fn hex_and_exponent() {
1437 assert_eq!(ev("0x10 == 16"), json!(true));
1438 assert_eq!(ev("1e3 == 1000"), json!(true));
1439 assert_eq!(ev("-0x10 < 0"), json!(true));
1440 }
1441
1442 #[test]
1443 fn case_insensitive_strings() {
1444 assert_eq!(ev("'ABC' == 'abc'"), json!(true));
1445 assert_eq!(ev("contains('Hello World', 'WORLD')"), json!(true));
1446 assert_eq!(ev("'a' < 'B'"), json!(true));
1447 }
1448
1449 #[test]
1450 fn case_insensitive_names() {
1451 assert_eq!(ev("GitHub.Event_Name"), json!("push"));
1452 assert_eq!(ev("ENV.node_version"), json!("18"));
1453 assert_eq!(ev("StartsWith(github.ref, 'refs/')"), json!(true));
1454 assert_eq!(ev("TOJSON(1)"), json!("1"));
1455 }
1456
1457 #[test]
1458 fn objects_and_arrays_are_never_equal() {
1459 assert_eq!(ev("github.event == github.event"), json!(false));
1460 assert_eq!(ev("fromJSON('[]') == fromJSON('[]')"), json!(false));
1461 assert_eq!(ev("fromJSON('[]') == 0"), json!(false));
1462 assert_eq!(ev("fromJSON('{}') < 1"), json!(false));
1463 }
1464
1465 #[test]
1466 fn nan_compares_false() {
1467 assert_eq!(ev("'abc' < 1"), json!(false));
1468 assert_eq!(ev("'abc' >= 1"), json!(false));
1469 assert_eq!(ev("'abc' == 'abc'"), json!(true));
1470 }
1471
1472 #[test]
1473 fn comparisons() {
1474 assert_eq!(ev("matrix.node > 16"), json!(true));
1475 assert_eq!(ev("matrix.node <= '18'"), json!(true));
1476 assert_eq!(ev("inputs.count < 3"), json!(false));
1477 assert_eq!(ev("null <= null"), json!(true));
1478 assert_eq!(ev("false < true"), json!(true));
1479 }
1480
1481 #[test]
1482 fn precedence() {
1483 // ! binds tighter than ==, == tighter than &&, && tighter than ||.
1484 assert_eq!(ev("!matrix.experimental == true"), json!(true));
1485 assert_eq!(ev("true || false && false"), json!(true));
1486 assert_eq!(ev("(true || false) && false"), json!(false));
1487 assert_eq!(ev("1 < 2 == true"), json!(true));
1488 assert_eq!(ev("!!'x'"), json!(true));
1489 }
1490
1491 #[test]
1492 fn indexing() {
1493 assert_eq!(ev("github['event']['pull_request']['number']"), json!(42));
1494 assert_eq!(ev("github.event.pull_request.labels[0].name"), json!("bug"));
1495 assert_eq!(ev("github.event.pull_request.labels[5]"), Value::Null);
1496 assert_eq!(ev("matrix['os']"), json!("ubuntu-latest"));
1497 assert_eq!(ev("strategy.fail-fast"), json!(true));
1498 assert_eq!(ev("strategy['job-index']"), json!(0));
1499 }
1500
1501 #[test]
1502 fn missing_properties_are_null() {
1503 assert_eq!(ev("github.event.release.tag_name"), Value::Null);
1504 assert_eq!(ev("github.ref.nope"), Value::Null);
1505 assert_eq!(ev("jobs.anything"), Value::Null);
1506 }
1507
1508 #[test]
1509 fn unrecognized_named_value() {
1510 let e = err("foo.bar == 1");
1511 assert_eq!(
1512 e,
1513 "Unrecognized named-value: 'foo'. Located at position 1 within expression: foo.bar == 1"
1514 );
1515 // Found at parse time, even in a branch that never runs.
1516 assert!(err("false && bogus").contains("Unrecognized named-value: 'bogus'"));
1517 }
1518
1519 #[test]
1520 fn function_errors() {
1521 assert!(err("nope(1)").starts_with("Unrecognized function: 'nope'"));
1522 assert!(err("contains('a')").starts_with("Too few parameters supplied: 'contains'"));
1523 assert!(err("success(1)").starts_with("Too many parameters supplied: 'success'"));
1524 assert!(err("toJSON()").starts_with("Too few parameters supplied: 'toJSON'"));
1525 }
1526
1527 #[test]
1528 fn syntax_errors() {
1529 assert!(err("github.ref ==").starts_with("Unexpected end of expression: '=='"));
1530 assert!(err("(true").starts_with("Unexpected end of expression"));
1531 assert!(err("true false").starts_with("Unexpected symbol: 'false'. Located at position 6"));
1532 assert!(err("'open").starts_with("Unexpected symbol: ''open'"));
1533 assert!(err("a = b").contains("Unexpected symbol"));
1534 assert!(err("github.").starts_with("Unexpected end of expression"));
1535 assert!(err("").contains("expression was expected"));
1536 }
1537
1538 #[test]
1539 fn contains_array_uses_loose_equality() {
1540 assert_eq!(ev("contains(fromJSON('[1, 2, 3]'), '2')"), json!(true));
1541 assert_eq!(
1542 ev("contains(fromJSON('[\"push\", \"pull_request\"]'), github.event_name)"),
1543 json!(true)
1544 );
1545 assert_eq!(
1546 ev("contains(github.event.head_commit.message, '[skip ci]')"),
1547 json!(true)
1548 );
1549 assert_eq!(ev("contains(github.actor, '[bot]')"), json!(true));
1550 }
1551
1552 #[test]
1553 fn join() {
1554 assert_eq!(
1555 ev("join(github.event.pull_request.labels.*.name)"),
1556 json!("bug,Enhancement")
1557 );
1558 assert_eq!(
1559 ev("join(github.event.pull_request.labels.*.name, ', ')"),
1560 json!("bug, Enhancement")
1561 );
1562 assert_eq!(ev("join('abc', '-')"), json!("abc"));
1563 assert_eq!(
1564 ev("join(fromJSON('[1, true, null]'), ' ')"),
1565 json!("1 true ")
1566 );
1567 }
1568
1569 #[test]
1570 fn to_json_pretty() {
1571 assert_eq!(
1572 ev("toJSON(steps.build.outputs)"),
1573 json!("{\n \"version\": \"1.2.3\"\n}")
1574 );
1575 assert_eq!(ev("toJSON('a')"), json!("\"a\""));
1576 assert_eq!(ev("toJSON(null)"), json!("null"));
1577 assert_eq!(ev("toJSON(0x10)"), json!("16"));
1578 }
1579
1580 #[test]
1581 fn truthiness() {
1582 assert!(!truthy(&json!(false)));
1583 assert!(!truthy(&json!(0)));
1584 assert!(!truthy(&json!(-0.0)));
1585 assert!(!truthy(&json!("")));
1586 assert!(!truthy(&Value::Null));
1587 assert!(truthy(&json!("0")));
1588 assert!(truthy(&json!("false")));
1589 assert!(truthy(&json!([])));
1590 assert!(truthy(&json!({})));
1591 assert!(truthy(&json!(0.5)));
1592 }
1593
1594 #[test]
1595 fn text_conversion() {
1596 assert_eq!(to_text(&Value::Null), "");
1597 assert_eq!(to_text(&json!(true)), "true");
1598 assert_eq!(to_text(&json!(3.0)), "3");
1599 assert_eq!(to_text(&json!(1.5)), "1.5");
1600 assert_eq!(to_text(&json!(-0.0299)), "-0.0299");
1601 assert_eq!(to_text(&json!(1e20)), "1E+20");
1602 assert_eq!(to_text(&json!(0.0000001)), "1E-07");
1603 assert_eq!(to_text(&json!(123456789012345_i64)), "123456789012345");
1604 assert_eq!(to_text(&json!(["a", 1])), "Array");
1605 assert_eq!(to_text(&json!({ "a": 1 })), "Object");
1606 assert_eq!(to_text(&json!("as-is")), "as-is");
1607 }
1608
1609 #[test]
1610 fn condition_implicit_success() {
1611 assert!(cond(Status::Success, "github.event_name == 'push'"));
1612 assert!(!cond(Status::Failure, "github.event_name == 'push'"));
1613 assert!(!cond(Status::Cancelled, "github.event_name == 'push'"));
1614 assert!(!cond(
1615 Status::Success,
1616 "github.event_name == 'pull_request'"
1617 ));
1618 }
1619
1620 #[test]
1621 fn condition_status_functions() {
1622 assert!(cond(Status::Failure, "always()"));
1623 assert!(cond(Status::Cancelled, "always()"));
1624 assert!(cond(Status::Failure, "failure()"));
1625 assert!(!cond(Status::Success, "failure()"));
1626 assert!(cond(Status::Cancelled, "cancelled()"));
1627 assert!(cond(Status::Success, "success()"));
1628 assert!(cond(
1629 Status::Failure,
1630 "${{ always() && github.ref == 'refs/heads/main' }}"
1631 ));
1632 // Nested inside another call still counts.
1633 assert!(cond(Status::Failure, "contains(toJSON(always()), 'true')"));
1634 }
1635
1636 /// A job's status from what it needs, and its `if:` with it, as on
1637 /// GitHub: a skipped need is not a failure.
1638 #[test]
1639 fn job_status_from_needs() {
1640 let ok = |needs: &[&str]| job_status(needs.iter().copied(), false, false);
1641 assert_eq!(ok(&[]), Status::Success);
1642 assert_eq!(ok(&["success", "success"]), Status::Success);
1643 assert_eq!(ok(&["success", "failure"]), Status::Failure);
1644 assert_eq!(ok(&["skipped"]), Status::Incomplete);
1645 assert_eq!(ok(&["success", "skipped"]), Status::Incomplete);
1646 // A need cancelled by itself, in a run that was not.
1647 assert_eq!(ok(&["cancelled"]), Status::Incomplete);
1648 // A failure anywhere wins over a skip.
1649 assert_eq!(ok(&["skipped", "failure"]), Status::Failure);
1650 // A failure further back, behind a need that was skipped for it.
1651 assert_eq!(job_status(["skipped"], true, false), Status::Failure);
1652 // A cancelled run is cancelled, whatever its jobs did.
1653 assert_eq!(job_status(["failure"], true, true), Status::Cancelled);
1654 assert_eq!(job_status(["success"], false, true), Status::Cancelled);
1655 }
1656
1657 #[test]
1658 fn job_conditions_after_a_skipped_need() {
1659 let skipped = job_status(["success", "skipped"], false, false);
1660 // The default `if:` (success()) skips it; so does any condition
1661 // that does not check status.
1662 assert!(!cond(skipped, ""));
1663 assert!(!cond(skipped, "success()"));
1664 assert!(!cond(skipped, "github.event_name == 'push'"));
1665 // Nothing failed and nothing was cancelled.
1666 assert!(!cond(skipped, "failure()"));
1667 assert!(!cond(skipped, "cancelled()"));
1668 assert!(cond(skipped, "always()"));
1669 assert!(cond(skipped, "!cancelled()"));
1670 assert!(cond(skipped, "!failure() && !cancelled()"));
1671 assert!(cond(skipped, "${{ !failure() && !cancelled() && github.event_name == 'push' }}"));
1672
1673 let failed = job_status(["skipped"], true, false);
1674 assert!(cond(failed, "failure()"));
1675 assert!(!cond(failed, "!failure() && !cancelled()"));
1676 assert!(!cond(failed, ""));
1677 assert!(cond(failed, "always()"));
1678
1679 let cancelled = job_status(["success"], false, true);
1680 assert!(cond(cancelled, "cancelled()"));
1681 assert!(!cond(cancelled, "!failure() && !cancelled()"));
1682 assert!(!cond(cancelled, "failure()"));
1683 assert!(!cond(cancelled, "success()"));
1684 }
1685
1686 #[test]
1687 fn condition_status_not_by_substring() {
1688 // 'failure()' inside a string is not a call; implicit success() applies.
1689 assert!(!cond(Status::Failure, "steps.test.outcome != 'failure()'"));
1690 assert!(cond(Status::Success, "steps.test.outcome != 'failure()'"));
1691 }
1692
1693 #[test]
1694 fn condition_wrapped_and_empty() {
1695 assert!(cond(
1696 Status::Success,
1697 "${{ github.ref == 'refs/heads/main' }}"
1698 ));
1699 assert!(!cond(
1700 Status::Success,
1701 " ${{ github.ref == 'refs/heads/dev' }} "
1702 ));
1703 assert!(cond(Status::Success, ""));
1704 assert!(!cond(Status::Failure, ""));
1705 assert!(cond(Status::Success, "${{ }}"));
1706 assert!(cond(Status::Success, "${{ matrix.os }}"));
1707 assert!(!cond(Status::Success, "${{ env.EMPTY }}"));
1708 assert!(cond(
1709 Status::Success,
1710 "vars.DEPLOY == 'yes' && !github.event.pull_request.draft"
1711 ));
1712 }
1713
1714 #[test]
1715 fn condition_with_text_around_is_a_string() {
1716 // On GitHub this is always true: it's the string "false && x", not an expression.
1717 assert!(cond(Status::Success, "${{ false }} && x"));
1718 }
1719
1720 #[test]
1721 fn interpolate_mixed_text() {
1722 let out = with(Status::Success, |s| {
1723 interpolate(
1724 "node-${{ matrix.node }}-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}",
1725 s,
1726 )
1727 })
1728 .unwrap();
1729 assert_eq!(out, "node-18-Linux-hash(**/yarn.lock)");
1730 let out = with(Status::Success, |s| {
1731 interpolate("echo \"PR #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}\"", s)
1732 })
1733 .unwrap();
1734 assert_eq!(out, "echo \"PR #42: Fix the thing\"");
1735 }
1736
1737 #[test]
1738 fn interpolate_edge_cases() {
1739 assert_eq!(
1740 with(Status::Success, |s| interpolate("plain $text {{ x }}", s)).unwrap(),
1741 "plain $text {{ x }}"
1742 );
1743 assert_eq!(
1744 with(Status::Success, |s| interpolate("${{ '}}' }}!", s)).unwrap(),
1745 "}}!"
1746 );
1747 assert_eq!(
1748 with(Status::Success, |s| interpolate("[${{ env.MISSING }}]", s)).unwrap(),
1749 "[]"
1750 );
1751 assert_eq!(
1752 with(Status::Success, |s| interpolate("${{ 1.50 }}", s)).unwrap(),
1753 "1.5"
1754 );
1755 assert!(
1756 with(Status::Success, |s| interpolate("oops ${{ github.ref", s))
1757 .unwrap_err()
1758 .contains("not closed")
1759 );
1760 assert!(with(Status::Success, |s| interpolate("${{ \"x\" }}", s)).is_err());
1761 }
1762
1763 #[test]
1764 fn interpolate_value_keeps_types() {
1765 let input = json!({
1766 "matrix": "${{ fromJSON(needs.setup.outputs.matrix) }}",
1767 "continue-on-error": "${{ matrix.experimental }}",
1768 "timeout-minutes": "${{ inputs.count }}",
1769 "name": "Build ${{ matrix.os }}",
1770 "env": { "VERSION_${{ matrix.node }}": "${{ steps.build.outputs.version }}" },
1771 "list": ["${{ github.event.pull_request.labels.*.name }}", 7, null],
1772 "plain": true
1773 });
1774 let out = with(Status::Success, |s| interpolate_value(&input, s)).unwrap();
1775 assert_eq!(
1776 out,
1777 json!({
1778 "matrix": { "os": ["ubuntu-latest", "windows-latest"], "node": [18, 20] },
1779 "continue-on-error": false,
1780 "timeout-minutes": 3,
1781 "name": "Build ubuntu-latest",
1782 "env": { "VERSION_18": "1.2.3" },
1783 "list": [["bug", "Enhancement"], 7, null],
1784 "plain": true
1785 })
1786 );
1787 }
1788
1789 #[test]
1790 fn has_expression_detects() {
1791 assert!(has_expression("a ${{ b }}"));
1792 assert!(!has_expression("a ${ b }"));
1793 assert!(!has_expression("{{ b }}"));
1794 }
1795
1796 #[test]
1797 fn dependabot_and_draft_guards() {
1798 assert!(!cond(Status::Success, "github.actor != 'dependabot[bot]'"));
1799 assert!(cond(
1800 Status::Success,
1801 "github.event.pull_request.draft == false"
1802 ));
1803 assert!(cond(
1804 Status::Success,
1805 "!contains(github.event.head_commit.message, '[skip deploy]')"
1806 ));
1807 }
1808
1809 #[test]
1810 fn tag_release_condition() {
1811 let contexts = {
1812 let mut c = contexts();
1813 c["github"]["ref"] = json!("refs/tags/v1.4.0");
1814 c["github"]["event_name"] = json!("push");
1815 c
1816 };
1817 let scope = Scope {
1818 contexts: &contexts,
1819 status: Status::Success,
1820 hash_files: None,
1821 };
1822 assert!(
1823 condition(
1824 "github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')",
1825 &scope
1826 )
1827 .unwrap()
1828 );
1829 assert_eq!(
1830 interpolate("${{ format('release-{0}', github.ref_name) }}", &scope).unwrap(),
1831 "release-main"
1832 );
1833 }
1834
1835 #[test]
1836 fn needs_result_and_number_format() {
1837 assert!(cond(Status::Success, "needs.setup.result == 'success'"));
1838 assert_eq!(ev("format('{0}', 0.1)"), json!("0.1"));
1839 assert_eq!(ev("format('{0}', 100)"), json!("100"));
1840 assert_eq!(ev("format('{0}|{1}', null, true)"), json!("|true"));
1841 }
1842}