1use 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#[derive(Debug, Clone)]
34pub struct GitHubActionsEmitter {
35 pub runner: String,
37 pub use_nix: bool,
39 pub use_cachix: bool,
41 pub cachix_name: Option<String>,
43 pub cachix_auth_token_secret: String,
45 pub default_paths_ignore: Vec<String>,
47 pub build_cuenv: bool,
49 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 #[must_use]
77 pub fn new() -> Self {
78 Self::default()
79 }
80
81 #[must_use]
85 pub fn from_config(config: &cuenv_core::ci::GitHubConfig) -> Self {
86 let mut emitter = Self::default();
87
88 if let Some(runner) = &config.runner {
90 emitter.runner = runner.as_single().unwrap_or("ubuntu-latest").to_string();
91 }
92
93 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 if let Some(paths_ignore) = &config.paths_ignore {
104 emitter.default_paths_ignore.clone_from(paths_ignore);
105 }
106
107 emitter
108 }
109
110 #[must_use]
112 pub fn runner_as_runs_on(&self) -> RunsOn {
113 RunsOn::Label(self.runner.clone())
114 }
115
116 #[must_use]
118 pub fn with_runner(mut self, runner: impl Into<String>) -> Self {
119 self.runner = runner.into();
120 self
121 }
122
123 #[must_use]
125 pub fn with_nix(mut self) -> Self {
126 self.use_nix = true;
127 self
128 }
129
130 #[must_use]
132 pub fn without_nix(mut self) -> Self {
133 self.use_nix = false;
134 self
135 }
136
137 #[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 #[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 #[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 #[must_use]
161 pub fn without_cuenv_build(mut self) -> Self {
162 self.build_cuenv = false;
163 self
164 }
165
166 #[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 pub fn emit_workflows(
182 &self,
183 ir: &IntermediateRepresentation,
184 ) -> EmitterResult<HashMap<String, String>> {
185 let mut workflows = HashMap::new();
186
187 let workflow_name = Self::build_workflow_name(ir);
189
190 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 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 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 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 fn build_push_trigger(&self, trigger: Option<&TriggerCondition>) -> Option<PushTrigger> {
241 let trigger = trigger?;
242
243 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 fn build_pr_trigger(&self, trigger: Option<&TriggerCondition>) -> Option<PullRequestTrigger> {
262 let trigger = trigger?;
263
264 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 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 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 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 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 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 #[allow(clippy::too_many_lines)]
384 fn build_job(&self, task: &Task, ir: &IntermediateRepresentation) -> Job {
385 let mut steps = Vec::new();
386
387 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 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 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 if has_cachix {
413 if let Some(cachix_task) = ir.stages.setup.iter().find(|t| t.id == "setup-cachix") {
415 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 let auth_token_secret = cachix_task
425 .env
426 .get("CACHIX_AUTH_TOKEN")
427 .and_then(|v| {
428 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 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 if has_1password {
462 steps.push(Step::run("cuenv secrets setup onepassword").with_name("Setup 1Password"));
463 }
464
465 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 for (key, value) in &task.env {
478 task_step.env.insert(key.clone(), value.clone());
479 }
480
481 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 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 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 let needs: Vec<String> = task.depends_on.iter().map(|d| sanitize_job_id(d)).collect();
530
531 let environment = if task.manual_approval {
533 Some(Environment::Name(self.approval_environment.clone()))
534 } else {
535 None
536 };
537
538 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 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 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 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 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
617fn 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
626fn sanitize_job_id(id: &str) -> String {
628 id.replace(['.', ' '], "-")
629 .chars()
630 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
631 .collect()
632}
633
634pub struct ReleaseWorkflowBuilder {
636 emitter: GitHubActionsEmitter,
637}
638
639impl ReleaseWorkflowBuilder {
640 #[must_use]
642 pub fn new(emitter: GitHubActionsEmitter) -> Self {
643 Self { emitter }
644 }
645
646 #[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 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 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 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 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}