1#[allow(unused_imports)]
2use bollard::Docker;
3use futures::future;
4use regex;
5use serde_yaml::Value;
6use std::collections::HashMap;
7use std::fs;
8use std::path::Path;
9use std::process::Command;
10use thiserror::Error;
11
12use crate::dependency;
13use crate::docker;
14use crate::environment;
15use crate::podman;
16use wrkflw_logging;
17use wrkflw_matrix::MatrixCombination;
18use wrkflw_models::gitlab::Pipeline;
19use wrkflw_parser::gitlab::{self, parse_pipeline};
20use wrkflw_parser::workflow::{self, parse_workflow, ActionInfo, Job, WorkflowDefinition};
21use wrkflw_runtime::container::ContainerRuntime;
22use wrkflw_runtime::emulation;
23
24#[allow(unused_variables, unused_assignments)]
25pub async fn execute_workflow(
27 workflow_path: &Path,
28 config: ExecutionConfig,
29) -> Result<ExecutionResult, ExecutionError> {
30 wrkflw_logging::info(&format!("Executing workflow: {}", workflow_path.display()));
31 wrkflw_logging::info(&format!("Runtime: {:?}", config.runtime_type));
32
33 let is_gitlab = is_gitlab_pipeline(workflow_path);
35
36 if is_gitlab {
37 execute_gitlab_pipeline(workflow_path, config.clone()).await
38 } else {
39 execute_github_workflow(workflow_path, config.clone()).await
40 }
41}
42
43fn is_gitlab_pipeline(path: &Path) -> bool {
45 if let Some(file_name) = path.file_name() {
47 if let Some(file_name_str) = file_name.to_str() {
48 return file_name_str == ".gitlab-ci.yml" || file_name_str.ends_with("gitlab-ci.yml");
49 }
50 }
51
52 if let Ok(content) = fs::read_to_string(path) {
54 if content.contains("stages:")
56 || content.contains("before_script:")
57 || content.contains("after_script:")
58 {
59 if !content.contains("on:")
61 && !content.contains("runs-on:")
62 && !content.contains("uses:")
63 {
64 return true;
65 }
66 }
67 }
68
69 false
70}
71
72async fn execute_github_workflow(
74 workflow_path: &Path,
75 config: ExecutionConfig,
76) -> Result<ExecutionResult, ExecutionError> {
77 let workflow = parse_workflow(workflow_path)?;
79
80 let execution_plan = dependency::resolve_dependencies(&workflow)?;
82
83 let runtime = initialize_runtime(
85 config.runtime_type.clone(),
86 config.preserve_containers_on_failure,
87 )?;
88
89 let workspace_dir = tempfile::tempdir()
91 .map_err(|e| ExecutionError::Execution(format!("Failed to create workspace: {}", e)))?;
92
93 let mut env_context = environment::create_github_context(&workflow, workspace_dir.path());
95
96 env_context.insert(
98 "WRKFLW_RUNTIME_MODE".to_string(),
99 match config.runtime_type {
100 RuntimeType::Emulation => "emulation".to_string(),
101 RuntimeType::Docker => "docker".to_string(),
102 RuntimeType::Podman => "podman".to_string(),
103 },
104 );
105
106 env_context.insert(
108 "WRKFLW_HIDE_ACTION_MESSAGES".to_string(),
109 "true".to_string(),
110 );
111
112 environment::setup_github_environment_files(workspace_dir.path()).map_err(|e| {
114 ExecutionError::Execution(format!("Failed to setup GitHub env files: {}", e))
115 })?;
116
117 let mut results = Vec::new();
119 let mut has_failures = false;
120 let mut failure_details = String::new();
121
122 for job_batch in execution_plan {
123 let job_results = execute_job_batch(
125 &job_batch,
126 &workflow,
127 runtime.as_ref(),
128 &env_context,
129 config.verbose,
130 )
131 .await?;
132
133 for job_result in &job_results {
135 if job_result.status == JobStatus::Failure {
136 has_failures = true;
137 failure_details.push_str(&format!("\nā Job failed: {}\n", job_result.name));
138
139 for step in &job_result.steps {
141 if step.status == StepStatus::Failure {
142 failure_details.push_str(&format!(" ā {}: {}\n", step.name, step.output));
143 }
144 }
145 }
146 }
147
148 results.extend(job_results);
149 }
150
151 if has_failures {
153 wrkflw_logging::error(&format!("Workflow execution failed:{}", failure_details));
154 }
155
156 Ok(ExecutionResult {
157 jobs: results,
158 failure_details: if has_failures {
159 Some(failure_details)
160 } else {
161 None
162 },
163 })
164}
165
166async fn execute_gitlab_pipeline(
168 pipeline_path: &Path,
169 config: ExecutionConfig,
170) -> Result<ExecutionResult, ExecutionError> {
171 wrkflw_logging::info("Executing GitLab CI/CD pipeline");
172
173 let pipeline = parse_pipeline(pipeline_path)
175 .map_err(|e| ExecutionError::Parse(format!("Failed to parse GitLab pipeline: {}", e)))?;
176
177 let workflow = gitlab::convert_to_workflow_format(&pipeline);
179
180 let execution_plan = resolve_gitlab_dependencies(&pipeline, &workflow)?;
182
183 let runtime = initialize_runtime(
185 config.runtime_type.clone(),
186 config.preserve_containers_on_failure,
187 )?;
188
189 let workspace_dir = tempfile::tempdir()
191 .map_err(|e| ExecutionError::Execution(format!("Failed to create workspace: {}", e)))?;
192
193 let mut env_context = create_gitlab_context(&pipeline, workspace_dir.path());
195
196 env_context.insert(
198 "WRKFLW_RUNTIME_MODE".to_string(),
199 match config.runtime_type {
200 RuntimeType::Emulation => "emulation".to_string(),
201 RuntimeType::Docker => "docker".to_string(),
202 RuntimeType::Podman => "podman".to_string(),
203 },
204 );
205
206 environment::setup_github_environment_files(workspace_dir.path()).map_err(|e| {
208 ExecutionError::Execution(format!("Failed to setup environment files: {}", e))
209 })?;
210
211 let mut results = Vec::new();
213 let mut has_failures = false;
214 let mut failure_details = String::new();
215
216 for job_batch in execution_plan {
217 let job_results = execute_job_batch(
219 &job_batch,
220 &workflow,
221 runtime.as_ref(),
222 &env_context,
223 config.verbose,
224 )
225 .await?;
226
227 for job_result in &job_results {
229 if job_result.status == JobStatus::Failure {
230 has_failures = true;
231 failure_details.push_str(&format!("\nā Job failed: {}\n", job_result.name));
232
233 for step in &job_result.steps {
235 if step.status == StepStatus::Failure {
236 failure_details.push_str(&format!(" ā {}: {}\n", step.name, step.output));
237 }
238 }
239 }
240 }
241
242 results.extend(job_results);
243 }
244
245 if has_failures {
247 wrkflw_logging::error(&format!("Pipeline execution failed:{}", failure_details));
248 }
249
250 Ok(ExecutionResult {
251 jobs: results,
252 failure_details: if has_failures {
253 Some(failure_details)
254 } else {
255 None
256 },
257 })
258}
259
260fn create_gitlab_context(pipeline: &Pipeline, workspace_dir: &Path) -> HashMap<String, String> {
262 let mut env_context = HashMap::new();
263
264 env_context.insert("CI".to_string(), "true".to_string());
266 env_context.insert("GITLAB_CI".to_string(), "true".to_string());
267
268 env_context.insert("WRKFLW_CI".to_string(), "true".to_string());
270
271 env_context.insert(
273 "CI_PROJECT_DIR".to_string(),
274 workspace_dir.to_string_lossy().to_string(),
275 );
276
277 env_context.insert(
279 "GITHUB_WORKSPACE".to_string(),
280 workspace_dir.to_string_lossy().to_string(),
281 );
282
283 if let Some(variables) = &pipeline.variables {
285 for (key, value) in variables {
286 env_context.insert(key.clone(), value.clone());
287 }
288 }
289
290 env_context
291}
292
293fn resolve_gitlab_dependencies(
295 pipeline: &Pipeline,
296 workflow: &WorkflowDefinition,
297) -> Result<Vec<Vec<String>>, ExecutionError> {
298 let stages = match &pipeline.stages {
303 Some(defined_stages) => defined_stages.clone(),
304 None => vec![
305 "build".to_string(),
306 "test".to_string(),
307 "deploy".to_string(),
308 ],
309 };
310
311 let mut execution_plan = Vec::new();
313
314 for stage in stages {
316 let mut stage_jobs = Vec::new();
317
318 for (job_name, job) in &pipeline.jobs {
319 if let Some(true) = job.template {
321 continue;
322 }
323
324 let default_stage = "test".to_string();
326 let job_stage = job.stage.as_ref().unwrap_or(&default_stage);
327
328 if job_stage == &stage {
330 stage_jobs.push(job_name.clone());
331 }
332 }
333
334 if !stage_jobs.is_empty() {
335 execution_plan.push(stage_jobs);
336 }
337 }
338
339 let mut stageless_jobs = Vec::new();
341
342 for (job_name, job) in &pipeline.jobs {
343 if let Some(true) = job.template {
345 continue;
346 }
347
348 if job.stage.is_none() {
349 stageless_jobs.push(job_name.clone());
350 }
351 }
352
353 if !stageless_jobs.is_empty() {
354 execution_plan.push(stageless_jobs);
355 }
356
357 Ok(execution_plan)
358}
359
360fn initialize_runtime(
362 runtime_type: RuntimeType,
363 preserve_containers_on_failure: bool,
364) -> Result<Box<dyn ContainerRuntime>, ExecutionError> {
365 match runtime_type {
366 RuntimeType::Docker => {
367 if docker::is_available() {
368 match docker::DockerRuntime::new_with_config(preserve_containers_on_failure) {
370 Ok(docker_runtime) => Ok(Box::new(docker_runtime)),
371 Err(e) => {
372 wrkflw_logging::error(&format!(
373 "Failed to initialize Docker runtime: {}, falling back to emulation mode",
374 e
375 ));
376 Ok(Box::new(emulation::EmulationRuntime::new()))
377 }
378 }
379 } else {
380 wrkflw_logging::error("Docker not available, falling back to emulation mode");
381 Ok(Box::new(emulation::EmulationRuntime::new()))
382 }
383 }
384 RuntimeType::Podman => {
385 if podman::is_available() {
386 match podman::PodmanRuntime::new_with_config(preserve_containers_on_failure) {
388 Ok(podman_runtime) => Ok(Box::new(podman_runtime)),
389 Err(e) => {
390 wrkflw_logging::error(&format!(
391 "Failed to initialize Podman runtime: {}, falling back to emulation mode",
392 e
393 ));
394 Ok(Box::new(emulation::EmulationRuntime::new()))
395 }
396 }
397 } else {
398 wrkflw_logging::error("Podman not available, falling back to emulation mode");
399 Ok(Box::new(emulation::EmulationRuntime::new()))
400 }
401 }
402 RuntimeType::Emulation => Ok(Box::new(emulation::EmulationRuntime::new())),
403 }
404}
405
406#[derive(Debug, Clone, PartialEq)]
407pub enum RuntimeType {
408 Docker,
409 Podman,
410 Emulation,
411}
412
413#[derive(Debug, Clone)]
414pub struct ExecutionConfig {
415 pub runtime_type: RuntimeType,
416 pub verbose: bool,
417 pub preserve_containers_on_failure: bool,
418}
419
420pub struct ExecutionResult {
421 pub jobs: Vec<JobResult>,
422 pub failure_details: Option<String>,
423}
424
425pub struct JobResult {
426 pub name: String,
427 pub status: JobStatus,
428 pub steps: Vec<StepResult>,
429 pub logs: String,
430}
431
432#[derive(Debug, Clone, PartialEq)]
433#[allow(dead_code)]
434pub enum JobStatus {
435 Success,
436 Failure,
437 Skipped,
438}
439
440#[derive(Debug, Clone)]
441pub struct StepResult {
442 pub name: String,
443 pub status: StepStatus,
444 pub output: String,
445}
446
447#[derive(Debug, Clone, PartialEq)]
448#[allow(dead_code)]
449pub enum StepStatus {
450 Success,
451 Failure,
452 Skipped,
453}
454
455#[derive(Error, Debug)]
456pub enum ExecutionError {
457 #[error("Parse error: {0}")]
458 Parse(String),
459
460 #[error("Runtime error: {0}")]
461 Runtime(String),
462
463 #[error("Execution error: {0}")]
464 Execution(String),
465
466 #[error("IO error: {0}")]
467 Io(#[from] std::io::Error),
468}
469
470impl From<String> for ExecutionError {
472 fn from(err: String) -> Self {
473 ExecutionError::Parse(err)
474 }
475}
476
477async fn prepare_action(
479 action: &ActionInfo,
480 runtime: &dyn ContainerRuntime,
481) -> Result<String, ExecutionError> {
482 if action.is_docker {
483 let image = action.repository.trim_start_matches("docker://");
485
486 runtime
487 .pull_image(image)
488 .await
489 .map_err(|e| ExecutionError::Runtime(format!("Failed to pull Docker image: {}", e)))?;
490
491 return Ok(image.to_string());
492 }
493
494 if action.is_local {
495 let action_dir = Path::new(&action.repository);
497
498 if !action_dir.exists() {
499 return Err(ExecutionError::Execution(format!(
500 "Local action directory not found: {}",
501 action_dir.display()
502 )));
503 }
504
505 let dockerfile = action_dir.join("Dockerfile");
506 if dockerfile.exists() {
507 let tag = format!("wrkflw-local-action:{}", uuid::Uuid::new_v4());
509
510 runtime
511 .build_image(&dockerfile, &tag)
512 .await
513 .map_err(|e| ExecutionError::Runtime(format!("Failed to build image: {}", e)))?;
514
515 return Ok(tag);
516 } else {
517 return Ok("node:16-buster-slim".to_string());
520 }
521 }
522
523 Ok("node:16-buster-slim".to_string())
526}
527
528async fn execute_job_batch(
529 jobs: &[String],
530 workflow: &WorkflowDefinition,
531 runtime: &dyn ContainerRuntime,
532 env_context: &HashMap<String, String>,
533 verbose: bool,
534) -> Result<Vec<JobResult>, ExecutionError> {
535 let futures = jobs
537 .iter()
538 .map(|job_name| execute_job_with_matrix(job_name, workflow, runtime, env_context, verbose));
539
540 let result_arrays = future::join_all(futures).await;
541
542 let mut results = Vec::new();
544 for result_array in result_arrays {
545 match result_array {
546 Ok(job_results) => results.extend(job_results),
547 Err(e) => return Err(e),
548 }
549 }
550
551 Ok(results)
552}
553
554struct JobExecutionContext<'a> {
556 job_name: &'a str,
557 workflow: &'a WorkflowDefinition,
558 runtime: &'a dyn ContainerRuntime,
559 env_context: &'a HashMap<String, String>,
560 verbose: bool,
561}
562
563async fn execute_job_with_matrix(
565 job_name: &str,
566 workflow: &WorkflowDefinition,
567 runtime: &dyn ContainerRuntime,
568 env_context: &HashMap<String, String>,
569 verbose: bool,
570) -> Result<Vec<JobResult>, ExecutionError> {
571 let job = workflow.jobs.get(job_name).ok_or_else(|| {
573 ExecutionError::Execution(format!("Job '{}' not found in workflow", job_name))
574 })?;
575
576 if let Some(if_condition) = &job.if_condition {
578 let should_run = evaluate_job_condition(if_condition, env_context, workflow);
579 if !should_run {
580 wrkflw_logging::info(&format!(
581 "āļø Skipping job '{}' due to condition: {}",
582 job_name, if_condition
583 ));
584 return Ok(vec![JobResult {
586 name: job_name.to_string(),
587 status: JobStatus::Skipped,
588 steps: Vec::new(),
589 logs: String::new(),
590 }]);
591 }
592 }
593
594 if let Some(matrix_config) = &job.matrix {
596 let combinations = wrkflw_matrix::expand_matrix(matrix_config)
598 .map_err(|e| ExecutionError::Execution(format!("Failed to expand matrix: {}", e)))?;
599
600 if combinations.is_empty() {
601 wrkflw_logging::info(&format!(
602 "Matrix job '{}' has no valid combinations",
603 job_name
604 ));
605 return Ok(Vec::new());
607 }
608
609 wrkflw_logging::info(&format!(
610 "Matrix job '{}' expanded to {} combinations",
611 job_name,
612 combinations.len()
613 ));
614
615 let max_parallel = matrix_config.max_parallel.unwrap_or_else(|| {
617 std::cmp::max(1, num_cpus::get())
619 });
620
621 execute_matrix_combinations(MatrixExecutionContext {
623 job_name,
624 job_template: job,
625 combinations: &combinations,
626 max_parallel,
627 fail_fast: matrix_config.fail_fast.unwrap_or(true),
628 workflow,
629 runtime,
630 env_context,
631 verbose,
632 })
633 .await
634 } else {
635 let ctx = JobExecutionContext {
637 job_name,
638 workflow,
639 runtime,
640 env_context,
641 verbose,
642 };
643 let result = execute_job(ctx).await?;
644 Ok(vec![result])
645 }
646}
647
648#[allow(unused_variables, unused_assignments)]
649async fn execute_job(ctx: JobExecutionContext<'_>) -> Result<JobResult, ExecutionError> {
650 let job = ctx.workflow.jobs.get(ctx.job_name).ok_or_else(|| {
652 ExecutionError::Execution(format!("Job '{}' not found in workflow", ctx.job_name))
653 })?;
654
655 let mut job_env = ctx.env_context.clone();
657
658 for (key, value) in &job.env {
660 job_env.insert(key.clone(), value.clone());
661 }
662
663 let mut step_results = Vec::new();
665 let mut job_logs = String::new();
666
667 let job_dir = tempfile::tempdir()
669 .map_err(|e| ExecutionError::Execution(format!("Failed to create job directory: {}", e)))?;
670
671 let current_dir = std::env::current_dir().map_err(|e| {
673 ExecutionError::Execution(format!("Failed to get current directory: {}", e))
674 })?;
675
676 wrkflw_logging::info(&format!(
678 "Copying project files to job workspace: {}",
679 job_dir.path().display()
680 ));
681 copy_directory_contents(¤t_dir, job_dir.path())?;
682
683 wrkflw_logging::info(&format!("Executing job: {}", ctx.job_name));
684
685 let mut job_success = true;
686
687 for (idx, step) in job.steps.iter().enumerate() {
689 let step_result = execute_step(StepExecutionContext {
690 step,
691 step_idx: idx,
692 job_env: &job_env,
693 working_dir: job_dir.path(),
694 runtime: ctx.runtime,
695 workflow: ctx.workflow,
696 runner_image: &get_runner_image(&job.runs_on),
697 verbose: ctx.verbose,
698 matrix_combination: &None,
699 })
700 .await;
701
702 match step_result {
703 Ok(result) => {
704 if result.status == StepStatus::Failure {
706 job_success = false;
707 }
708
709 if ctx.verbose || result.status == StepStatus::Failure {
711 job_logs.push_str(&format!(
712 "\n=== Output from step '{}' ===\n{}\n=== End output ===\n\n",
713 result.name, result.output
714 ));
715 } else {
716 job_logs.push_str(&format!(
718 "Step '{}' completed with status: {:?}\n",
719 result.name, result.status
720 ));
721 }
722
723 step_results.push(result);
724 }
725 Err(e) => {
726 job_success = false;
727 job_logs.push_str(&format!("\n=== ERROR in step {} ===\n{}\n", idx + 1, e));
728
729 step_results.push(StepResult {
731 name: step
732 .name
733 .clone()
734 .unwrap_or_else(|| format!("Step {}", idx + 1)),
735 status: StepStatus::Failure,
736 output: format!("Error: {}", e),
737 });
738
739 break;
741 }
742 }
743 }
744
745 Ok(JobResult {
746 name: ctx.job_name.to_string(),
747 status: if job_success {
748 JobStatus::Success
749 } else {
750 JobStatus::Failure
751 },
752 steps: step_results,
753 logs: job_logs,
754 })
755}
756
757struct MatrixExecutionContext<'a> {
759 job_name: &'a str,
760 job_template: &'a Job,
761 combinations: &'a [MatrixCombination],
762 max_parallel: usize,
763 fail_fast: bool,
764 workflow: &'a WorkflowDefinition,
765 runtime: &'a dyn ContainerRuntime,
766 env_context: &'a HashMap<String, String>,
767 verbose: bool,
768}
769
770async fn execute_matrix_combinations(
772 ctx: MatrixExecutionContext<'_>,
773) -> Result<Vec<JobResult>, ExecutionError> {
774 let mut results = Vec::new();
775 let mut any_failed = false;
776
777 for chunk in ctx.combinations.chunks(ctx.max_parallel) {
779 if ctx.fail_fast && any_failed {
781 for combination in chunk {
783 let combination_name =
784 wrkflw_matrix::format_combination_name(ctx.job_name, combination);
785 results.push(JobResult {
786 name: combination_name,
787 status: JobStatus::Skipped,
788 steps: Vec::new(),
789 logs: "Job skipped due to previous matrix job failure".to_string(),
790 });
791 }
792 continue;
793 }
794
795 let chunk_futures = chunk.iter().map(|combination| {
797 execute_matrix_job(
798 ctx.job_name,
799 ctx.job_template,
800 combination,
801 ctx.workflow,
802 ctx.runtime,
803 ctx.env_context,
804 ctx.verbose,
805 )
806 });
807
808 let chunk_results = future::join_all(chunk_futures).await;
809
810 for result in chunk_results {
812 match result {
813 Ok(job_result) => {
814 if job_result.status == JobStatus::Failure {
815 any_failed = true;
816 }
817 results.push(job_result);
818 }
819 Err(e) => {
820 any_failed = true;
822 wrkflw_logging::error(&format!("Matrix job failed: {}", e));
823
824 if ctx.fail_fast {
825 return Err(e);
826 }
827 }
828 }
829 }
830 }
831
832 Ok(results)
833}
834
835async fn execute_matrix_job(
837 job_name: &str,
838 job_template: &Job,
839 combination: &MatrixCombination,
840 workflow: &WorkflowDefinition,
841 runtime: &dyn ContainerRuntime,
842 base_env_context: &HashMap<String, String>,
843 verbose: bool,
844) -> Result<JobResult, ExecutionError> {
845 let matrix_job_name = wrkflw_matrix::format_combination_name(job_name, combination);
847
848 wrkflw_logging::info(&format!("Executing matrix job: {}", matrix_job_name));
849
850 let mut job_env = base_env_context.clone();
852 environment::add_matrix_context(&mut job_env, combination);
853
854 for (key, value) in &job_template.env {
856 job_env.insert(key.clone(), value.clone());
858 }
859
860 let mut step_results = Vec::new();
862 let mut job_logs = String::new();
863
864 let job_dir = tempfile::tempdir()
866 .map_err(|e| ExecutionError::Execution(format!("Failed to create job directory: {}", e)))?;
867
868 let current_dir = std::env::current_dir().map_err(|e| {
870 ExecutionError::Execution(format!("Failed to get current directory: {}", e))
871 })?;
872
873 wrkflw_logging::info(&format!(
875 "Copying project files to job workspace: {}",
876 job_dir.path().display()
877 ));
878 copy_directory_contents(¤t_dir, job_dir.path())?;
879
880 let job_success = if job_template.steps.is_empty() {
881 wrkflw_logging::warning(&format!("Job '{}' has no steps", matrix_job_name));
882 true
883 } else {
884 for (idx, step) in job_template.steps.iter().enumerate() {
886 match execute_step(StepExecutionContext {
887 step,
888 step_idx: idx,
889 job_env: &job_env,
890 working_dir: job_dir.path(),
891 runtime,
892 workflow,
893 runner_image: &get_runner_image(&job_template.runs_on),
894 verbose,
895 matrix_combination: &Some(combination.values.clone()),
896 })
897 .await
898 {
899 Ok(result) => {
900 job_logs.push_str(&format!("Step: {}\n", result.name));
901 job_logs.push_str(&format!("Status: {:?}\n", result.status));
902
903 if verbose || result.status == StepStatus::Failure {
905 job_logs.push_str(&result.output);
906 job_logs.push_str("\n\n");
907 } else {
908 job_logs.push('\n');
909 job_logs.push('\n');
910 }
911
912 step_results.push(result.clone());
913
914 if result.status != StepStatus::Success {
915 return Ok(JobResult {
917 name: matrix_job_name,
918 status: JobStatus::Failure,
919 steps: step_results,
920 logs: job_logs,
921 });
922 }
923 }
924 Err(e) => {
925 job_logs.push_str(&format!("Step execution error: {}\n\n", e));
927 return Ok(JobResult {
928 name: matrix_job_name,
929 status: JobStatus::Failure,
930 steps: step_results,
931 logs: job_logs,
932 });
933 }
934 }
935 }
936
937 true
938 };
939
940 Ok(JobResult {
942 name: matrix_job_name,
943 status: if job_success {
944 JobStatus::Success
945 } else {
946 JobStatus::Failure
947 },
948 steps: step_results,
949 logs: job_logs,
950 })
951}
952
953struct StepExecutionContext<'a> {
955 step: &'a workflow::Step,
956 step_idx: usize,
957 job_env: &'a HashMap<String, String>,
958 working_dir: &'a Path,
959 runtime: &'a dyn ContainerRuntime,
960 workflow: &'a WorkflowDefinition,
961 runner_image: &'a str,
962 verbose: bool,
963 #[allow(dead_code)]
964 matrix_combination: &'a Option<HashMap<String, Value>>,
965}
966
967async fn execute_step(ctx: StepExecutionContext<'_>) -> Result<StepResult, ExecutionError> {
968 let step_name = ctx
969 .step
970 .name
971 .clone()
972 .unwrap_or_else(|| format!("Step {}", ctx.step_idx + 1));
973
974 if ctx.verbose {
975 wrkflw_logging::info(&format!(" Executing step: {}", step_name));
976 }
977
978 let mut step_env = ctx.job_env.clone();
980
981 for (key, value) in &ctx.step.env {
983 step_env.insert(key.clone(), value.clone());
984 }
985
986 let step_result = if let Some(uses) = &ctx.step.uses {
988 let action_info = ctx.workflow.resolve_action(uses);
990
991 if uses.starts_with("actions/checkout") {
993 let current_dir = std::env::current_dir().map_err(|e| {
995 ExecutionError::Execution(format!("Failed to get current dir: {}", e))
996 })?;
997
998 copy_directory_contents(¤t_dir, ctx.working_dir)?;
1000
1001 let output = if ctx.verbose {
1003 let mut detailed_output =
1004 "Emulated checkout: Copied current directory to workspace\n\n".to_string();
1005
1006 detailed_output.push_str("Checkout Details:\n");
1008 detailed_output.push_str(" - Source: Local directory\n");
1009 detailed_output
1010 .push_str(&format!(" - Destination: {}\n", ctx.working_dir.display()));
1011
1012 detailed_output.push_str("\nTop-level files/directories copied:\n");
1014 if let Ok(entries) = std::fs::read_dir(¤t_dir) {
1015 for (i, entry) in entries.take(10).enumerate() {
1016 if let Ok(entry) = entry {
1017 let file_type = if entry.path().is_dir() {
1018 "directory"
1019 } else {
1020 "file"
1021 };
1022 detailed_output.push_str(&format!(
1023 " - {} ({})\n",
1024 entry.file_name().to_string_lossy(),
1025 file_type
1026 ));
1027 }
1028
1029 if i >= 9 {
1030 detailed_output.push_str(" - ... (more items not shown)\n");
1031 break;
1032 }
1033 }
1034 }
1035
1036 detailed_output
1037 } else {
1038 "Emulated checkout: Copied current directory to workspace".to_string()
1039 };
1040
1041 if ctx.verbose {
1042 println!(" Emulated actions/checkout: copied project files to workspace");
1043 }
1044
1045 StepResult {
1046 name: step_name,
1047 status: StepStatus::Success,
1048 output,
1049 }
1050 } else {
1051 let image = prepare_action(&action_info, ctx.runtime).await?;
1053
1054 if image == "composite" && action_info.is_local {
1056 let action_path = Path::new(&action_info.repository);
1058 execute_composite_action(
1059 ctx.step,
1060 action_path,
1061 &step_env,
1062 ctx.working_dir,
1063 ctx.runtime,
1064 ctx.runner_image,
1065 ctx.verbose,
1066 )
1067 .await?
1068 } else {
1069 let mut cmd = Vec::new();
1073 let mut owned_strings: Vec<String> = Vec::new(); if uses.starts_with("actions-rs/") {
1077 wrkflw_logging::info(
1078 "š Detected Rust action - using system Rust installation",
1079 );
1080
1081 if uses.starts_with("actions-rs/toolchain@") {
1083 let rustc_version = Command::new("rustc")
1084 .arg("--version")
1085 .output()
1086 .map(|output| String::from_utf8_lossy(&output.stdout).to_string())
1087 .unwrap_or_else(|_| "not found".to_string());
1088
1089 wrkflw_logging::info(&format!(
1090 "š Using system Rust: {}",
1091 rustc_version.trim()
1092 ));
1093
1094 return Ok(StepResult {
1096 name: step_name,
1097 status: StepStatus::Success,
1098 output: format!("Using system Rust: {}", rustc_version.trim()),
1099 });
1100 }
1101
1102 if uses.starts_with("actions-rs/cargo@") {
1104 let cargo_version = Command::new("cargo")
1105 .arg("--version")
1106 .output()
1107 .map(|output| String::from_utf8_lossy(&output.stdout).to_string())
1108 .unwrap_or_else(|_| "not found".to_string());
1109
1110 wrkflw_logging::info(&format!(
1111 "š Using system Rust/Cargo: {}",
1112 cargo_version.trim()
1113 ));
1114
1115 if let Some(with_params) = &ctx.step.with {
1117 if let Some(command) = with_params.get("command") {
1118 wrkflw_logging::info(&format!(
1119 "š Found command parameter: {}",
1120 command
1121 ));
1122
1123 let mut real_command = format!("cargo {}", command);
1125
1126 if let Some(args) = with_params.get("args") {
1128 if !args.is_empty() {
1129 let resolved_args = if args.contains("${{") {
1131 wrkflw_logging::info(&format!(
1132 "š Resolving workflow variables in: {}",
1133 args
1134 ));
1135
1136 let mut resolved =
1138 args.replace("${{ matrix.target }}", "");
1139 resolved = resolved.replace("${{ matrix.os }}", "");
1140
1141 let re_pattern =
1143 regex::Regex::new(r"\$\{\{\s*([^}]+)\s*\}\}")
1144 .unwrap_or_else(|_| {
1145 wrkflw_logging::error(
1146 "Failed to create regex pattern",
1147 );
1148 regex::Regex::new(r"\$\{\{.*?\}\}").unwrap()
1149 });
1150
1151 let resolved =
1152 re_pattern.replace_all(&resolved, "").to_string();
1153 wrkflw_logging::info(&format!(
1154 "š Resolved to: {}",
1155 resolved
1156 ));
1157
1158 resolved.trim().to_string()
1159 } else {
1160 args.clone()
1161 };
1162
1163 if !resolved_args.is_empty() && resolved_args != "--target"
1166 {
1167 real_command.push_str(&format!(" {}", resolved_args));
1168 }
1169 }
1170 }
1171
1172 wrkflw_logging::info(&format!(
1173 "š Running actual command: {}",
1174 real_command
1175 ));
1176
1177 let mut cmd = Command::new("sh");
1179 cmd.arg("-c");
1180 cmd.arg(&real_command);
1181 cmd.current_dir(ctx.working_dir);
1182
1183 for (key, value) in step_env {
1185 cmd.env(key, value);
1186 }
1187
1188 match cmd.output() {
1189 Ok(output) => {
1190 let exit_code = output.status.code().unwrap_or(-1);
1191 let stdout =
1192 String::from_utf8_lossy(&output.stdout).to_string();
1193 let stderr =
1194 String::from_utf8_lossy(&output.stderr).to_string();
1195
1196 return Ok(StepResult {
1197 name: step_name,
1198 status: if exit_code == 0 {
1199 StepStatus::Success
1200 } else {
1201 StepStatus::Failure
1202 },
1203 output: format!("{}\n{}", stdout, stderr),
1204 });
1205 }
1206 Err(e) => {
1207 return Ok(StepResult {
1208 name: step_name,
1209 status: StepStatus::Failure,
1210 output: format!("Failed to execute command: {}", e),
1211 });
1212 }
1213 }
1214 }
1215 }
1216 }
1217 }
1218
1219 if action_info.is_docker {
1220 cmd.push("sh");
1222 cmd.push("-c");
1223 cmd.push("echo 'Executing Docker action'");
1224 } else if action_info.is_local {
1225 let action_dir = Path::new(&action_info.repository);
1227 let action_yaml = action_dir.join("action.yml");
1228
1229 if action_yaml.exists() {
1230 cmd.push("sh");
1233 cmd.push("-c");
1234 cmd.push("echo 'Local action without action.yml'");
1235 } else {
1236 cmd.push("sh");
1237 cmd.push("-c");
1238 cmd.push("echo 'Local action without action.yml'");
1239 }
1240 } else {
1241 if let Err(e) = emulation::handle_special_action(uses).await {
1243 println!(" Warning: Special action handling failed: {}", e);
1245 }
1246
1247 let hide_action_value = ctx
1249 .job_env
1250 .get("WRKFLW_HIDE_ACTION_MESSAGES")
1251 .cloned()
1252 .unwrap_or_else(|| "not set".to_string());
1253
1254 wrkflw_logging::debug(&format!(
1255 "WRKFLW_HIDE_ACTION_MESSAGES value: {}",
1256 hide_action_value
1257 ));
1258
1259 let hide_messages = hide_action_value == "true";
1260 wrkflw_logging::debug(&format!("Should hide messages: {}", hide_messages));
1261
1262 if !hide_messages {
1264 println!(" āļø Would execute GitHub action: {}", uses);
1266 }
1267
1268 let mut should_run_real_command = false;
1270 let mut real_command_parts = Vec::new();
1271
1272 if let Some(with_params) = &ctx.step.with {
1274 if let Some(cmd) = with_params.get("command") {
1276 if ctx.verbose {
1277 wrkflw_logging::info(&format!(
1278 "š Found command parameter: {}",
1279 cmd
1280 ));
1281 }
1282
1283 if uses.contains("cargo") || uses.contains("rust") {
1285 real_command_parts.push("cargo".to_string());
1287 real_command_parts.push(cmd.clone());
1288 should_run_real_command = true;
1289 } else if uses.contains("node") || uses.contains("npm") {
1290 if cmd == "npm" || cmd == "yarn" || cmd == "pnpm" {
1292 real_command_parts.push(cmd.clone());
1293 } else {
1294 real_command_parts.push("npm".to_string());
1295 real_command_parts.push("run".to_string());
1296 real_command_parts.push(cmd.clone());
1297 }
1298 should_run_real_command = true;
1299 } else if uses.contains("python") || uses.contains("pip") {
1300 if cmd == "pip" {
1302 real_command_parts.push("pip".to_string());
1303 } else {
1304 real_command_parts.push("python".to_string());
1305 real_command_parts.push("-m".to_string());
1306 real_command_parts.push(cmd.clone());
1307 }
1308 should_run_real_command = true;
1309 } else {
1310 real_command_parts.push(cmd.clone());
1312 should_run_real_command = true;
1313 }
1314
1315 if let Some(args) = with_params.get("args") {
1317 if !args.is_empty() {
1318 let resolved_args = if args.contains("${{") {
1320 wrkflw_logging::info(&format!(
1321 "š Resolving workflow variables in: {}",
1322 args
1323 ));
1324
1325 let mut resolved = args.replace("${{ matrix.target }}", "");
1327 resolved = resolved.replace("${{ matrix.os }}", "");
1328
1329 let re_pattern =
1331 regex::Regex::new(r"\$\{\{\s*([^}]+)\s*\}\}")
1332 .unwrap_or_else(|_| {
1333 wrkflw_logging::error(
1334 "Failed to create regex pattern",
1335 );
1336 regex::Regex::new(r"\$\{\{.*?\}\}").unwrap()
1337 });
1338
1339 let resolved =
1340 re_pattern.replace_all(&resolved, "").to_string();
1341 wrkflw_logging::info(&format!(
1342 "š Resolved to: {}",
1343 resolved
1344 ));
1345
1346 resolved.trim().to_string()
1347 } else {
1348 args.clone()
1349 };
1350
1351 if !resolved_args.is_empty() {
1353 real_command_parts.push(resolved_args);
1354 }
1355 }
1356 }
1357 }
1358 }
1359
1360 if should_run_real_command && !real_command_parts.is_empty() {
1361 let command_str = real_command_parts.join(" ");
1363 wrkflw_logging::info(&format!(
1364 "š Running actual command: {}",
1365 command_str
1366 ));
1367
1368 cmd.clear();
1370 cmd.push("sh");
1371 cmd.push("-c");
1372 owned_strings.push(command_str);
1373 cmd.push(owned_strings.last().unwrap());
1374 } else {
1375 cmd.clear();
1377 cmd.push("sh");
1378 cmd.push("-c");
1379
1380 let echo_msg = format!("echo 'Would execute GitHub action: {}'", uses);
1381 owned_strings.push(echo_msg);
1382 cmd.push(owned_strings.last().unwrap());
1383 }
1384 }
1385
1386 if let Some(with_params) = &ctx.step.with {
1388 for (key, value) in with_params {
1389 step_env.insert(format!("INPUT_{}", key.to_uppercase()), value.clone());
1390 }
1391 }
1392
1393 let env_vars: Vec<(&str, &str)> = step_env
1395 .iter()
1396 .map(|(k, v)| (k.as_str(), v.as_str()))
1397 .collect();
1398
1399 let container_workspace = Path::new("/github/workspace");
1401
1402 let volumes: Vec<(&Path, &Path)> = vec![(ctx.working_dir, container_workspace)];
1404
1405 let output = ctx
1406 .runtime
1407 .run_container(
1408 ctx.runner_image,
1409 &cmd.to_vec(),
1410 &env_vars,
1411 container_workspace,
1412 &volumes,
1413 )
1414 .await
1415 .map_err(|e| ExecutionError::Runtime(format!("{}", e)))?;
1416
1417 if output.exit_code == 0 {
1419 let output_text = if ctx.verbose
1421 && uses.contains('/')
1422 && !uses.starts_with("./")
1423 {
1424 let mut detailed_output =
1425 format!("Would execute GitHub action: {}\n", uses);
1426
1427 if let Some(with_params) = &ctx.step.with {
1429 detailed_output.push_str("\nAction inputs:\n");
1430 for (key, value) in with_params {
1431 detailed_output.push_str(&format!(" {}: {}\n", key, value));
1432 }
1433 }
1434
1435 detailed_output.push_str("\nEnvironment variables:\n");
1437 for (key, value) in step_env.iter() {
1438 if key.starts_with("GITHUB_") || key.starts_with("INPUT_") {
1439 detailed_output.push_str(&format!(" {}: {}\n", key, value));
1440 }
1441 }
1442
1443 detailed_output
1445 .push_str(&format!("\nOutput:\n{}\n{}", output.stdout, output.stderr));
1446 detailed_output
1447 } else {
1448 format!("{}\n{}", output.stdout, output.stderr)
1449 };
1450
1451 if output.exit_code != 0 && (uses.contains("cargo") || uses.contains("rust")) {
1453 let mut error_details = format!(
1455 "\n\nā Command failed with exit code: {}\n",
1456 output.exit_code
1457 );
1458
1459 error_details.push_str(&format!("Command: {}\n", cmd.join(" ")));
1461
1462 error_details.push_str("\nEnvironment:\n");
1464 for (key, value) in step_env.iter() {
1465 if key.starts_with("GITHUB_")
1466 || key.starts_with("INPUT_")
1467 || key.starts_with("RUST")
1468 {
1469 error_details.push_str(&format!(" {}: {}\n", key, value));
1470 }
1471 }
1472
1473 error_details.push_str("\nDetailed output:\n");
1475 error_details.push_str(&output.stdout);
1476 error_details.push_str(&output.stderr);
1477
1478 return Ok(StepResult {
1480 name: step_name,
1481 status: StepStatus::Failure,
1482 output: format!("{}\n{}", output_text, error_details),
1483 });
1484 }
1485
1486 StepResult {
1487 name: step_name,
1488 status: if output.exit_code == 0 {
1489 StepStatus::Success
1490 } else {
1491 StepStatus::Failure
1492 },
1493 output: format!(
1494 "Exit code: {}
1495{}
1496{}",
1497 output.exit_code, output.stdout, output.stderr
1498 ),
1499 }
1500 } else {
1501 StepResult {
1502 name: step_name,
1503 status: StepStatus::Failure,
1504 output: format!(
1505 "Exit code: {}\n{}\n{}",
1506 output.exit_code, output.stdout, output.stderr
1507 ),
1508 }
1509 }
1510 }
1511 }
1512 } else if let Some(run) = &ctx.step.run {
1513 let mut output = String::new();
1515 let mut status = StepStatus::Success;
1516 let mut error_details = None;
1517
1518 let is_cargo_cmd = run.trim().starts_with("cargo");
1520
1521 let cmd_parts: Vec<&str> = run.split_whitespace().collect();
1523
1524 let env_vars: Vec<(&str, &str)> = step_env
1526 .iter()
1527 .map(|(k, v)| (k.as_str(), v.as_str()))
1528 .collect();
1529
1530 let container_workspace = Path::new("/github/workspace");
1532
1533 let volumes: Vec<(&Path, &Path)> = vec![(ctx.working_dir, container_workspace)];
1535
1536 match ctx
1538 .runtime
1539 .run_container(
1540 ctx.runner_image,
1541 &cmd_parts,
1542 &env_vars,
1543 container_workspace,
1544 &volumes,
1545 )
1546 .await
1547 {
1548 Ok(container_output) => {
1549 output.push_str(&format!("Command: {}\n\n", run));
1551
1552 if !container_output.stdout.is_empty() {
1553 output.push_str("Standard Output:\n");
1554 output.push_str(&container_output.stdout);
1555 output.push('\n');
1556 }
1557
1558 if !container_output.stderr.is_empty() {
1559 output.push_str("Standard Error:\n");
1560 output.push_str(&container_output.stderr);
1561 output.push('\n');
1562 }
1563
1564 if container_output.exit_code != 0 {
1565 status = StepStatus::Failure;
1566
1567 if is_cargo_cmd {
1569 let mut error_msg = String::new();
1570 error_msg.push_str(&format!(
1571 "\nCargo command failed with exit code {}\n",
1572 container_output.exit_code
1573 ));
1574 error_msg.push_str("Common causes for cargo command failures:\n");
1575
1576 if run.contains("fmt") {
1577 error_msg.push_str(
1578 "- Code formatting issues. Run 'cargo fmt' locally to fix.\n",
1579 );
1580 } else if run.contains("clippy") {
1581 error_msg.push_str("- Linter warnings treated as errors. Run 'cargo clippy' locally to see details.\n");
1582 } else if run.contains("test") {
1583 error_msg.push_str("- Test failures. Run 'cargo test' locally to see which tests failed.\n");
1584 } else if run.contains("build") {
1585 error_msg.push_str(
1586 "- Compilation errors. Check the error messages above.\n",
1587 );
1588 }
1589
1590 error_details = Some(error_msg);
1591 }
1592 }
1593 }
1594 Err(e) => {
1595 status = StepStatus::Failure;
1596 output.push_str(&format!("Error executing command: {}\n", e));
1597 }
1598 }
1599
1600 if let Some(details) = error_details {
1602 output.push_str(&details);
1603 }
1604
1605 StepResult {
1606 name: step_name,
1607 status,
1608 output,
1609 }
1610 } else {
1611 return Ok(StepResult {
1612 name: step_name,
1613 status: StepStatus::Skipped,
1614 output: "Step has neither 'uses' nor 'run'".to_string(),
1615 });
1616 };
1617
1618 Ok(step_result)
1619}
1620
1621fn copy_directory_contents(from: &Path, to: &Path) -> Result<(), ExecutionError> {
1622 for entry in std::fs::read_dir(from)
1623 .map_err(|e| ExecutionError::Execution(format!("Failed to read directory: {}", e)))?
1624 {
1625 let entry =
1626 entry.map_err(|e| ExecutionError::Execution(format!("Failed to read entry: {}", e)))?;
1627 let path = entry.path();
1628
1629 let file_name = match path.file_name() {
1631 Some(name) => name.to_string_lossy(),
1632 None => {
1633 return Err(ExecutionError::Execution(format!(
1634 "Failed to get file name from path: {:?}",
1635 path
1636 )));
1637 }
1638 };
1639 if file_name.starts_with(".") || file_name == "target" {
1640 continue;
1641 }
1642
1643 let dest_path = match path.file_name() {
1644 Some(name) => to.join(name),
1645 None => {
1646 return Err(ExecutionError::Execution(format!(
1647 "Failed to get file name from path: {:?}",
1648 path
1649 )));
1650 }
1651 };
1652
1653 if path.is_dir() {
1654 std::fs::create_dir_all(&dest_path)
1655 .map_err(|e| ExecutionError::Execution(format!("Failed to create dir: {}", e)))?;
1656
1657 copy_directory_contents(&path, &dest_path)?;
1659 } else {
1660 std::fs::copy(&path, &dest_path)
1661 .map_err(|e| ExecutionError::Execution(format!("Failed to copy file: {}", e)))?;
1662 }
1663 }
1664
1665 Ok(())
1666}
1667
1668fn get_runner_image(runs_on: &str) -> String {
1669 match runs_on.trim() {
1671 "ubuntu-latest" => "node:16-buster-slim",
1673 "ubuntu-22.04" => "node:16-bullseye-slim",
1674 "ubuntu-20.04" => "node:16-buster-slim",
1675 "ubuntu-18.04" => "node:16-buster-slim",
1676
1677 "ubuntu-latest-medium" => "catthehacker/ubuntu:act-latest",
1679 "ubuntu-22.04-medium" => "catthehacker/ubuntu:act-22.04",
1680 "ubuntu-20.04-medium" => "catthehacker/ubuntu:act-20.04",
1681 "ubuntu-18.04-medium" => "catthehacker/ubuntu:act-18.04",
1682
1683 "ubuntu-latest-large" => "catthehacker/ubuntu:full-latest",
1685 "ubuntu-22.04-large" => "catthehacker/ubuntu:full-22.04",
1686 "ubuntu-20.04-large" => "catthehacker/ubuntu:full-20.04",
1687 "ubuntu-18.04-large" => "catthehacker/ubuntu:full-18.04",
1688
1689 "macos-latest" => "rust:latest",
1691 "macos-12" => "rust:latest", "macos-11" => "rust:latest", "macos-10.15" => "rust:latest", "windows-latest" => "mcr.microsoft.com/windows/servercore:ltsc2022",
1697 "windows-2022" => "mcr.microsoft.com/windows/servercore:ltsc2022",
1698 "windows-2019" => "mcr.microsoft.com/windows/servercore:ltsc2019",
1699
1700 "python-latest" => "python:3.11-slim",
1702 "python-3.11" => "python:3.11-slim",
1703 "python-3.10" => "python:3.10-slim",
1704 "python-3.9" => "python:3.9-slim",
1705 "python-3.8" => "python:3.8-slim",
1706
1707 "node-latest" => "node:20-slim",
1708 "node-20" => "node:20-slim",
1709 "node-18" => "node:18-slim",
1710 "node-16" => "node:16-slim",
1711
1712 "java-latest" => "eclipse-temurin:17-jdk",
1713 "java-17" => "eclipse-temurin:17-jdk",
1714 "java-11" => "eclipse-temurin:11-jdk",
1715 "java-8" => "eclipse-temurin:8-jdk",
1716
1717 "go-latest" => "golang:1.21-slim",
1718 "go-1.21" => "golang:1.21-slim",
1719 "go-1.20" => "golang:1.20-slim",
1720 "go-1.19" => "golang:1.19-slim",
1721
1722 "dotnet-latest" => "mcr.microsoft.com/dotnet/sdk:7.0",
1723 "dotnet-7.0" => "mcr.microsoft.com/dotnet/sdk:7.0",
1724 "dotnet-6.0" => "mcr.microsoft.com/dotnet/sdk:6.0",
1725 "dotnet-5.0" => "mcr.microsoft.com/dotnet/sdk:5.0",
1726
1727 _ => {
1729 let runs_on_lower = runs_on.trim().to_lowercase();
1731 if runs_on_lower.starts_with("macos") {
1732 "rust:latest" } else if runs_on_lower.starts_with("windows") {
1734 "mcr.microsoft.com/windows/servercore:ltsc2022" } else if runs_on_lower.starts_with("python") {
1736 "python:3.11-slim" } else if runs_on_lower.starts_with("node") {
1738 "node:20-slim" } else if runs_on_lower.starts_with("java") {
1740 "eclipse-temurin:17-jdk" } else if runs_on_lower.starts_with("go") {
1742 "golang:1.21-slim" } else if runs_on_lower.starts_with("dotnet") {
1744 "mcr.microsoft.com/dotnet/sdk:7.0" } else {
1746 "ubuntu:latest" }
1748 }
1749 }
1750 .to_string()
1751}
1752
1753#[allow(dead_code)]
1754async fn prepare_runner_image(
1755 image: &str,
1756 runtime: &dyn ContainerRuntime,
1757 verbose: bool,
1758) -> Result<(), ExecutionError> {
1759 if let Err(e) = runtime.pull_image(image).await {
1761 wrkflw_logging::warning(&format!("Failed to pull image {}: {}", image, e));
1762 }
1763
1764 let language_info = extract_language_info(image);
1766 if let Some((language, version)) = language_info {
1767 if let Ok(custom_image) = runtime
1769 .prepare_language_environment(language, version, None)
1770 .await
1771 .map_err(|e| ExecutionError::Runtime(e.to_string()))
1772 {
1773 if verbose {
1774 wrkflw_logging::info(&format!("Using customized image: {}", custom_image));
1775 }
1776 return Ok(());
1777 }
1778 }
1779
1780 Ok(())
1781}
1782
1783#[allow(dead_code)]
1784fn extract_language_info(image: &str) -> Option<(&'static str, Option<&str>)> {
1785 let image_lower = image.to_lowercase();
1786
1787 if image_lower.starts_with("python:") {
1789 Some(("python", Some(&image[7..])))
1790 } else if image_lower.starts_with("node:") {
1791 Some(("node", Some(&image[5..])))
1792 } else if image_lower.starts_with("eclipse-temurin:") {
1793 Some(("java", Some(&image[15..])))
1794 } else if image_lower.starts_with("golang:") {
1795 Some(("go", Some(&image[6..])))
1796 } else if image_lower.starts_with("mcr.microsoft.com/dotnet/sdk:") {
1797 Some(("dotnet", Some(&image[29..])))
1798 } else if image_lower.starts_with("rust:") {
1799 Some(("rust", Some(&image[5..])))
1800 } else {
1801 None
1802 }
1803}
1804
1805async fn execute_composite_action(
1806 step: &workflow::Step,
1807 action_path: &Path,
1808 job_env: &HashMap<String, String>,
1809 working_dir: &Path,
1810 runtime: &dyn ContainerRuntime,
1811 runner_image: &str,
1812 verbose: bool,
1813) -> Result<StepResult, ExecutionError> {
1814 let action_yaml = action_path.join("action.yml");
1816 let action_yaml_alt = action_path.join("action.yaml");
1817
1818 let action_file = if action_yaml.exists() {
1819 action_yaml
1820 } else if action_yaml_alt.exists() {
1821 action_yaml_alt
1822 } else {
1823 return Err(ExecutionError::Execution(format!(
1824 "No action.yml or action.yaml found in {}",
1825 action_path.display()
1826 )));
1827 };
1828
1829 let action_content = fs::read_to_string(&action_file)
1831 .map_err(|e| ExecutionError::Execution(format!("Failed to read action file: {}", e)))?;
1832
1833 let action_def: serde_yaml::Value = serde_yaml::from_str(&action_content)
1834 .map_err(|e| ExecutionError::Execution(format!("Invalid action YAML: {}", e)))?;
1835
1836 match action_def.get("runs").and_then(|v| v.get("using")) {
1838 Some(serde_yaml::Value::String(using)) if using == "composite" => {
1839 let steps = match action_def.get("runs").and_then(|v| v.get("steps")) {
1841 Some(serde_yaml::Value::Sequence(steps)) => steps,
1842 _ => {
1843 return Err(ExecutionError::Execution(
1844 "Composite action is missing steps".to_string(),
1845 ))
1846 }
1847 };
1848
1849 let mut action_env = job_env.clone();
1851 if let Some(inputs_def) = action_def.get("inputs") {
1852 if let Some(inputs_map) = inputs_def.as_mapping() {
1853 for (input_name, input_def) in inputs_map {
1854 if let Some(input_name_str) = input_name.as_str() {
1855 let default_value = input_def
1857 .get("default")
1858 .and_then(|v| v.as_str())
1859 .unwrap_or("");
1860
1861 let input_value = step
1863 .with
1864 .as_ref()
1865 .and_then(|with| with.get(input_name_str))
1866 .unwrap_or(&default_value.to_string())
1867 .clone();
1868
1869 action_env.insert(
1871 format!("INPUT_{}", input_name_str.to_uppercase()),
1872 input_value,
1873 );
1874 }
1875 }
1876 }
1877 }
1878
1879 let mut step_outputs = Vec::new();
1881 for (idx, step_def) in steps.iter().enumerate() {
1882 let composite_step = match convert_yaml_to_step(step_def) {
1884 Ok(step) => step,
1885 Err(e) => {
1886 return Err(ExecutionError::Execution(format!(
1887 "Failed to process composite action step {}: {}",
1888 idx + 1,
1889 e
1890 )))
1891 }
1892 };
1893
1894 let step_result = Box::pin(execute_step(StepExecutionContext {
1896 step: &composite_step,
1897 step_idx: idx,
1898 job_env: &action_env,
1899 working_dir,
1900 runtime,
1901 workflow: &workflow::WorkflowDefinition {
1902 name: "Composite Action".to_string(),
1903 on: vec![],
1904 on_raw: serde_yaml::Value::Null,
1905 jobs: HashMap::new(),
1906 },
1907 runner_image,
1908 verbose,
1909 matrix_combination: &None,
1910 }))
1911 .await?;
1912
1913 step_outputs.push(format!("Step {}: {}", idx + 1, step_result.output));
1915
1916 if step_result.status == StepStatus::Failure {
1918 return Ok(StepResult {
1919 name: step
1920 .name
1921 .clone()
1922 .unwrap_or_else(|| "Composite Action".to_string()),
1923 status: StepStatus::Failure,
1924 output: step_outputs.join("\n"),
1925 });
1926 }
1927 }
1928
1929 let output = if verbose {
1931 let mut detailed_output = format!(
1932 "Executed composite action from: {}\n\n",
1933 action_path.display()
1934 );
1935
1936 if let Ok(action_content) =
1938 serde_yaml::from_str::<serde_yaml::Value>(&action_content)
1939 {
1940 if let Some(name) = action_content.get("name").and_then(|v| v.as_str()) {
1941 detailed_output.push_str(&format!("Action name: {}\n", name));
1942 }
1943
1944 if let Some(description) =
1945 action_content.get("description").and_then(|v| v.as_str())
1946 {
1947 detailed_output.push_str(&format!("Description: {}\n", description));
1948 }
1949
1950 detailed_output.push('\n');
1951 }
1952
1953 detailed_output.push_str("Step outputs:\n");
1955 for output in &step_outputs {
1956 detailed_output.push_str(&format!("{}\n", output));
1957 }
1958
1959 detailed_output
1960 } else {
1961 format!(
1962 "Executed composite action with {} steps",
1963 step_outputs.len()
1964 )
1965 };
1966
1967 Ok(StepResult {
1968 name: step
1969 .name
1970 .clone()
1971 .unwrap_or_else(|| "Composite Action".to_string()),
1972 status: StepStatus::Success,
1973 output,
1974 })
1975 }
1976 _ => Err(ExecutionError::Execution(
1977 "Action is not a composite action or has invalid format".to_string(),
1978 )),
1979 }
1980}
1981
1982fn convert_yaml_to_step(step_yaml: &serde_yaml::Value) -> Result<workflow::Step, String> {
1984 let name = step_yaml
1986 .get("name")
1987 .and_then(|v| v.as_str())
1988 .map(|s| s.to_string());
1989
1990 let uses = step_yaml
1991 .get("uses")
1992 .and_then(|v| v.as_str())
1993 .map(|s| s.to_string());
1994
1995 let run = step_yaml
1996 .get("run")
1997 .and_then(|v| v.as_str())
1998 .map(|s| s.to_string());
1999
2000 let shell = step_yaml
2001 .get("shell")
2002 .and_then(|v| v.as_str())
2003 .map(|s| s.to_string());
2004
2005 let with = step_yaml.get("with").and_then(|v| v.as_mapping()).map(|m| {
2006 let mut with_map = HashMap::new();
2007 for (k, v) in m {
2008 if let (Some(key), Some(value)) = (k.as_str(), v.as_str()) {
2009 with_map.insert(key.to_string(), value.to_string());
2010 }
2011 }
2012 with_map
2013 });
2014
2015 let env = step_yaml
2016 .get("env")
2017 .and_then(|v| v.as_mapping())
2018 .map(|m| {
2019 let mut env_map = HashMap::new();
2020 for (k, v) in m {
2021 if let (Some(key), Some(value)) = (k.as_str(), v.as_str()) {
2022 env_map.insert(key.to_string(), value.to_string());
2023 }
2024 }
2025 env_map
2026 })
2027 .unwrap_or_default();
2028
2029 let final_run = run;
2031
2032 let continue_on_error = step_yaml.get("continue-on-error").and_then(|v| v.as_bool());
2034
2035 Ok(workflow::Step {
2036 name,
2037 uses,
2038 run: final_run,
2039 with,
2040 env,
2041 continue_on_error,
2042 })
2043}
2044
2045fn evaluate_job_condition(
2048 condition: &str,
2049 env_context: &HashMap<String, String>,
2050 workflow: &WorkflowDefinition,
2051) -> bool {
2052 wrkflw_logging::debug(&format!("Evaluating condition: {}", condition));
2053
2054 if condition == "true" {
2059 return true;
2060 }
2061 if condition == "false" {
2062 return false;
2063 }
2064
2065 if condition.contains("github.event.pull_request.draft == false") {
2067 return true;
2069 }
2070
2071 if condition.contains("needs.") && condition.contains(".outputs.") {
2073 wrkflw_logging::debug(
2076 "Evaluating needs.outputs condition - defaulting to false for local execution",
2077 );
2078 return false;
2079 }
2080
2081 wrkflw_logging::warning(&format!(
2083 "Unknown condition pattern: '{}' - defaulting to true",
2084 condition
2085 ));
2086 true
2087}