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 ignore::{gitignore::GitignoreBuilder, Match};
13
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::{self, parse_workflow, ActionInfo, Job, WorkflowDefinition};
23use wrkflw_runtime::container::ContainerRuntime;
24use wrkflw_runtime::emulation;
25use wrkflw_secrets::{SecretConfig, SecretManager, SecretMasker, SecretSubstitution};
26
27#[allow(unused_variables, unused_assignments)]
28pub async fn execute_workflow(
30 workflow_path: &Path,
31 config: ExecutionConfig,
32) -> Result<ExecutionResult, ExecutionError> {
33 wrkflw_logging::info(&format!("Executing workflow: {}", workflow_path.display()));
34 wrkflw_logging::info(&format!("Runtime: {:?}", config.runtime_type));
35
36 let is_gitlab = is_gitlab_pipeline(workflow_path);
38
39 if is_gitlab {
40 execute_gitlab_pipeline(workflow_path, config.clone()).await
41 } else {
42 execute_github_workflow(workflow_path, config.clone()).await
43 }
44}
45
46fn is_gitlab_pipeline(path: &Path) -> bool {
48 if let Some(file_name) = path.file_name() {
50 if let Some(file_name_str) = file_name.to_str() {
51 return file_name_str == ".gitlab-ci.yml" || file_name_str.ends_with("gitlab-ci.yml");
52 }
53 }
54
55 if let Ok(content) = fs::read_to_string(path) {
57 if content.contains("stages:")
59 || content.contains("before_script:")
60 || content.contains("after_script:")
61 {
62 if !content.contains("on:")
64 && !content.contains("runs-on:")
65 && !content.contains("uses:")
66 {
67 return true;
68 }
69 }
70 }
71
72 false
73}
74
75async fn execute_github_workflow(
77 workflow_path: &Path,
78 config: ExecutionConfig,
79) -> Result<ExecutionResult, ExecutionError> {
80 let workflow = parse_workflow(workflow_path)?;
82
83 let execution_plan = dependency::resolve_dependencies(&workflow)?;
85
86 let runtime = initialize_runtime(
88 config.runtime_type.clone(),
89 config.preserve_containers_on_failure,
90 )?;
91
92 let workspace_dir = tempfile::tempdir()
94 .map_err(|e| ExecutionError::Execution(format!("Failed to create workspace: {}", e)))?;
95
96 let mut env_context = environment::create_github_context(&workflow, workspace_dir.path());
98
99 env_context.insert(
101 "WRKFLW_RUNTIME_MODE".to_string(),
102 match config.runtime_type {
103 RuntimeType::Emulation => "emulation".to_string(),
104 RuntimeType::SecureEmulation => "secure_emulation".to_string(),
105 RuntimeType::Docker => "docker".to_string(),
106 RuntimeType::Podman => "podman".to_string(),
107 },
108 );
109
110 env_context.insert(
112 "WRKFLW_HIDE_ACTION_MESSAGES".to_string(),
113 "true".to_string(),
114 );
115
116 environment::setup_github_environment_files(workspace_dir.path()).map_err(|e| {
118 ExecutionError::Execution(format!("Failed to setup GitHub env files: {}", e))
119 })?;
120
121 let secret_manager = if let Some(secrets_config) = &config.secrets_config {
123 Some(
124 SecretManager::new(secrets_config.clone())
125 .await
126 .map_err(|e| {
127 ExecutionError::Execution(format!("Failed to initialize secret manager: {}", e))
128 })?,
129 )
130 } else {
131 Some(SecretManager::default().await.map_err(|e| {
132 ExecutionError::Execution(format!(
133 "Failed to initialize default secret manager: {}",
134 e
135 ))
136 })?)
137 };
138
139 let secret_masker = SecretMasker::new();
140
141 let mut results = Vec::new();
143 let mut has_failures = false;
144 let mut failure_details = String::new();
145
146 for job_batch in execution_plan {
147 let job_results = execute_job_batch(
149 &job_batch,
150 &workflow,
151 runtime.as_ref(),
152 &env_context,
153 config.verbose,
154 secret_manager.as_ref(),
155 Some(&secret_masker),
156 )
157 .await?;
158
159 for job_result in &job_results {
161 if job_result.status == JobStatus::Failure {
162 has_failures = true;
163 failure_details.push_str(&format!("\nā Job failed: {}\n", job_result.name));
164
165 for step in &job_result.steps {
167 if step.status == StepStatus::Failure {
168 failure_details.push_str(&format!(" ā {}: {}\n", step.name, step.output));
169 }
170 }
171 }
172 }
173
174 results.extend(job_results);
175 }
176
177 if has_failures {
179 wrkflw_logging::error(&format!("Workflow execution failed:{}", failure_details));
180 }
181
182 Ok(ExecutionResult {
183 jobs: results,
184 failure_details: if has_failures {
185 Some(failure_details)
186 } else {
187 None
188 },
189 })
190}
191
192async fn execute_gitlab_pipeline(
194 pipeline_path: &Path,
195 config: ExecutionConfig,
196) -> Result<ExecutionResult, ExecutionError> {
197 wrkflw_logging::info("Executing GitLab CI/CD pipeline");
198
199 let pipeline = parse_pipeline(pipeline_path)
201 .map_err(|e| ExecutionError::Parse(format!("Failed to parse GitLab pipeline: {}", e)))?;
202
203 let workflow = gitlab::convert_to_workflow_format(&pipeline);
205
206 let execution_plan = resolve_gitlab_dependencies(&pipeline, &workflow)?;
208
209 let runtime = initialize_runtime(
211 config.runtime_type.clone(),
212 config.preserve_containers_on_failure,
213 )?;
214
215 let workspace_dir = tempfile::tempdir()
217 .map_err(|e| ExecutionError::Execution(format!("Failed to create workspace: {}", e)))?;
218
219 let mut env_context = create_gitlab_context(&pipeline, workspace_dir.path());
221
222 env_context.insert(
224 "WRKFLW_RUNTIME_MODE".to_string(),
225 match config.runtime_type {
226 RuntimeType::Emulation => "emulation".to_string(),
227 RuntimeType::SecureEmulation => "secure_emulation".to_string(),
228 RuntimeType::Docker => "docker".to_string(),
229 RuntimeType::Podman => "podman".to_string(),
230 },
231 );
232
233 environment::setup_github_environment_files(workspace_dir.path()).map_err(|e| {
235 ExecutionError::Execution(format!("Failed to setup environment files: {}", e))
236 })?;
237
238 let secret_manager = if let Some(secrets_config) = &config.secrets_config {
240 Some(
241 SecretManager::new(secrets_config.clone())
242 .await
243 .map_err(|e| {
244 ExecutionError::Execution(format!("Failed to initialize secret manager: {}", e))
245 })?,
246 )
247 } else {
248 Some(SecretManager::default().await.map_err(|e| {
249 ExecutionError::Execution(format!(
250 "Failed to initialize default secret manager: {}",
251 e
252 ))
253 })?)
254 };
255
256 let secret_masker = SecretMasker::new();
257
258 let mut results = Vec::new();
260 let mut has_failures = false;
261 let mut failure_details = String::new();
262
263 for job_batch in execution_plan {
264 let job_results = execute_job_batch(
266 &job_batch,
267 &workflow,
268 runtime.as_ref(),
269 &env_context,
270 config.verbose,
271 secret_manager.as_ref(),
272 Some(&secret_masker),
273 )
274 .await?;
275
276 for job_result in &job_results {
278 if job_result.status == JobStatus::Failure {
279 has_failures = true;
280 failure_details.push_str(&format!("\nā Job failed: {}\n", job_result.name));
281
282 for step in &job_result.steps {
284 if step.status == StepStatus::Failure {
285 failure_details.push_str(&format!(" ā {}: {}\n", step.name, step.output));
286 }
287 }
288 }
289 }
290
291 results.extend(job_results);
292 }
293
294 if has_failures {
296 wrkflw_logging::error(&format!("Pipeline execution failed:{}", failure_details));
297 }
298
299 Ok(ExecutionResult {
300 jobs: results,
301 failure_details: if has_failures {
302 Some(failure_details)
303 } else {
304 None
305 },
306 })
307}
308
309fn create_gitlab_context(pipeline: &Pipeline, workspace_dir: &Path) -> HashMap<String, String> {
311 let mut env_context = HashMap::new();
312
313 env_context.insert("CI".to_string(), "true".to_string());
315 env_context.insert("GITLAB_CI".to_string(), "true".to_string());
316
317 env_context.insert("WRKFLW_CI".to_string(), "true".to_string());
319
320 env_context.insert(
322 "CI_PROJECT_DIR".to_string(),
323 workspace_dir.to_string_lossy().to_string(),
324 );
325
326 env_context.insert(
328 "GITHUB_WORKSPACE".to_string(),
329 workspace_dir.to_string_lossy().to_string(),
330 );
331
332 if let Some(variables) = &pipeline.variables {
334 for (key, value) in variables {
335 env_context.insert(key.clone(), value.clone());
336 }
337 }
338
339 env_context
340}
341
342fn resolve_gitlab_dependencies(
344 pipeline: &Pipeline,
345 workflow: &WorkflowDefinition,
346) -> Result<Vec<Vec<String>>, ExecutionError> {
347 let stages = match &pipeline.stages {
352 Some(defined_stages) => defined_stages.clone(),
353 None => vec![
354 "build".to_string(),
355 "test".to_string(),
356 "deploy".to_string(),
357 ],
358 };
359
360 let mut execution_plan = Vec::new();
362
363 for stage in stages {
365 let mut stage_jobs = Vec::new();
366
367 for (job_name, job) in &pipeline.jobs {
368 if let Some(true) = job.template {
370 continue;
371 }
372
373 let default_stage = "test".to_string();
375 let job_stage = job.stage.as_ref().unwrap_or(&default_stage);
376
377 if job_stage == &stage {
379 stage_jobs.push(job_name.clone());
380 }
381 }
382
383 if !stage_jobs.is_empty() {
384 execution_plan.push(stage_jobs);
385 }
386 }
387
388 let mut stageless_jobs = Vec::new();
390
391 for (job_name, job) in &pipeline.jobs {
392 if let Some(true) = job.template {
394 continue;
395 }
396
397 if job.stage.is_none() {
398 stageless_jobs.push(job_name.clone());
399 }
400 }
401
402 if !stageless_jobs.is_empty() {
403 execution_plan.push(stageless_jobs);
404 }
405
406 Ok(execution_plan)
407}
408
409fn initialize_runtime(
411 runtime_type: RuntimeType,
412 preserve_containers_on_failure: bool,
413) -> Result<Box<dyn ContainerRuntime>, ExecutionError> {
414 match runtime_type {
415 RuntimeType::Docker => {
416 if docker::is_available() {
417 match docker::DockerRuntime::new_with_config(preserve_containers_on_failure) {
419 Ok(docker_runtime) => Ok(Box::new(docker_runtime)),
420 Err(e) => {
421 wrkflw_logging::error(&format!(
422 "Failed to initialize Docker runtime: {}, falling back to emulation mode",
423 e
424 ));
425 Ok(Box::new(emulation::EmulationRuntime::new()))
426 }
427 }
428 } else {
429 wrkflw_logging::error("Docker not available, falling back to emulation mode");
430 Ok(Box::new(emulation::EmulationRuntime::new()))
431 }
432 }
433 RuntimeType::Podman => {
434 if podman::is_available() {
435 match podman::PodmanRuntime::new_with_config(preserve_containers_on_failure) {
437 Ok(podman_runtime) => Ok(Box::new(podman_runtime)),
438 Err(e) => {
439 wrkflw_logging::error(&format!(
440 "Failed to initialize Podman runtime: {}, falling back to emulation mode",
441 e
442 ));
443 Ok(Box::new(emulation::EmulationRuntime::new()))
444 }
445 }
446 } else {
447 wrkflw_logging::error("Podman not available, falling back to emulation mode");
448 Ok(Box::new(emulation::EmulationRuntime::new()))
449 }
450 }
451 RuntimeType::Emulation => Ok(Box::new(emulation::EmulationRuntime::new())),
452 RuntimeType::SecureEmulation => Ok(Box::new(
453 wrkflw_runtime::secure_emulation::SecureEmulationRuntime::new(),
454 )),
455 }
456}
457
458#[derive(Debug, Clone, PartialEq)]
459pub enum RuntimeType {
460 Docker,
461 Podman,
462 Emulation,
463 SecureEmulation,
464}
465
466#[derive(Debug, Clone)]
467pub struct ExecutionConfig {
468 pub runtime_type: RuntimeType,
469 pub verbose: bool,
470 pub preserve_containers_on_failure: bool,
471 pub secrets_config: Option<SecretConfig>,
472}
473
474pub struct ExecutionResult {
475 pub jobs: Vec<JobResult>,
476 pub failure_details: Option<String>,
477}
478
479pub struct JobResult {
480 pub name: String,
481 pub status: JobStatus,
482 pub steps: Vec<StepResult>,
483 pub logs: String,
484}
485
486#[derive(Debug, Clone, PartialEq)]
487#[allow(dead_code)]
488pub enum JobStatus {
489 Success,
490 Failure,
491 Skipped,
492}
493
494#[derive(Debug, Clone)]
495pub struct StepResult {
496 pub name: String,
497 pub status: StepStatus,
498 pub output: String,
499}
500
501#[derive(Debug, Clone, PartialEq)]
502#[allow(dead_code)]
503pub enum StepStatus {
504 Success,
505 Failure,
506 Skipped,
507}
508
509#[derive(Error, Debug)]
510pub enum ExecutionError {
511 #[error("Parse error: {0}")]
512 Parse(String),
513
514 #[error("Runtime error: {0}")]
515 Runtime(String),
516
517 #[error("Execution error: {0}")]
518 Execution(String),
519
520 #[error("IO error: {0}")]
521 Io(#[from] std::io::Error),
522}
523
524impl From<String> for ExecutionError {
526 fn from(err: String) -> Self {
527 ExecutionError::Parse(err)
528 }
529}
530
531async fn prepare_action(
533 action: &ActionInfo,
534 runtime: &dyn ContainerRuntime,
535) -> Result<String, ExecutionError> {
536 if action.is_docker {
537 let image = action.repository.trim_start_matches("docker://");
539
540 runtime
541 .pull_image(image)
542 .await
543 .map_err(|e| ExecutionError::Runtime(format!("Failed to pull Docker image: {}", e)))?;
544
545 return Ok(image.to_string());
546 }
547
548 if action.is_local {
549 let action_dir = Path::new(&action.repository);
551
552 if !action_dir.exists() {
553 return Err(ExecutionError::Execution(format!(
554 "Local action directory not found: {}",
555 action_dir.display()
556 )));
557 }
558
559 let dockerfile = action_dir.join("Dockerfile");
560 if dockerfile.exists() {
561 let tag = format!("wrkflw-local-action:{}", uuid::Uuid::new_v4());
563
564 runtime
565 .build_image(&dockerfile, &tag)
566 .await
567 .map_err(|e| ExecutionError::Runtime(format!("Failed to build image: {}", e)))?;
568
569 return Ok(tag);
570 } else {
571 return Ok("node:20-slim".to_string());
574 }
575 }
576
577 let image = determine_action_image(&action.repository);
579 Ok(image)
580}
581
582fn determine_action_image(repository: &str) -> String {
584 match repository {
586 repo if repo.starts_with("shivammathur/setup-php") => {
588 "composer:latest".to_string() }
590
591 repo if repo.starts_with("actions/setup-python") => "python:3.11-slim".to_string(),
593
594 repo if repo.starts_with("actions/setup-node") => "node:20-slim".to_string(),
596
597 repo if repo.starts_with("actions/setup-java") => "eclipse-temurin:17-jdk".to_string(),
599
600 repo if repo.starts_with("actions/setup-go") => "golang:1.21-slim".to_string(),
602
603 repo if repo.starts_with("actions/setup-dotnet") => {
605 "mcr.microsoft.com/dotnet/sdk:7.0".to_string()
606 }
607
608 repo if repo.starts_with("actions-rs/toolchain")
610 || repo.starts_with("dtolnay/rust-toolchain") =>
611 {
612 "rust:latest".to_string()
613 }
614
615 repo if repo.starts_with("docker/") => "docker:latest".to_string(),
617
618 repo if repo.starts_with("aws-actions/") => "amazon/aws-cli:latest".to_string(),
620
621 _ => {
623 if repository.starts_with("actions/checkout")
625 || repository.starts_with("actions/upload-artifact")
626 || repository.starts_with("actions/download-artifact")
627 || repository.starts_with("actions/cache")
628 {
629 "catthehacker/ubuntu:act-latest".to_string() } else {
631 "node:20-slim".to_string() }
633 }
634 }
635}
636
637async fn execute_job_batch(
638 jobs: &[String],
639 workflow: &WorkflowDefinition,
640 runtime: &dyn ContainerRuntime,
641 env_context: &HashMap<String, String>,
642 verbose: bool,
643 secret_manager: Option<&SecretManager>,
644 secret_masker: Option<&SecretMasker>,
645) -> Result<Vec<JobResult>, ExecutionError> {
646 let futures = jobs.iter().map(|job_name| {
648 execute_job_with_matrix(
649 job_name,
650 workflow,
651 runtime,
652 env_context,
653 verbose,
654 secret_manager,
655 secret_masker,
656 )
657 });
658
659 let result_arrays = future::join_all(futures).await;
660
661 let mut results = Vec::new();
663 for result_array in result_arrays {
664 match result_array {
665 Ok(job_results) => results.extend(job_results),
666 Err(e) => return Err(e),
667 }
668 }
669
670 Ok(results)
671}
672
673struct JobExecutionContext<'a> {
675 job_name: &'a str,
676 workflow: &'a WorkflowDefinition,
677 runtime: &'a dyn ContainerRuntime,
678 env_context: &'a HashMap<String, String>,
679 verbose: bool,
680 secret_manager: Option<&'a SecretManager>,
681 secret_masker: Option<&'a SecretMasker>,
682}
683
684async fn execute_job_with_matrix(
686 job_name: &str,
687 workflow: &WorkflowDefinition,
688 runtime: &dyn ContainerRuntime,
689 env_context: &HashMap<String, String>,
690 verbose: bool,
691 secret_manager: Option<&SecretManager>,
692 secret_masker: Option<&SecretMasker>,
693) -> Result<Vec<JobResult>, ExecutionError> {
694 let job = workflow.jobs.get(job_name).ok_or_else(|| {
696 ExecutionError::Execution(format!("Job '{}' not found in workflow", job_name))
697 })?;
698
699 if let Some(if_condition) = &job.if_condition {
701 let should_run = evaluate_job_condition(if_condition, env_context, workflow);
702 if !should_run {
703 wrkflw_logging::info(&format!(
704 "āļø Skipping job '{}' due to condition: {}",
705 job_name, if_condition
706 ));
707 return Ok(vec![JobResult {
709 name: job_name.to_string(),
710 status: JobStatus::Skipped,
711 steps: Vec::new(),
712 logs: String::new(),
713 }]);
714 }
715 }
716
717 if let Some(matrix_config) = &job.matrix {
719 let combinations = wrkflw_matrix::expand_matrix(matrix_config)
721 .map_err(|e| ExecutionError::Execution(format!("Failed to expand matrix: {}", e)))?;
722
723 if combinations.is_empty() {
724 wrkflw_logging::info(&format!(
725 "Matrix job '{}' has no valid combinations",
726 job_name
727 ));
728 return Ok(Vec::new());
730 }
731
732 wrkflw_logging::info(&format!(
733 "Matrix job '{}' expanded to {} combinations",
734 job_name,
735 combinations.len()
736 ));
737
738 let max_parallel = matrix_config.max_parallel.unwrap_or_else(|| {
740 std::cmp::max(1, num_cpus::get())
742 });
743
744 execute_matrix_combinations(MatrixExecutionContext {
746 job_name,
747 job_template: job,
748 combinations: &combinations,
749 max_parallel,
750 fail_fast: matrix_config.fail_fast.unwrap_or(true),
751 workflow,
752 runtime,
753 env_context,
754 verbose,
755 secret_manager,
756 secret_masker,
757 })
758 .await
759 } else {
760 let ctx = JobExecutionContext {
762 job_name,
763 workflow,
764 runtime,
765 env_context,
766 verbose,
767 secret_manager,
768 secret_masker,
769 };
770 let result = execute_job(ctx).await?;
771 Ok(vec![result])
772 }
773}
774
775#[allow(unused_variables, unused_assignments)]
776async fn execute_job(ctx: JobExecutionContext<'_>) -> Result<JobResult, ExecutionError> {
777 let job = ctx.workflow.jobs.get(ctx.job_name).ok_or_else(|| {
779 ExecutionError::Execution(format!("Job '{}' not found in workflow", ctx.job_name))
780 })?;
781
782 if let Some(uses) = &job.uses {
784 return execute_reusable_workflow_job(&ctx, uses, job.with.as_ref(), job.secrets.as_ref())
785 .await;
786 }
787
788 let mut job_env = ctx.env_context.clone();
790
791 for (key, value) in &job.env {
793 job_env.insert(key.clone(), value.clone());
794 }
795
796 let mut step_results = Vec::new();
798 let mut job_logs = String::new();
799
800 let job_dir = tempfile::tempdir()
802 .map_err(|e| ExecutionError::Execution(format!("Failed to create job directory: {}", e)))?;
803
804 let current_dir = std::env::current_dir().map_err(|e| {
806 ExecutionError::Execution(format!("Failed to get current directory: {}", e))
807 })?;
808
809 wrkflw_logging::info(&format!("Executing job: {}", ctx.job_name));
810
811 let mut job_success = true;
812
813 let runner_image_value = get_runner_image_from_opt(&job.runs_on);
816
817 for (idx, step) in job.steps.iter().enumerate() {
818 let step_result = execute_step(StepExecutionContext {
819 step,
820 step_idx: idx,
821 job_env: &job_env,
822 working_dir: job_dir.path(),
823 runtime: ctx.runtime,
824 workflow: ctx.workflow,
825 runner_image: &runner_image_value,
826 verbose: ctx.verbose,
827 matrix_combination: &None,
828 secret_manager: ctx.secret_manager,
829 secret_masker: ctx.secret_masker,
830 })
831 .await;
832
833 match step_result {
834 Ok(result) => {
835 if result.status == StepStatus::Failure {
837 job_success = false;
838 }
839
840 if ctx.verbose || result.status == StepStatus::Failure {
842 job_logs.push_str(&format!(
843 "\n=== Output from step '{}' ===\n{}\n=== End output ===\n\n",
844 result.name, result.output
845 ));
846 } else {
847 job_logs.push_str(&format!(
849 "Step '{}' completed with status: {:?}\n",
850 result.name, result.status
851 ));
852 }
853
854 step_results.push(result);
855 }
856 Err(e) => {
857 job_success = false;
858 job_logs.push_str(&format!("\n=== ERROR in step {} ===\n{}\n", idx + 1, e));
859
860 step_results.push(StepResult {
862 name: step
863 .name
864 .clone()
865 .unwrap_or_else(|| format!("Step {}", idx + 1)),
866 status: StepStatus::Failure,
867 output: format!("Error: {}", e),
868 });
869
870 break;
872 }
873 }
874 }
875
876 Ok(JobResult {
877 name: ctx.job_name.to_string(),
878 status: if job_success {
879 JobStatus::Success
880 } else {
881 JobStatus::Failure
882 },
883 steps: step_results,
884 logs: job_logs,
885 })
886}
887
888struct MatrixExecutionContext<'a> {
890 job_name: &'a str,
891 job_template: &'a Job,
892 combinations: &'a [MatrixCombination],
893 max_parallel: usize,
894 fail_fast: bool,
895 workflow: &'a WorkflowDefinition,
896 runtime: &'a dyn ContainerRuntime,
897 env_context: &'a HashMap<String, String>,
898 verbose: bool,
899 #[allow(dead_code)] secret_manager: Option<&'a SecretManager>,
901 #[allow(dead_code)] secret_masker: Option<&'a SecretMasker>,
903}
904
905async fn execute_matrix_combinations(
907 ctx: MatrixExecutionContext<'_>,
908) -> Result<Vec<JobResult>, ExecutionError> {
909 let mut results = Vec::new();
910 let mut any_failed = false;
911
912 for chunk in ctx.combinations.chunks(ctx.max_parallel) {
914 if ctx.fail_fast && any_failed {
916 for combination in chunk {
918 let combination_name =
919 wrkflw_matrix::format_combination_name(ctx.job_name, combination);
920 results.push(JobResult {
921 name: combination_name,
922 status: JobStatus::Skipped,
923 steps: Vec::new(),
924 logs: "Job skipped due to previous matrix job failure".to_string(),
925 });
926 }
927 continue;
928 }
929
930 let chunk_futures = chunk.iter().map(|combination| {
932 execute_matrix_job(
933 ctx.job_name,
934 ctx.job_template,
935 combination,
936 ctx.workflow,
937 ctx.runtime,
938 ctx.env_context,
939 ctx.verbose,
940 )
941 });
942
943 let chunk_results = future::join_all(chunk_futures).await;
944
945 for result in chunk_results {
947 match result {
948 Ok(job_result) => {
949 if job_result.status == JobStatus::Failure {
950 any_failed = true;
951 }
952 results.push(job_result);
953 }
954 Err(e) => {
955 any_failed = true;
957 wrkflw_logging::error(&format!("Matrix job failed: {}", e));
958
959 if ctx.fail_fast {
960 return Err(e);
961 }
962 }
963 }
964 }
965 }
966
967 Ok(results)
968}
969
970async fn execute_matrix_job(
972 job_name: &str,
973 job_template: &Job,
974 combination: &MatrixCombination,
975 workflow: &WorkflowDefinition,
976 runtime: &dyn ContainerRuntime,
977 base_env_context: &HashMap<String, String>,
978 verbose: bool,
979) -> Result<JobResult, ExecutionError> {
980 let matrix_job_name = wrkflw_matrix::format_combination_name(job_name, combination);
982
983 wrkflw_logging::info(&format!("Executing matrix job: {}", matrix_job_name));
984
985 let mut job_env = base_env_context.clone();
987 environment::add_matrix_context(&mut job_env, combination);
988
989 for (key, value) in &job_template.env {
991 job_env.insert(key.clone(), value.clone());
993 }
994
995 let mut step_results = Vec::new();
997 let mut job_logs = String::new();
998
999 let job_dir = tempfile::tempdir()
1001 .map_err(|e| ExecutionError::Execution(format!("Failed to create job directory: {}", e)))?;
1002
1003 let current_dir = std::env::current_dir().map_err(|e| {
1005 ExecutionError::Execution(format!("Failed to get current directory: {}", e))
1006 })?;
1007
1008 let job_success = if job_template.steps.is_empty() {
1009 wrkflw_logging::warning(&format!("Job '{}' has no steps", matrix_job_name));
1010 true
1011 } else {
1012 let runner_image_value = get_runner_image_from_opt(&job_template.runs_on);
1015
1016 for (idx, step) in job_template.steps.iter().enumerate() {
1017 match execute_step(StepExecutionContext {
1018 step,
1019 step_idx: idx,
1020 job_env: &job_env,
1021 working_dir: job_dir.path(),
1022 runtime,
1023 workflow,
1024 runner_image: &runner_image_value,
1025 verbose,
1026 matrix_combination: &Some(combination.values.clone()),
1027 secret_manager: None, secret_masker: None,
1029 })
1030 .await
1031 {
1032 Ok(result) => {
1033 job_logs.push_str(&format!("Step: {}\n", result.name));
1034 job_logs.push_str(&format!("Status: {:?}\n", result.status));
1035
1036 if verbose || result.status == StepStatus::Failure {
1038 job_logs.push_str(&result.output);
1039 job_logs.push_str("\n\n");
1040 } else {
1041 job_logs.push('\n');
1042 job_logs.push('\n');
1043 }
1044
1045 step_results.push(result.clone());
1046
1047 if result.status != StepStatus::Success {
1048 return Ok(JobResult {
1050 name: matrix_job_name,
1051 status: JobStatus::Failure,
1052 steps: step_results,
1053 logs: job_logs,
1054 });
1055 }
1056 }
1057 Err(e) => {
1058 job_logs.push_str(&format!("Step execution error: {}\n\n", e));
1060 return Ok(JobResult {
1061 name: matrix_job_name,
1062 status: JobStatus::Failure,
1063 steps: step_results,
1064 logs: job_logs,
1065 });
1066 }
1067 }
1068 }
1069
1070 true
1071 };
1072
1073 Ok(JobResult {
1075 name: matrix_job_name,
1076 status: if job_success {
1077 JobStatus::Success
1078 } else {
1079 JobStatus::Failure
1080 },
1081 steps: step_results,
1082 logs: job_logs,
1083 })
1084}
1085
1086struct StepExecutionContext<'a> {
1088 step: &'a workflow::Step,
1089 step_idx: usize,
1090 job_env: &'a HashMap<String, String>,
1091 working_dir: &'a Path,
1092 runtime: &'a dyn ContainerRuntime,
1093 workflow: &'a WorkflowDefinition,
1094 runner_image: &'a str,
1095 verbose: bool,
1096 #[allow(dead_code)]
1097 matrix_combination: &'a Option<HashMap<String, Value>>,
1098 secret_manager: Option<&'a SecretManager>,
1099 #[allow(dead_code)] secret_masker: Option<&'a SecretMasker>,
1101}
1102
1103async fn execute_step(ctx: StepExecutionContext<'_>) -> Result<StepResult, ExecutionError> {
1104 let step_name = ctx
1105 .step
1106 .name
1107 .clone()
1108 .unwrap_or_else(|| format!("Step {}", ctx.step_idx + 1));
1109
1110 if ctx.verbose {
1111 wrkflw_logging::info(&format!(" Executing step: {}", step_name));
1112 }
1113
1114 let mut step_env = ctx.job_env.clone();
1116
1117 for (key, value) in &ctx.step.env {
1119 let resolved_value = if let Some(secret_manager) = ctx.secret_manager {
1120 let mut substitution = SecretSubstitution::new(secret_manager);
1121 match substitution.substitute(value).await {
1122 Ok(resolved) => resolved,
1123 Err(e) => {
1124 wrkflw_logging::error(&format!(
1125 "Failed to resolve secrets in environment variable {}: {}",
1126 key, e
1127 ));
1128 value.clone()
1129 }
1130 }
1131 } else {
1132 value.clone()
1133 };
1134 step_env.insert(key.clone(), resolved_value);
1135 }
1136
1137 let step_result = if let Some(uses) = &ctx.step.uses {
1139 let action_info = ctx.workflow.resolve_action(uses);
1141
1142 if uses.starts_with("actions/checkout") {
1144 let current_dir = std::env::current_dir().map_err(|e| {
1146 ExecutionError::Execution(format!("Failed to get current dir: {}", e))
1147 })?;
1148
1149 copy_directory_contents(¤t_dir, ctx.working_dir)?;
1151
1152 let output = if ctx.verbose {
1154 let mut detailed_output =
1155 "Emulated checkout: Copied current directory to workspace\n\n".to_string();
1156
1157 detailed_output.push_str("Checkout Details:\n");
1159 detailed_output.push_str(" - Source: Local directory\n");
1160 detailed_output
1161 .push_str(&format!(" - Destination: {}\n", ctx.working_dir.display()));
1162
1163 if let Ok(entries) = std::fs::read_dir(¤t_dir) {
1165 let entry_count = entries.count();
1166 detailed_output.push_str(&format!(
1167 "\nCopied {} top-level items to workspace\n",
1168 entry_count
1169 ));
1170 }
1171
1172 detailed_output
1173 } else {
1174 "Emulated checkout: Copied current directory to workspace".to_string()
1175 };
1176
1177 if ctx.verbose {
1178 println!(" Emulated actions/checkout: copied project files to workspace");
1179 }
1180
1181 StepResult {
1182 name: step_name,
1183 status: StepStatus::Success,
1184 output,
1185 }
1186 } else {
1187 let image = prepare_action(&action_info, ctx.runtime).await?;
1189
1190 if image == "composite" && action_info.is_local {
1192 let action_path = Path::new(&action_info.repository);
1194 execute_composite_action(
1195 ctx.step,
1196 action_path,
1197 &step_env,
1198 ctx.working_dir,
1199 ctx.runtime,
1200 ctx.runner_image,
1201 ctx.verbose,
1202 )
1203 .await?
1204 } else {
1205 let mut cmd = Vec::new();
1209 let mut owned_strings: Vec<String> = Vec::new(); if uses.starts_with("actions-rs/") || uses.starts_with("dtolnay/rust-toolchain") {
1213 wrkflw_logging::info(
1214 "š Detected Rust action - using system Rust installation",
1215 );
1216
1217 if uses.starts_with("actions-rs/toolchain@")
1219 || uses.starts_with("dtolnay/rust-toolchain@")
1220 {
1221 let rustc_version = Command::new("rustc")
1222 .arg("--version")
1223 .output()
1224 .map(|output| String::from_utf8_lossy(&output.stdout).to_string())
1225 .unwrap_or_else(|_| "not found".to_string());
1226
1227 wrkflw_logging::info(&format!(
1228 "š Using system Rust: {}",
1229 rustc_version.trim()
1230 ));
1231
1232 return Ok(StepResult {
1234 name: step_name,
1235 status: StepStatus::Success,
1236 output: format!("Using system Rust: {}", rustc_version.trim()),
1237 });
1238 }
1239
1240 if uses.starts_with("actions-rs/cargo@") {
1242 let cargo_version = Command::new("cargo")
1243 .arg("--version")
1244 .output()
1245 .map(|output| String::from_utf8_lossy(&output.stdout).to_string())
1246 .unwrap_or_else(|_| "not found".to_string());
1247
1248 wrkflw_logging::info(&format!(
1249 "š Using system Rust/Cargo: {}",
1250 cargo_version.trim()
1251 ));
1252
1253 if let Some(with_params) = &ctx.step.with {
1255 if let Some(command) = with_params.get("command") {
1256 wrkflw_logging::info(&format!(
1257 "š Found command parameter: {}",
1258 command
1259 ));
1260
1261 let mut real_command = format!("cargo {}", command);
1263
1264 if let Some(args) = with_params.get("args") {
1266 if !args.is_empty() {
1267 let resolved_args = if args.contains("${{") {
1269 wrkflw_logging::info(&format!(
1270 "š Resolving workflow variables in: {}",
1271 args
1272 ));
1273
1274 let mut resolved =
1276 args.replace("${{ matrix.target }}", "");
1277 resolved = resolved.replace("${{ matrix.os }}", "");
1278
1279 let re_pattern =
1281 regex::Regex::new(r"\$\{\{\s*([^}]+)\s*\}\}")
1282 .unwrap_or_else(|_| {
1283 wrkflw_logging::error(
1284 "Failed to create regex pattern",
1285 );
1286 regex::Regex::new(r"\$\{\{.*?\}\}").unwrap()
1287 });
1288
1289 let resolved =
1290 re_pattern.replace_all(&resolved, "").to_string();
1291 wrkflw_logging::info(&format!(
1292 "š Resolved to: {}",
1293 resolved
1294 ));
1295
1296 resolved.trim().to_string()
1297 } else {
1298 args.clone()
1299 };
1300
1301 if !resolved_args.is_empty() && resolved_args != "--target"
1304 {
1305 real_command.push_str(&format!(" {}", resolved_args));
1306 }
1307 }
1308 }
1309
1310 wrkflw_logging::info(&format!(
1311 "š Running actual command: {}",
1312 real_command
1313 ));
1314
1315 let mut cmd = Command::new("sh");
1317 cmd.arg("-c");
1318 cmd.arg(&real_command);
1319 cmd.current_dir(ctx.working_dir);
1320
1321 for (key, value) in step_env {
1323 cmd.env(key, value);
1324 }
1325
1326 match cmd.output() {
1327 Ok(output) => {
1328 let exit_code = output.status.code().unwrap_or(-1);
1329 let stdout =
1330 String::from_utf8_lossy(&output.stdout).to_string();
1331 let stderr =
1332 String::from_utf8_lossy(&output.stderr).to_string();
1333
1334 return Ok(StepResult {
1335 name: step_name,
1336 status: if exit_code == 0 {
1337 StepStatus::Success
1338 } else {
1339 StepStatus::Failure
1340 },
1341 output: format!("{}\n{}", stdout, stderr),
1342 });
1343 }
1344 Err(e) => {
1345 return Ok(StepResult {
1346 name: step_name,
1347 status: StepStatus::Failure,
1348 output: format!("Failed to execute command: {}", e),
1349 });
1350 }
1351 }
1352 }
1353 }
1354 }
1355 }
1356
1357 if action_info.is_docker {
1358 cmd.push("sh");
1360 cmd.push("-c");
1361 cmd.push("echo 'Executing Docker action'");
1362 } else if action_info.is_local {
1363 let action_dir = Path::new(&action_info.repository);
1365 let action_yaml = action_dir.join("action.yml");
1366
1367 if action_yaml.exists() {
1368 cmd.push("sh");
1371 cmd.push("-c");
1372 cmd.push("echo 'Local action without action.yml'");
1373 } else {
1374 cmd.push("sh");
1375 cmd.push("-c");
1376 cmd.push("echo 'Local action without action.yml'");
1377 }
1378 } else {
1379 if let Err(e) = emulation::handle_special_action(uses).await {
1381 println!(" Warning: Special action handling failed: {}", e);
1383 }
1384
1385 let hide_action_value = ctx
1387 .job_env
1388 .get("WRKFLW_HIDE_ACTION_MESSAGES")
1389 .cloned()
1390 .unwrap_or_else(|| "not set".to_string());
1391
1392 wrkflw_logging::debug(&format!(
1393 "WRKFLW_HIDE_ACTION_MESSAGES value: {}",
1394 hide_action_value
1395 ));
1396
1397 let hide_messages = hide_action_value == "true";
1398 wrkflw_logging::debug(&format!("Should hide messages: {}", hide_messages));
1399
1400 if !hide_messages {
1402 println!(" āļø Would execute GitHub action: {}", uses);
1404 }
1405
1406 let mut should_run_real_command = false;
1408 let mut real_command_parts = Vec::new();
1409
1410 if let Some(with_params) = &ctx.step.with {
1412 if let Some(cmd) = with_params.get("command") {
1414 if ctx.verbose {
1415 wrkflw_logging::info(&format!(
1416 "š Found command parameter: {}",
1417 cmd
1418 ));
1419 }
1420
1421 if uses.contains("cargo") || uses.contains("rust") {
1423 real_command_parts.push("cargo".to_string());
1425 real_command_parts.push(cmd.clone());
1426 should_run_real_command = true;
1427 } else if uses.contains("node") || uses.contains("npm") {
1428 if cmd == "npm" || cmd == "yarn" || cmd == "pnpm" {
1430 real_command_parts.push(cmd.clone());
1431 } else {
1432 real_command_parts.push("npm".to_string());
1433 real_command_parts.push("run".to_string());
1434 real_command_parts.push(cmd.clone());
1435 }
1436 should_run_real_command = true;
1437 } else if uses.contains("python") || uses.contains("pip") {
1438 if cmd == "pip" {
1440 real_command_parts.push("pip".to_string());
1441 } else {
1442 real_command_parts.push("python".to_string());
1443 real_command_parts.push("-m".to_string());
1444 real_command_parts.push(cmd.clone());
1445 }
1446 should_run_real_command = true;
1447 } else {
1448 real_command_parts.push(cmd.clone());
1450 should_run_real_command = true;
1451 }
1452
1453 if let Some(args) = with_params.get("args") {
1455 if !args.is_empty() {
1456 let resolved_args = if args.contains("${{") {
1458 wrkflw_logging::info(&format!(
1459 "š Resolving workflow variables in: {}",
1460 args
1461 ));
1462
1463 let mut resolved = args.replace("${{ matrix.target }}", "");
1465 resolved = resolved.replace("${{ matrix.os }}", "");
1466
1467 let re_pattern =
1469 regex::Regex::new(r"\$\{\{\s*([^}]+)\s*\}\}")
1470 .unwrap_or_else(|_| {
1471 wrkflw_logging::error(
1472 "Failed to create regex pattern",
1473 );
1474 regex::Regex::new(r"\$\{\{.*?\}\}").unwrap()
1475 });
1476
1477 let resolved =
1478 re_pattern.replace_all(&resolved, "").to_string();
1479 wrkflw_logging::info(&format!(
1480 "š Resolved to: {}",
1481 resolved
1482 ));
1483
1484 resolved.trim().to_string()
1485 } else {
1486 args.clone()
1487 };
1488
1489 if !resolved_args.is_empty() {
1491 real_command_parts.push(resolved_args);
1492 }
1493 }
1494 }
1495 }
1496 }
1497
1498 if should_run_real_command && !real_command_parts.is_empty() {
1499 let command_str = real_command_parts.join(" ");
1501 wrkflw_logging::info(&format!(
1502 "š Running actual command: {}",
1503 command_str
1504 ));
1505
1506 cmd.clear();
1508 cmd.push("sh");
1509 cmd.push("-c");
1510 owned_strings.push(command_str);
1511 cmd.push(owned_strings.last().unwrap());
1512 } else {
1513 cmd.clear();
1515 cmd.push("sh");
1516 cmd.push("-c");
1517
1518 let echo_msg = format!("echo 'Would execute GitHub action: {}'", uses);
1519 owned_strings.push(echo_msg);
1520 cmd.push(owned_strings.last().unwrap());
1521 }
1522 }
1523
1524 if let Some(with_params) = &ctx.step.with {
1526 for (key, value) in with_params {
1527 step_env.insert(format!("INPUT_{}", key.to_uppercase()), value.clone());
1528 }
1529 }
1530
1531 let env_vars: Vec<(&str, &str)> = step_env
1533 .iter()
1534 .map(|(k, v)| (k.as_str(), v.as_str()))
1535 .collect();
1536
1537 let container_workspace = Path::new("/github/workspace");
1539
1540 let volumes: Vec<(&Path, &Path)> = vec![(ctx.working_dir, container_workspace)];
1542
1543 let output = ctx
1544 .runtime
1545 .run_container(
1546 &image,
1547 &cmd.to_vec(),
1548 &env_vars,
1549 container_workspace,
1550 &volumes,
1551 )
1552 .await
1553 .map_err(|e| ExecutionError::Runtime(format!("{}", e)))?;
1554
1555 if output.exit_code == 0 {
1557 let output_text = if ctx.verbose
1559 && uses.contains('/')
1560 && !uses.starts_with("./")
1561 {
1562 let mut detailed_output =
1563 format!("Would execute GitHub action: {}\n", uses);
1564
1565 if let Some(with_params) = &ctx.step.with {
1567 detailed_output.push_str("\nAction inputs:\n");
1568 for (key, value) in with_params {
1569 detailed_output.push_str(&format!(" {}: {}\n", key, value));
1570 }
1571 }
1572
1573 detailed_output.push_str("\nEnvironment variables:\n");
1575 for (key, value) in step_env.iter() {
1576 if key.starts_with("GITHUB_") || key.starts_with("INPUT_") {
1577 detailed_output.push_str(&format!(" {}: {}\n", key, value));
1578 }
1579 }
1580
1581 detailed_output
1583 .push_str(&format!("\nOutput:\n{}\n{}", output.stdout, output.stderr));
1584 detailed_output
1585 } else {
1586 format!("{}\n{}", output.stdout, output.stderr)
1587 };
1588
1589 if output.exit_code != 0 && (uses.contains("cargo") || uses.contains("rust")) {
1591 let mut error_details = format!(
1593 "\n\nā Command failed with exit code: {}\n",
1594 output.exit_code
1595 );
1596
1597 error_details.push_str(&format!("Command: {}\n", cmd.join(" ")));
1599
1600 error_details.push_str("\nEnvironment:\n");
1602 for (key, value) in step_env.iter() {
1603 if key.starts_with("GITHUB_")
1604 || key.starts_with("INPUT_")
1605 || key.starts_with("RUST")
1606 {
1607 error_details.push_str(&format!(" {}: {}\n", key, value));
1608 }
1609 }
1610
1611 error_details.push_str("\nDetailed output:\n");
1613 error_details.push_str(&output.stdout);
1614 error_details.push_str(&output.stderr);
1615
1616 return Ok(StepResult {
1618 name: step_name,
1619 status: StepStatus::Failure,
1620 output: format!("{}\n{}", output_text, error_details),
1621 });
1622 }
1623
1624 StepResult {
1625 name: step_name,
1626 status: if output.exit_code == 0 {
1627 StepStatus::Success
1628 } else {
1629 StepStatus::Failure
1630 },
1631 output: format!(
1632 "Exit code: {}
1633{}
1634{}",
1635 output.exit_code, output.stdout, output.stderr
1636 ),
1637 }
1638 } else {
1639 StepResult {
1640 name: step_name,
1641 status: StepStatus::Failure,
1642 output: format!(
1643 "Exit code: {}\n{}\n{}",
1644 output.exit_code, output.stdout, output.stderr
1645 ),
1646 }
1647 }
1648 }
1649 }
1650 } else if let Some(run) = &ctx.step.run {
1651 let mut output = String::new();
1653 let mut status = StepStatus::Success;
1654 let mut error_details = None;
1655
1656 let resolved_run = if let Some(secret_manager) = ctx.secret_manager {
1658 let mut substitution = SecretSubstitution::new(secret_manager);
1659 match substitution.substitute(run).await {
1660 Ok(resolved) => resolved,
1661 Err(e) => {
1662 return Ok(StepResult {
1663 name: step_name,
1664 status: StepStatus::Failure,
1665 output: format!("Secret substitution failed: {}", e),
1666 });
1667 }
1668 }
1669 } else {
1670 run.clone()
1671 };
1672
1673 let is_cargo_cmd = resolved_run.trim().starts_with("cargo");
1675
1676 let cmd_parts = vec!["bash", "-c", &resolved_run];
1679
1680 let env_vars: Vec<(&str, &str)> = step_env
1682 .iter()
1683 .map(|(k, v)| (k.as_str(), v.as_str()))
1684 .collect();
1685
1686 let container_workspace = Path::new("/github/workspace");
1688
1689 let volumes: Vec<(&Path, &Path)> = vec![(ctx.working_dir, container_workspace)];
1691
1692 match ctx
1694 .runtime
1695 .run_container(
1696 ctx.runner_image,
1697 &cmd_parts,
1698 &env_vars,
1699 container_workspace,
1700 &volumes,
1701 )
1702 .await
1703 {
1704 Ok(container_output) => {
1705 output.push_str(&format!("Command: {}\n\n", run));
1707
1708 if !container_output.stdout.is_empty() {
1709 output.push_str("Standard Output:\n");
1710 output.push_str(&container_output.stdout);
1711 output.push('\n');
1712 }
1713
1714 if !container_output.stderr.is_empty() {
1715 output.push_str("Standard Error:\n");
1716 output.push_str(&container_output.stderr);
1717 output.push('\n');
1718 }
1719
1720 if container_output.exit_code != 0 {
1721 status = StepStatus::Failure;
1722
1723 if is_cargo_cmd {
1725 let mut error_msg = String::new();
1726 error_msg.push_str(&format!(
1727 "\nCargo command failed with exit code {}\n",
1728 container_output.exit_code
1729 ));
1730 error_msg.push_str("Common causes for cargo command failures:\n");
1731
1732 if run.contains("fmt") {
1733 error_msg.push_str(
1734 "- Code formatting issues. Run 'cargo fmt' locally to fix.\n",
1735 );
1736 } else if run.contains("clippy") {
1737 error_msg.push_str("- Linter warnings treated as errors. Run 'cargo clippy' locally to see details.\n");
1738 } else if run.contains("test") {
1739 error_msg.push_str("- Test failures. Run 'cargo test' locally to see which tests failed.\n");
1740 } else if run.contains("build") {
1741 error_msg.push_str(
1742 "- Compilation errors. Check the error messages above.\n",
1743 );
1744 }
1745
1746 error_details = Some(error_msg);
1747 }
1748 }
1749 }
1750 Err(e) => {
1751 status = StepStatus::Failure;
1752 output.push_str(&format!("Error executing command: {}\n", e));
1753 }
1754 }
1755
1756 if let Some(details) = error_details {
1758 output.push_str(&details);
1759 }
1760
1761 StepResult {
1762 name: step_name,
1763 status,
1764 output,
1765 }
1766 } else {
1767 return Ok(StepResult {
1768 name: step_name,
1769 status: StepStatus::Skipped,
1770 output: "Step has neither 'uses' nor 'run'".to_string(),
1771 });
1772 };
1773
1774 Ok(step_result)
1775}
1776
1777fn create_gitignore_matcher(
1779 dir: &Path,
1780) -> Result<Option<ignore::gitignore::Gitignore>, ExecutionError> {
1781 let mut builder = GitignoreBuilder::new(dir);
1782
1783 let gitignore_path = dir.join(".gitignore");
1785 if gitignore_path.exists() {
1786 builder.add(&gitignore_path);
1787 }
1788
1789 builder.add_line(None, "target/").map_err(|e| {
1791 ExecutionError::Execution(format!("Failed to add default ignore pattern: {}", e))
1792 })?;
1793 builder.add_line(None, ".git/").map_err(|e| {
1794 ExecutionError::Execution(format!("Failed to add default ignore pattern: {}", e))
1795 })?;
1796
1797 match builder.build() {
1798 Ok(gitignore) => Ok(Some(gitignore)),
1799 Err(e) => {
1800 wrkflw_logging::warning(&format!("Failed to build gitignore matcher: {}", e));
1801 Ok(None)
1802 }
1803 }
1804}
1805
1806fn copy_directory_contents(from: &Path, to: &Path) -> Result<(), ExecutionError> {
1807 copy_directory_contents_with_gitignore(from, to, None)
1808}
1809
1810fn copy_directory_contents_with_gitignore(
1811 from: &Path,
1812 to: &Path,
1813 gitignore: Option<&ignore::gitignore::Gitignore>,
1814) -> Result<(), ExecutionError> {
1815 let root_gitignore;
1817 let gitignore = if gitignore.is_none() {
1818 root_gitignore = create_gitignore_matcher(from)?;
1819 root_gitignore.as_ref()
1820 } else {
1821 gitignore
1822 };
1823
1824 wrkflw_logging::debug(&format!(
1826 "Copying directory contents from {} to {}",
1827 from.display(),
1828 to.display()
1829 ));
1830
1831 for entry in std::fs::read_dir(from)
1832 .map_err(|e| ExecutionError::Execution(format!("Failed to read directory: {}", e)))?
1833 {
1834 let entry =
1835 entry.map_err(|e| ExecutionError::Execution(format!("Failed to read entry: {}", e)))?;
1836 let path = entry.path();
1837
1838 if let Some(gitignore) = gitignore {
1840 let relative_path = path.strip_prefix(from).unwrap_or(&path);
1841 match gitignore.matched(relative_path, path.is_dir()) {
1842 Match::Ignore(_) => {
1843 wrkflw_logging::debug(&format!("Skipping ignored file/directory: {path:?}"));
1844 continue;
1845 }
1846 Match::Whitelist(_) | Match::None => {
1847 }
1849 }
1850 }
1851
1852 let file_name = match path.file_name() {
1856 Some(name) => name.to_string_lossy(),
1857 None => {
1858 return Err(ExecutionError::Execution(format!(
1859 "Failed to get file name from path: {:?}",
1860 path
1861 )));
1862 }
1863 };
1864
1865 if file_name.starts_with(".")
1867 && file_name != ".gitignore"
1868 && file_name != ".github"
1869 && !file_name.starts_with(".env")
1870 {
1871 continue;
1872 }
1873
1874 let dest_path = match path.file_name() {
1875 Some(name) => to.join(name),
1876 None => {
1877 return Err(ExecutionError::Execution(format!(
1878 "Failed to get file name from path: {:?}",
1879 path
1880 )));
1881 }
1882 };
1883
1884 if path.is_dir() {
1885 std::fs::create_dir_all(&dest_path)
1886 .map_err(|e| ExecutionError::Execution(format!("Failed to create dir: {}", e)))?;
1887
1888 copy_directory_contents_with_gitignore(&path, &dest_path, gitignore)?;
1890 } else {
1891 std::fs::copy(&path, &dest_path)
1892 .map_err(|e| ExecutionError::Execution(format!("Failed to copy file: {}", e)))?;
1893 }
1894 }
1895
1896 Ok(())
1897}
1898
1899fn get_runner_image(runs_on: &str) -> String {
1900 match runs_on.trim() {
1902 "ubuntu-latest" => "ubuntu:latest",
1904 "ubuntu-22.04" => "ubuntu:22.04",
1905 "ubuntu-20.04" => "ubuntu:20.04",
1906 "ubuntu-18.04" => "ubuntu:18.04",
1907
1908 "ubuntu-latest-medium" => "catthehacker/ubuntu:act-latest",
1910 "ubuntu-22.04-medium" => "catthehacker/ubuntu:act-22.04",
1911 "ubuntu-20.04-medium" => "catthehacker/ubuntu:act-20.04",
1912 "ubuntu-18.04-medium" => "catthehacker/ubuntu:act-18.04",
1913
1914 "ubuntu-latest-large" => "catthehacker/ubuntu:full-latest",
1916 "ubuntu-22.04-large" => "catthehacker/ubuntu:full-22.04",
1917 "ubuntu-20.04-large" => "catthehacker/ubuntu:full-20.04",
1918 "ubuntu-18.04-large" => "catthehacker/ubuntu:full-18.04",
1919
1920 "macos-latest" => "rust:latest",
1922 "macos-12" => "rust:latest", "macos-11" => "rust:latest", "macos-10.15" => "rust:latest", "windows-latest" => "mcr.microsoft.com/windows/servercore:ltsc2022",
1928 "windows-2022" => "mcr.microsoft.com/windows/servercore:ltsc2022",
1929 "windows-2019" => "mcr.microsoft.com/windows/servercore:ltsc2019",
1930
1931 "python-latest" => "python:3.11-slim",
1933 "python-3.11" => "python:3.11-slim",
1934 "python-3.10" => "python:3.10-slim",
1935 "python-3.9" => "python:3.9-slim",
1936 "python-3.8" => "python:3.8-slim",
1937
1938 "node-latest" => "node:20-slim",
1939 "node-20" => "node:20-slim",
1940 "node-18" => "node:18-slim",
1941 "node-16" => "node:16-slim",
1942
1943 "java-latest" => "eclipse-temurin:17-jdk",
1944 "java-17" => "eclipse-temurin:17-jdk",
1945 "java-11" => "eclipse-temurin:11-jdk",
1946 "java-8" => "eclipse-temurin:8-jdk",
1947
1948 "go-latest" => "golang:1.21-slim",
1949 "go-1.21" => "golang:1.21-slim",
1950 "go-1.20" => "golang:1.20-slim",
1951 "go-1.19" => "golang:1.19-slim",
1952
1953 "dotnet-latest" => "mcr.microsoft.com/dotnet/sdk:7.0",
1954 "dotnet-7.0" => "mcr.microsoft.com/dotnet/sdk:7.0",
1955 "dotnet-6.0" => "mcr.microsoft.com/dotnet/sdk:6.0",
1956 "dotnet-5.0" => "mcr.microsoft.com/dotnet/sdk:5.0",
1957
1958 _ => {
1960 let runs_on_lower = runs_on.trim().to_lowercase();
1962 if runs_on_lower.starts_with("macos") {
1963 "rust:latest" } else if runs_on_lower.starts_with("windows") {
1965 "mcr.microsoft.com/windows/servercore:ltsc2022" } else if runs_on_lower.starts_with("python") {
1967 "python:3.11-slim" } else if runs_on_lower.starts_with("node") {
1969 "node:20-slim" } else if runs_on_lower.starts_with("java") {
1971 "eclipse-temurin:17-jdk" } else if runs_on_lower.starts_with("go") {
1973 "golang:1.21-slim" } else if runs_on_lower.starts_with("dotnet") {
1975 "mcr.microsoft.com/dotnet/sdk:7.0" } else {
1977 "ubuntu:latest" }
1979 }
1980 }
1981 .to_string()
1982}
1983
1984fn get_runner_image_from_opt(runs_on: &Option<Vec<String>>) -> String {
1985 let default = "ubuntu-latest";
1986 let ro = runs_on
1987 .as_ref()
1988 .and_then(|vec| vec.first())
1989 .map(|s| s.as_str())
1990 .unwrap_or(default);
1991 get_runner_image(ro)
1992}
1993
1994async fn execute_reusable_workflow_job(
1995 ctx: &JobExecutionContext<'_>,
1996 uses: &str,
1997 with: Option<&HashMap<String, String>>,
1998 secrets: Option<&serde_yaml::Value>,
1999) -> Result<JobResult, ExecutionError> {
2000 wrkflw_logging::info(&format!(
2001 "Executing reusable workflow job '{}' -> {}",
2002 ctx.job_name, uses
2003 ));
2004
2005 enum UsesRef<'a> {
2007 LocalPath(&'a str),
2008 Remote {
2009 owner: String,
2010 repo: String,
2011 path: String,
2012 r#ref: String,
2013 },
2014 }
2015
2016 let uses_ref = if uses.starts_with("./") || uses.starts_with('/') {
2017 UsesRef::LocalPath(uses)
2018 } else {
2019 let parts: Vec<&str> = uses.split('@').collect();
2021 if parts.len() != 2 {
2022 return Err(ExecutionError::Execution(format!(
2023 "Invalid reusable workflow reference: {}",
2024 uses
2025 )));
2026 }
2027 let left = parts[0];
2028 let r#ref = parts[1].to_string();
2029 let mut segs = left.splitn(3, '/');
2030 let owner = segs.next().unwrap_or("").to_string();
2031 let repo = segs.next().unwrap_or("").to_string();
2032 let path = segs.next().unwrap_or("").to_string();
2033 if owner.is_empty() || repo.is_empty() || path.is_empty() {
2034 return Err(ExecutionError::Execution(format!(
2035 "Invalid reusable workflow reference: {}",
2036 uses
2037 )));
2038 }
2039 UsesRef::Remote {
2040 owner,
2041 repo,
2042 path,
2043 r#ref,
2044 }
2045 };
2046
2047 let workflow_path = match uses_ref {
2049 UsesRef::LocalPath(p) => {
2050 let current_dir = std::env::current_dir().map_err(|e| {
2052 ExecutionError::Execution(format!("Failed to get current dir: {}", e))
2053 })?;
2054 let path = current_dir.join(p);
2055 if !path.exists() {
2056 return Err(ExecutionError::Execution(format!(
2057 "Reusable workflow not found at path: {}",
2058 path.display()
2059 )));
2060 }
2061 path
2062 }
2063 UsesRef::Remote {
2064 owner,
2065 repo,
2066 path,
2067 r#ref,
2068 } => {
2069 let tempdir = tempfile::tempdir().map_err(|e| {
2071 ExecutionError::Execution(format!("Failed to create temp dir: {}", e))
2072 })?;
2073 let repo_url = format!("https://github.com/{}/{}.git", owner, repo);
2074 let status = Command::new("git")
2076 .arg("clone")
2077 .arg("--depth")
2078 .arg("1")
2079 .arg("--branch")
2080 .arg(&r#ref)
2081 .arg(&repo_url)
2082 .arg(tempdir.path())
2083 .status()
2084 .map_err(|e| ExecutionError::Execution(format!("Failed to execute git: {}", e)))?;
2085 if !status.success() {
2086 return Err(ExecutionError::Execution(format!(
2087 "Failed to clone {}@{}",
2088 repo_url, r#ref
2089 )));
2090 }
2091 let joined = tempdir.path().join(path);
2092 if !joined.exists() {
2093 return Err(ExecutionError::Execution(format!(
2094 "Reusable workflow file not found in repo: {}",
2095 joined.display()
2096 )));
2097 }
2098 joined
2099 }
2100 };
2101
2102 let called = parse_workflow(&workflow_path)?;
2104
2105 let mut child_env = ctx.env_context.clone();
2107 if let Some(with_map) = with {
2108 for (k, v) in with_map {
2109 child_env.insert(format!("INPUT_{}", k.to_uppercase()), v.clone());
2110 }
2111 }
2112 if let Some(secrets_val) = secrets {
2113 if let Some(map) = secrets_val.as_mapping() {
2114 for (k, v) in map {
2115 if let (Some(key), Some(value)) = (k.as_str(), v.as_str()) {
2116 child_env.insert(format!("SECRET_{}", key.to_uppercase()), value.to_string());
2117 }
2118 }
2119 }
2120 }
2121
2122 let plan = dependency::resolve_dependencies(&called)?;
2124 let mut all_results = Vec::new();
2125 let mut any_failed = false;
2126 for batch in plan {
2127 let results = execute_job_batch(
2128 &batch,
2129 &called,
2130 ctx.runtime,
2131 &child_env,
2132 ctx.verbose,
2133 None,
2134 None,
2135 )
2136 .await?;
2137 for r in &results {
2138 if r.status == JobStatus::Failure {
2139 any_failed = true;
2140 }
2141 }
2142 all_results.extend(results);
2143 }
2144
2145 let mut logs = String::new();
2147 logs.push_str(&format!("Called workflow: {}\n", workflow_path.display()));
2148 for r in &all_results {
2149 logs.push_str(&format!("- {}: {:?}\n", r.name, r.status));
2150 }
2151
2152 let summary_step = StepResult {
2154 name: format!("Run reusable workflow: {}", uses),
2155 status: if any_failed {
2156 StepStatus::Failure
2157 } else {
2158 StepStatus::Success
2159 },
2160 output: logs.clone(),
2161 };
2162
2163 Ok(JobResult {
2164 name: ctx.job_name.to_string(),
2165 status: if any_failed {
2166 JobStatus::Failure
2167 } else {
2168 JobStatus::Success
2169 },
2170 steps: vec![summary_step],
2171 logs,
2172 })
2173}
2174
2175#[allow(dead_code)]
2176async fn prepare_runner_image(
2177 image: &str,
2178 runtime: &dyn ContainerRuntime,
2179 verbose: bool,
2180) -> Result<(), ExecutionError> {
2181 if let Err(e) = runtime.pull_image(image).await {
2183 wrkflw_logging::warning(&format!("Failed to pull image {}: {}", image, e));
2184 }
2185
2186 let language_info = extract_language_info(image);
2188 if let Some((language, version)) = language_info {
2189 if let Ok(custom_image) = runtime
2191 .prepare_language_environment(language, version, None)
2192 .await
2193 .map_err(|e| ExecutionError::Runtime(e.to_string()))
2194 {
2195 if verbose {
2196 wrkflw_logging::info(&format!("Using customized image: {}", custom_image));
2197 }
2198 return Ok(());
2199 }
2200 }
2201
2202 Ok(())
2203}
2204
2205#[allow(dead_code)]
2206fn extract_language_info(image: &str) -> Option<(&'static str, Option<&str>)> {
2207 let image_lower = image.to_lowercase();
2208
2209 if image_lower.starts_with("python:") {
2211 Some(("python", Some(&image[7..])))
2212 } else if image_lower.starts_with("node:") {
2213 Some(("node", Some(&image[5..])))
2214 } else if image_lower.starts_with("eclipse-temurin:") {
2215 Some(("java", Some(&image[15..])))
2216 } else if image_lower.starts_with("golang:") {
2217 Some(("go", Some(&image[6..])))
2218 } else if image_lower.starts_with("mcr.microsoft.com/dotnet/sdk:") {
2219 Some(("dotnet", Some(&image[29..])))
2220 } else if image_lower.starts_with("rust:") {
2221 Some(("rust", Some(&image[5..])))
2222 } else {
2223 None
2224 }
2225}
2226
2227async fn execute_composite_action(
2228 step: &workflow::Step,
2229 action_path: &Path,
2230 job_env: &HashMap<String, String>,
2231 working_dir: &Path,
2232 runtime: &dyn ContainerRuntime,
2233 runner_image: &str,
2234 verbose: bool,
2235) -> Result<StepResult, ExecutionError> {
2236 let action_yaml = action_path.join("action.yml");
2238 let action_yaml_alt = action_path.join("action.yaml");
2239
2240 let action_file = if action_yaml.exists() {
2241 action_yaml
2242 } else if action_yaml_alt.exists() {
2243 action_yaml_alt
2244 } else {
2245 return Err(ExecutionError::Execution(format!(
2246 "No action.yml or action.yaml found in {}",
2247 action_path.display()
2248 )));
2249 };
2250
2251 let action_content = fs::read_to_string(&action_file)
2253 .map_err(|e| ExecutionError::Execution(format!("Failed to read action file: {}", e)))?;
2254
2255 let action_def: serde_yaml::Value = serde_yaml::from_str(&action_content)
2256 .map_err(|e| ExecutionError::Execution(format!("Invalid action YAML: {}", e)))?;
2257
2258 match action_def.get("runs").and_then(|v| v.get("using")) {
2260 Some(serde_yaml::Value::String(using)) if using == "composite" => {
2261 let steps = match action_def.get("runs").and_then(|v| v.get("steps")) {
2263 Some(serde_yaml::Value::Sequence(steps)) => steps,
2264 _ => {
2265 return Err(ExecutionError::Execution(
2266 "Composite action is missing steps".to_string(),
2267 ))
2268 }
2269 };
2270
2271 let mut action_env = job_env.clone();
2273 if let Some(inputs_def) = action_def.get("inputs") {
2274 if let Some(inputs_map) = inputs_def.as_mapping() {
2275 for (input_name, input_def) in inputs_map {
2276 if let Some(input_name_str) = input_name.as_str() {
2277 let default_value = input_def
2279 .get("default")
2280 .and_then(|v| v.as_str())
2281 .unwrap_or("");
2282
2283 let input_value = step
2285 .with
2286 .as_ref()
2287 .and_then(|with| with.get(input_name_str))
2288 .unwrap_or(&default_value.to_string())
2289 .clone();
2290
2291 action_env.insert(
2293 format!("INPUT_{}", input_name_str.to_uppercase()),
2294 input_value,
2295 );
2296 }
2297 }
2298 }
2299 }
2300
2301 let mut step_outputs = Vec::new();
2303 for (idx, step_def) in steps.iter().enumerate() {
2304 let composite_step = match convert_yaml_to_step(step_def) {
2306 Ok(step) => step,
2307 Err(e) => {
2308 return Err(ExecutionError::Execution(format!(
2309 "Failed to process composite action step {}: {}",
2310 idx + 1,
2311 e
2312 )))
2313 }
2314 };
2315
2316 let step_result = Box::pin(execute_step(StepExecutionContext {
2318 step: &composite_step,
2319 step_idx: idx,
2320 job_env: &action_env,
2321 working_dir,
2322 runtime,
2323 workflow: &workflow::WorkflowDefinition {
2324 name: "Composite Action".to_string(),
2325 on: vec![],
2326 on_raw: serde_yaml::Value::Null,
2327 jobs: HashMap::new(),
2328 },
2329 runner_image,
2330 verbose,
2331 matrix_combination: &None,
2332 secret_manager: None, secret_masker: None,
2334 }))
2335 .await?;
2336
2337 step_outputs.push(format!("Step {}: {}", idx + 1, step_result.output));
2339
2340 if step_result.status == StepStatus::Failure {
2342 return Ok(StepResult {
2343 name: step
2344 .name
2345 .clone()
2346 .unwrap_or_else(|| "Composite Action".to_string()),
2347 status: StepStatus::Failure,
2348 output: step_outputs.join("\n"),
2349 });
2350 }
2351 }
2352
2353 let output = if verbose {
2355 let mut detailed_output = format!(
2356 "Executed composite action from: {}\n\n",
2357 action_path.display()
2358 );
2359
2360 if let Ok(action_content) =
2362 serde_yaml::from_str::<serde_yaml::Value>(&action_content)
2363 {
2364 if let Some(name) = action_content.get("name").and_then(|v| v.as_str()) {
2365 detailed_output.push_str(&format!("Action name: {}\n", name));
2366 }
2367
2368 if let Some(description) =
2369 action_content.get("description").and_then(|v| v.as_str())
2370 {
2371 detailed_output.push_str(&format!("Description: {}\n", description));
2372 }
2373
2374 detailed_output.push('\n');
2375 }
2376
2377 detailed_output.push_str("Step outputs:\n");
2379 for output in &step_outputs {
2380 detailed_output.push_str(&format!("{}\n", output));
2381 }
2382
2383 detailed_output
2384 } else {
2385 format!(
2386 "Executed composite action with {} steps",
2387 step_outputs.len()
2388 )
2389 };
2390
2391 Ok(StepResult {
2392 name: step
2393 .name
2394 .clone()
2395 .unwrap_or_else(|| "Composite Action".to_string()),
2396 status: StepStatus::Success,
2397 output,
2398 })
2399 }
2400 _ => Err(ExecutionError::Execution(
2401 "Action is not a composite action or has invalid format".to_string(),
2402 )),
2403 }
2404}
2405
2406fn convert_yaml_to_step(step_yaml: &serde_yaml::Value) -> Result<workflow::Step, String> {
2408 let name = step_yaml
2410 .get("name")
2411 .and_then(|v| v.as_str())
2412 .map(|s| s.to_string());
2413
2414 let uses = step_yaml
2415 .get("uses")
2416 .and_then(|v| v.as_str())
2417 .map(|s| s.to_string());
2418
2419 let run = step_yaml
2420 .get("run")
2421 .and_then(|v| v.as_str())
2422 .map(|s| s.to_string());
2423
2424 let shell = step_yaml
2425 .get("shell")
2426 .and_then(|v| v.as_str())
2427 .map(|s| s.to_string());
2428
2429 let with = step_yaml.get("with").and_then(|v| v.as_mapping()).map(|m| {
2430 let mut with_map = HashMap::new();
2431 for (k, v) in m {
2432 if let (Some(key), Some(value)) = (k.as_str(), v.as_str()) {
2433 with_map.insert(key.to_string(), value.to_string());
2434 }
2435 }
2436 with_map
2437 });
2438
2439 let env = step_yaml
2440 .get("env")
2441 .and_then(|v| v.as_mapping())
2442 .map(|m| {
2443 let mut env_map = HashMap::new();
2444 for (k, v) in m {
2445 if let (Some(key), Some(value)) = (k.as_str(), v.as_str()) {
2446 env_map.insert(key.to_string(), value.to_string());
2447 }
2448 }
2449 env_map
2450 })
2451 .unwrap_or_default();
2452
2453 let final_run = run;
2455
2456 let continue_on_error = step_yaml.get("continue-on-error").and_then(|v| v.as_bool());
2458
2459 Ok(workflow::Step {
2460 name,
2461 uses,
2462 run: final_run,
2463 with,
2464 env,
2465 continue_on_error,
2466 })
2467}
2468
2469fn evaluate_job_condition(
2472 condition: &str,
2473 env_context: &HashMap<String, String>,
2474 workflow: &WorkflowDefinition,
2475) -> bool {
2476 wrkflw_logging::debug(&format!("Evaluating condition: {}", condition));
2477
2478 if condition == "true" {
2483 return true;
2484 }
2485 if condition == "false" {
2486 return false;
2487 }
2488
2489 if condition.contains("github.event.pull_request.draft == false") {
2491 return true;
2493 }
2494
2495 if condition.contains("needs.") && condition.contains(".outputs.") {
2497 wrkflw_logging::debug(
2500 "Evaluating needs.outputs condition - defaulting to false for local execution",
2501 );
2502 return false;
2503 }
2504
2505 wrkflw_logging::warning(&format!(
2507 "Unknown condition pattern: '{}' - defaulting to true",
2508 condition
2509 ));
2510 true
2511}