steps.rs

  1use gh_workflow::*;
  2
  3use crate::tasks::workflows::{runners::Platform, vars, vars::StepOutput};
  4
  5const BASH_SHELL: &str = "bash -euxo pipefail {0}";
  6// https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsshell
  7pub const PWSH_SHELL: &str = "pwsh";
  8
  9pub(crate) struct Nextest(Step<Run>);
 10
 11pub(crate) fn cargo_nextest(platform: Platform) -> Nextest {
 12    Nextest(named::run(
 13        platform,
 14        "cargo nextest run --workspace --no-fail-fast",
 15    ))
 16}
 17
 18impl Nextest {
 19    pub(crate) fn with_target(mut self, target: &str) -> Step<Run> {
 20        if let Some(nextest_command) = self.0.value.run.as_mut() {
 21            nextest_command.push_str(&format!(r#" --target "{target}""#));
 22        }
 23        self.into()
 24    }
 25
 26    #[allow(dead_code)]
 27    pub(crate) fn with_filter_expr(mut self, filter_expr: &str) -> Self {
 28        if let Some(nextest_command) = self.0.value.run.as_mut() {
 29            nextest_command.push_str(&format!(r#" -E "{filter_expr}""#));
 30        }
 31        self
 32    }
 33
 34    pub(crate) fn with_changed_packages_filter(mut self, orchestrate_job: &str) -> Self {
 35        if let Some(nextest_command) = self.0.value.run.as_mut() {
 36            nextest_command.push_str(&format!(
 37                r#"${{{{ needs.{orchestrate_job}.outputs.changed_packages && format(' -E "{{0}}"', needs.{orchestrate_job}.outputs.changed_packages) || '' }}}}"#
 38            ));
 39        }
 40        self
 41    }
 42}
 43
 44impl From<Nextest> for Step<Run> {
 45    fn from(value: Nextest) -> Self {
 46        value.0
 47    }
 48}
 49
 50pub fn restore_mtime() -> Step<Use> {
 51    named::uses(
 52        "zed-industries",
 53        "git-restore-mtime-action",
 54        "72cefcf20b50bf4fcd74ea8a0d2927b0680e2114",
 55    )
 56}
 57
 58pub fn checkout_repo() -> Step<Use> {
 59    named::uses(
 60        "actions",
 61        "checkout",
 62        "11bd71901bbe5b1630ceea73d27597364c9af683", // v4
 63    )
 64    // prevent checkout action from running `git clean -ffdx` which
 65    // would delete the target directory
 66    .add_with(("clean", false))
 67}
 68
 69pub fn checkout_repo_with_token(token: &StepOutput) -> Step<Use> {
 70    named::uses(
 71        "actions",
 72        "checkout",
 73        "11bd71901bbe5b1630ceea73d27597364c9af683", // v4
 74    )
 75    .add_with(("clean", false))
 76    .add_with(("token", token.to_string()))
 77}
 78
 79pub fn setup_pnpm() -> Step<Use> {
 80    named::uses(
 81        "pnpm",
 82        "action-setup",
 83        "fe02b34f77f8bc703788d5817da081398fad5dd2", // v4.0.0
 84    )
 85    .add_with(("version", "9"))
 86}
 87
 88pub fn setup_node() -> Step<Use> {
 89    named::uses(
 90        "actions",
 91        "setup-node",
 92        "49933ea5288caeca8642d1e84afbd3f7d6820020", // v4
 93    )
 94    .add_with(("node-version", "20"))
 95}
 96
 97pub fn setup_sentry() -> Step<Use> {
 98    named::uses(
 99        "matbour",
100        "setup-sentry-cli",
101        "3e938c54b3018bdd019973689ef984e033b0454b",
102    )
103    .add_with(("token", vars::SENTRY_AUTH_TOKEN))
104}
105
106pub fn prettier() -> Step<Run> {
107    named::bash("./script/prettier")
108}
109
110pub fn cargo_fmt() -> Step<Run> {
111    named::bash("cargo fmt --all -- --check")
112}
113
114pub fn cargo_install_nextest() -> Step<Use> {
115    named::uses("taiki-e", "install-action", "nextest")
116}
117
118pub fn setup_cargo_config(platform: Platform) -> Step<Run> {
119    match platform {
120        Platform::Windows => named::pwsh(indoc::indoc! {r#"
121            New-Item -ItemType Directory -Path "./../.cargo" -Force
122            Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml"
123        "#}),
124
125        Platform::Linux | Platform::Mac => named::bash(indoc::indoc! {r#"
126            mkdir -p ./../.cargo
127            cp ./.cargo/ci-config.toml ./../.cargo/config.toml
128        "#}),
129    }
130}
131
132pub fn cleanup_cargo_config(platform: Platform) -> Step<Run> {
133    let step = match platform {
134        Platform::Windows => named::pwsh(indoc::indoc! {r#"
135            Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue
136        "#}),
137        Platform::Linux | Platform::Mac => named::bash(indoc::indoc! {r#"
138            rm -rf ./../.cargo
139        "#}),
140    };
141
142    step.if_condition(Expression::new("always()"))
143}
144
145pub fn clear_target_dir_if_large(platform: Platform) -> Step<Run> {
146    match platform {
147        Platform::Windows => named::pwsh("./script/clear-target-dir-if-larger-than.ps1 250"),
148        Platform::Linux => named::bash("./script/clear-target-dir-if-larger-than 250"),
149        Platform::Mac => named::bash("./script/clear-target-dir-if-larger-than 300"),
150    }
151}
152
153pub fn clippy(platform: Platform) -> Step<Run> {
154    match platform {
155        Platform::Windows => named::pwsh("./script/clippy.ps1"),
156        _ => named::bash("./script/clippy"),
157    }
158}
159
160pub fn cache_rust_dependencies_namespace() -> Step<Use> {
161    named::uses("namespacelabs", "nscloud-cache-action", "v1")
162        .add_with(("cache", "rust"))
163        .add_with(("path", "~/.rustup"))
164}
165
166pub fn cache_nix_dependencies_namespace() -> Step<Use> {
167    named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("cache", "nix"))
168}
169
170pub fn cache_nix_store_macos() -> Step<Use> {
171    // On macOS, `/nix` is on a read-only root filesystem so nscloud's `cache: nix`
172    // cannot mount or symlink there. Instead we cache a user-writable directory and
173    // use nix-store --import/--export in separate steps to transfer store paths.
174    named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("path", "~/nix-cache"))
175}
176
177pub fn setup_linux() -> Step<Run> {
178    named::bash("./script/linux")
179}
180
181fn install_mold() -> Step<Run> {
182    named::bash("./script/install-mold")
183}
184
185fn download_wasi_sdk() -> Step<Run> {
186    named::bash("./script/download-wasi-sdk")
187}
188
189pub(crate) fn install_linux_dependencies(job: Job) -> Job {
190    job.add_step(setup_linux())
191        .add_step(install_mold())
192        .add_step(download_wasi_sdk())
193}
194
195pub fn script(name: &str) -> Step<Run> {
196    if name.ends_with(".ps1") {
197        Step::new(name).run(name).shell(PWSH_SHELL)
198    } else {
199        Step::new(name).run(name)
200    }
201}
202
203pub struct NamedJob<J: JobType = RunJob> {
204    pub name: String,
205    pub job: Job<J>,
206}
207
208// impl NamedJob {
209//     pub fn map(self, f: impl FnOnce(Job) -> Job) -> Self {
210//         NamedJob {
211//             name: self.name,
212//             job: f(self.job),
213//         }
214//     }
215// }
216
217pub(crate) const DEFAULT_REPOSITORY_OWNER_GUARD: &str =
218    "(github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')";
219
220pub fn repository_owner_guard_expression(trigger_always: bool) -> Expression {
221    Expression::new(format!(
222        "{}{}",
223        DEFAULT_REPOSITORY_OWNER_GUARD,
224        trigger_always.then_some(" && always()").unwrap_or_default()
225    ))
226}
227
228pub trait CommonJobConditions: Sized {
229    fn with_repository_owner_guard(self) -> Self;
230}
231
232impl CommonJobConditions for Job {
233    fn with_repository_owner_guard(self) -> Self {
234        self.cond(repository_owner_guard_expression(false))
235    }
236}
237
238pub(crate) fn release_job(deps: &[&NamedJob]) -> Job {
239    dependant_job(deps)
240        .with_repository_owner_guard()
241        .timeout_minutes(60u32)
242}
243
244pub(crate) fn dependant_job(deps: &[&NamedJob]) -> Job {
245    let job = Job::default();
246    if deps.len() > 0 {
247        job.needs(deps.iter().map(|j| j.name.clone()).collect::<Vec<_>>())
248    } else {
249        job
250    }
251}
252
253impl FluentBuilder for Job {}
254impl FluentBuilder for Workflow {}
255impl FluentBuilder for Input {}
256
257/// A helper trait for building complex objects with imperative conditionals in a fluent style.
258/// Copied from GPUI to avoid adding GPUI as dependency
259/// todo(ci) just put this in gh-workflow
260#[allow(unused)]
261pub trait FluentBuilder {
262    /// Imperatively modify self with the given closure.
263    fn map<U>(self, f: impl FnOnce(Self) -> U) -> U
264    where
265        Self: Sized,
266    {
267        f(self)
268    }
269
270    /// Conditionally modify self with the given closure.
271    fn when(self, condition: bool, then: impl FnOnce(Self) -> Self) -> Self
272    where
273        Self: Sized,
274    {
275        self.map(|this| if condition { then(this) } else { this })
276    }
277
278    /// Conditionally modify self with the given closure.
279    fn when_else(
280        self,
281        condition: bool,
282        then: impl FnOnce(Self) -> Self,
283        else_fn: impl FnOnce(Self) -> Self,
284    ) -> Self
285    where
286        Self: Sized,
287    {
288        self.map(|this| if condition { then(this) } else { else_fn(this) })
289    }
290
291    /// Conditionally unwrap and modify self with the given closure, if the given option is Some.
292    fn when_some<T>(self, option: Option<T>, then: impl FnOnce(Self, T) -> Self) -> Self
293    where
294        Self: Sized,
295    {
296        self.map(|this| {
297            if let Some(value) = option {
298                then(this, value)
299            } else {
300                this
301            }
302        })
303    }
304    /// Conditionally unwrap and modify self with the given closure, if the given option is None.
305    fn when_none<T>(self, option: &Option<T>, then: impl FnOnce(Self) -> Self) -> Self
306    where
307        Self: Sized,
308    {
309        self.map(|this| if option.is_some() { this } else { then(this) })
310    }
311}
312
313// (janky) helper to generate steps with a name that corresponds
314// to the name of the calling function.
315pub mod named {
316    use super::*;
317
318    /// Returns a uses step with the same name as the enclosing function.
319    /// (You shouldn't inline this function into the workflow definition, you must
320    /// wrap it in a new function.)
321    pub fn uses(owner: &str, repo: &str, ref_: &str) -> Step<Use> {
322        Step::new(function_name(1)).uses(owner, repo, ref_)
323    }
324
325    /// Returns a bash-script step with the same name as the enclosing function.
326    /// (You shouldn't inline this function into the workflow definition, you must
327    /// wrap it in a new function.)
328    pub fn bash(script: impl AsRef<str>) -> Step<Run> {
329        Step::new(function_name(1)).run(script.as_ref())
330    }
331
332    /// Returns a pwsh-script step with the same name as the enclosing function.
333    /// (You shouldn't inline this function into the workflow definition, you must
334    /// wrap it in a new function.)
335    pub fn pwsh(script: &str) -> Step<Run> {
336        Step::new(function_name(1)).run(script).shell(PWSH_SHELL)
337    }
338
339    /// Runs the command in either powershell or bash, depending on platform.
340    /// (You shouldn't inline this function into the workflow definition, you must
341    /// wrap it in a new function.)
342    pub fn run(platform: Platform, script: &str) -> Step<Run> {
343        match platform {
344            Platform::Windows => Step::new(function_name(1)).run(script).shell(PWSH_SHELL),
345            Platform::Linux | Platform::Mac => Step::new(function_name(1)).run(script),
346        }
347    }
348
349    /// Returns a Workflow with the same name as the enclosing module with default
350    /// set for the running shell.
351    pub fn workflow() -> Workflow {
352        Workflow::default()
353            .name(
354                named::function_name(1)
355                    .split("::")
356                    .collect::<Vec<_>>()
357                    .into_iter()
358                    .rev()
359                    .skip(1)
360                    .rev()
361                    .collect::<Vec<_>>()
362                    .join("::"),
363            )
364            .defaults(Defaults::default().run(RunDefaults::default().shell(BASH_SHELL)))
365    }
366
367    /// Returns a Job with the same name as the enclosing function.
368    /// (note job names may not contain `::`)
369    pub fn job<J: JobType>(job: Job<J>) -> NamedJob<J> {
370        NamedJob {
371            name: function_name(1).split("::").last().unwrap().to_owned(),
372            job,
373        }
374    }
375
376    /// Returns the function name N callers above in the stack
377    /// (typically 1).
378    /// This only works because xtask always runs debug builds.
379    pub fn function_name(i: usize) -> String {
380        let mut name = "<unknown>".to_string();
381        let mut count = 0;
382        backtrace::trace(|frame| {
383            if count < i + 3 {
384                count += 1;
385                return true;
386            }
387            backtrace::resolve_frame(frame, |cb| {
388                if let Some(s) = cb.name() {
389                    name = s.to_string()
390                }
391            });
392            false
393        });
394
395        name.split("::")
396            .skip_while(|s| s != &"workflows")
397            .skip(1)
398            .collect::<Vec<_>>()
399            .join("::")
400    }
401}
402
403pub fn git_checkout(ref_name: &dyn std::fmt::Display) -> Step<Run> {
404    named::bash(&format!(
405        "git fetch origin {ref_name} && git checkout {ref_name}"
406    ))
407}
408
409pub fn authenticate_as_zippy() -> (Step<Use>, StepOutput) {
410    let step = named::uses(
411        "actions",
412        "create-github-app-token",
413        "bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1",
414    )
415    .add_with(("app-id", vars::ZED_ZIPPY_APP_ID))
416    .add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY))
417    .id("get-app-token");
418    let output = StepOutput::new(&step, "token");
419    (step, output)
420}