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)]
29pub struct StandardWorkflow {
30    /// When enabled, a release job is added to the workflow.
31    /// *IMPORTANT:* Ensure `secrets.CARGO_REGISTRY_TOKEN` is set for your
32    /// github action.
33    pub auto_release: bool,
34
35    /// Name of the workflow.
36    pub name: String,
37
38    /// When enabled, a benchmark job is added to the workflow.
39    pub benchmarks: bool,
40
41    /// Auto-fixes the code after
42    pub auto_fix: bool,
43
44    /// Steps to be executed before the checkout step
45    pub setup: Vec<Step<Run>>,
46
47    /// The test runner to use for running tests
48    pub test_runner: TestRunner,
49}
50
51impl Default for StandardWorkflow {
52    fn default() -> Self {
53        Self {
54            auto_release: false,
55            name: "ci".into(),
56            benchmarks: false,
57            auto_fix: false,
58            setup: Vec::new(),
59            test_runner: TestRunner::default(),
60        }
61    }
62}
63
64impl StandardWorkflow {
65    /// Initialize a job with common configuration including:
66    /// - Permissions
67    /// - Setup steps
68    /// - Checkout step
69    ///
70    /// This reduces duplication across different job types.
71    fn init_job(&self, name: impl ToString) -> Job {
72        let mut job = Job::new(name).permissions(Permissions::default().contents(Level::Read));
73
74        // Add setup steps in reverse order to maintain the correct sequence
75        for step in self.setup.iter().rev() {
76            job = job.add_step(step.clone());
77        }
78
79        job.add_step(Step::checkout())
80    }
81
82    /// Add a setup step to be executed before the checkout step.
83    ///
84    /// # Example
85    /// ```ignore
86    /// use gh_workflow_tailcall::*;
87    /// let workflow = StandardWorkflow::default()
88    ///     .add_setup(Step::run("git config --global core.autocrlf false")
89    ///         .name("Configure git"));
90    /// ```
91    pub fn add_setup<S: Into<Step<Run>>>(mut self, step: S) -> Self {
92        self.setup.push(step.into());
93        self
94    }
95}
96
97impl StandardWorkflow {
98    /// Generates and tests the workflow file.
99    pub fn generate(self) -> Result<()> {
100        self.to_ci_workflow().generate()?;
101        Generate::new(self.to_autofix_workflow())
102            .name("autofix.yml")
103            .generate()?;
104        Ok(())
105    }
106
107    /// Converts the workflow into a Github workflow.
108    fn to_autofix_workflow(&self) -> GHWorkflow {
109        // NOTE: The workflow name needs to by `autofix.ci`
110        GHWorkflow::new("autofix.ci")
111            .add_env(self.workflow_flags())
112            .on(self.workflow_event())
113            .add_job("lint", self.lint_job(true))
114    }
115
116    /// Converts the workflow into a Github workflow.
117    pub fn to_ci_workflow(&self) -> GHWorkflow {
118        GHWorkflow::new(self.name.clone())
119            .add_env(self.workflow_flags())
120            .on(self.workflow_event())
121            .add_job("build", self.test_job())
122            .add_job("lint", self.lint_job(false))
123            .add_job_when(
124                self.auto_release,
125                "release",
126                self.release_job(Command::Release),
127            )
128            .add_job_when(
129                self.auto_release,
130                "release-pr",
131                self.release_job(Command::ReleasePR),
132            )
133    }
134
135    fn release_job(&self, cmd: Command) -> Job {
136        self.init_job(cmd.to_string().to_title_case())
137            .concurrency(
138                Concurrency::new(Expression::new("release-${{github.ref}}"))
139                    .cancel_in_progress(false),
140            )
141            .cond(self.workflow_cond())
142            .add_needs(self.test_job())
143            .add_needs(self.lint_job(false))
144            .add_env(Env::github())
145            .add_env(Env::new(
146                "CARGO_REGISTRY_TOKEN",
147                "${{ secrets.CARGO_REGISTRY_TOKEN }}",
148            ))
149            .permissions(self.write_permissions())
150            .add_step(Release::default().command(cmd))
151    }
152
153    fn lint_job(&self, auto_fix: bool) -> Job {
154        let job = self.init_job(if auto_fix { "Lint Fix" } else { "Lint" });
155
156        let job = if auto_fix {
157            job.concurrency(
158                Concurrency::new(Expression::new("autofix-${{github.ref}}"))
159                    .cancel_in_progress(false),
160            )
161        } else {
162            job
163        };
164
165        job.add_step(
166            Toolchain::default()
167                .add_nightly()
168                .add_clippy()
169                .add_fmt()
170                .cache(true)
171                .cache_directories(vec![
172                    "~/.cargo/registry".into(),
173                    "~/.cargo/git".into(),
174                    "target".into(),
175                ]),
176        )
177        .add_step(
178            Cargo::new("fmt")
179                .name("Cargo Fmt")
180                .nightly()
181                .add_args("--all")
182                .add_args_when(!auto_fix, "--check"),
183        )
184        .add_step(
185            Cargo::new("clippy")
186                .name("Cargo Clippy")
187                .nightly()
188                .add_args_when(auto_fix, "--fix")
189                .add_args_when(auto_fix, "--allow-dirty")
190                .add_args("--all-features --workspace -- -D warnings"),
191        )
192        .add_step_when(
193            auto_fix,
194            Step::uses(
195                "autofix-ci",
196                "action",
197                "551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef",
198            ),
199        )
200    }
201
202    /// Creates the "Build and Test" job for the workflow.
203    fn test_job(&self) -> Job {
204        self.init_job("Build and Test")
205            .add_step(Toolchain::default().add_stable())
206            .add_step_when(
207                matches!(self.test_runner, TestRunner::Nextest),
208                Cargo::new("install")
209                    .args("cargo-nextest --locked")
210                    .name("Install nextest"),
211            )
212            .add_step(match self.test_runner {
213                TestRunner::Cargo => Cargo::new("test")
214                    .args("--all-features --workspace")
215                    .name("Cargo Test"),
216                TestRunner::Nextest => Cargo::new("nextest")
217                    .args("run --all-features --workspace")
218                    .name("Cargo Nextest"),
219            })
220            .add_step_when(
221                self.benchmarks,
222                Cargo::new("bench").args("--workspace").name("Cargo Bench"),
223            )
224    }
225
226    fn write_permissions(&self) -> Permissions {
227        Permissions::default()
228            .pull_requests(Level::Write)
229            .packages(Level::Write)
230            .contents(Level::Write)
231    }
232
233    fn workflow_cond(&self) -> Context<bool> {
234        let is_main = Context::github().ref_().eq("refs/heads/main".into());
235        let is_push = Context::github().event_name().eq("push".into());
236
237        is_main.and(is_push)
238    }
239
240    fn workflow_event(&self) -> Event {
241        Event::default()
242            .push(Push::default().add_branch("main"))
243            .pull_request(
244                PullRequest::default()
245                    .add_type(PullRequestType::Opened)
246                    .add_type(PullRequestType::Synchronize)
247                    .add_type(PullRequestType::Reopened)
248                    .add_branch("main"),
249            )
250    }
251
252    fn workflow_flags(&self) -> RustFlags {
253        RustFlags::deny("warnings")
254    }
255}