cuenv_github/workflow/
emitter.rs

1//! GitHub Actions Workflow Emitter
2//!
3//! Transforms cuenv IR into GitHub Actions workflow YAML files.
4
5use crate::workflow::schema::{
6    Concurrency, Environment, Job, PermissionLevel, Permissions, PullRequestTrigger, PushTrigger,
7    ReleaseTrigger, RunsOn, ScheduleTrigger, Step, Workflow, WorkflowDispatchTrigger,
8    WorkflowInput, WorkflowTriggers,
9};
10use cuenv_ci::emitter::{Emitter, EmitterError, EmitterResult};
11use cuenv_ci::ir::{IntermediateRepresentation, OutputType, Task, TriggerCondition};
12use indexmap::IndexMap;
13use std::collections::HashMap;
14
15/// GitHub Actions workflow emitter
16///
17/// Transforms cuenv IR into GitHub Actions workflow YAML that can be
18/// committed to `.github/workflows/`.
19///
20/// # IR to GitHub Actions Mapping
21///
22/// | IR Field | GitHub Actions |
23/// |----------|----------------|
24/// | `pipeline.name` | Workflow `name:` |
25/// | `pipeline.trigger.branch` | `on.push.branches` / `on.pull_request.branches` |
26/// | `task.id` | Job key |
27/// | `task.command` | Step with `run: cuenv task {task.id}` |
28/// | `task.depends_on` | Job `needs:` |
29/// | `task.manual_approval` | Job with `environment:` |
30/// | `task.concurrency_group` | Job-level `concurrency:` |
31/// | `task.resources.tags` | `runs-on:` |
32/// | `task.outputs` (orchestrator) | `actions/upload-artifact` step |
33#[derive(Debug, Clone)]
34pub struct GitHubActionsEmitter {
35    /// Default runner for jobs
36    pub runner: String,
37    /// Include Nix installation steps
38    pub use_nix: bool,
39    /// Include Cachix caching steps
40    pub use_cachix: bool,
41    /// Cachix cache name
42    pub cachix_name: Option<String>,
43    /// Cachix auth token secret name
44    pub cachix_auth_token_secret: String,
45    /// Default paths to ignore in triggers
46    pub default_paths_ignore: Vec<String>,
47    /// Include cuenv build step (via nix build)
48    pub build_cuenv: bool,
49    /// Environment name for manual approval tasks
50    pub approval_environment: String,
51}
52
53impl Default for GitHubActionsEmitter {
54    fn default() -> Self {
55        Self {
56            runner: "ubuntu-latest".to_string(),
57            use_nix: true,
58            use_cachix: false,
59            cachix_name: None,
60            cachix_auth_token_secret: "CACHIX_AUTH_TOKEN".to_string(),
61            default_paths_ignore: vec![
62                "docs/**".to_string(),
63                "examples/**".to_string(),
64                "*.md".to_string(),
65                "LICENSE".to_string(),
66                ".vscode/**".to_string(),
67            ],
68            build_cuenv: true,
69            approval_environment: "production".to_string(),
70        }
71    }
72}
73
74impl GitHubActionsEmitter {
75    /// Create a new GitHub Actions emitter with default settings
76    #[must_use]
77    pub fn new() -> Self {
78        Self::default()
79    }
80
81    /// Create an emitter from a `GitHubConfig` manifest configuration.
82    ///
83    /// This applies all configuration from the CUE manifest to the emitter.
84    #[must_use]
85    pub fn from_config(config: &cuenv_core::ci::GitHubConfig) -> Self {
86        let mut emitter = Self::default();
87
88        // Apply runner configuration
89        if let Some(runner) = &config.runner {
90            emitter.runner = runner.as_single().unwrap_or("ubuntu-latest").to_string();
91        }
92
93        // Apply Cachix configuration
94        if let Some(cachix) = &config.cachix {
95            emitter.use_cachix = true;
96            emitter.cachix_name = Some(cachix.name.clone());
97            if let Some(auth_token) = &cachix.auth_token {
98                emitter.cachix_auth_token_secret.clone_from(auth_token);
99            }
100        }
101
102        // Apply paths ignore
103        if let Some(paths_ignore) = &config.paths_ignore {
104            emitter.default_paths_ignore.clone_from(paths_ignore);
105        }
106
107        emitter
108    }
109
110    /// Get the configured runner as a `RunsOn` value
111    #[must_use]
112    pub fn runner_as_runs_on(&self) -> RunsOn {
113        RunsOn::Label(self.runner.clone())
114    }
115
116    /// Set the default runner for jobs
117    #[must_use]
118    pub fn with_runner(mut self, runner: impl Into<String>) -> Self {
119        self.runner = runner.into();
120        self
121    }
122
123    /// Enable Nix installation steps
124    #[must_use]
125    pub fn with_nix(mut self) -> Self {
126        self.use_nix = true;
127        self
128    }
129
130    /// Disable Nix installation steps
131    #[must_use]
132    pub fn without_nix(mut self) -> Self {
133        self.use_nix = false;
134        self
135    }
136
137    /// Enable Cachix caching with the given cache name
138    #[must_use]
139    pub fn with_cachix(mut self, name: impl Into<String>) -> Self {
140        self.use_cachix = true;
141        self.cachix_name = Some(name.into());
142        self
143    }
144
145    /// Set the Cachix auth token secret name
146    #[must_use]
147    pub fn with_cachix_auth_token_secret(mut self, secret: impl Into<String>) -> Self {
148        self.cachix_auth_token_secret = secret.into();
149        self
150    }
151
152    /// Set default paths to ignore in triggers
153    #[must_use]
154    pub fn with_paths_ignore(mut self, paths: Vec<String>) -> Self {
155        self.default_paths_ignore = paths;
156        self
157    }
158
159    /// Disable automatic cuenv build step
160    #[must_use]
161    pub fn without_cuenv_build(mut self) -> Self {
162        self.build_cuenv = false;
163        self
164    }
165
166    /// Set the environment name for manual approval tasks
167    #[must_use]
168    pub fn with_approval_environment(mut self, env: impl Into<String>) -> Self {
169        self.approval_environment = env.into();
170        self
171    }
172
173    /// Emit multiple workflow files for projects with multiple pipelines.
174    ///
175    /// Returns a map of filename to YAML content.
176    /// Each pipeline in the IR generates a separate workflow file.
177    ///
178    /// # Errors
179    ///
180    /// Returns `EmitterError::Serialization` if YAML serialization fails.
181    pub fn emit_workflows(
182        &self,
183        ir: &IntermediateRepresentation,
184    ) -> EmitterResult<HashMap<String, String>> {
185        let mut workflows = HashMap::new();
186
187        // Build workflow name with optional project prefix for monorepo support
188        let workflow_name = Self::build_workflow_name(ir);
189
190        // Generate a single workflow with all tasks as jobs
191        let workflow = self.build_workflow(ir, &workflow_name);
192        let filename = format!("{}.yml", sanitize_filename(&workflow_name));
193        let yaml = Self::serialize_workflow(&workflow)?;
194        workflows.insert(filename, yaml);
195
196        Ok(workflows)
197    }
198
199    /// Build the workflow name, prefixing with project name if available (for monorepo support)
200    fn build_workflow_name(ir: &IntermediateRepresentation) -> String {
201        match &ir.pipeline.project_name {
202            Some(project) => format!("{}-{}", project, ir.pipeline.name),
203            None => ir.pipeline.name.clone(),
204        }
205    }
206
207    /// Build a workflow from the IR
208    fn build_workflow(&self, ir: &IntermediateRepresentation, workflow_name: &str) -> Workflow {
209        let triggers = self.build_triggers(ir);
210        let permissions = Self::build_permissions(ir);
211        let jobs = self.build_jobs(ir);
212
213        Workflow {
214            name: workflow_name.to_string(),
215            on: triggers,
216            concurrency: Some(Concurrency {
217                group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}".to_string(),
218                cancel_in_progress: Some(true),
219            }),
220            permissions: Some(permissions),
221            env: IndexMap::new(),
222            jobs,
223        }
224    }
225
226    /// Build workflow triggers from IR
227    fn build_triggers(&self, ir: &IntermediateRepresentation) -> WorkflowTriggers {
228        let trigger = ir.pipeline.trigger.as_ref();
229
230        WorkflowTriggers {
231            push: self.build_push_trigger(trigger),
232            pull_request: self.build_pr_trigger(trigger),
233            release: Self::build_release_trigger(trigger),
234            workflow_dispatch: Self::build_manual_trigger(trigger),
235            schedule: Self::build_schedule_trigger(trigger),
236        }
237    }
238
239    /// Build push trigger from IR trigger condition
240    fn build_push_trigger(&self, trigger: Option<&TriggerCondition>) -> Option<PushTrigger> {
241        let trigger = trigger?;
242
243        // Only emit push trigger if we have branch conditions
244        if trigger.branches.is_empty() {
245            return None;
246        }
247
248        Some(PushTrigger {
249            branches: trigger.branches.clone(),
250            paths: trigger.paths.clone(),
251            paths_ignore: if trigger.paths_ignore.is_empty() {
252                self.default_paths_ignore.clone()
253            } else {
254                trigger.paths_ignore.clone()
255            },
256            ..Default::default()
257        })
258    }
259
260    /// Build pull request trigger from IR trigger condition
261    fn build_pr_trigger(&self, trigger: Option<&TriggerCondition>) -> Option<PullRequestTrigger> {
262        let trigger = trigger?;
263
264        // Only emit PR trigger if explicitly enabled - never default to running on PRs
265        if trigger.pull_request == Some(true) {
266            Some(PullRequestTrigger {
267                branches: trigger.branches.clone(),
268                paths: trigger.paths.clone(),
269                paths_ignore: if trigger.paths_ignore.is_empty() {
270                    self.default_paths_ignore.clone()
271                } else {
272                    trigger.paths_ignore.clone()
273                },
274                ..Default::default()
275            })
276        } else {
277            None
278        }
279    }
280
281    /// Build release trigger from IR trigger condition
282    fn build_release_trigger(trigger: Option<&TriggerCondition>) -> Option<ReleaseTrigger> {
283        let trigger = trigger?;
284
285        if trigger.release.is_empty() {
286            return None;
287        }
288
289        Some(ReleaseTrigger {
290            types: trigger.release.clone(),
291        })
292    }
293
294    /// Build schedule trigger from IR trigger condition
295    fn build_schedule_trigger(trigger: Option<&TriggerCondition>) -> Option<Vec<ScheduleTrigger>> {
296        let trigger = trigger?;
297
298        if trigger.scheduled.is_empty() {
299            return None;
300        }
301
302        Some(
303            trigger
304                .scheduled
305                .iter()
306                .map(|cron| ScheduleTrigger { cron: cron.clone() })
307                .collect(),
308        )
309    }
310
311    /// Build manual (`workflow_dispatch`) trigger from IR trigger condition
312    fn build_manual_trigger(trigger: Option<&TriggerCondition>) -> Option<WorkflowDispatchTrigger> {
313        let trigger = trigger?;
314        let manual = trigger.manual.as_ref()?;
315
316        if !manual.enabled && manual.inputs.is_empty() {
317            return None;
318        }
319
320        Some(WorkflowDispatchTrigger {
321            inputs: manual
322                .inputs
323                .iter()
324                .map(|(k, v)| {
325                    (
326                        k.clone(),
327                        WorkflowInput {
328                            description: v.description.clone(),
329                            required: Some(v.required),
330                            default: v.default.clone(),
331                            input_type: v.input_type.clone(),
332                            options: if v.options.is_empty() {
333                                None
334                            } else {
335                                Some(v.options.clone())
336                            },
337                        },
338                    )
339                })
340                .collect(),
341        })
342    }
343
344    /// Build permissions based on task requirements
345    fn build_permissions(ir: &IntermediateRepresentation) -> Permissions {
346        let has_deployments = ir.tasks.iter().any(|t| t.deployment);
347        let has_outputs = ir.tasks.iter().any(|t| {
348            t.outputs
349                .iter()
350                .any(|o| o.output_type == OutputType::Orchestrator)
351        });
352
353        Permissions {
354            contents: Some(if has_deployments {
355                PermissionLevel::Write
356            } else {
357                PermissionLevel::Read
358            }),
359            checks: Some(PermissionLevel::Write),
360            pull_requests: Some(PermissionLevel::Write),
361            packages: if has_outputs {
362                Some(PermissionLevel::Write)
363            } else {
364                None
365            },
366            ..Default::default()
367        }
368    }
369
370    /// Build jobs from IR tasks
371    fn build_jobs(&self, ir: &IntermediateRepresentation) -> IndexMap<String, Job> {
372        let mut jobs = IndexMap::new();
373
374        for task in &ir.tasks {
375            let job = self.build_job(task, ir);
376            jobs.insert(sanitize_job_id(&task.id), job);
377        }
378
379        jobs
380    }
381
382    /// Build a job from an IR task
383    #[allow(clippy::too_many_lines)]
384    fn build_job(&self, task: &Task, ir: &IntermediateRepresentation) -> Job {
385        let mut steps = Vec::new();
386
387        // Checkout step
388        steps.push(
389            Step::uses("actions/checkout@v4")
390                .with_name("Checkout")
391                .with_input("fetch-depth", serde_yaml::Value::Number(2.into())),
392        );
393
394        // Check IR stages for what setup is needed
395        let has_install_nix = ir.stages.bootstrap.iter().any(|t| t.id == "install-nix");
396        let has_cachix = ir.stages.setup.iter().any(|t| t.id == "setup-cachix");
397        let has_1password = ir.stages.setup.iter().any(|t| t.id == "setup-1password");
398
399        // Nix installation (from install-nix stage task)
400        if has_install_nix {
401            steps.push(
402                Step::uses("DeterminateSystems/nix-installer-action@v16")
403                    .with_name("Install Nix")
404                    .with_input(
405                        "extra-conf",
406                        serde_yaml::Value::String("accept-flake-config = true".to_string()),
407                    ),
408            );
409        }
410
411        // Cachix setup (from setup-cachix stage task)
412        if has_cachix {
413            // Get cachix config from the stage task
414            if let Some(cachix_task) = ir.stages.setup.iter().find(|t| t.id == "setup-cachix") {
415                // Extract cache name from the task command
416                // Command format: ". /nix/... && nix-env -iA cachix... && cachix use {name}"
417                let cache_name = cachix_task
418                    .command
419                    .first()
420                    .and_then(|cmd| cmd.split("cachix use ").nth(1))
421                    .unwrap_or("cuenv");
422
423                // Get auth token secret name from env
424                let auth_token_secret = cachix_task
425                    .env
426                    .get("CACHIX_AUTH_TOKEN")
427                    .and_then(|v| {
428                        // Extract secret name from ${SECRET_NAME}
429                        v.strip_prefix("${").and_then(|s| s.strip_suffix("}"))
430                    })
431                    .unwrap_or("CACHIX_AUTH_TOKEN");
432
433                let mut cachix_step = Step::uses("cachix/cachix-action@v15")
434                    .with_name("Setup Cachix")
435                    .with_input("name", serde_yaml::Value::String(cache_name.to_string()))
436                    .with_input(
437                        "authToken",
438                        serde_yaml::Value::String(format!(
439                            "${{{{ secrets.{auth_token_secret} }}}}"
440                        )),
441                    );
442                cachix_step.with_inputs.insert(
443                    "pushFilter".to_string(),
444                    serde_yaml::Value::String("(-source$|nixpkgs\\.tar\\.gz$)".to_string()),
445                );
446                steps.push(cachix_step);
447            }
448        }
449
450        // Setup cuenv (from setup-cuenv stage task)
451        if let Some(cuenv_task) = ir.stages.setup.iter().find(|t| t.id == "setup-cuenv") {
452            let command = cuenv_task.command.first().cloned().unwrap_or_default();
453            let name = cuenv_task
454                .label
455                .clone()
456                .unwrap_or_else(|| "Setup cuenv".to_string());
457            steps.push(Step::run(&command).with_name(&name));
458        }
459
460        // Setup 1Password (from setup-1password stage task)
461        if has_1password {
462            steps.push(Step::run("cuenv secrets setup onepassword").with_name("Setup 1Password"));
463        }
464
465        // Run the task
466        let environment = ir.pipeline.environment.as_deref();
467        let task_command = if let Some(env) = environment {
468            format!("cuenv task {} -e {}", task.id, env)
469        } else {
470            format!("cuenv task {}", task.id)
471        };
472        let mut task_step = Step::run(task_command)
473            .with_name(task.id.clone())
474            .with_env("GITHUB_TOKEN", "${{ secrets.GITHUB_TOKEN }}");
475
476        // Add task environment variables
477        for (key, value) in &task.env {
478            task_step.env.insert(key.clone(), value.clone());
479        }
480
481        // Add 1Password service account token if needed
482        if has_1password {
483            task_step.env.insert(
484                "OP_SERVICE_ACCOUNT_TOKEN".to_string(),
485                "${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}".to_string(),
486            );
487        }
488
489        steps.push(task_step);
490
491        // Upload artifacts for orchestrator outputs
492        let orchestrator_outputs: Vec<_> = task
493            .outputs
494            .iter()
495            .filter(|o| o.output_type == OutputType::Orchestrator)
496            .collect();
497
498        if !orchestrator_outputs.is_empty() {
499            let paths: Vec<String> = orchestrator_outputs
500                .iter()
501                .map(|o| o.path.clone())
502                .collect();
503            let mut artifact_step = Step::uses("actions/upload-artifact@v4")
504                .with_name("Upload artifacts")
505                .with_input(
506                    "name",
507                    serde_yaml::Value::String(format!("{}-artifacts", task.id)),
508                )
509                .with_input("path", serde_yaml::Value::String(paths.join("\n")));
510            artifact_step.with_inputs.insert(
511                "if-no-files-found".to_string(),
512                serde_yaml::Value::String("ignore".to_string()),
513            );
514            artifact_step.if_condition = Some("always()".to_string());
515            steps.push(artifact_step);
516        }
517
518        // Determine runner
519        let runs_on = task
520            .resources
521            .as_ref()
522            .and_then(|r| r.tags.first())
523            .map_or_else(
524                || RunsOn::Label(self.runner.clone()),
525                |tag| RunsOn::Label(tag.clone()),
526            );
527
528        // Map dependencies to sanitized job IDs
529        let needs: Vec<String> = task.depends_on.iter().map(|d| sanitize_job_id(d)).collect();
530
531        // Handle manual approval via environment
532        let environment = if task.manual_approval {
533            Some(Environment::Name(self.approval_environment.clone()))
534        } else {
535            None
536        };
537
538        // Handle concurrency group
539        let concurrency = task.concurrency_group.as_ref().map(|group| Concurrency {
540            group: group.clone(),
541            cancel_in_progress: Some(false),
542        });
543
544        Job {
545            name: Some(task.id.clone()),
546            runs_on,
547            needs,
548            if_condition: None,
549            environment,
550            env: IndexMap::new(),
551            concurrency,
552            continue_on_error: None,
553            timeout_minutes: None,
554            steps,
555        }
556    }
557
558    /// Serialize a workflow to YAML with a generation header
559    fn serialize_workflow(workflow: &Workflow) -> EmitterResult<String> {
560        let yaml = serde_yaml::to_string(workflow)
561            .map_err(|e| EmitterError::Serialization(e.to_string()))?;
562
563        // Add generation header
564        let header = "# Generated by cuenv - do not edit manually\n# Regenerate with: cuenv ci --format github\n\n";
565
566        Ok(format!("{header}{yaml}"))
567    }
568}
569
570impl Emitter for GitHubActionsEmitter {
571    fn emit(&self, ir: &IntermediateRepresentation) -> EmitterResult<String> {
572        let workflow_name = Self::build_workflow_name(ir);
573        let workflow = self.build_workflow(ir, &workflow_name);
574        Self::serialize_workflow(&workflow)
575    }
576
577    fn format_name(&self) -> &'static str {
578        "github"
579    }
580
581    fn file_extension(&self) -> &'static str {
582        "yml"
583    }
584
585    fn description(&self) -> &'static str {
586        "GitHub Actions workflow YAML emitter"
587    }
588
589    fn validate(&self, ir: &IntermediateRepresentation) -> EmitterResult<()> {
590        // Validate task IDs are valid job identifiers
591        for task in &ir.tasks {
592            if task.id.contains(' ') {
593                return Err(EmitterError::InvalidIR(format!(
594                    "Task ID '{}' contains spaces, which are not allowed in GitHub Actions job IDs",
595                    task.id
596                )));
597            }
598        }
599
600        // Validate dependencies exist
601        let task_ids: std::collections::HashSet<_> = ir.tasks.iter().map(|t| &t.id).collect();
602        for task in &ir.tasks {
603            for dep in &task.depends_on {
604                if !task_ids.contains(dep) {
605                    return Err(EmitterError::InvalidIR(format!(
606                        "Task '{}' depends on non-existent task '{}'",
607                        task.id, dep
608                    )));
609                }
610            }
611        }
612
613        Ok(())
614    }
615}
616
617/// Sanitize a string for use as a workflow filename
618fn sanitize_filename(name: &str) -> String {
619    name.to_lowercase()
620        .replace(' ', "-")
621        .chars()
622        .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
623        .collect()
624}
625
626/// Sanitize a string for use as a job ID
627fn sanitize_job_id(id: &str) -> String {
628    id.replace(['.', ' '], "-")
629        .chars()
630        .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
631        .collect()
632}
633
634/// Builder for creating workflows with release triggers
635pub struct ReleaseWorkflowBuilder {
636    emitter: GitHubActionsEmitter,
637}
638
639impl ReleaseWorkflowBuilder {
640    /// Create a new release workflow builder
641    #[must_use]
642    pub fn new(emitter: GitHubActionsEmitter) -> Self {
643        Self { emitter }
644    }
645
646    /// Build a release workflow from IR
647    #[must_use]
648    pub fn build(&self, ir: &IntermediateRepresentation) -> Workflow {
649        let workflow_name = GitHubActionsEmitter::build_workflow_name(ir);
650        let mut workflow = self.emitter.build_workflow(ir, &workflow_name);
651
652        // Override triggers for release workflows
653        workflow.on = WorkflowTriggers {
654            release: Some(ReleaseTrigger {
655                types: vec!["published".to_string()],
656            }),
657            workflow_dispatch: Some(WorkflowDispatchTrigger {
658                inputs: {
659                    let mut inputs = IndexMap::new();
660                    inputs.insert(
661                        "tag_name".to_string(),
662                        WorkflowInput {
663                            description: "Tag to release (e.g., 0.6.0)".to_string(),
664                            required: Some(true),
665                            default: None,
666                            input_type: Some("string".to_string()),
667                            options: None,
668                        },
669                    );
670                    inputs
671                },
672            }),
673            ..Default::default()
674        };
675
676        // Update permissions for releases
677        workflow.permissions = Some(Permissions {
678            contents: Some(PermissionLevel::Write),
679            id_token: Some(PermissionLevel::Write),
680            ..Default::default()
681        });
682
683        workflow
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use cuenv_ci::ir::{
691        CachePolicy, PipelineMetadata, ResourceRequirements, StageConfiguration, StageTask,
692    };
693
694    fn make_ir(tasks: Vec<Task>) -> IntermediateRepresentation {
695        IntermediateRepresentation {
696            version: "1.4".to_string(),
697            pipeline: PipelineMetadata {
698                name: "test-pipeline".to_string(),
699                environment: None,
700                requires_onepassword: false,
701                project_name: None,
702                trigger: None,
703            },
704            runtimes: vec![],
705            stages: StageConfiguration::default(),
706            tasks,
707        }
708    }
709
710    fn make_task(id: &str, command: &[&str]) -> Task {
711        Task {
712            id: id.to_string(),
713            runtime: None,
714            command: command.iter().map(|s| (*s).to_string()).collect(),
715            shell: false,
716            env: HashMap::new(),
717            secrets: HashMap::new(),
718            resources: None,
719            concurrency_group: None,
720            inputs: vec![],
721            outputs: vec![],
722            depends_on: vec![],
723            cache_policy: CachePolicy::Normal,
724            deployment: false,
725            manual_approval: false,
726        }
727    }
728
729    #[test]
730    fn test_simple_workflow() {
731        let emitter = GitHubActionsEmitter::new()
732            .without_nix()
733            .without_cuenv_build();
734        let ir = make_ir(vec![make_task("build", &["cargo", "build"])]);
735
736        let yaml = emitter.emit(&ir).unwrap();
737
738        assert!(yaml.contains("name: test-pipeline"));
739        assert!(yaml.contains("jobs:"));
740        assert!(yaml.contains("build:"));
741        assert!(yaml.contains("cuenv task build"));
742    }
743
744    #[test]
745    fn test_workflow_with_nix() {
746        let emitter = GitHubActionsEmitter::new().with_nix();
747        let mut ir = make_ir(vec![make_task("build", &["cargo", "build"])]);
748
749        // Add stage tasks that would be contributed by NixContributor
750        ir.stages.bootstrap.push(StageTask {
751            id: "install-nix".to_string(),
752            provider: "nix".to_string(),
753            label: Some("Install Nix".to_string()),
754            command: vec!["curl ... | sh".to_string()],
755            shell: true,
756            priority: 0,
757            ..Default::default()
758        });
759        ir.stages.setup.push(StageTask {
760            id: "setup-cuenv".to_string(),
761            provider: "cuenv".to_string(),
762            label: Some("Setup cuenv".to_string()),
763            command: vec!["nix build .#cuenv".to_string()],
764            shell: true,
765            depends_on: vec!["install-nix".to_string()],
766            priority: 10,
767            ..Default::default()
768        });
769
770        let yaml = emitter.emit(&ir).unwrap();
771
772        assert!(yaml.contains("DeterminateSystems/nix-installer-action"));
773        assert!(yaml.contains("nix build .#cuenv"));
774    }
775
776    #[test]
777    fn test_workflow_with_cachix() {
778        let emitter = GitHubActionsEmitter::new()
779            .with_nix()
780            .with_cachix("my-cache");
781        let mut ir = make_ir(vec![make_task("build", &["cargo", "build"])]);
782
783        // Add stage tasks for Cachix
784        ir.stages.bootstrap.push(StageTask {
785            id: "install-nix".to_string(),
786            provider: "nix".to_string(),
787            label: Some("Install Nix".to_string()),
788            command: vec!["curl ... | sh".to_string()],
789            shell: true,
790            priority: 0,
791            ..Default::default()
792        });
793        let mut env = HashMap::new();
794        env.insert(
795            "CACHIX_AUTH_TOKEN".to_string(),
796            "${CACHIX_AUTH_TOKEN}".to_string(),
797        );
798        ir.stages.setup.push(StageTask {
799            id: "setup-cachix".to_string(),
800            provider: "cachix".to_string(),
801            label: Some("Setup Cachix".to_string()),
802            command: vec!["nix-env -iA cachix && cachix use my-cache".to_string()],
803            shell: true,
804            env,
805            depends_on: vec!["install-nix".to_string()],
806            priority: 5,
807            ..Default::default()
808        });
809
810        let yaml = emitter.emit(&ir).unwrap();
811
812        assert!(yaml.contains("cachix/cachix-action"));
813        assert!(yaml.contains("name: my-cache"));
814    }
815
816    #[test]
817    fn test_workflow_with_dependencies() {
818        let emitter = GitHubActionsEmitter::new()
819            .without_nix()
820            .without_cuenv_build();
821        let mut test_task = make_task("test", &["cargo", "test"]);
822        test_task.depends_on = vec!["build".to_string()];
823
824        let ir = make_ir(vec![make_task("build", &["cargo", "build"]), test_task]);
825
826        let yaml = emitter.emit(&ir).unwrap();
827
828        assert!(yaml.contains("needs:"));
829        assert!(yaml.contains("- build"));
830    }
831
832    #[test]
833    fn test_workflow_with_manual_approval() {
834        let emitter = GitHubActionsEmitter::new()
835            .without_nix()
836            .without_cuenv_build()
837            .with_approval_environment("staging");
838        let mut deploy_task = make_task("deploy", &["./deploy.sh"]);
839        deploy_task.manual_approval = true;
840
841        let ir = make_ir(vec![deploy_task]);
842
843        let yaml = emitter.emit(&ir).unwrap();
844
845        assert!(yaml.contains("environment: staging"));
846    }
847
848    #[test]
849    fn test_workflow_with_concurrency_group() {
850        let emitter = GitHubActionsEmitter::new()
851            .without_nix()
852            .without_cuenv_build();
853        let mut deploy_task = make_task("deploy", &["./deploy.sh"]);
854        deploy_task.concurrency_group = Some("production".to_string());
855
856        let ir = make_ir(vec![deploy_task]);
857
858        let yaml = emitter.emit(&ir).unwrap();
859
860        assert!(yaml.contains("concurrency:"));
861        assert!(yaml.contains("group: production"));
862    }
863
864    #[test]
865    fn test_workflow_with_custom_runner() {
866        let emitter = GitHubActionsEmitter::new()
867            .without_nix()
868            .without_cuenv_build()
869            .with_runner("self-hosted");
870        let ir = make_ir(vec![make_task("build", &["cargo", "build"])]);
871
872        let yaml = emitter.emit(&ir).unwrap();
873
874        assert!(yaml.contains("runs-on: self-hosted"));
875    }
876
877    #[test]
878    fn test_workflow_with_resource_tags() {
879        let emitter = GitHubActionsEmitter::new()
880            .without_nix()
881            .without_cuenv_build();
882        let mut task = make_task("build", &["cargo", "build"]);
883        task.resources = Some(ResourceRequirements {
884            cpu: None,
885            memory: None,
886            tags: vec!["blacksmith-8vcpu-ubuntu-2404".to_string()],
887        });
888
889        let ir = make_ir(vec![task]);
890
891        let yaml = emitter.emit(&ir).unwrap();
892
893        assert!(yaml.contains("runs-on: blacksmith-8vcpu-ubuntu-2404"));
894    }
895
896    #[test]
897    fn test_emit_workflows() {
898        let emitter = GitHubActionsEmitter::new()
899            .without_nix()
900            .without_cuenv_build();
901        let ir = make_ir(vec![make_task("build", &["cargo", "build"])]);
902
903        let workflows = emitter.emit_workflows(&ir).unwrap();
904
905        assert_eq!(workflows.len(), 1);
906        assert!(workflows.contains_key("test-pipeline.yml"));
907    }
908
909    #[test]
910    fn test_sanitize_filename() {
911        assert_eq!(sanitize_filename("CI Pipeline"), "ci-pipeline");
912        assert_eq!(sanitize_filename("release/v1"), "releasev1");
913        assert_eq!(sanitize_filename("test_workflow"), "test_workflow");
914    }
915
916    #[test]
917    fn test_sanitize_job_id() {
918        assert_eq!(sanitize_job_id("build.test"), "build-test");
919        assert_eq!(sanitize_job_id("deploy prod"), "deploy-prod");
920    }
921
922    #[test]
923    fn test_validation_invalid_id() {
924        let emitter = GitHubActionsEmitter::new();
925        let ir = make_ir(vec![make_task("invalid task", &["echo"])]);
926
927        let result = emitter.validate(&ir);
928        assert!(result.is_err());
929    }
930
931    #[test]
932    fn test_validation_missing_dependency() {
933        let emitter = GitHubActionsEmitter::new();
934        let mut task = make_task("test", &["cargo", "test"]);
935        task.depends_on = vec!["nonexistent".to_string()];
936
937        let ir = make_ir(vec![task]);
938
939        let result = emitter.validate(&ir);
940        assert!(result.is_err());
941    }
942
943    #[test]
944    fn test_format_name() {
945        let emitter = GitHubActionsEmitter::new();
946        assert_eq!(emitter.format_name(), "github");
947        assert_eq!(emitter.file_extension(), "yml");
948    }
949
950    #[test]
951    fn test_generation_header() {
952        let emitter = GitHubActionsEmitter::new()
953            .without_nix()
954            .without_cuenv_build();
955        let ir = make_ir(vec![make_task("build", &["cargo", "build"])]);
956
957        let yaml = emitter.emit(&ir).unwrap();
958
959        assert!(yaml.starts_with("# Generated by cuenv"));
960        assert!(yaml.contains("cuenv ci --format github"));
961    }
962}