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