gh_workflow_tailcall/
standard.rs

1//! StandardWorkflow is designed to be used for most Rust projects that are
2//! built at Tailcall. Though gh-workflow makes it much easier to write
3//! workflows you still need to constantly keep referring to the Github
4//! documentation to write your own workflows. This module saves all that time
5//! by using feature flags to enable or disable features that you want in your
6//! workflow. Based on the features enabled or disabled a workflow is generated.
7
8use ctx::Context;
9use derive_setters::Setters;
10use generate::Generate;
11use gh_workflow::error::Result;
12use gh_workflow::{Workflow as GHWorkflow, *};
13use heck::ToTitleCase;
14use release_plz::{Command, Release};
15use toolchain::Toolchain;
16
17/// Defines the test runner to use for running tests
18#[derive(Debug, Clone, Default)]
19pub enum TestRunner {
20    /// Uses the default cargo test runner
21    Cargo,
22
23    /// Uses cargo-nextest for running tests
24    #[default]
25    Nextest,
26}
27
28#[derive(Debug, Clone, Setters)]
29#[setters(strip_option, into)]
30pub struct StandardWorkflow {
31    /// When enabled, a release job is added to the workflow.
32    /// *IMPORTANT:* Ensure `secrets.CARGO_REGISTRY_TOKEN` is set for your
33    /// github action.
34    pub auto_release: bool,
35
36    /// Name of the workflow.
37    pub name: String,
38
39    /// When enabled, a benchmark job is added to the workflow.
40    pub benchmarks: bool,
41
42    /// Auto-fixes the code after
43    pub auto_fix: bool,
44
45    /// Steps to be executed before the checkout step
46    pub setup: Vec<Step<Run>>,
47
48    /// The test runner to use for running tests
49    pub test_runner: TestRunner,
50}
51
52impl Default for StandardWorkflow {
53    fn default() -> Self {
54        Self {
55            auto_release: false,
56            name: "ci".into(),
57            benchmarks: false,
58            auto_fix: false,
59            setup: Vec::new(),
60            test_runner: TestRunner::default(),
61        }
62    }
63}
64
65impl StandardWorkflow {
66    /// Initialize a job with common configuration including:
67    /// - Permissions
68    /// - Setup steps
69    /// - Checkout step
70    ///
71    /// This reduces duplication across different job types.
72    fn init_job(&self, name: impl ToString) -> Job {
73        let mut job = Job::new(name).permissions(Permissions::default().contents(Level::Read));
74
75        // Add setup steps in reverse order to maintain the correct sequence
76        for step in self.setup.iter().rev() {
77            job = job.add_step(step.clone());
78        }
79
80        job.add_step(Step::checkout())
81    }
82
83    /// Add a setup step to be executed before the checkout step.
84    ///
85    /// # Example
86    /// ```ignore
87    /// use gh_workflow_tailcall::*;
88    /// let workflow = StandardWorkflow::default()
89    ///     .add_setup(Step::run("git config --global core.autocrlf false")
90    ///         .name("Configure git"));
91    /// ```
92    pub fn add_setup<S: Into<Step<Run>>>(mut self, step: S) -> Self {
93        self.setup.push(step.into());
94        self
95    }
96}
97
98impl StandardWorkflow {
99    /// Generates and tests the workflow file.
100    pub fn generate(self) -> Result<()> {
101        self.to_ci_workflow().generate()?;
102        Generate::new(self.to_autofix_workflow())
103            .name("autofix.yml")
104            .generate()?;
105        Ok(())
106    }
107
108    /// Converts the workflow into a Github workflow.
109    fn to_autofix_workflow(&self) -> GHWorkflow {
110        // NOTE: The workflow name needs to by `autofix.ci`
111        GHWorkflow::new("autofix.ci")
112            .add_env(self.workflow_flags())
113            .on(self.workflow_event())
114            .add_job("lint", self.lint_job(true))
115    }
116
117    /// Converts the workflow into a Github workflow.
118    pub fn to_ci_workflow(&self) -> GHWorkflow {
119        let mut workflow = GHWorkflow::new(self.name.clone())
120            .add_env(self.workflow_flags())
121            .on(self.workflow_event())
122            .add_job("build", self.test_job())
123            .add_job("lint", self.lint_job(false));
124
125        if self.auto_release {
126            workflow = workflow
127                .add_job("release", self.release_job(Command::Release))
128                .add_job("release-pr", self.release_job(Command::ReleasePR));
129        }
130
131        workflow
132    }
133
134    fn release_job(&self, cmd: Command) -> Job {
135        self.init_job(cmd.to_string().to_title_case())
136            .concurrency(
137                Concurrency::new(Expression::new("release-${{github.ref}}"))
138                    .cancel_in_progress(false),
139            )
140            .cond(self.workflow_cond())
141            .add_needs("build")
142            .add_needs("lint")
143            .add_env(Env::github())
144            .add_env(Env::new(
145                "CARGO_REGISTRY_TOKEN",
146                "${{ secrets.CARGO_REGISTRY_TOKEN }}",
147            ))
148            .permissions(self.write_permissions())
149            .add_step(Release::default().command(cmd))
150    }
151
152    fn lint_job(&self, auto_fix: bool) -> Job {
153        let mut job = self.init_job(if auto_fix { "Lint Fix" } else { "Lint" });
154
155        if auto_fix {
156            job = job.concurrency(
157                Concurrency::new(Expression::new("autofix-${{github.ref}}"))
158                    .cancel_in_progress(false),
159            );
160        }
161
162        let mut fmt_step = Cargo::new("fmt")
163            .name("Cargo Fmt")
164            .nightly()
165            .add_args("--all");
166
167        if !auto_fix {
168            fmt_step = fmt_step.add_args("--check");
169        }
170
171        let mut clippy_step = Cargo::new("clippy").name("Cargo Clippy").nightly();
172
173        if auto_fix {
174            clippy_step = clippy_step.add_args("--fix").add_args("--allow-dirty");
175        }
176
177        clippy_step = clippy_step.add_args("--all-features --workspace -- -D warnings");
178
179        job = job
180            .add_step(
181                Toolchain::default()
182                    .add_nightly()
183                    .add_clippy()
184                    .add_fmt()
185                    .cache(true)
186                    .cache_directories(vec![
187                        "~/.cargo/registry".into(),
188                        "~/.cargo/git".into(),
189                        "target".into(),
190                    ]),
191            )
192            .add_step(fmt_step)
193            .add_step(clippy_step);
194
195        if auto_fix {
196            job = job.add_step(Step::new("auto-fix").uses("autofix-ci", "action", "v1"));
197        }
198        job
199    }
200
201    /// Creates the "Build and Test" job for the workflow.
202    fn test_job(&self) -> Job {
203        let mut job = self
204            .init_job("Build and Test")
205            .add_step(Toolchain::default().add_stable());
206
207        if matches!(self.test_runner, TestRunner::Nextest) {
208            job = job.add_step(
209                Step::new("Install nextest")
210                    .uses("taiki-e", "install-action", "v2")
211                    .add_with(("tool", "nextest")),
212            );
213        }
214        job = job
215            .add_step(
216                Step::new("Cache Rust dependencies")
217                    .uses("Swatinem", "rust-cache", "v2")
218                    .add_with(("cache-all-crates", "true")),
219            )
220            .add_step(match self.test_runner {
221                TestRunner::Cargo => Cargo::new("test")
222                    .args("--all-features --workspace")
223                    .name("Cargo Test"),
224                TestRunner::Nextest => Cargo::new("nextest")
225                    .args("run --all-features --workspace")
226                    .name("Cargo Nextest"),
227            });
228
229        if self.benchmarks {
230            job = job.add_step(Cargo::new("bench").args("--workspace").name("Cargo Bench"));
231        }
232
233        job
234    }
235
236    fn write_permissions(&self) -> Permissions {
237        Permissions::default()
238            .pull_requests(Level::Write)
239            .packages(Level::Write)
240            .contents(Level::Write)
241    }
242
243    fn workflow_cond(&self) -> Context<bool> {
244        let is_main = Context::github().ref_().eq("refs/heads/main".into());
245        let is_push = Context::github().event_name().eq("push".into());
246
247        is_main.and(is_push)
248    }
249
250    fn workflow_event(&self) -> Event {
251        Event::default()
252            .push(Push::default().add_branch("main").add_tag("v*"))
253            .pull_request(
254                PullRequest::default()
255                    .add_type(PullRequestType::Opened)
256                    .add_type(PullRequestType::Synchronize)
257                    .add_type(PullRequestType::Reopened)
258                    .add_branch("main"),
259            )
260    }
261
262    fn workflow_flags(&self) -> RustFlags {
263        RustFlags::deny("warnings")
264    }
265}