mirror of
https://github.com/bahdotsh/wrkflw.git
synced 2026-05-18 05:05:35 +02:00
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:
@@ -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()
|
||||
|
||||
1155
crates/executor/src/expression.rs
Normal file
1155
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;
|
||||
|
||||
@@ -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: ®ex::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: ®ex::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: ®ex::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: ®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(_) => 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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user