mirror of
https://github.com/bahdotsh/wrkflw.git
synced 2026-05-18 05:05:35 +02:00
feat(executor): add GitHub Actions expression evaluator (#86)
* feat(executor): add GitHub Actions expression evaluator
Add a full expression evaluator for GitHub Actions `${{ }}` syntax,
replacing the regex-only substitution that failed on complex expressions
like those in dtolnay/rust-toolchain.
- Add `inputs.*`, `github.*`, `runner.*` context substitution patterns
- Build recursive-descent expression evaluator with support for:
- Operators: `==`, `!=`, `<`, `<=`, `>`, `>=`, `&&`, `||`, `!`
- String/number/boolean/null literals with GHA-compatible truthiness
- Context resolution: inputs, env, github, runner, matrix, steps
- Built-in functions: contains, startsWith, endsWith, format,
success, failure, always, cancelled
- Integrate evaluator into substitution pipeline for complex expressions
- Replace hardcoded `evaluate_job_condition` with evaluator for `if:`
- Add missing RUNNER_OS, RUNNER_ARCH, RUNNER_NAME env vars
- Add catch-all fallback to prevent bash "bad substitution" errors
* fix(executor): fix correctness bugs in expression evaluator
The expression evaluator landed with a few landmines worth defusing
before they bite someone in production.
First, the EXPRESSION_FALLBACK regex used [^}]* to match expression
content, which breaks the *moment* someone uses format placeholders
like {0} inside an expression. The single } in {0} would terminate
the match early, producing garbage. Fix the regex to
(?:[^}]|\}[^}])* so it only stops at the actual }} delimiter.
Second, NaN was truthy. In IEEE 754, NaN != 0.0 is true, so
is_truthy() would happily report NaN as truthy. GitHub Actions
disagrees. Add !n.is_nan() to match the spec. While at it, guard
to_output_string() with n.is_finite() before the as-i64 cast to
prevent overflow on non-finite values.
Third, preprocess_fallback was dead code — evaluate_remaining_
expressions completely replaced its role in the pipeline. Remove it
and its tests rather than leaving a function that exists only to
confuse future readers.
* refactor(executor): route all expressions through the evaluator
The expression evaluator was added in 2954b05, but it only handled
"complex" expressions as a fallback. Simple context references like
${{ env.FOO }} and ${{ runner.os }} were still resolved by six
separate regex preprocessors — each duplicating context resolution
logic that the evaluator already handles perfectly well.
This is the kind of redundancy that makes you maintain the same
mapping in two places and then wonder why they drift apart.
Rip out the individual regex preprocessors (preprocess_env_context,
preprocess_inputs_context, preprocess_github_context,
preprocess_runner_context, preprocess_step_outputs, and five regex
patterns). Now preprocess_expressions does exactly two things:
resolve hashFiles() (needs filesystem access), then route *all*
remaining ${{ }} through the expression evaluator. Net -46 lines.
While at it, fix three issues from code review:
- Add debug logging when expression evaluation fails in the
substitution path, instead of silently swallowing the error
- Document the surprising Bool/String coercion in expr_eq (where
false == "random" is true per GitHub Actions semantics)
- Add test coverage for that coercion edge case
This commit is contained in:
@@ -2096,7 +2096,12 @@ async fn run_step_with_guards(
|
||||
|
||||
// Check step-level if condition
|
||||
if let Some(if_cond) = &step.if_condition {
|
||||
let should_run = evaluate_job_condition(if_cond, job_env, workflow);
|
||||
let should_run = evaluate_condition_with_context(
|
||||
if_cond,
|
||||
job_env,
|
||||
step_exec_ctx.step_outputs,
|
||||
step_exec_ctx.matrix_combination,
|
||||
);
|
||||
if !should_run {
|
||||
wrkflw_logging::info(&format!(
|
||||
" {} Skipping step '{}' due to condition: {}",
|
||||
@@ -2910,8 +2915,9 @@ async fn execute_step(ctx: StepExecutionContext<'_>) -> Result<StepResult, Execu
|
||||
.await
|
||||
{
|
||||
Ok(container_output) => {
|
||||
// Add command details to output
|
||||
output.push_str(&format!("Command: {}\n\n", run));
|
||||
// Add command details to output (show resolved version so
|
||||
// users can see expression substitutions were applied)
|
||||
output.push_str(&format!("Command: {}\n\n", resolved_run));
|
||||
|
||||
if !container_output.stdout.is_empty() {
|
||||
output.push_str("Standard Output:\n");
|
||||
@@ -3972,88 +3978,48 @@ fn convert_yaml_to_step(step_yaml: &serde_yaml::Value) -> Result<workflow::Step,
|
||||
fn evaluate_job_condition(
|
||||
condition: &str,
|
||||
env_context: &HashMap<String, String>,
|
||||
workflow: &WorkflowDefinition,
|
||||
_workflow: &WorkflowDefinition,
|
||||
) -> bool {
|
||||
evaluate_condition_with_context(condition, env_context, &HashMap::new(), &None)
|
||||
}
|
||||
|
||||
/// Evaluate a job/step `if:` condition using the expression evaluator.
|
||||
///
|
||||
/// Accepts the full expression context (env, step outputs, matrix) for accurate
|
||||
/// resolution of context references and operators.
|
||||
fn evaluate_condition_with_context(
|
||||
condition: &str,
|
||||
env_context: &HashMap<String, String>,
|
||||
step_outputs: &HashMap<String, HashMap<String, String>>,
|
||||
matrix_combination: &Option<HashMap<String, Value>>,
|
||||
) -> bool {
|
||||
use crate::expression::{evaluate_as_bool, ExpressionContext};
|
||||
|
||||
wrkflw_logging::debug(&format!("Evaluating condition: {}", condition));
|
||||
|
||||
// Handle status functions and step references that we can't fully evaluate.
|
||||
// We default conservatively: only `always()` and `success()` resolve to true,
|
||||
// since those represent the common "run this step" intent. Bare `steps.*`
|
||||
// references (e.g. `steps.X.outcome == 'failure'`) default to false to avoid
|
||||
// running steps that depend on prior failure/output we can't evaluate.
|
||||
let has_always = condition.contains("always()");
|
||||
let has_success = condition.contains("success()");
|
||||
let has_failure = condition.contains("failure()");
|
||||
let has_cancelled = condition.contains("cancelled()");
|
||||
// Match "steps." only at word boundaries to avoid false positives on env var
|
||||
// names like "env.MY_STEPS_COUNT" or "env._STEPS_CHECK". We check for
|
||||
// start-of-string or a character that isn't alphanumeric/underscore before "steps.".
|
||||
let has_steps_ref = condition.match_indices("steps.").any(|(pos, _)| {
|
||||
pos == 0 || {
|
||||
let b = condition.as_bytes()[pos - 1];
|
||||
!b.is_ascii_alphanumeric() && b != b'_'
|
||||
let ctx = ExpressionContext {
|
||||
env_context,
|
||||
step_outputs,
|
||||
matrix_combination,
|
||||
};
|
||||
|
||||
match evaluate_as_bool(condition, &ctx) {
|
||||
Ok(result) => {
|
||||
wrkflw_logging::debug(&format!(
|
||||
"Condition '{}' evaluated to {}",
|
||||
condition, result
|
||||
));
|
||||
result
|
||||
}
|
||||
});
|
||||
let has_unsupported =
|
||||
has_always || has_success || has_failure || has_cancelled || has_steps_ref;
|
||||
|
||||
if has_unsupported {
|
||||
wrkflw_logging::warning(&format!(
|
||||
"Condition '{}' uses status functions/step references not fully supported in local execution",
|
||||
condition
|
||||
));
|
||||
|
||||
// In GitHub Actions, `always()` means "run this step regardless of job
|
||||
// status" — it is a *scheduling* directive, not a boolean `true` literal.
|
||||
// Similarly, `success()` means "run when all previous steps succeeded".
|
||||
// Since we can't evaluate actual job/step status locally, we treat
|
||||
// `always()` and `success()` as "likely to run" → true, and `failure()`
|
||||
// / `cancelled()` as "unlikely" → false.
|
||||
//
|
||||
// Known limitation: compound expressions like `always() && failure()` will
|
||||
// return true (because `always()` is present) even though a real evaluator
|
||||
// would AND the two. This is acceptable because we lack step-status context
|
||||
// and would rather over-run than silently skip steps.
|
||||
if has_always || has_success {
|
||||
return true;
|
||||
Err(e) => {
|
||||
wrkflw_logging::warning(&format!(
|
||||
"Failed to evaluate condition '{}': {} — defaulting to true",
|
||||
condition, e
|
||||
));
|
||||
// Default to true to avoid breaking workflows
|
||||
true
|
||||
}
|
||||
// Bare steps.* refs, failure(), cancelled() without positive counterpart → false
|
||||
return false;
|
||||
}
|
||||
|
||||
// For now, implement basic pattern matching for common conditions
|
||||
// TODO: Implement a full GitHub Actions expression evaluator
|
||||
|
||||
// Handle simple boolean conditions
|
||||
if condition == "true" {
|
||||
return true;
|
||||
}
|
||||
if condition == "false" {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle github.event.pull_request.draft == false
|
||||
if condition.contains("github.event.pull_request.draft == false") {
|
||||
// For local execution, assume this is always true (not a draft)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle needs.jobname.outputs.outputname == 'value' patterns
|
||||
if condition.contains("needs.") && condition.contains(".outputs.") {
|
||||
// For now, simulate that outputs are available but empty
|
||||
// This means conditions like needs.changes.outputs.source-code == 'true' will be false
|
||||
wrkflw_logging::debug(
|
||||
"Evaluating needs.outputs condition - defaulting to false for local execution",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default to true for unknown conditions to avoid breaking workflows
|
||||
wrkflw_logging::warning(&format!(
|
||||
"Unknown condition pattern: '{}' - defaulting to true",
|
||||
condition
|
||||
));
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -4425,15 +4391,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn condition_steps_reference_defaults_false() {
|
||||
fn condition_steps_reference_evaluates_with_default_outcome() {
|
||||
let env = HashMap::new();
|
||||
let wf = empty_workflow();
|
||||
// Bare step-level expressions default to false (conservative — we can't evaluate them)
|
||||
assert!(!evaluate_job_condition(
|
||||
// steps.build.outcome defaults to "success" in local execution since
|
||||
// we don't track actual step status — so == 'success' is true.
|
||||
assert!(evaluate_job_condition(
|
||||
"steps.build.outcome == 'success'",
|
||||
&env,
|
||||
&wf
|
||||
));
|
||||
// Checking for failure should be false
|
||||
assert!(!evaluate_job_condition(
|
||||
"steps.build.outcome == 'failure'",
|
||||
&env,
|
||||
&wf
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4485,37 +4458,37 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn condition_always_with_failure_defaults_true() {
|
||||
fn condition_always_and_failure_evaluates_correctly() {
|
||||
let env = HashMap::new();
|
||||
let wf = empty_workflow();
|
||||
// always() present → true regardless of other functions
|
||||
assert!(evaluate_job_condition("always() && failure()", &env, &wf));
|
||||
// always() → true, failure() → false, true && false → false
|
||||
// The expression evaluator correctly evaluates the compound expression
|
||||
assert!(!evaluate_job_condition("always() && failure()", &env, &wf));
|
||||
// always() alone → true
|
||||
assert!(evaluate_job_condition("always()", &env, &wf));
|
||||
// always() || failure() → true (|| returns first truthy)
|
||||
assert!(evaluate_job_condition("always() || failure()", &env, &wf));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn condition_env_var_containing_steps_not_treated_as_step_ref() {
|
||||
let env = HashMap::new();
|
||||
fn condition_env_context_evaluates_correctly() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("MY_STEPS_COUNT".to_string(), "5".to_string());
|
||||
env.insert("_STEPS_CHECK".to_string(), "ok".to_string());
|
||||
let wf = empty_workflow();
|
||||
// "env.MY_STEPS_COUNT" contains "steps." as a substring but should NOT
|
||||
// trigger the step-reference heuristic (which returns false). Instead it
|
||||
// falls through to the unknown-condition default (true).
|
||||
// A bare "steps.build.outcome" at the start SHOULD be caught.
|
||||
// env.MY_STEPS_COUNT resolves via the env context, not as a steps ref
|
||||
assert!(evaluate_job_condition(
|
||||
"env.MY_STEPS_COUNT == '5'",
|
||||
&env,
|
||||
&wf
|
||||
));
|
||||
// Underscore-prefixed names should also NOT be treated as step refs
|
||||
assert!(evaluate_job_condition(
|
||||
"env._STEPS_CHECK == 'ok'",
|
||||
&env,
|
||||
&wf
|
||||
));
|
||||
assert!(!evaluate_job_condition(
|
||||
"steps.build.outcome == 'success'",
|
||||
&env,
|
||||
&wf
|
||||
));
|
||||
// Missing env var → null, null != '5' → false
|
||||
assert!(!evaluate_job_condition("env.MISSING_VAR == '5'", &env, &wf));
|
||||
}
|
||||
|
||||
// --- volume path traversal tests ---
|
||||
|
||||
@@ -122,7 +122,11 @@ pub fn create_github_context(
|
||||
// Miscellaneous
|
||||
env.insert("GITHUB_RETENTION_DAYS".to_string(), "90".to_string());
|
||||
|
||||
// Path-related variables
|
||||
// Runner variables
|
||||
env.insert("RUNNER_OS".to_string(), get_runner_os());
|
||||
env.insert("RUNNER_ARCH".to_string(), get_runner_arch());
|
||||
env.insert("RUNNER_NAME".to_string(), "wrkflw-local".to_string());
|
||||
env.insert("RUNNER_ENVIRONMENT".to_string(), "local".to_string());
|
||||
env.insert("RUNNER_TEMP".to_string(), get_temp_dir());
|
||||
env.insert("RUNNER_TOOL_CACHE".to_string(), get_tool_cache_dir());
|
||||
|
||||
@@ -275,6 +279,23 @@ fn get_current_ref() -> String {
|
||||
"refs/heads/main".to_string()
|
||||
}
|
||||
|
||||
fn get_runner_os() -> String {
|
||||
match std::env::consts::OS {
|
||||
"macos" => "macOS".to_string(),
|
||||
"linux" => "Linux".to_string(),
|
||||
"windows" => "Windows".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_runner_arch() -> String {
|
||||
match std::env::consts::ARCH {
|
||||
"x86_64" | "x86" => "X64".to_string(),
|
||||
"aarch64" => "ARM64".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_temp_dir() -> String {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
temp_dir.join("wrkflw").to_string_lossy().to_string()
|
||||
|
||||
1185
crates/executor/src/expression.rs
Normal file
1185
crates/executor/src/expression.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ pub mod dependency;
|
||||
pub mod docker;
|
||||
pub mod engine;
|
||||
pub mod environment;
|
||||
pub mod expression;
|
||||
pub mod github_env_files;
|
||||
pub mod podman;
|
||||
pub mod substitution;
|
||||
|
||||
@@ -10,12 +10,10 @@ lazy_static! {
|
||||
Regex::new(r"\$\{\{\s*matrix\.([a-zA-Z0-9_]+)\s*\}\}").unwrap();
|
||||
static ref HASH_FILES_PATTERN: Regex =
|
||||
Regex::new(r"\$\{\{\s*hashFiles\(([^)]+)\)\s*\}\}").unwrap();
|
||||
static ref STEPS_OUTPUT_PATTERN: Regex = Regex::new(
|
||||
r"\$\{\{\s*steps\.([a-zA-Z_][a-zA-Z0-9_-]*)\.outputs\.([a-zA-Z_][a-zA-Z0-9_-]*)\s*\}\}"
|
||||
)
|
||||
.unwrap();
|
||||
static ref ENV_CONTEXT_PATTERN: Regex =
|
||||
Regex::new(r"\$\{\{\s*env\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}").unwrap();
|
||||
/// Matches any `${{ ... }}` expression. Handles single `}` inside format
|
||||
/// placeholders like `{0}` by requiring the closing `}}` pair.
|
||||
static ref EXPRESSION_PATTERN: Regex =
|
||||
Regex::new(r"\$\{\{(?:[^}]|\}[^}])*\}\}").unwrap();
|
||||
}
|
||||
|
||||
/// Preprocesses a command string to replace GitHub-style matrix variable references
|
||||
@@ -58,38 +56,6 @@ pub fn process_step_run(run: &str, matrix_combination: &Option<HashMap<String, V
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace `${{ steps.<id>.outputs.<key> }}` with the corresponding step output value.
|
||||
///
|
||||
/// Missing step IDs or output keys resolve to an empty string, matching GitHub Actions behavior.
|
||||
pub fn preprocess_step_outputs(
|
||||
text: &str,
|
||||
step_outputs: &HashMap<String, HashMap<String, String>>,
|
||||
) -> String {
|
||||
STEPS_OUTPUT_PATTERN
|
||||
.replace_all(text, |caps: ®ex::Captures| {
|
||||
let step_id = &caps[1];
|
||||
let output_key = &caps[2];
|
||||
step_outputs
|
||||
.get(step_id)
|
||||
.and_then(|m| m.get(output_key))
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// Replace `${{ env.<name> }}` with the value of the environment variable.
|
||||
///
|
||||
/// Missing variables resolve to an empty string, matching GitHub Actions behavior.
|
||||
pub fn preprocess_env_context(text: &str, env: &HashMap<String, String>) -> String {
|
||||
ENV_CONTEXT_PATTERN
|
||||
.replace_all(text, |caps: ®ex::Captures| {
|
||||
let var_name = &caps[1];
|
||||
env.get(var_name).cloned().unwrap_or_default()
|
||||
})
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// Replace `${{ hashFiles(...) }}` expressions with the SHA-256 hash of matched files.
|
||||
///
|
||||
/// Accepts one or more comma-separated, quoted glob patterns. Files are matched
|
||||
@@ -182,7 +148,15 @@ fn compute_hash_files(args_raw: &str, workspace: &Path) -> Result<String, String
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
/// Apply all expression substitutions: hashFiles, step outputs, env context, matrix variables.
|
||||
/// Apply all expression substitutions to a text string.
|
||||
///
|
||||
/// `hashFiles()` is resolved first (needs filesystem access), then all remaining
|
||||
/// `${{ ... }}` expressions are evaluated through the expression evaluator which
|
||||
/// handles env, inputs, github, runner, matrix, and steps contexts as well as
|
||||
/// operators and built-in functions.
|
||||
///
|
||||
/// Expressions that fail evaluation are replaced with empty string as a safety
|
||||
/// fallback, matching GitHub Actions behavior.
|
||||
///
|
||||
/// Returns `Err` if a `hashFiles()` expression fails (e.g. unreadable file).
|
||||
pub fn preprocess_expressions(
|
||||
@@ -192,22 +166,39 @@ pub fn preprocess_expressions(
|
||||
step_outputs: &HashMap<String, HashMap<String, String>>,
|
||||
env_context: &HashMap<String, String>,
|
||||
) -> Result<String, String> {
|
||||
// Resolve hashFiles first (needs filesystem access)
|
||||
use crate::expression::{evaluate, ExpressionContext};
|
||||
|
||||
// Resolve hashFiles first (needs filesystem access not available in the
|
||||
// expression evaluator)
|
||||
let result = preprocess_hash_files(text, workspace)?;
|
||||
// Then resolve step outputs and env context
|
||||
let result = preprocess_step_outputs(&result, step_outputs);
|
||||
let result = preprocess_env_context(&result, env_context);
|
||||
// Finally resolve matrix variables
|
||||
Ok(if let Some(matrix) = matrix_combination {
|
||||
preprocess_command(&result, matrix)
|
||||
} else {
|
||||
MATRIX_PATTERN
|
||||
.replace_all(&result, |caps: ®ex::Captures| {
|
||||
let var_name = &caps[1];
|
||||
format!("\\${{{{ matrix.{} }}}}", var_name)
|
||||
})
|
||||
.to_string()
|
||||
})
|
||||
|
||||
let ctx = ExpressionContext {
|
||||
env_context,
|
||||
step_outputs,
|
||||
matrix_combination,
|
||||
};
|
||||
|
||||
// Evaluate all remaining ${{ ... }} expressions through the expression evaluator
|
||||
let result = EXPRESSION_PATTERN
|
||||
.replace_all(&result, |caps: ®ex::Captures| {
|
||||
let full_match = &caps[0];
|
||||
// Extract the inner expression (strip "${{" and "}}")
|
||||
let inner = &full_match[3..full_match.len() - 2];
|
||||
match evaluate(inner, &ctx) {
|
||||
Ok(val) => val.to_output_string(),
|
||||
Err(e) => {
|
||||
wrkflw_logging::debug(&format!(
|
||||
"Expression evaluation failed for '{}': {} — substituting empty string",
|
||||
inner.trim(),
|
||||
e
|
||||
));
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
})
|
||||
.into_owned();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -390,56 +381,74 @@ mod tests {
|
||||
assert!(result.unwrap_err().contains("path traversal"));
|
||||
}
|
||||
|
||||
// -- step outputs via expression evaluator --
|
||||
|
||||
#[test]
|
||||
fn step_output_substitution() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut step_outputs = HashMap::new();
|
||||
let mut build_outputs = HashMap::new();
|
||||
build_outputs.insert("version".to_string(), "1.2.3".to_string());
|
||||
step_outputs.insert("build".to_string(), build_outputs);
|
||||
|
||||
let text = "Version is ${{ steps.build.outputs.version }}";
|
||||
let result = preprocess_step_outputs(text, &step_outputs);
|
||||
let result =
|
||||
preprocess_expressions(text, dir.path(), &None, &step_outputs, &HashMap::new())
|
||||
.unwrap();
|
||||
assert_eq!(result, "Version is 1.2.3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_output_missing_returns_empty() {
|
||||
let step_outputs = HashMap::new();
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
let text = "Value: ${{ steps.unknown.outputs.key }}";
|
||||
let result = preprocess_step_outputs(text, &step_outputs);
|
||||
let result =
|
||||
preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &HashMap::new())
|
||||
.unwrap();
|
||||
assert_eq!(result, "Value: ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_output_missing_key_returns_empty() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut step_outputs = HashMap::new();
|
||||
step_outputs.insert("build".to_string(), HashMap::new());
|
||||
|
||||
let text = "${{ steps.build.outputs.missing }}";
|
||||
let result = preprocess_step_outputs(text, &step_outputs);
|
||||
let result =
|
||||
preprocess_expressions(text, dir.path(), &None, &step_outputs, &HashMap::new())
|
||||
.unwrap();
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
// -- env context via expression evaluator --
|
||||
|
||||
#[test]
|
||||
fn env_context_substitution() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut env = HashMap::new();
|
||||
env.insert("MY_VAR".to_string(), "hello".to_string());
|
||||
|
||||
let text = "Value: ${{ env.MY_VAR }}";
|
||||
let result = preprocess_env_context(text, &env);
|
||||
let result =
|
||||
preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &env).unwrap();
|
||||
assert_eq!(result, "Value: hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_context_missing_returns_empty() {
|
||||
let env = HashMap::new();
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
let text = "${{ env.MISSING }}";
|
||||
let result = preprocess_env_context(text, &env);
|
||||
let result =
|
||||
preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &HashMap::new())
|
||||
.unwrap();
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
// -- combined contexts --
|
||||
|
||||
#[test]
|
||||
fn combined_substitutions() {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -461,4 +470,175 @@ mod tests {
|
||||
preprocess_expressions(text, dir.path(), &Some(matrix), &step_outputs, &env).unwrap();
|
||||
assert_eq!(result, "ubuntu-v1-true");
|
||||
}
|
||||
|
||||
// -- inputs context via expression evaluator --
|
||||
|
||||
#[test]
|
||||
fn inputs_context_substitution() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut env = HashMap::new();
|
||||
env.insert("INPUT_TOOLCHAIN".to_string(), "stable".to_string());
|
||||
env.insert("INPUT_COMPONENTS".to_string(), "rustfmt".to_string());
|
||||
|
||||
let text =
|
||||
"rustup toolchain install ${{ inputs.toolchain }} --component ${{ inputs.components }}";
|
||||
let result =
|
||||
preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &env).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
"rustup toolchain install stable --component rustfmt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inputs_context_hyphenated_name() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut env = HashMap::new();
|
||||
env.insert("INPUT_NODE_VERSION".to_string(), "18".to_string());
|
||||
|
||||
let text = "${{ inputs.node-version }}";
|
||||
let result =
|
||||
preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &env).unwrap();
|
||||
assert_eq!(result, "18");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inputs_context_missing_returns_empty() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
let text = "${{ inputs.missing }}";
|
||||
let result =
|
||||
preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &HashMap::new())
|
||||
.unwrap();
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
// -- github context via expression evaluator --
|
||||
|
||||
#[test]
|
||||
fn github_context_substitution() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut env = HashMap::new();
|
||||
env.insert("GITHUB_REPOSITORY".to_string(), "owner/repo".to_string());
|
||||
env.insert("GITHUB_REF_NAME".to_string(), "main".to_string());
|
||||
|
||||
let text = "${{ github.repository }}/${{ github.ref_name }}";
|
||||
let result =
|
||||
preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &env).unwrap();
|
||||
assert_eq!(result, "owner/repo/main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_context_missing_returns_empty() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
let text = "${{ github.token }}";
|
||||
let result =
|
||||
preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &HashMap::new())
|
||||
.unwrap();
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
// -- runner context via expression evaluator --
|
||||
|
||||
#[test]
|
||||
fn runner_context_substitution() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut env = HashMap::new();
|
||||
env.insert("RUNNER_OS".to_string(), "Linux".to_string());
|
||||
env.insert("RUNNER_TEMP".to_string(), "/tmp/runner".to_string());
|
||||
|
||||
let text = "${{ runner.os }} ${{ runner.temp }}";
|
||||
let result =
|
||||
preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &env).unwrap();
|
||||
assert_eq!(result, "Linux /tmp/runner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_context_missing_returns_empty() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
let text = "${{ runner.arch }}";
|
||||
let result =
|
||||
preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &HashMap::new())
|
||||
.unwrap();
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
// -- all contexts via preprocess_expressions --
|
||||
|
||||
#[test]
|
||||
fn preprocess_expressions_includes_inputs_and_github() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
let mut env = HashMap::new();
|
||||
env.insert("INPUT_TOOLCHAIN".to_string(), "nightly".to_string());
|
||||
env.insert("GITHUB_REPOSITORY".to_string(), "foo/bar".to_string());
|
||||
env.insert("RUNNER_OS".to_string(), "Linux".to_string());
|
||||
|
||||
let text = "${{ inputs.toolchain }}-${{ github.repository }}-${{ runner.os }}";
|
||||
let result =
|
||||
preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &env).unwrap();
|
||||
assert_eq!(result, "nightly-foo/bar-Linux");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preprocess_expressions_unknown_context_returns_empty() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
let text = "echo ${{ unknown_context.value }}";
|
||||
let result =
|
||||
preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &HashMap::new())
|
||||
.unwrap();
|
||||
assert_eq!(result, "echo ");
|
||||
}
|
||||
|
||||
// -- complex expressions --
|
||||
|
||||
#[test]
|
||||
fn preprocess_expressions_evaluates_complex_expression() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut env = HashMap::new();
|
||||
env.insert("INPUT_COMPONENTS".to_string(), "rustfmt".to_string());
|
||||
|
||||
let mut step_outputs = HashMap::new();
|
||||
let mut parse_out = HashMap::new();
|
||||
parse_out.insert("toolchain".to_string(), "nightly".to_string());
|
||||
step_outputs.insert("parse".to_string(), parse_out);
|
||||
|
||||
// This is the dtolnay/rust-toolchain pattern that triggered the original bug
|
||||
let text = "rustup toolchain install nightly${{ steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || '' }}";
|
||||
let result = preprocess_expressions(text, dir.path(), &None, &step_outputs, &env).unwrap();
|
||||
assert_eq!(result, "rustup toolchain install nightly --allow-downgrade");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preprocess_expressions_evaluates_comparison_to_empty() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut env = HashMap::new();
|
||||
env.insert("INPUT_COMPONENTS".to_string(), "rustfmt".to_string());
|
||||
|
||||
let mut step_outputs = HashMap::new();
|
||||
let mut parse_out = HashMap::new();
|
||||
parse_out.insert("toolchain".to_string(), "stable".to_string());
|
||||
step_outputs.insert("parse".to_string(), parse_out);
|
||||
|
||||
let text = "rustup toolchain install stable${{ steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || '' }}";
|
||||
let result = preprocess_expressions(text, dir.path(), &None, &step_outputs, &env).unwrap();
|
||||
// stable != nightly, so the expression evaluates to ''
|
||||
assert_eq!(result, "rustup toolchain install stable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preprocess_expressions_no_spaces() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut env = HashMap::new();
|
||||
env.insert("RUNNER_OS".to_string(), "Linux".to_string());
|
||||
|
||||
// dtolnay/rust-toolchain uses ${{runner.os}} without spaces
|
||||
let text = "if [[ ${{runner.os}} == macOS ]]; then echo mac; fi";
|
||||
let result =
|
||||
preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &env).unwrap();
|
||||
assert_eq!(result, "if [[ Linux == macOS ]]; then echo mac; fi");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user