steps.rs

  1use gh_workflow::*;
  2use serde_json::Value;
  3
  4use crate::tasks::workflows::{
  5    runners::Platform,
  6    steps::named::function_name,
  7    vars::{self, StepOutput},
  8};
  9
 10pub(crate) fn use_clang(job: Job) -> Job {
 11    job.add_env(Env::new("CC", "clang"))
 12        .add_env(Env::new("CXX", "clang++"))
 13}
 14
 15const SCCACHE_R2_BUCKET: &str = "sccache-zed";
 16
 17pub(crate) const BASH_SHELL: &str = "bash -euxo pipefail {0}";
 18// https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsshell
 19pub const PWSH_SHELL: &str = "pwsh";
 20
 21pub(crate) struct Nextest(Step<Run>);
 22
 23pub(crate) fn cargo_nextest(platform: Platform) -> Nextest {
 24    Nextest(named::run(
 25        platform,
 26        "cargo nextest run --workspace --no-fail-fast --no-tests=warn",
 27    ))
 28}
 29
 30impl Nextest {
 31    #[allow(dead_code)]
 32    pub(crate) fn with_filter_expr(mut self, filter_expr: &str) -> Self {
 33        if let Some(nextest_command) = self.0.value.run.as_mut() {
 34            nextest_command.push_str(&format!(r#" -E "{filter_expr}""#));
 35        }
 36        self
 37    }
 38
 39    pub(crate) fn with_changed_packages_filter(mut self, orchestrate_job: &str) -> Self {
 40        if let Some(nextest_command) = self.0.value.run.as_mut() {
 41            nextest_command.push_str(&format!(
 42                r#"${{{{ needs.{orchestrate_job}.outputs.changed_packages && format(' -E "{{0}}"', needs.{orchestrate_job}.outputs.changed_packages) || '' }}}}"#
 43            ));
 44        }
 45        self
 46    }
 47}
 48
 49impl From<Nextest> for Step<Run> {
 50    fn from(value: Nextest) -> Self {
 51        value.0
 52    }
 53}
 54
 55#[derive(Default)]
 56enum FetchDepth {
 57    #[default]
 58    Shallow,
 59    Full,
 60    Custom(serde_json::Value),
 61}
 62
 63#[derive(Default)]
 64pub(crate) struct CheckoutStep {
 65    fetch_depth: FetchDepth,
 66    name: Option<String>,
 67    token: Option<String>,
 68    path: Option<String>,
 69    repository: Option<String>,
 70    ref_: Option<String>,
 71}
 72
 73impl CheckoutStep {
 74    pub fn with_full_history(mut self) -> Self {
 75        self.fetch_depth = FetchDepth::Full;
 76        self
 77    }
 78
 79    pub fn with_custom_name(mut self, name: &str) -> Self {
 80        self.name = Some(name.to_string());
 81        self
 82    }
 83
 84    pub fn with_custom_fetch_depth(mut self, fetch_depth: impl Into<Value>) -> Self {
 85        self.fetch_depth = FetchDepth::Custom(fetch_depth.into());
 86        self
 87    }
 88
 89    /// Sets `fetch-depth` to `2` on the main branch and `350` on all other branches.
 90    pub fn with_deep_history_on_non_main(self) -> Self {
 91        self.with_custom_fetch_depth("${{ github.ref == 'refs/heads/main' && 2 || 350 }}")
 92    }
 93
 94    pub fn with_token(mut self, token: &StepOutput) -> Self {
 95        self.token = Some(token.to_string());
 96        self
 97    }
 98
 99    pub fn with_path(mut self, path: &str) -> Self {
100        self.path = Some(path.to_string());
101        self
102    }
103
104    pub fn with_repository(mut self, repository: &str) -> Self {
105        self.repository = Some(repository.to_string());
106        self
107    }
108
109    pub fn with_ref(mut self, ref_: impl ToString) -> Self {
110        self.ref_ = Some(ref_.to_string());
111        self
112    }
113}
114
115impl From<CheckoutStep> for Step<Use> {
116    fn from(value: CheckoutStep) -> Self {
117        Step::new(value.name.unwrap_or("steps::checkout_repo".to_string()))
118            .uses(
119                "actions",
120                "checkout",
121                "93cb6efe18208431cddfb8368fd83d5badbf9bfd", // v5.0.1
122            )
123            // prevent checkout action from running `git clean -ffdx` which
124            // would delete the target directory
125            .add_with(("clean", false))
126            .map(|step| match value.fetch_depth {
127                FetchDepth::Shallow => step,
128                FetchDepth::Full => step.add_with(("fetch-depth", 0)),
129                FetchDepth::Custom(depth) => step.add_with(("fetch-depth", depth)),
130            })
131            .when_some(value.path, |step, path| step.add_with(("path", path)))
132            .when_some(value.repository, |step, repository| {
133                step.add_with(("repository", repository))
134            })
135            .when_some(value.ref_, |step, ref_| step.add_with(("ref", ref_)))
136            .when_some(value.token, |step, token| step.add_with(("token", token)))
137    }
138}
139
140impl FluentBuilder for CheckoutStep {}
141
142pub fn checkout_repo() -> CheckoutStep {
143    CheckoutStep::default()
144}
145
146pub fn setup_pnpm() -> Step<Use> {
147    named::uses(
148        "pnpm",
149        "action-setup",
150        "fe02b34f77f8bc703788d5817da081398fad5dd2", // v4.0.0
151    )
152    .add_with(("version", "9"))
153}
154
155pub fn setup_node() -> Step<Use> {
156    named::uses(
157        "actions",
158        "setup-node",
159        "49933ea5288caeca8642d1e84afbd3f7d6820020", // v4
160    )
161    .add_with(("node-version", "20"))
162}
163
164pub fn setup_sentry() -> Step<Use> {
165    named::uses(
166        "matbour",
167        "setup-sentry-cli",
168        "3e938c54b3018bdd019973689ef984e033b0454b",
169    )
170    .add_with(("token", vars::SENTRY_AUTH_TOKEN))
171}
172
173pub fn prettier() -> Step<Run> {
174    named::bash("./script/prettier")
175}
176
177pub fn cargo_fmt() -> Step<Run> {
178    named::bash("cargo fmt --all -- --check")
179}
180
181pub fn cargo_install_nextest() -> Step<Use> {
182    named::uses(
183        "taiki-e",
184        "install-action",
185        "921e2c9f7148d7ba14cd819f417db338f63e733c", // nextest
186    )
187}
188
189pub fn setup_cargo_config(platform: Platform) -> Step<Run> {
190    match platform {
191        Platform::Windows => named::pwsh(indoc::indoc! {r#"
192            New-Item -ItemType Directory -Path "./../.cargo" -Force
193            Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml"
194        "#}),
195
196        Platform::Linux | Platform::Mac => named::bash(indoc::indoc! {r#"
197            mkdir -p ./../.cargo
198            cp ./.cargo/ci-config.toml ./../.cargo/config.toml
199        "#}),
200    }
201}
202
203pub fn cleanup_cargo_config(platform: Platform) -> Step<Run> {
204    let step = match platform {
205        Platform::Windows => named::pwsh(indoc::indoc! {r#"
206            Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue
207        "#}),
208        Platform::Linux | Platform::Mac => named::bash(indoc::indoc! {r#"
209            rm -rf ./../.cargo
210        "#}),
211    };
212
213    step.if_condition(Expression::new("always()"))
214}
215
216pub fn clear_target_dir_if_large(platform: Platform) -> Step<Run> {
217    match platform {
218        Platform::Windows => named::pwsh("./script/clear-target-dir-if-larger-than.ps1 350 200"),
219        Platform::Linux => named::bash("./script/clear-target-dir-if-larger-than 350 200"),
220        Platform::Mac => named::bash("./script/clear-target-dir-if-larger-than 350 200"),
221    }
222}
223
224pub fn clippy(platform: Platform, target: Option<&str>) -> Step<Run> {
225    match platform {
226        Platform::Windows => named::pwsh("./script/clippy.ps1"),
227        _ => match target {
228            Some(target) => named::bash(format!("./script/clippy --target {target}")),
229            None => named::bash("./script/clippy"),
230        },
231    }
232}
233
234pub fn install_rustup_target(target: &str) -> Step<Run> {
235    named::bash(format!("rustup target add {target}"))
236}
237
238pub fn cache_rust_dependencies_namespace() -> Step<Use> {
239    named::uses(
240        "namespacelabs",
241        "nscloud-cache-action",
242        "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1
243    )
244    .add_with(("cache", "rust"))
245    .add_with(("path", "~/.rustup"))
246}
247
248pub fn setup_sccache(platform: Platform) -> Step<Run> {
249    let step = match platform {
250        Platform::Windows => named::pwsh("./script/setup-sccache.ps1"),
251        Platform::Linux | Platform::Mac => named::bash("./script/setup-sccache"),
252    };
253    step.add_env(("R2_ACCOUNT_ID", vars::R2_ACCOUNT_ID))
254        .add_env(("R2_ACCESS_KEY_ID", vars::R2_ACCESS_KEY_ID))
255        .add_env(("R2_SECRET_ACCESS_KEY", vars::R2_SECRET_ACCESS_KEY))
256        .add_env(("SCCACHE_BUCKET", SCCACHE_R2_BUCKET))
257}
258
259pub fn show_sccache_stats(platform: Platform) -> Step<Run> {
260    match platform {
261        // Use $env:RUSTC_WRAPPER (absolute path) because GITHUB_PATH changes
262        // don't take effect until the next step in PowerShell.
263        // Check if RUSTC_WRAPPER is set first (it won't be for fork PRs without secrets).
264        Platform::Windows => {
265            named::pwsh("if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0")
266        }
267        Platform::Linux | Platform::Mac => named::bash("sccache --show-stats || true"),
268    }
269}
270
271pub fn cache_nix_dependencies_namespace() -> Step<Use> {
272    named::uses(
273        "namespacelabs",
274        "nscloud-cache-action",
275        "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1
276    )
277    .add_with(("cache", "nix"))
278}
279
280pub fn cache_nix_store_macos() -> Step<Use> {
281    // On macOS, `/nix` is on a read-only root filesystem so nscloud's `cache: nix`
282    // cannot mount or symlink there. Instead we cache a user-writable directory and
283    // use nix-store --import/--export in separate steps to transfer store paths.
284    named::uses(
285        "namespacelabs",
286        "nscloud-cache-action",
287        "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1
288    )
289    .add_with(("path", "~/nix-cache"))
290}
291
292pub fn setup_linux() -> Step<Run> {
293    named::bash("./script/linux")
294}
295
296fn download_wasi_sdk() -> Step<Run> {
297    named::bash("./script/download-wasi-sdk")
298}
299
300pub(crate) fn install_linux_dependencies(job: Job) -> Job {
301    job.add_step(setup_linux()).add_step(download_wasi_sdk())
302}
303
304pub fn script(name: &str) -> Step<Run> {
305    if name.ends_with(".ps1") {
306        Step::new(name).run(name).shell(PWSH_SHELL)
307    } else {
308        Step::new(name).run(name)
309    }
310}
311
312pub struct NamedJob<J: JobType = RunJob> {
313    pub name: String,
314    pub job: Job<J>,
315}
316
317// impl NamedJob {
318//     pub fn map(self, f: impl FnOnce(Job) -> Job) -> Self {
319//         NamedJob {
320//             name: self.name,
321//             job: f(self.job),
322//         }
323//     }
324// }
325
326pub(crate) const DEFAULT_REPOSITORY_OWNER_GUARD: &str =
327    "(github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')";
328
329pub fn repository_owner_guard_expression(trigger_always: bool) -> Expression {
330    Expression::new(format!(
331        "{}{}",
332        DEFAULT_REPOSITORY_OWNER_GUARD,
333        trigger_always.then_some(" && always()").unwrap_or_default()
334    ))
335}
336
337pub trait CommonJobConditions: Sized {
338    fn with_repository_owner_guard(self) -> Self;
339}
340
341impl CommonJobConditions for Job {
342    fn with_repository_owner_guard(self) -> Self {
343        self.cond(repository_owner_guard_expression(false))
344    }
345}
346
347pub(crate) fn release_job(deps: &[&NamedJob]) -> Job {
348    dependant_job(deps)
349        .with_repository_owner_guard()
350        .timeout_minutes(60u32)
351}
352
353pub(crate) fn dependant_job(deps: &[&NamedJob]) -> Job {
354    let job = Job::default();
355    if deps.len() > 0 {
356        job.needs(deps.iter().map(|j| j.name.clone()).collect::<Vec<_>>())
357    } else {
358        job
359    }
360}
361
362impl FluentBuilder for Job {}
363impl FluentBuilder for Workflow {}
364impl FluentBuilder for Input {}
365impl<T> FluentBuilder for Step<T> {}
366
367/// A helper trait for building complex objects with imperative conditionals in a fluent style.
368/// Copied from GPUI to avoid adding GPUI as dependency
369/// todo(ci) just put this in gh-workflow
370#[allow(unused)]
371pub trait FluentBuilder {
372    /// Imperatively modify self with the given closure.
373    fn map<U>(self, f: impl FnOnce(Self) -> U) -> U
374    where
375        Self: Sized,
376    {
377        f(self)
378    }
379
380    /// Conditionally modify self with the given closure.
381    fn when(self, condition: bool, then: impl FnOnce(Self) -> Self) -> Self
382    where
383        Self: Sized,
384    {
385        self.map(|this| if condition { then(this) } else { this })
386    }
387
388    /// Conditionally modify self with the given closure.
389    fn when_else(
390        self,
391        condition: bool,
392        then: impl FnOnce(Self) -> Self,
393        else_fn: impl FnOnce(Self) -> Self,
394    ) -> Self
395    where
396        Self: Sized,
397    {
398        self.map(|this| if condition { then(this) } else { else_fn(this) })
399    }
400
401    /// Conditionally unwrap and modify self with the given closure, if the given option is Some.
402    fn when_some<T>(self, option: Option<T>, then: impl FnOnce(Self, T) -> Self) -> Self
403    where
404        Self: Sized,
405    {
406        self.map(|this| {
407            if let Some(value) = option {
408                then(this, value)
409            } else {
410                this
411            }
412        })
413    }
414    /// Conditionally unwrap and modify self with the given closure, if the given option is None.
415    fn when_none<T>(self, option: &Option<T>, then: impl FnOnce(Self) -> Self) -> Self
416    where
417        Self: Sized,
418    {
419        self.map(|this| if option.is_some() { this } else { then(this) })
420    }
421}
422
423// (janky) helper to generate steps with a name that corresponds
424// to the name of the calling function.
425pub mod named {
426    use super::*;
427
428    /// Returns a uses step with the same name as the enclosing function.
429    /// (You shouldn't inline this function into the workflow definition, you must
430    /// wrap it in a new function.)
431    pub fn uses(owner: &str, repo: &str, ref_: &str) -> Step<Use> {
432        Step::new(function_name(1)).uses(owner, repo, ref_)
433    }
434
435    /// Returns a bash-script step with the same name as the enclosing function.
436    /// (You shouldn't inline this function into the workflow definition, you must
437    /// wrap it in a new function.)
438    pub fn bash(script: impl AsRef<str>) -> Step<Run> {
439        Step::new(function_name(1)).run(script.as_ref())
440    }
441
442    /// Returns a pwsh-script step with the same name as the enclosing function.
443    /// (You shouldn't inline this function into the workflow definition, you must
444    /// wrap it in a new function.)
445    pub fn pwsh(script: &str) -> Step<Run> {
446        Step::new(function_name(1)).run(script).shell(PWSH_SHELL)
447    }
448
449    /// Runs the command in either powershell or bash, depending on platform.
450    /// (You shouldn't inline this function into the workflow definition, you must
451    /// wrap it in a new function.)
452    pub fn run(platform: Platform, script: &str) -> Step<Run> {
453        match platform {
454            Platform::Windows => Step::new(function_name(1)).run(script).shell(PWSH_SHELL),
455            Platform::Linux | Platform::Mac => Step::new(function_name(1)).run(script),
456        }
457    }
458
459    /// Returns a Workflow with the same name as the enclosing module with default
460    /// set for the running shell.
461    pub fn workflow() -> Workflow {
462        Workflow::default()
463            .name(
464                named::function_name(1)
465                    .split("::")
466                    .collect::<Vec<_>>()
467                    .into_iter()
468                    .rev()
469                    .skip(1)
470                    .rev()
471                    .collect::<Vec<_>>()
472                    .join("::"),
473            )
474            .defaults(Defaults::default().run(RunDefaults::default().shell(BASH_SHELL)))
475    }
476
477    /// Returns a Job with the same name as the enclosing function.
478    /// (note job names may not contain `::`)
479    pub fn job<J: JobType>(job: Job<J>) -> NamedJob<J> {
480        NamedJob {
481            name: function_name(1).split("::").last().unwrap().to_owned(),
482            job,
483        }
484    }
485
486    /// Returns the function name N callers above in the stack
487    /// (typically 1).
488    /// This only works because xtask always runs debug builds.
489    pub fn function_name(i: usize) -> String {
490        let mut name = "<unknown>".to_string();
491        let mut count = 0;
492        backtrace::trace(|frame| {
493            if count < i + 3 {
494                count += 1;
495                return true;
496            }
497            backtrace::resolve_frame(frame, |cb| {
498                if let Some(s) = cb.name() {
499                    name = s.to_string()
500                }
501            });
502            false
503        });
504
505        name.split("::")
506            .skip_while(|s| s != &"workflows")
507            .skip(1)
508            .collect::<Vec<_>>()
509            .join("::")
510    }
511}
512
513pub fn git_checkout(ref_name: &dyn std::fmt::Display) -> Step<Run> {
514    named::bash(r#"git fetch origin "$REF_NAME" && git checkout "$REF_NAME""#)
515        .add_env(("REF_NAME", ref_name.to_string()))
516}
517
518/// Non-exhaustive list of the permissions to be set for a GitHub app token.
519///
520/// See https://github.com/actions/create-github-app-token?tab=readme-ov-file#permission-permission-name
521/// and beyond for a full list of available permissions.
522#[allow(unused)]
523pub(crate) enum TokenPermissions {
524    Contents,
525    Issues,
526    PullRequests,
527    Workflows,
528}
529
530impl TokenPermissions {
531    pub fn environment_name(&self) -> &'static str {
532        match self {
533            TokenPermissions::Contents => "permission-contents",
534            TokenPermissions::Issues => "permission-issues",
535            TokenPermissions::PullRequests => "permission-pull-requests",
536            TokenPermissions::Workflows => "permission-workflows",
537        }
538    }
539}
540
541pub(crate) struct GenerateAppToken<'a> {
542    job_name: String,
543    app_id: &'a str,
544    app_secret: &'a str,
545    repository_target: Option<RepositoryTarget>,
546    permissions: Option<Vec<(TokenPermissions, Level)>>,
547}
548
549impl<'a> GenerateAppToken<'a> {
550    pub fn for_repository(self, repository_target: RepositoryTarget) -> Self {
551        Self {
552            repository_target: Some(repository_target),
553            ..self
554        }
555    }
556
557    pub fn with_permissions(self, permissions: impl Into<Vec<(TokenPermissions, Level)>>) -> Self {
558        Self {
559            permissions: Some(permissions.into()),
560            ..self
561        }
562    }
563}
564
565impl<'a> From<GenerateAppToken<'a>> for (Step<Use>, StepOutput) {
566    fn from(token: GenerateAppToken<'a>) -> Self {
567        let step = Step::new(token.job_name)
568            .uses(
569                "actions",
570                "create-github-app-token",
571                "f8d387b68d61c58ab83c6c016672934102569859",
572            )
573            .id("generate-token")
574            .add_with(
575                Input::default()
576                    .add("app-id", token.app_id)
577                    .add("private-key", token.app_secret)
578                    .when_some(
579                        token.repository_target,
580                        |input,
581                         RepositoryTarget {
582                             owner,
583                             repositories,
584                         }| {
585                            input
586                                .when_some(owner, |input, owner| input.add("owner", owner))
587                                .when_some(repositories, |input, repositories| {
588                                    input.add("repositories", repositories)
589                                })
590                        },
591                    )
592                    .when_some(token.permissions, |input, permissions| {
593                        permissions
594                            .into_iter()
595                            .fold(input, |input, (permission, level)| {
596                                input.add(
597                                    permission.environment_name(),
598                                    serde_json::to_value(&level).unwrap_or_default(),
599                                )
600                            })
601                    }),
602            );
603
604        let generated_token = StepOutput::new(&step, "token");
605        (step, generated_token)
606    }
607}
608
609pub(crate) struct RepositoryTarget {
610    owner: Option<String>,
611    repositories: Option<String>,
612}
613
614impl RepositoryTarget {
615    pub fn new<T: ToString>(owner: T, repositories: &[&str]) -> Self {
616        Self {
617            owner: Some(owner.to_string()),
618            repositories: Some(repositories.join("\n")),
619        }
620    }
621
622    pub fn current() -> Self {
623        Self {
624            owner: None,
625            repositories: None,
626        }
627    }
628}
629
630pub(crate) fn generate_token<'a>(
631    app_id_source: &'a str,
632    app_secret_source: &'a str,
633) -> GenerateAppToken<'a> {
634    generate_token_with_job_name(app_id_source, app_secret_source)
635}
636
637pub fn authenticate_as_zippy() -> GenerateAppToken<'static> {
638    generate_token_with_job_name(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY)
639}
640
641fn generate_token_with_job_name<'a>(
642    app_id_source: &'a str,
643    app_secret_source: &'a str,
644) -> GenerateAppToken<'a> {
645    GenerateAppToken {
646        job_name: function_name(1),
647        app_id: app_id_source,
648        app_secret: app_secret_source,
649        repository_target: None,
650        permissions: None,
651    }
652}