Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 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 | ||
| 12 | use serde_json::{Map, Value}; | |
| 13 | use std::borrow::Cow; | |
| 14 | use std::cmp::Ordering; | |
| 15 | ||
| 16 | /// How the job is going, for success(), failure(), cancelled(), always(). | |
| 17 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 18 | pub 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. | |
| 39 | pub 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 | ||
| 60 | pub 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. | |
| 70 | const 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:`). | |
| success(), failure(), cancelled() and always() work only in an if:, as on GitHub. Anywhere else, such as a step's env, g1t now refuses them with GitHub's own error, "Unrecognized function: 'success'"; before, g1t accepted them everywhere, and the Conformance workflow, which read success() in its env, ran on g1t and failed on GitHub. The workflow no longer reports it, and the Actions guide says where the status functions may be called. | 76 | /// The status functions exist only in `if:`; anywhere else they are unrecognized, as on GitHub. |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 77 | pub fn evaluate(expression: &str, scope: &Scope) -> Result<Value, String> { |
| success(), failure(), cancelled() and always() work only in an if:, as on GitHub. Anywhere else, such as a step's env, g1t now refuses them with GitHub's own error, "Unrecognized function: 'success'"; before, g1t accepted them everywhere, and the Conformance workflow, which read success() in its env, ran on g1t and failed on GitHub. The workflow no longer reports it, and the Actions guide says where the status functions may be called. | 78 | let ast = parse(expression, scope.contexts, false)?; |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 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()`. | |
| 83 | pub 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) => { | |
| success(), failure(), cancelled() and always() work only in an if:, as on GitHub. Anywhere else, such as a step's env, g1t now refuses them with GitHub's own error, "Unrecognized function: 'success'"; before, g1t accepted them everywhere, and the Conformance workflow, which read success() in its env, ran on g1t and failed on GitHub. The workflow no longer reports it, and the Actions guide says where the status functions may be called. | 91 | let text = interpolate_in(trimmed, scope, true)?; |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 92 | return Ok(success && !text.is_empty()); |
| 93 | } | |
| 94 | None => trimmed, | |
| 95 | }; | |
| 96 | if source.trim().is_empty() { | |
| 97 | return Ok(success); | |
| 98 | } | |
| success(), failure(), cancelled() and always() work only in an if:, as on GitHub. Anywhere else, such as a step's env, g1t now refuses them with GitHub's own error, "Unrecognized function: 'success'"; before, g1t accepted them everywhere, and the Conformance workflow, which read success() in its env, ran on g1t and failed on GitHub. The workflow no longer reports it, and the Actions guide says where the status functions may be called. | 99 | let ast = parse(source, scope.contexts, true)?; |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 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. | |
| 108 | pub fn interpolate(text: &str, scope: &Scope) -> Result<String, String> { | |
| success(), failure(), cancelled() and always() work only in an if:, as on GitHub. Anywhere else, such as a step's env, g1t now refuses them with GitHub's own error, "Unrecognized function: 'success'"; before, g1t accepted them everywhere, and the Conformance workflow, which read success() in its env, ran on g1t and failed on GitHub. The workflow no longer reports it, and the Actions guide says where the status functions may be called. | 109 | interpolate_in(text, scope, false) |
| 110 | } | |
| 111 | ||
| 112 | /// `interpolate`, with the status functions allowed when `status` (inside an `if:`). | |
| 113 | fn interpolate_in(text: &str, scope: &Scope, status: bool) -> Result<String, String> { | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 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 | })?; | |
| success(), failure(), cancelled() and always() work only in an if:, as on GitHub. Anywhere else, such as a step's env, g1t now refuses them with GitHub's own error, "Unrecognized function: 'success'"; before, g1t accepted them everywhere, and the Conformance workflow, which read success() in its env, ran on g1t and failed on GitHub. The workflow no longer reports it, and the Actions guide says where the status functions may be called. | 128 | let ast = parse(&text[body..close], scope.contexts, status)?; |
| 129 | let value = eval(&ast, scope)?.into_value(); | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 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: ${{ ... }}`). | |
| 138 | pub 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. | |
| 162 | pub 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). | |
| 173 | pub 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 `${{`. | |
| 185 | pub 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 `}}`). | |
| 194 | fn 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. | |
| 210 | fn 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)] | |
| 223 | enum 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)] | |
| 249 | struct 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 | ||
| 257 | fn located(message: &str, pos: usize, src: &str) -> String { | |
| 258 | format!("{message}. Located at position {pos} within expression: {src}") | |
| 259 | } | |
| 260 | ||
| 261 | fn 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. | |
| 413 | fn 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)] | |
| 480 | enum 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. | |
| 496 | const 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)] | |
| 512 | enum CmpOp { | |
| 513 | Lt, | |
| 514 | Le, | |
| 515 | Gt, | |
| 516 | Ge, | |
| 517 | Eq, | |
| 518 | Ne, | |
| 519 | } | |
| 520 | ||
| 521 | #[derive(Debug)] | |
| 522 | enum 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 | ||
| success(), failure(), cancelled() and always() work only in an if:, as on GitHub. Anywhere else, such as a step's env, g1t now refuses them with GitHub's own error, "Unrecognized function: 'success'"; before, g1t accepted them everywhere, and the Conformance workflow, which read success() in its env, ran on g1t and failed on GitHub. The workflow no longer reports it, and the Actions guide says where the status functions may be called. | 535 | /// `status`: whether success(), failure(), cancelled() and always() may be called. |
| 536 | fn parse(src: &str, contexts: &Map<String, Value>, status: bool) -> Result<Expr, String> { | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 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, | |
| success(), failure(), cancelled() and always() work only in an if:, as on GitHub. Anywhere else, such as a step's env, g1t now refuses them with GitHub's own error, "Unrecognized function: 'success'"; before, g1t accepted them everywhere, and the Conformance workflow, which read success() in its env, ran on g1t and failed on GitHub. The workflow no longer reports it, and the Actions guide says where the status functions may be called. | 546 | status, |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 547 | }; |
| 548 | let expr = parser.or()?; | |
| 549 | if parser.i < parser.toks.len() { | |
| 550 | return Err(parser.unexpected()); | |
| 551 | } | |
| 552 | Ok(expr) | |
| 553 | } | |
| 554 | ||
| 555 | struct Parser<'s> { | |
| 556 | toks: Vec<Token>, | |
| 557 | i: usize, | |
| 558 | src: &'s str, | |
| 559 | contexts: &'s Map<String, Value>, | |
| success(), failure(), cancelled() and always() work only in an if:, as on GitHub. Anywhere else, such as a step's env, g1t now refuses them with GitHub's own error, "Unrecognized function: 'success'"; before, g1t accepted them everywhere, and the Conformance workflow, which read success() in its env, ran on g1t and failed on GitHub. The workflow no longer reports it, and the Actions guide says where the status functions may be called. | 560 | status: bool, |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 561 | } |
| 562 | ||
| 563 | impl 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)) | |
| success(), failure(), cancelled() and always() work only in an if:, as on GitHub. Anywhere else, such as a step's env, g1t now refuses them with GitHub's own error, "Unrecognized function: 'success'"; before, g1t accepted them everywhere, and the Conformance workflow, which read success() in its env, ran on g1t and failed on GitHub. The workflow no longer reports it, and the Actions guide says where the status functions may be called. | 724 | .filter(|(_, func, ..)| self.status || !is_status(*func)) |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 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 | ||
| success(), failure(), cancelled() and always() work only in an if:, as on GitHub. Anywhere else, such as a step's env, g1t now refuses them with GitHub's own error, "Unrecognized function: 'success'"; before, g1t accepted them everywhere, and the Conformance workflow, which read success() in its env, ran on g1t and failed on GitHub. The workflow no longer reports it, and the Actions guide says where the status functions may be called. | 763 | fn is_status(func: Func) -> bool { |
| 764 | matches!( | |
| 765 | func, | |
| 766 | Func::Success | Func::Failure | Func::Cancelled | Func::Always | |
| 767 | ) | |
| 768 | } | |
| 769 | ||
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 770 | /// Whether the expression calls success(), failure(), cancelled() or always(). |
| 771 | fn 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 | } | |
| success(), failure(), cancelled() and always() work only in an if:, as on GitHub. Anywhere else, such as a step's env, g1t now refuses them with GitHub's own error, "Unrecognized function: 'success'"; before, g1t accepted them everywhere, and the Conformance workflow, which read success() in its env, ran on g1t and failed on GitHub. The workflow no longer reports it, and the Actions guide says where the status functions may be called. | 778 | Expr::Call(func, args) => is_status(*func) || args.iter().any(uses_status), |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 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. | |
| 788 | enum Ev<'c> { | |
| 789 | One(Cow<'c, Value>), | |
| 790 | Filtered(Vec<Cow<'c, Value>>), | |
| 791 | } | |
| 792 | ||
| 793 | impl 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 | ||
| 809 | fn 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. | |
| 814 | fn 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 | ||
| 821 | enum Key { | |
| 822 | Name(String), | |
| 823 | Index(usize), | |
| 824 | } | |
| 825 | ||
| 826 | fn 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 | ||
| 843 | fn 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. | |
| 854 | fn 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 | ||
| 865 | fn 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 | ||
| 944 | fn 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. | |
| 997 | fn 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 | ||
| 1043 | fn upper(s: &str) -> String { | |
| 1044 | s.to_uppercase() | |
| 1045 | } | |
| 1046 | ||
| 1047 | /// A value coerced to a number, as GitHub does when operand types differ. | |
| 1048 | fn 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. | |
| 1061 | fn 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. | |
| 1073 | fn 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". | |
| 1084 | fn 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. | |
| 1093 | fn 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 | ||
| 1104 | fn 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. | |
| 1111 | fn 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)] | |
| 1162 | mod 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'")); | |
| success(), failure(), cancelled() and always() work only in an if:, as on GitHub. Anywhere else, such as a step's env, g1t now refuses them with GitHub's own error, "Unrecognized function: 'success'"; before, g1t accepted them everywhere, and the Conformance workflow, which read success() in its env, ran on g1t and failed on GitHub. The workflow no longer reports it, and the Actions guide says where the status functions may be called. | 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")); | |
| Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.