1mod command;
2mod docker;
3mod job;
4mod plugins;
5mod runner_builder;
6mod secret;
7mod sender;
8mod step;
9mod utils;
10mod volumes;
11mod workflow;
12mod workflow_runner;
13
14pub use self::{
15 job::{Job, JobContext},
16 plugins::{Plugin, PluginManager},
17 runner_builder::RunnerBuilder,
18 secret::{GlobalSecret, OrganizationSecret, RepositorySecret, Secret},
19 sender::{EventHandler, JobMessageSender, MessageSender, WorkflowMessageSender},
20 step::{Step, StepRunContext, StepSecret, StepVolume},
21 utils::{get_workflow_event_payload, should_skip_workflow},
22 volumes::{GlobalVolume, OrganizationVolume, RepositoryVolume, Volume},
23 workflow::Workflow,
24 workflow_runner::{WorkflowRunContext, WorkflowRunOptions, WorkflowRunner},
25};
26use crate::{
27 actions::{Action, Actions},
28 constants::COODEV_CONTAINER_IMAGE,
29 error,
30 user_config::{UserCommandStep, UserJob, UserStep, UserWorkflow},
31 WorkflowEvent, WorkflowMessage,
32};
33use derive_builder::Builder;
34use parking_lot::Mutex;
35use serde::{Deserialize, Serialize};
36use std::{collections::HashMap, env, path::PathBuf, sync::Arc};
37use tokio::sync::Semaphore;
38
39#[derive(Serialize, Deserialize, Debug, Clone)]
40pub enum GithubAuthorization {
41 PersonalAccessToken(String),
42 GithubApp { app_id: u64, private_key: String },
43}
44
45#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
46#[serde(rename_all = "snake_case")]
47pub enum WorkflowRefType {
48 Branch,
49 Tag,
50}
51
52#[derive(Serialize, Deserialize, Debug, Clone, Builder)]
53pub struct CreateWorkflowOptions {
54 pub config: String,
55 pub event: WorkflowEvent,
56}
57
58pub struct RunnerInner {
59 actions: Actions,
61 secrets: HashMap<String, Secret>,
62 volumes: HashMap<String, Volume>,
63 plugin_manager: PluginManager,
64 workflow_message_sender: MessageSender,
65}
66
67#[derive(Clone)]
68pub struct Runner {
69 github_authorization: GithubAuthorization,
70 working_dir: Option<PathBuf>,
72 inner: Arc<Mutex<RunnerInner>>,
73 parallel_semaphore: Arc<Semaphore>,
74}
75
76impl Runner {
77 pub fn builder() -> RunnerBuilder {
78 RunnerBuilder::default()
79 }
80
81 pub fn register_action<T>(&self, name: impl Into<String>, action: T) -> &Self
82 where
83 T: Action + 'static,
84 {
85 self
86 .inner
87 .lock()
88 .actions
89 .register(name.into(), Box::new(action) as Box<dyn Action>);
90
91 self
92 }
93
94 pub fn register_secret(&self, secret: Secret) -> &Self {
95 match secret.clone() {
96 Secret::Repository(RepositorySecret {
97 key, repository, ..
98 }) => {
99 self
100 .inner
101 .lock()
102 .secrets
103 .insert(format!("{}@{}", key.to_uppercase(), repository), secret);
104 }
105 Secret::Organization(OrganizationSecret {
106 key, organization, ..
107 }) => {
108 self
109 .inner
110 .lock()
111 .secrets
112 .insert(format!("{}@{}", key.to_uppercase(), organization), secret);
113 }
114 Secret::Global(GlobalSecret { key, .. }) => {
115 self.inner.lock().secrets.insert(key.to_uppercase(), secret);
116 }
117 }
118
119 self
120 }
121
122 pub fn register_volume(&self, volume: Volume) -> &Self {
123 match volume.clone() {
124 Volume::Repository(RepositoryVolume {
125 key, repository, ..
126 }) => {
127 self
128 .inner
129 .lock()
130 .volumes
131 .insert(format!("{}@{}", key.to_uppercase(), repository), volume);
132 }
133 Volume::Organization(OrganizationVolume {
134 key, organization, ..
135 }) => {
136 self
137 .inner
138 .lock()
139 .volumes
140 .insert(format!("{}@{}", key.to_uppercase(), organization), volume);
141 }
142 Volume::Global(GlobalVolume { key, .. }) => {
143 self.inner.lock().volumes.insert(key.to_uppercase(), volume);
144 }
145 }
146
147 self
148 }
149
150 pub fn register_plugin<T>(&self, plugin: T) -> &Self
151 where
152 T: Plugin + 'static,
153 {
154 plugin.on_init(self);
155
156 self
157 .inner
158 .lock()
159 .plugin_manager
160 .register(Box::new(plugin) as Box<dyn Plugin>);
161
162 self
163 }
164
165 pub fn on_event<F>(&self, event_handler: F) -> &Self
166 where
167 F: Fn(&WorkflowMessage) + Send + Sync + 'static,
168 {
169 self
170 .inner
171 .lock()
172 .workflow_message_sender
173 .register(Box::new(event_handler) as EventHandler);
174
175 self
176 }
177
178 pub fn create_workflow_runner(
179 &self,
180 options: CreateWorkflowOptions,
181 ) -> crate::Result<WorkflowRunner> {
182 let event = options.event.clone();
183 let workflow = self.parse_workflow(options)?;
184 let workflow_runner = self.create_workflow_runner_from_normalized_workflow(workflow, event)?;
185
186 Ok(workflow_runner)
187 }
188
189 pub fn create_workflow_runner_from_normalized_workflow(
190 &self,
191 workflow: Workflow,
192 event: WorkflowEvent,
193 ) -> crate::Result<WorkflowRunner> {
194 let runner_working_dir = match self.working_dir {
195 Some(ref path) => path.clone(),
196 #[allow(deprecated)]
197 None => env::home_dir()
198 .ok_or(
199 error::Error::workflow_config_error(
200 "Failed to get working directory. Please set working directory by Runner::builder().working_dir()"
201 )
202 )?
203 .join("coodev-runner"),
204 };
205
206 let workflow_runner = WorkflowRunner::builder()
207 .workflow(workflow)
208 .workflow_semaphore(self.parallel_semaphore.clone())
209 .workflow_message_sender(self.inner.lock().workflow_message_sender.clone())
210 .runner_working_dir(runner_working_dir)
211 .github_authorization(self.github_authorization.clone())
212 .runner_inner(self.inner.clone())
213 .event(event)
214 .build()
215 .map_err(|err| error::Error::internal_runtime_error(err.to_string()))?;
216
217 self
218 .inner
219 .lock()
220 .plugin_manager
221 .on_workflow_runner_init(&workflow_runner);
222
223 Ok(workflow_runner)
224 }
225
226 pub fn parse_workflow(&self, options: CreateWorkflowOptions) -> crate::Result<Workflow> {
227 let workflow = UserWorkflow::from_str(&options.config)?;
228 let mut jobs = HashMap::new();
229 for (job_id, job) in workflow.jobs {
230 let normalized_job = self.normalize_job(job, &options)?;
231
232 jobs.insert(job_id, normalized_job);
233 }
234
235 let workflow = Workflow {
236 name: workflow.name,
237 on: workflow.on,
238 jobs,
239 };
240
241 Ok(workflow)
242 }
243
244 fn normalize_job(&self, job: UserJob, options: &CreateWorkflowOptions) -> crate::Result<Job> {
245 let image = job.image.unwrap_or(COODEV_CONTAINER_IMAGE.to_string());
246 let steps = self.normalize_steps(job.steps, image.clone(), &options)?;
249 Ok(Job {
250 name: job.name,
251 image,
252 steps,
253 depends_on: job.depends_on,
254 working_dirs: job.working_dirs,
255 })
256 }
257
258 fn normalize_steps(
259 &self,
260 steps: Vec<UserStep>,
261 job_image: String,
262 options: &CreateWorkflowOptions,
263 ) -> crate::Result<Vec<Step>> {
264 let event_payload = get_workflow_event_payload(options.event.clone())?;
265 let mut normalized_steps = vec![];
266
267 let steps = self.normalize_user_steps(steps)?;
268 for step in steps.iter() {
269 if let UserStep::Command(command_step) = step.clone() {
270 let UserCommandStep {
271 name,
272 image,
273 run,
274 continue_on_error,
275 environments,
276 volumes,
277 secrets,
278 timeout,
279 security_opts,
280 } = command_step;
281 let runner_dir = "/home/runner".to_string();
282 let cache_dir = "/home/runner/cache".to_string();
283
284 let inner_state = self.inner.lock();
285
286 let mut step_volumes: Vec<StepVolume> = vec![];
288 if let Some(volumes) = volumes {
289 for volume in volumes {
290 let idx = volume.find(':').ok_or(error::Error::workflow_config_error(
291 "Invalid volume format. The format should be `volume_key:to`",
292 ))?;
293 let (volume_key, volume_to) = volume.split_at(idx);
294
295 let uppercase_key = volume_key.to_uppercase();
296 let repository = format!("{}/{}", event_payload.repo_owner, event_payload.repo_name);
297 let repo_volume_key = format!("{}@{}", uppercase_key, repository);
298 if inner_state.volumes.contains_key(&repo_volume_key) {
299 step_volumes.push(StepVolume {
300 key: repo_volume_key,
301 to: volume_to[1..].to_string(),
302 });
303 continue;
304 }
305
306 let org_volume_key = format!("{}@{}", uppercase_key, event_payload.repo_owner);
307 if inner_state.volumes.contains_key(&org_volume_key) {
308 step_volumes.push(StepVolume {
309 key: org_volume_key,
310 to: volume_to[1..].to_string(),
311 });
312 continue;
313 }
314
315 if inner_state.volumes.contains_key(&uppercase_key) {
316 step_volumes.push(StepVolume {
317 key: uppercase_key,
318 to: volume_to[1..].to_string(),
319 });
320 continue;
321 }
322
323 return Err(error::Error::workflow_config_error(format!(
324 "Volume `{}` is not defined.",
325 volume_key
326 )));
327 }
328 }
329
330 let mut step_secrets: Vec<StepSecret> = vec![];
331 if let Some(secret_keys) = secrets {
332 for secret_key in secret_keys {
333 let uppercase_key = secret_key.to_uppercase();
334 let repository = format!("{}/{}", event_payload.repo_owner, event_payload.repo_name);
335 let repo_secret_key = format!("{}@{}", uppercase_key, repository);
336 if inner_state.secrets.contains_key(&repo_secret_key) {
337 step_secrets.push(StepSecret {
338 key: repo_secret_key,
339 });
340 continue;
341 }
342
343 let org_secret_key = format!("{}@{}", uppercase_key, event_payload.repo_owner);
344 if inner_state.secrets.contains_key(&org_secret_key) {
345 step_secrets.push(StepSecret {
346 key: org_secret_key,
347 });
348 continue;
349 }
350
351 if inner_state.secrets.contains_key(&uppercase_key) {
352 step_secrets.push(StepSecret { key: uppercase_key });
353 continue;
354 }
355
356 return Err(error::Error::workflow_config_error(format!(
357 "Secret `{}` is not defined.",
358 secret_key
359 )));
360 }
361 }
362
363 let timeout = timeout.unwrap_or("60m".to_string());
364 let timeout = humantime::parse_duration(&timeout).map_err(|err| {
365 log::error!("Invalid timeout format: {}", err);
366 error::Error::workflow_config_error(
367 "Invalid timeout format. The format should like `60m` or `1h`.",
368 )
369 })?;
370
371 let normalized_step = Step {
372 name,
373 image: image.unwrap_or(job_image.clone()),
374 run,
375 continue_on_error: continue_on_error.unwrap_or(false),
376 environments,
377 runner_dir,
378 working_dir: format!("/home/runner/work/{}", event_payload.repo_name),
379 cache_dir,
380 volumes: step_volumes,
381 secrets: step_secrets,
382 timeout,
383 security_opts,
384 };
385 normalized_steps.push(normalized_step);
386 } else {
387 return Err(error::Error::unsupported_feature(
388 "Currently, only `command` step is supported.",
389 ));
390 }
391 }
392
393 Ok(normalized_steps)
394 }
395
396 fn normalize_user_steps(&self, user_steps: Vec<UserStep>) -> crate::Result<Vec<UserStep>> {
397 let mut pre_steps = vec![];
398 let mut steps = vec![];
399 let mut post_steps = vec![];
400
401 for step in user_steps {
402 if let UserStep::Action(user_action_step) = &step {
403 let action_name = &user_action_step.uses;
404 let action = self.inner.lock().actions.get(action_name).ok_or_else(|| {
405 error::Error::workflow_config_error(format!("Action `{}` is not found", action_name))
406 })?;
407
408 let action_steps = action.normalize(user_action_step.clone())?;
409 if let Some(pre) = action_steps.pre {
410 pre_steps.push(pre);
411 }
412
413 if let Some(post) = action_steps.post {
414 post_steps.insert(0, post)
415 }
416
417 steps.push(action_steps.run);
418 continue;
419 }
420
421 steps.push(step.clone());
422 }
423
424 let steps: Vec<UserStep> = vec![]
425 .into_iter()
426 .chain(pre_steps.into_iter())
427 .chain(steps.into_iter())
428 .chain(post_steps.into_iter())
429 .collect();
430
431 Ok(steps)
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use crate::{
438 runner::{plugins::Plugin, GithubAuthorization, Runner, Secret, Volume, WorkflowRunOptions},
439 utils::test::{
440 create_runner, create_workflow_options, create_workflow_runner, enable_logger,
441 get_default_run_options, load_config, run_workflow,
442 },
443 WorkflowState,
444 };
445 use std::path::PathBuf;
446 use tokio::fs;
447
448 #[tokio::test]
449 #[ignore]
450 async fn test() -> anyhow::Result<()> {
451 let res = run_workflow(
452 r#"
453 name: Test workflow
454 on:
455 push:
456 branches:
457 - master
458 jobs:
459 test:
460 image: ubuntu
461 steps:
462 - name: Test step
463 run: echo "Hello world"
464 test2:
465 image: ubuntu
466 steps:
467 - name: Test step
468 environments:
469 TEST: "test"
470 run: |
471 pwd
472 echo "Hello world2 ${TEST}"
473 "#,
474 )
475 .await?;
476
477 assert_eq!(res.state, WorkflowState::Succeeded);
478
479 Ok(())
480 }
481
482 #[tokio::test]
483 #[ignore]
484 async fn test_working_directories() -> anyhow::Result<()> {
485 let res = run_workflow(
486 r#"
487 jobs:
488 test:
489 working-directories:
490 - /root/.cache
491 steps:
492 - name: Test create file
493 run: |
494 mkdir -p /root/.cache
495 echo "Hello world" > /root/.cache/test.txt
496 - name: Test step
497 run: |
498 contents=$(cat /root/.cache/test.txt)
499 if [ "$contents" != "Hello world" ]; then
500 exit 1
501 fi
502 echo "Contents: $contents"
503 "#,
504 )
505 .await?;
506
507 assert_eq!(res.state, WorkflowState::Succeeded);
508
509 Ok(())
510 }
511
512 #[tokio::test]
513 #[ignore]
514 async fn test_timeout() -> anyhow::Result<()> {
515 let res = run_workflow(
516 r#"
517 jobs:
518 test:
519 steps:
520 - name: Timeout step
521 timeout: 5s
522 run: sleep 1m
523 "#,
524 )
525 .await?;
526
527 assert_eq!(res.state, WorkflowState::Cancelled);
528
529 Ok(())
530 }
531
532 #[tokio::test]
533 #[ignore]
534 async fn test_continue_on_error() -> anyhow::Result<()> {
535 let res = run_workflow(
536 r#"
537 jobs:
538 test:
539 image: ubuntu
540 steps:
541 - name: Throw error
542 run: exit 1
543 - name: Test step
544 allow-failure: true
545 run: echo "\e[31mHello world\e[0m"
546 "#,
547 )
548 .await?;
549
550 assert_eq!(res.state, WorkflowState::Failed);
551
552 Ok(())
553 }
554
555 #[tokio::test]
556 #[ignore]
557 async fn test_buildkit() -> anyhow::Result<()> {
558 let res = run_workflow(
559 r#"
560 jobs:
561 test:
562 image: ubuntu
563 steps:
564 - name: Build Docker image
565 image: moby/buildkit:rootless
566 environments:
567 BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
568 security-opts:
569 - seccomp=unconfined
570 - apparmor=unconfined
571 run: |
572 echo "FROM alpine" > Dockerfile
573
574 buildctl-daemonless.sh build \
575 --frontend dockerfile.v0 \
576 --local context=. \
577 --local dockerfile=. \
578 --output type=image
579 "#,
580 )
581 .await?;
582
583 assert_eq!(res.state, WorkflowState::Succeeded);
584
585 Ok(())
586 }
587
588 #[tokio::test]
589 #[ignore]
590 async fn test_secret() -> anyhow::Result<()> {
591 let runner = create_runner()?;
592
593 runner
594 .register_secret(
595 Secret::new("TEST_SECRET")
596 .value("test_secret_value")
597 .build()?,
598 )
599 .register_secret(
600 Secret::new("lowercase_secret")
601 .value("test_lowercase_secret_value")
602 .owner("panghu-huang")
603 .repository("octocrate")
604 .build()?,
605 )
606 .register_secret(
607 Secret::new("owner_lowercase_secret")
608 .value("test_owner_lowercase_secret_value")
609 .owner("panghu-huang")
610 .build()?,
611 );
612
613 let workflow_runner = runner.create_workflow_runner(create_workflow_options(
614 r#"
615 name: Test workflow
616 on:
617 push:
618 branches:
619 - master
620
621 jobs:
622 test:
623 image: ubuntu
624 steps:
625 - name: Test step
626 secrets:
627 - TEST_SECRET
628 - lowercase_secret
629 - owner_lowercase_secret
630 run: |
631 echo "TEST_SECRET is $TEST_SECRET"
632 echo "LOWERCASE_SECRET is $LOWERCASE_SECRET"
633 echo "OWNER_LOWERCASE_SECRET is $OWNER_LOWERCASE_SECRET"
634 "#,
635 ))?;
636
637 let res = workflow_runner.run(get_default_run_options()).await?;
638
639 assert_eq!(res.state, WorkflowState::Succeeded);
640
641 Ok(())
642 }
643
644 #[tokio::test]
645 async fn test_out_of_scope_secret() -> anyhow::Result<()> {
646 let runner = create_runner()?;
647
648 runner
649 .register_secret(
650 Secret::new("lowercase_secret")
651 .value("global_secret")
652 .build()?,
653 )
654 .register_secret(
655 Secret::new("lowercase_secret")
656 .value("test_lowercase_secret_value")
657 .owner("panghu-huang")
658 .repository("github-api")
659 .build()?,
660 )
661 .register_secret(
662 Secret::new("owner_lowercase_secret")
663 .value("test_owner_lowercase_secret_value")
664 .owner("coodevjs")
665 .build()?,
666 );
667
668 let res = runner.create_workflow_runner(create_workflow_options(
669 r#"
670 name: Test workflow
671 jobs:
672 test:
673 image: ubuntu
674 steps:
675 - name: Test step
676 secrets:
677 - lowercase_secret
678 - owner_lowercase_secret
679 run: |
680 echo "LOWERCASE_SECRET is $LOWERCASE_SECRET"
681 echo "OWNER_LOWERCASE_SECRET is $OWNER_LOWERCASE_SECRET"
682 "#,
683 ));
684
685 match res {
686 Err(err) => assert_eq!(
687 err.to_string(),
688 "Failed to parse user config: Secret `owner_lowercase_secret` is not defined."
689 ),
690 _ => assert!(false),
691 }
692
693 Ok(())
694 }
695
696 #[tokio::test]
697 #[ignore]
698 async fn test_volume() -> anyhow::Result<()> {
699 let runner = create_runner()?;
700 #[allow(deprecated)]
701 let home_dir = std::env::home_dir().unwrap();
702 let filepath = PathBuf::from(home_dir).join("test.txt");
703 let filepath = filepath.to_str().ok_or(anyhow::anyhow!(
704 "Failed to convert path to string: {:?}",
705 filepath
706 ))?;
707
708 fs::write(filepath, "test volume content").await?;
709
710 runner.register_volume(
711 Volume::new("test_volume")
712 .path(filepath)
713 .owner("panghu-huang")
714 .repository("octocrate")
715 .build()?,
716 );
717
718 let workflow_runner = runner.create_workflow_runner(create_workflow_options(
719 r#"
720 name: Test workflow
721 jobs:
722 test:
723 image: ubuntu
724 steps:
725 - name: Test step
726 volumes:
727 - test_volume:/home/runner/work/octocrate/test.txt
728 run: |
729 echo "Before"
730 pwd
731 ls
732 echo "File content: $(cat ./test.txt)"
733 echo "After"
734 "#,
735 ))?;
736
737 let res = workflow_runner.run(get_default_run_options()).await?;
738
739 fs::remove_file(filepath).await?;
740
741 assert_eq!(res.state, WorkflowState::Succeeded);
742
743 Ok(())
744 }
745
746 #[tokio::test]
747 #[ignore]
748 async fn depends_on() -> anyhow::Result<()> {
749 let res = run_workflow(
750 r#"
751 name: Test workflow
752 jobs:
753 test:
754 image: ubuntu
755 steps:
756 - name: Test step
757 run: |
758 sleep 5s
759 echo "Hello world"
760 test2:
761 image: ubuntu
762 steps:
763 - name: Test step
764 environments:
765 TEST: "test"
766 run: echo "Hello world2 ${TEST}"
767 test3:
768 image: ubuntu
769 depends-on:
770 - test
771 - test2
772 steps:
773 - name: Test step
774 environments:
775 TEST: "test"
776 run: echo "Hello world3 ${TEST}"
777 "#,
778 )
779 .await?;
780 assert_eq!(res.state, WorkflowState::Succeeded);
781
782 Ok(())
783 }
784
785 #[tokio::test]
786 #[ignore]
787 async fn cancel() -> anyhow::Result<()> {
788 let workflow_runner = create_workflow_runner(
789 r#"
790 name: Test workflow
791 jobs:
792 test:
793 image: ubuntu
794 steps:
795 - name: Test step
796 run: |
797 sleep 5s
798 echo "Hello world"
799 test2:
800 image: ubuntu
801 steps:
802 - name: Test step
803 environments:
804 TEST: "test"
805 run: |
806 sleep 5s
807 echo "Hello world2 ${TEST}"
808 "#,
809 )?;
810
811 let cloned = workflow_runner.clone();
812 let handle = tokio::spawn(async move {
813 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
814 cloned.cancel().await.unwrap();
815 log::info!("Workflow cancelled");
816 });
817 let res = workflow_runner.run(get_default_run_options()).await?;
818
819 handle.await?;
820
821 assert_eq!(res.state, WorkflowState::Cancelled);
822
823 Ok(())
824 }
825
826 #[tokio::test]
827 #[ignore]
828 async fn queue() -> anyhow::Result<()> {
829 enable_logger();
830 let config = load_config()?;
831 let github_authorization = GithubAuthorization::GithubApp {
832 app_id: config.github_app_id,
833 private_key: config.github_app_private_key,
834 };
835 let runner = Runner::builder()
836 .github_authorization(github_authorization)
837 .parallel_size(3 as usize)
838 .build()?;
839
840 let mut handles = vec![];
841
842 for idx in 0..10 {
843 let sleep = 10 - idx;
844 let workflow_runner = runner.create_workflow_runner(create_workflow_options(format!(
845 r#"
846 name: Test workflow
847
848 jobs:
849 test:
850 image: ubuntu
851 steps:
852 - name: Test step
853 run: |
854 echo "Running {idx}"
855 sleep {sleep}s
856 echo "Finished {idx}"
857 "#
858 )))?;
859
860 let handle = tokio::spawn(async move {
861 let options = WorkflowRunOptions {
862 run_id: idx.to_string(),
863 environments: None,
864 };
865 workflow_runner.run(options).await.unwrap()
866 });
867
868 handles.push(handle);
869 }
870
871 for handle in handles {
872 let res = handle.await?;
873 assert_eq!(res.state, WorkflowState::Succeeded);
874 }
875
876 Ok(())
877 }
878
879 #[tokio::test]
880 #[ignore]
881 async fn test_plugin() -> anyhow::Result<()> {
882 enable_logger();
883 let runner = create_runner()?;
884
885 struct TestPlugin;
886
887 impl Plugin for TestPlugin {
888 fn on_init(&self, _runner: &Runner) {
889 log::info!("TestPlugin init");
890 }
891 }
892
893 runner.register_plugin(TestPlugin);
894
895 Ok(())
896 }
897
898 #[tokio::test]
899 #[ignore]
900 async fn test_on_event() -> anyhow::Result<()> {
901 enable_logger();
902 let runner = create_runner()?;
903
904 runner.on_event(|event| {
905 log::info!("Event: {:?}", event);
906 });
907
908 let workflow_runner = runner.create_workflow_runner(create_workflow_options(format!(
909 r#"
910 name: Test workflow
911
912 jobs:
913 test:
914 image: ubuntu
915 steps:
916 - name: Test step
917 run: |
918 echo "Running"
919 "#
920 )))?;
921
922 let _res = workflow_runner.run(get_default_run_options()).await?;
923
924 Ok(())
925 }
926}