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
140pub fn checkout_repo() -> CheckoutStep {
141 CheckoutStep::default()
142}
143
144pub fn setup_pnpm() -> Step<Use> {
145 named::uses(
146 "pnpm",
147 "action-setup",
148 "fe02b34f77f8bc703788d5817da081398fad5dd2", // v4.0.0
149 )
150 .add_with(("version", "9"))
151}
152
153pub fn setup_node() -> Step<Use> {
154 named::uses(
155 "actions",
156 "setup-node",
157 "49933ea5288caeca8642d1e84afbd3f7d6820020", // v4
158 )
159 .add_with(("node-version", "20"))
160}
161
162pub fn setup_sentry() -> Step<Use> {
163 named::uses(
164 "matbour",
165 "setup-sentry-cli",
166 "3e938c54b3018bdd019973689ef984e033b0454b",
167 )
168 .add_with(("token", vars::SENTRY_AUTH_TOKEN))
169}
170
171pub fn prettier() -> Step<Run> {
172 named::bash("./script/prettier")
173}
174
175pub fn cargo_fmt() -> Step<Run> {
176 named::bash("cargo fmt --all -- --check")
177}
178
179pub fn cargo_install_nextest() -> Step<Use> {
180 named::uses("taiki-e", "install-action", "nextest")
181}
182
183pub fn setup_cargo_config(platform: Platform) -> Step<Run> {
184 match platform {
185 Platform::Windows => named::pwsh(indoc::indoc! {r#"
186 New-Item -ItemType Directory -Path "./../.cargo" -Force
187 Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml"
188 "#}),
189
190 Platform::Linux | Platform::Mac => named::bash(indoc::indoc! {r#"
191 mkdir -p ./../.cargo
192 cp ./.cargo/ci-config.toml ./../.cargo/config.toml
193 "#}),
194 }
195}
196
197pub fn cleanup_cargo_config(platform: Platform) -> Step<Run> {
198 let step = match platform {
199 Platform::Windows => named::pwsh(indoc::indoc! {r#"
200 Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue
201 "#}),
202 Platform::Linux | Platform::Mac => named::bash(indoc::indoc! {r#"
203 rm -rf ./../.cargo
204 "#}),
205 };
206
207 step.if_condition(Expression::new("always()"))
208}
209
210pub fn clear_target_dir_if_large(platform: Platform) -> Step<Run> {
211 match platform {
212 Platform::Windows => named::pwsh("./script/clear-target-dir-if-larger-than.ps1 250"),
213 Platform::Linux => named::bash("./script/clear-target-dir-if-larger-than 250"),
214 Platform::Mac => named::bash("./script/clear-target-dir-if-larger-than 300"),
215 }
216}
217
218pub fn clippy(platform: Platform, target: Option<&str>) -> Step<Run> {
219 match platform {
220 Platform::Windows => named::pwsh("./script/clippy.ps1"),
221 _ => match target {
222 Some(target) => named::bash(format!("./script/clippy --target {target}")),
223 None => named::bash("./script/clippy"),
224 },
225 }
226}
227
228pub fn install_rustup_target(target: &str) -> Step<Run> {
229 named::bash(format!("rustup target add {target}"))
230}
231
232pub fn cache_rust_dependencies_namespace() -> Step<Use> {
233 named::uses("namespacelabs", "nscloud-cache-action", "v1")
234 .add_with(("cache", "rust"))
235 .add_with(("path", "~/.rustup"))
236}
237
238pub fn setup_sccache(platform: Platform) -> Step<Run> {
239 let step = match platform {
240 Platform::Windows => named::pwsh("./script/setup-sccache.ps1"),
241 Platform::Linux | Platform::Mac => named::bash("./script/setup-sccache"),
242 };
243 step.add_env(("R2_ACCOUNT_ID", vars::R2_ACCOUNT_ID))
244 .add_env(("R2_ACCESS_KEY_ID", vars::R2_ACCESS_KEY_ID))
245 .add_env(("R2_SECRET_ACCESS_KEY", vars::R2_SECRET_ACCESS_KEY))
246 .add_env(("SCCACHE_BUCKET", SCCACHE_R2_BUCKET))
247}
248
249pub fn show_sccache_stats(platform: Platform) -> Step<Run> {
250 match platform {
251 // Use $env:RUSTC_WRAPPER (absolute path) because GITHUB_PATH changes
252 // don't take effect until the next step in PowerShell.
253 // Check if RUSTC_WRAPPER is set first (it won't be for fork PRs without secrets).
254 Platform::Windows => {
255 named::pwsh("if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0")
256 }
257 Platform::Linux | Platform::Mac => named::bash("sccache --show-stats || true"),
258 }
259}
260
261pub fn cache_nix_dependencies_namespace() -> Step<Use> {
262 named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("cache", "nix"))
263}
264
265pub fn cache_nix_store_macos() -> Step<Use> {
266 // On macOS, `/nix` is on a read-only root filesystem so nscloud's `cache: nix`
267 // cannot mount or symlink there. Instead we cache a user-writable directory and
268 // use nix-store --import/--export in separate steps to transfer store paths.
269 named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("path", "~/nix-cache"))
270}
271
272pub fn setup_linux() -> Step<Run> {
273 named::bash("./script/linux")
274}
275
276fn download_wasi_sdk() -> Step<Run> {
277 named::bash("./script/download-wasi-sdk")
278}
279
280pub(crate) fn install_linux_dependencies(job: Job) -> Job {
281 job.add_step(setup_linux()).add_step(download_wasi_sdk())
282}
283
284pub fn script(name: &str) -> Step<Run> {
285 if name.ends_with(".ps1") {
286 Step::new(name).run(name).shell(PWSH_SHELL)
287 } else {
288 Step::new(name).run(name)
289 }
290}
291
292pub struct NamedJob<J: JobType = RunJob> {
293 pub name: String,
294 pub job: Job<J>,
295}
296
297// impl NamedJob {
298// pub fn map(self, f: impl FnOnce(Job) -> Job) -> Self {
299// NamedJob {
300// name: self.name,
301// job: f(self.job),
302// }
303// }
304// }
305
306pub(crate) const DEFAULT_REPOSITORY_OWNER_GUARD: &str =
307 "(github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')";
308
309pub fn repository_owner_guard_expression(trigger_always: bool) -> Expression {
310 Expression::new(format!(
311 "{}{}",
312 DEFAULT_REPOSITORY_OWNER_GUARD,
313 trigger_always.then_some(" && always()").unwrap_or_default()
314 ))
315}
316
317pub trait CommonJobConditions: Sized {
318 fn with_repository_owner_guard(self) -> Self;
319}
320
321impl CommonJobConditions for Job {
322 fn with_repository_owner_guard(self) -> Self {
323 self.cond(repository_owner_guard_expression(false))
324 }
325}
326
327pub(crate) fn release_job(deps: &[&NamedJob]) -> Job {
328 dependant_job(deps)
329 .with_repository_owner_guard()
330 .timeout_minutes(60u32)
331}
332
333pub(crate) fn dependant_job(deps: &[&NamedJob]) -> Job {
334 let job = Job::default();
335 if deps.len() > 0 {
336 job.needs(deps.iter().map(|j| j.name.clone()).collect::<Vec<_>>())
337 } else {
338 job
339 }
340}
341
342impl FluentBuilder for Job {}
343impl FluentBuilder for Workflow {}
344impl FluentBuilder for Input {}
345impl<T> FluentBuilder for Step<T> {}
346
347/// A helper trait for building complex objects with imperative conditionals in a fluent style.
348/// Copied from GPUI to avoid adding GPUI as dependency
349/// todo(ci) just put this in gh-workflow
350#[allow(unused)]
351pub trait FluentBuilder {
352 /// Imperatively modify self with the given closure.
353 fn map<U>(self, f: impl FnOnce(Self) -> U) -> U
354 where
355 Self: Sized,
356 {
357 f(self)
358 }
359
360 /// Conditionally modify self with the given closure.
361 fn when(self, condition: bool, then: impl FnOnce(Self) -> Self) -> Self
362 where
363 Self: Sized,
364 {
365 self.map(|this| if condition { then(this) } else { this })
366 }
367
368 /// Conditionally modify self with the given closure.
369 fn when_else(
370 self,
371 condition: bool,
372 then: impl FnOnce(Self) -> Self,
373 else_fn: impl FnOnce(Self) -> Self,
374 ) -> Self
375 where
376 Self: Sized,
377 {
378 self.map(|this| if condition { then(this) } else { else_fn(this) })
379 }
380
381 /// Conditionally unwrap and modify self with the given closure, if the given option is Some.
382 fn when_some<T>(self, option: Option<T>, then: impl FnOnce(Self, T) -> Self) -> Self
383 where
384 Self: Sized,
385 {
386 self.map(|this| {
387 if let Some(value) = option {
388 then(this, value)
389 } else {
390 this
391 }
392 })
393 }
394 /// Conditionally unwrap and modify self with the given closure, if the given option is None.
395 fn when_none<T>(self, option: &Option<T>, then: impl FnOnce(Self) -> Self) -> Self
396 where
397 Self: Sized,
398 {
399 self.map(|this| if option.is_some() { this } else { then(this) })
400 }
401}
402
403// (janky) helper to generate steps with a name that corresponds
404// to the name of the calling function.
405pub mod named {
406 use super::*;
407
408 /// Returns a uses step with the same name as the enclosing function.
409 /// (You shouldn't inline this function into the workflow definition, you must
410 /// wrap it in a new function.)
411 pub fn uses(owner: &str, repo: &str, ref_: &str) -> Step<Use> {
412 Step::new(function_name(1)).uses(owner, repo, ref_)
413 }
414
415 /// Returns a bash-script step with the same name as the enclosing function.
416 /// (You shouldn't inline this function into the workflow definition, you must
417 /// wrap it in a new function.)
418 pub fn bash(script: impl AsRef<str>) -> Step<Run> {
419 Step::new(function_name(1)).run(script.as_ref())
420 }
421
422 /// Returns a pwsh-script step with the same name as the enclosing function.
423 /// (You shouldn't inline this function into the workflow definition, you must
424 /// wrap it in a new function.)
425 pub fn pwsh(script: &str) -> Step<Run> {
426 Step::new(function_name(1)).run(script).shell(PWSH_SHELL)
427 }
428
429 /// Runs the command in either powershell or bash, depending on platform.
430 /// (You shouldn't inline this function into the workflow definition, you must
431 /// wrap it in a new function.)
432 pub fn run(platform: Platform, script: &str) -> Step<Run> {
433 match platform {
434 Platform::Windows => Step::new(function_name(1)).run(script).shell(PWSH_SHELL),
435 Platform::Linux | Platform::Mac => Step::new(function_name(1)).run(script),
436 }
437 }
438
439 /// Returns a Workflow with the same name as the enclosing module with default
440 /// set for the running shell.
441 pub fn workflow() -> Workflow {
442 Workflow::default()
443 .name(
444 named::function_name(1)
445 .split("::")
446 .collect::<Vec<_>>()
447 .into_iter()
448 .rev()
449 .skip(1)
450 .rev()
451 .collect::<Vec<_>>()
452 .join("::"),
453 )
454 .defaults(Defaults::default().run(RunDefaults::default().shell(BASH_SHELL)))
455 }
456
457 /// Returns a Job with the same name as the enclosing function.
458 /// (note job names may not contain `::`)
459 pub fn job<J: JobType>(job: Job<J>) -> NamedJob<J> {
460 NamedJob {
461 name: function_name(1).split("::").last().unwrap().to_owned(),
462 job,
463 }
464 }
465
466 /// Returns the function name N callers above in the stack
467 /// (typically 1).
468 /// This only works because xtask always runs debug builds.
469 pub fn function_name(i: usize) -> String {
470 let mut name = "<unknown>".to_string();
471 let mut count = 0;
472 backtrace::trace(|frame| {
473 if count < i + 3 {
474 count += 1;
475 return true;
476 }
477 backtrace::resolve_frame(frame, |cb| {
478 if let Some(s) = cb.name() {
479 name = s.to_string()
480 }
481 });
482 false
483 });
484
485 name.split("::")
486 .skip_while(|s| s != &"workflows")
487 .skip(1)
488 .collect::<Vec<_>>()
489 .join("::")
490 }
491}
492
493pub fn git_checkout(ref_name: &dyn std::fmt::Display) -> Step<Run> {
494 named::bash(r#"git fetch origin "$REF_NAME" && git checkout "$REF_NAME""#)
495 .add_env(("REF_NAME", ref_name.to_string()))
496}
497
498pub(crate) struct GenerateAppToken<'a> {
499 job_name: String,
500 app_id: &'a str,
501 app_secret: &'a str,
502 repository_target: Option<RepositoryTarget>,
503}
504
505impl<'a> GenerateAppToken<'a> {
506 pub fn for_repository(self, repository_target: RepositoryTarget) -> (Step<Use>, StepOutput) {
507 Self {
508 repository_target: Some(repository_target),
509 ..self
510 }
511 .into()
512 }
513}
514
515impl<'a> From<GenerateAppToken<'a>> for (Step<Use>, StepOutput) {
516 fn from(token: GenerateAppToken<'a>) -> Self {
517 let step = Step::new(token.job_name)
518 .uses(
519 "actions",
520 "create-github-app-token",
521 "f8d387b68d61c58ab83c6c016672934102569859",
522 )
523 .id("generate-token")
524 .add_with(
525 Input::default()
526 .add("app-id", token.app_id)
527 .add("private-key", token.app_secret)
528 .when_some(
529 token.repository_target,
530 |input,
531 RepositoryTarget {
532 owner,
533 repositories,
534 permissions,
535 }| {
536 input
537 .when_some(owner, |input, owner| input.add("owner", owner))
538 .when_some(repositories, |input, repositories| {
539 input.add("repositories", repositories)
540 })
541 .when_some(permissions, |input, permissions| {
542 permissions.into_iter().fold(
543 input,
544 |input, (permission, level)| {
545 input.add(
546 permission,
547 serde_json::to_value(&level).unwrap_or_default(),
548 )
549 },
550 )
551 })
552 },
553 ),
554 );
555
556 let generated_token = StepOutput::new(&step, "token");
557 (step, generated_token)
558 }
559}
560
561pub(crate) struct RepositoryTarget {
562 owner: Option<String>,
563 repositories: Option<String>,
564 permissions: Option<Vec<(String, Level)>>,
565}
566
567impl RepositoryTarget {
568 pub fn new<T: ToString>(owner: T, repositories: &[&str]) -> Self {
569 Self {
570 owner: Some(owner.to_string()),
571 repositories: Some(repositories.join("\n")),
572 permissions: None,
573 }
574 }
575
576 pub fn current() -> Self {
577 Self {
578 owner: None,
579 repositories: None,
580 permissions: None,
581 }
582 }
583
584 pub fn permissions(self, permissions: impl Into<Vec<(String, Level)>>) -> Self {
585 Self {
586 permissions: Some(permissions.into()),
587 ..self
588 }
589 }
590}
591
592pub(crate) fn generate_token<'a>(
593 app_id_source: &'a str,
594 app_secret_source: &'a str,
595) -> GenerateAppToken<'a> {
596 generate_token_with_job_name(app_id_source, app_secret_source)
597}
598
599pub fn authenticate_as_zippy() -> (Step<Use>, StepOutput) {
600 generate_token_with_job_name(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY).into()
601}
602
603fn generate_token_with_job_name<'a>(
604 app_id_source: &'a str,
605 app_secret_source: &'a str,
606) -> GenerateAppToken<'a> {
607 GenerateAppToken {
608 job_name: function_name(1),
609 app_id: app_id_source,
610 app_secret: app_secret_source,
611 repository_target: None,
612 }
613}