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
This commit is contained in:
bahdotsh
2026-04-03 12:22:55 +05:30
parent f650f5533c
commit 2954b05bb2
5 changed files with 1539 additions and 102 deletions

View File

@@ -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 ---

View File

@@ -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()

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -16,6 +16,17 @@ lazy_static! {
.unwrap();
static ref ENV_CONTEXT_PATTERN: Regex =
Regex::new(r"\$\{\{\s*env\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}").unwrap();
static ref INPUTS_PATTERN: Regex =
Regex::new(r"\$\{\{\s*inputs\.([a-zA-Z_][a-zA-Z0-9_-]*)\s*\}\}").unwrap();
static ref GITHUB_CONTEXT_PATTERN: Regex =
Regex::new(r"\$\{\{\s*github\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}").unwrap();
static ref RUNNER_PATTERN: Regex =
Regex::new(r"\$\{\{\s*runner\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}").unwrap();
/// Catch-all for any remaining `${{ ... }}` expressions that weren't handled
/// by specific patterns. Replaces with empty string to match GitHub Actions
/// behavior and prevent bash "bad substitution" errors.
static ref EXPRESSION_FALLBACK: Regex =
Regex::new(r"\$\{\{[^}]*\}\}").unwrap();
}
/// Preprocesses a command string to replace GitHub-style matrix variable references
@@ -90,6 +101,56 @@ pub fn preprocess_env_context(text: &str, env: &HashMap<String, String>) -> Stri
.into_owned()
}
/// Replace `${{ inputs.<name> }}` with the value from INPUT_* environment variables.
///
/// Composite actions convert `with:` parameters to `INPUT_*` env vars. This resolves
/// the expression-syntax counterpart. Missing inputs resolve to empty string.
pub fn preprocess_inputs_context(text: &str, env: &HashMap<String, String>) -> String {
INPUTS_PATTERN
.replace_all(text, |caps: &regex::Captures| {
let input_name = &caps[1];
// GitHub Actions converts input names: uppercase, hyphens → underscores
let env_key = format!("INPUT_{}", input_name.to_uppercase().replace('-', "_"));
env.get(&env_key).cloned().unwrap_or_default()
})
.into_owned()
}
/// Replace `${{ github.<name> }}` with the corresponding GITHUB_* environment variable.
///
/// Missing variables resolve to empty string, matching GitHub Actions behavior.
pub fn preprocess_github_context(text: &str, env: &HashMap<String, String>) -> String {
GITHUB_CONTEXT_PATTERN
.replace_all(text, |caps: &regex::Captures| {
let var_name = &caps[1];
let env_key = format!("GITHUB_{}", var_name.to_uppercase());
env.get(&env_key).cloned().unwrap_or_default()
})
.into_owned()
}
/// Replace `${{ runner.<name> }}` with the corresponding RUNNER_* environment variable.
///
/// Missing variables resolve to empty string, matching GitHub Actions behavior.
pub fn preprocess_runner_context(text: &str, env: &HashMap<String, String>) -> String {
RUNNER_PATTERN
.replace_all(text, |caps: &regex::Captures| {
let var_name = &caps[1];
let env_key = format!("RUNNER_{}", var_name.to_uppercase());
env.get(&env_key).cloned().unwrap_or_default()
})
.into_owned()
}
/// Replace any remaining `${{ ... }}` expressions with empty string.
///
/// This is a safety net that runs after all specific substitutions. It prevents
/// unresolved expressions from reaching bash (which interprets `${{` as brace
/// expansion and fails with "bad substitution").
pub fn preprocess_fallback(text: &str) -> String {
EXPRESSION_FALLBACK.replace_all(text, "").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 +243,13 @@ 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: hashFiles, step outputs, env/inputs/github/runner
/// context, matrix variables, and expression evaluation for complex expressions.
///
/// Simple patterns (e.g. `${{ env.FOO }}`) are resolved via fast regex substitution.
/// Complex expressions (e.g. `${{ a == 'b' && c || '' }}`) are evaluated using the
/// expression evaluator. Any expressions that fail evaluation are replaced with empty
/// string as a safety fallback.
///
/// Returns `Err` if a `hashFiles()` expression fails (e.g. unreadable file).
pub fn preprocess_expressions(
@@ -197,8 +264,12 @@ pub fn preprocess_expressions(
// 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 {
// Resolve inputs, github, and runner contexts
let result = preprocess_inputs_context(&result, env_context);
let result = preprocess_github_context(&result, env_context);
let result = preprocess_runner_context(&result, env_context);
// Resolve matrix variables
let result = if let Some(matrix) = matrix_combination {
preprocess_command(&result, matrix)
} else {
MATRIX_PATTERN
@@ -207,7 +278,44 @@ pub fn preprocess_expressions(
format!("\\${{{{ matrix.{} }}}}", var_name)
})
.to_string()
})
};
// Evaluate any remaining ${{ ... }} expressions using the expression evaluator
Ok(evaluate_remaining_expressions(
&result,
env_context,
step_outputs,
matrix_combination,
))
}
/// Find remaining `${{ ... }}` expressions and evaluate them.
///
/// Falls back to empty string if evaluation fails, matching GitHub Actions behavior.
fn evaluate_remaining_expressions(
text: &str,
env_context: &HashMap<String, String>,
step_outputs: &HashMap<String, HashMap<String, String>>,
matrix_combination: &Option<HashMap<String, Value>>,
) -> String {
use crate::expression::{evaluate, ExpressionContext};
let ctx = ExpressionContext {
env_context,
step_outputs,
matrix_combination,
};
EXPRESSION_FALLBACK
.replace_all(text, |caps: &regex::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(_) => String::new(), // fallback to empty on error
}
})
.into_owned()
}
#[cfg(test)]
@@ -461,4 +569,183 @@ mod tests {
preprocess_expressions(text, dir.path(), &Some(matrix), &step_outputs, &env).unwrap();
assert_eq!(result, "ubuntu-v1-true");
}
#[test]
fn inputs_context_substitution() {
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_inputs_context(text, &env);
assert_eq!(
result,
"rustup toolchain install stable --component rustfmt"
);
}
#[test]
fn inputs_context_hyphenated_name() {
let mut env = HashMap::new();
env.insert("INPUT_NODE_VERSION".to_string(), "18".to_string());
let text = "${{ inputs.node-version }}";
let result = preprocess_inputs_context(text, &env);
assert_eq!(result, "18");
}
#[test]
fn inputs_context_missing_returns_empty() {
let env = HashMap::new();
let text = "${{ inputs.missing }}";
let result = preprocess_inputs_context(text, &env);
assert_eq!(result, "");
}
#[test]
fn github_context_substitution() {
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_github_context(text, &env);
assert_eq!(result, "owner/repo/main");
}
#[test]
fn github_context_missing_returns_empty() {
let env = HashMap::new();
let text = "${{ github.token }}";
let result = preprocess_github_context(text, &env);
assert_eq!(result, "");
}
#[test]
fn runner_context_substitution() {
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_runner_context(text, &env);
assert_eq!(result, "Linux /tmp/runner");
}
#[test]
fn runner_context_missing_returns_empty() {
let env = HashMap::new();
let text = "${{ runner.arch }}";
let result = preprocess_runner_context(text, &env);
assert_eq!(result, "");
}
#[test]
fn fallback_removes_unresolved_expressions() {
let text = "before ${{ some.unknown.expression }} after";
let result = preprocess_fallback(text);
assert_eq!(result, "before after");
}
#[test]
fn fallback_removes_complex_expressions() {
let text =
"cmd${{ steps.parse.outputs.toolchain == 'nightly' && ' --allow-downgrade' || '' }}end";
let result = preprocess_fallback(text);
assert_eq!(result, "cmdend");
}
#[test]
fn fallback_preserves_resolved_text() {
let text = "no expressions here, just ${SHELL_VAR}";
let result = preprocess_fallback(text);
assert_eq!(result, "no expressions here, just ${SHELL_VAR}");
}
#[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_fallback_cleans_remaining() {
let dir = tempdir().unwrap();
let env = HashMap::new();
let text = "echo ${{ unknown_context.value }}";
let result =
preprocess_expressions(text, dir.path(), &None, &HashMap::new(), &env).unwrap();
assert_eq!(result, "echo ");
}
#[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 runner_context_no_spaces() {
let mut env = HashMap::new();
env.insert("RUNNER_OS".to_string(), "macOS".to_string());
// dtolnay/rust-toolchain uses ${{runner.os}} without spaces
let text = "if [[ ${{runner.os}} == macOS ]]; then";
let result = preprocess_runner_context(text, &env);
assert_eq!(result, "if [[ macOS == macOS ]]; then");
}
#[test]
fn preprocess_expressions_no_spaces_runner() {
let dir = tempdir().unwrap();
let mut env = HashMap::new();
env.insert("RUNNER_OS".to_string(), "Linux".to_string());
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");
}
}