Skip to repository content1158 lines · 34.2 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T06:49:04.152Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
steps.rs
1use gh_workflow::{ctx::Context, *};
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 fetch_tags: bool,
67 name: Option<String>,
68 token: Option<String>,
69 path: Option<String>,
70 repository: Option<String>,
71 ref_: Option<String>,
72}
73
74impl CheckoutStep {
75 pub fn with_full_history(mut self) -> Self {
76 self.fetch_depth = FetchDepth::Full;
77 self
78 }
79
80 pub fn with_custom_name(mut self, name: &str) -> Self {
81 self.name = Some(name.to_string());
82 self
83 }
84
85 pub fn with_custom_fetch_depth(mut self, fetch_depth: impl Into<Value>) -> Self {
86 self.fetch_depth = FetchDepth::Custom(fetch_depth.into());
87 self
88 }
89
90 /// Sets `fetch-depth` to `2` on the main branch and `350` on all other branches.
91 pub fn with_deep_history_on_non_main(self) -> Self {
92 self.with_custom_fetch_depth("${{ github.ref == 'refs/heads/main' && 2 || 350 }}")
93 }
94
95 pub fn with_token(mut self, token: &StepOutput) -> Self {
96 self.token = Some(token.to_string());
97 self
98 }
99
100 pub fn with_path(mut self, path: &str) -> Self {
101 self.path = Some(path.to_string());
102 self
103 }
104
105 pub fn with_repository(mut self, repository: &str) -> Self {
106 self.repository = Some(repository.to_string());
107 self
108 }
109
110 pub fn with_ref(mut self, ref_: impl ToString) -> Self {
111 self.ref_ = Some(ref_.to_string());
112 self
113 }
114
115 pub fn with_fetch_tags(mut self) -> Self {
116 self.fetch_tags = true;
117 self
118 }
119}
120
121impl From<CheckoutStep> for Step<Use> {
122 fn from(value: CheckoutStep) -> Self {
123 Step::new(value.name.unwrap_or("steps::checkout_repo".to_string()))
124 .uses(
125 "actions",
126 "checkout",
127 "93cb6efe18208431cddfb8368fd83d5badbf9bfd", // v5.0.1
128 )
129 // prevent checkout action from running `git clean -ffdx` which
130 // would delete the target directory
131 .add_with(("clean", false))
132 .map(|step| match value.fetch_depth {
133 FetchDepth::Shallow => step,
134 FetchDepth::Full => step.add_with(("fetch-depth", 0)),
135 FetchDepth::Custom(depth) => step.add_with(("fetch-depth", depth)),
136 })
137 .when_some(value.path, |step, path| step.add_with(("path", path)))
138 .when_some(value.repository, |step, repository| {
139 step.add_with(("repository", repository))
140 })
141 .when(value.fetch_tags, |step| step.add_with(("fetch-tags", true)))
142 .when_some(value.ref_, |step, ref_| step.add_with(("ref", ref_)))
143 .when_some(value.token, |step, token| step.add_with(("token", token)))
144 }
145}
146
147impl FluentBuilder for CheckoutStep {}
148
149pub fn checkout_repo() -> CheckoutStep {
150 CheckoutStep::default()
151}
152
153// Audit mode: logs egress, blocks nothing. Namespace requires v2.19.0+.
154pub fn harden_runner() -> Step<Use> {
155 named::uses(
156 "step-security",
157 "harden-runner",
158 "9af89fc71515a100421586dfdb3dc9c984fbf411", // v2.19.4
159 )
160 .add_with(("egress-policy", "audit"))
161}
162
163pub fn setup_pnpm() -> Step<Use> {
164 named::uses(
165 "pnpm",
166 "action-setup",
167 "fc06bc1257f339d1d5d8b3a19a8cae5388b55320", // v4.4.0
168 )
169 .add_with(("version", "9"))
170}
171
172pub fn setup_node() -> Step<Use> {
173 named::uses(
174 "actions",
175 "setup-node",
176 "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e", // v6.4
177 )
178 .add_with(("node-version", "24"))
179 .add_with(("check-latest", true))
180 .add_with(("package-manager-cache", false))
181}
182
183pub fn setup_sentry() -> Step<Use> {
184 named::uses(
185 "matbour",
186 "setup-sentry-cli",
187 "3e938c54b3018bdd019973689ef984e033b0454b",
188 )
189 .add_with(("token", vars::SENTRY_AUTH_TOKEN))
190}
191
192pub fn prettier() -> Step<Run> {
193 named::bash("./script/prettier")
194}
195
196pub fn cargo_fmt() -> Step<Run> {
197 named::bash("cargo fmt --all -- --check")
198}
199
200pub fn install_cargo_edit() -> Step<Use> {
201 taiki_install_action("cargo-edit")
202}
203
204pub fn taiki_install_action(tool: &str) -> Step<Use> {
205 Step::new(named::function_name(1))
206 .uses(
207 "taiki-e",
208 "install-action",
209 "a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9", // v2.84.0
210 )
211 .add_with(("tool", tool))
212}
213
214pub fn cargo_install_nextest() -> Step<Use> {
215 taiki_install_action("nextest")
216}
217
218pub fn setup_cargo_config(platform: Platform) -> Step<Run> {
219 match platform {
220 Platform::Windows => named::pwsh(indoc::indoc! {r#"
221 New-Item -ItemType Directory -Path "./../.cargo" -Force
222 Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml"
223 "#}),
224
225 Platform::Linux | Platform::Mac => named::bash(indoc::indoc! {r#"
226 mkdir -p ./../.cargo
227 cp ./.cargo/ci-config.toml ./../.cargo/config.toml
228 "#}),
229 }
230}
231
232pub fn cleanup_cargo_config(platform: Platform) -> Step<Run> {
233 let step = match platform {
234 Platform::Windows => named::pwsh(indoc::indoc! {r#"
235 Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue
236 "#}),
237 Platform::Linux | Platform::Mac => named::bash(indoc::indoc! {r#"
238 rm -rf ./../.cargo
239 "#}),
240 };
241
242 step.if_condition(Expression::new("always()"))
243}
244
245pub fn clear_target_dir_if_large(platform: Platform) -> Step<Run> {
246 match platform {
247 Platform::Windows => named::pwsh("./script/clear-target-dir-if-larger-than.ps1 350 200"),
248 Platform::Linux => named::bash("./script/clear-target-dir-if-larger-than 350 200"),
249 Platform::Mac => named::bash("./script/clear-target-dir-if-larger-than 350 200"),
250 }
251}
252
253pub fn clippy(platform: Platform, target: Option<&str>) -> Step<Run> {
254 match platform {
255 Platform::Windows => named::pwsh("./script/clippy.ps1"),
256 _ => match target {
257 Some(target) => named::bash(format!("./script/clippy --target {target}")),
258 None => named::bash("./script/clippy"),
259 },
260 }
261}
262
263pub fn install_rustup_target(target: &str) -> Step<Run> {
264 named::bash(format!("rustup target add {target}"))
265}
266
267pub fn cache_rust_dependencies_namespace() -> Step<Use> {
268 named::uses(
269 "namespacelabs",
270 "nscloud-cache-action",
271 "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1
272 )
273 .add_with(("cache", "rust"))
274 .add_with(("path", "~/.rustup"))
275}
276
277pub fn setup_sccache(platform: Platform) -> Step<Run> {
278 let step = match platform {
279 Platform::Windows => named::pwsh("./script/setup-sccache.ps1"),
280 Platform::Linux | Platform::Mac => named::bash("./script/setup-sccache"),
281 };
282 step.add_env(("R2_ACCOUNT_ID", vars::R2_ACCOUNT_ID))
283 .add_env(("R2_ACCESS_KEY_ID", vars::R2_ACCESS_KEY_ID))
284 .add_env(("R2_SECRET_ACCESS_KEY", vars::R2_SECRET_ACCESS_KEY))
285 .add_env(("SCCACHE_BUCKET", SCCACHE_R2_BUCKET))
286}
287
288pub fn show_sccache_stats(platform: Platform) -> Step<Run> {
289 match platform {
290 // Use $env:RUSTC_WRAPPER (absolute path) because GITHUB_PATH changes
291 // don't take effect until the next step in PowerShell.
292 // Check if RUSTC_WRAPPER is set first (it won't be for fork PRs without secrets).
293 Platform::Windows => {
294 named::pwsh("if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0")
295 }
296 Platform::Linux | Platform::Mac => named::bash("sccache --show-stats || true"),
297 }
298}
299
300pub fn cache_nix_dependencies_namespace() -> Step<Use> {
301 named::uses(
302 "namespacelabs",
303 "nscloud-cache-action",
304 "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1
305 )
306 .add_with(("cache", "nix"))
307}
308
309pub fn cache_nix_store_macos() -> Step<Use> {
310 // On macOS, `/nix` is on a read-only root filesystem so nscloud's `cache: nix`
311 // cannot mount or symlink there. Instead we cache a user-writable directory and
312 // use nix-store --import/--export in separate steps to transfer store paths.
313 named::uses(
314 "namespacelabs",
315 "nscloud-cache-action",
316 "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1
317 )
318 .add_with(("path", "~/nix-cache"))
319}
320
321pub fn setup_linux() -> Step<Run> {
322 named::bash("./script/linux")
323}
324
325fn download_wasi_sdk() -> Step<Run> {
326 named::bash("./script/download-wasi-sdk")
327}
328
329pub(crate) fn install_linux_dependencies(job: Job) -> Job {
330 job.add_step(setup_linux()).add_step(download_wasi_sdk())
331}
332
333pub fn script(name: &str) -> Step<Run> {
334 if name.ends_with(".ps1") {
335 Step::new(name).run(name).shell(PWSH_SHELL)
336 } else {
337 Step::new(name).run(name)
338 }
339}
340
341pub struct NamedJob<J: JobType = RunJob> {
342 pub name: String,
343 pub job: Job<J>,
344}
345
346// impl NamedJob {
347// pub fn map(self, f: impl FnOnce(Job) -> Job) -> Self {
348// NamedJob {
349// name: self.name,
350// job: f(self.job),
351// }
352// }
353// }
354
355pub(crate) const DEFAULT_REPOSITORY_OWNER_GUARD: &str =
356 "(github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')";
357
358pub fn repository_owner_guard_expression(trigger_always: bool) -> Expression {
359 Expression::new(format!(
360 "{}{}",
361 DEFAULT_REPOSITORY_OWNER_GUARD,
362 trigger_always.then_some(" && always()").unwrap_or_default()
363 ))
364}
365
366pub trait CommonJobConditions: Sized {
367 fn with_repository_owner_guard(self) -> Self;
368}
369
370impl CommonJobConditions for Job {
371 fn with_repository_owner_guard(self) -> Self {
372 self.cond(repository_owner_guard_expression(false))
373 }
374}
375
376pub trait CommonPermissionSets: Sized {
377 fn with_minimal_permissions(self) -> Self;
378}
379
380impl CommonPermissionSets for Workflow {
381 fn with_minimal_permissions(self) -> Self {
382 self.permissions(Permissions::default().contents(Level::Read))
383 }
384}
385
386pub(crate) fn release_job(deps: &[&NamedJob]) -> Job {
387 dependant_job(deps)
388 .with_repository_owner_guard()
389 .timeout_minutes(60u32)
390}
391
392pub(crate) fn dependant_job(deps: &[&NamedJob]) -> Job {
393 let job = Job::default();
394 if deps.len() > 0 {
395 job.needs(deps.iter().map(|j| j.name.clone()).collect::<Vec<_>>())
396 } else {
397 job
398 }
399}
400
401impl FluentBuilder for Job {}
402impl FluentBuilder for Workflow {}
403impl FluentBuilder for Input {}
404impl<T> FluentBuilder for Step<T> {}
405
406/// A helper trait for building complex objects with imperative conditionals in a fluent style.
407/// Copied from GPUI to avoid adding GPUI as dependency
408/// todo(ci) just put this in gh-workflow
409#[allow(unused)]
410pub trait FluentBuilder {
411 /// Imperatively modify self with the given closure.
412 fn map<U>(self, f: impl FnOnce(Self) -> U) -> U
413 where
414 Self: Sized,
415 {
416 f(self)
417 }
418
419 /// Conditionally modify self with the given closure.
420 fn when(self, condition: bool, then: impl FnOnce(Self) -> Self) -> Self
421 where
422 Self: Sized,
423 {
424 self.map(|this| if condition { then(this) } else { this })
425 }
426
427 /// Conditionally modify self with the given closure.
428 fn when_else(
429 self,
430 condition: bool,
431 then: impl FnOnce(Self) -> Self,
432 else_fn: impl FnOnce(Self) -> Self,
433 ) -> Self
434 where
435 Self: Sized,
436 {
437 self.map(|this| if condition { then(this) } else { else_fn(this) })
438 }
439
440 /// Conditionally unwrap and modify self with the given closure, if the given option is Some.
441 fn when_some<T>(self, option: Option<T>, then: impl FnOnce(Self, T) -> Self) -> Self
442 where
443 Self: Sized,
444 {
445 self.map(|this| {
446 if let Some(value) = option {
447 then(this, value)
448 } else {
449 this
450 }
451 })
452 }
453 /// Conditionally unwrap and modify self with the given closure, if the given option is None.
454 fn when_none<T>(self, option: &Option<T>, then: impl FnOnce(Self) -> Self) -> Self
455 where
456 Self: Sized,
457 {
458 self.map(|this| if option.is_some() { this } else { then(this) })
459 }
460}
461
462// (janky) helper to generate steps with a name that corresponds
463// to the name of the calling function.
464pub mod named {
465 use super::*;
466
467 /// Returns a uses step with the same name as the enclosing function.
468 /// (You shouldn't inline this function into the workflow definition, you must
469 /// wrap it in a new function.)
470 pub fn uses(owner: &str, repo: &str, ref_: &str) -> Step<Use> {
471 Step::new(function_name(1)).uses(owner, repo, ref_)
472 }
473
474 /// Returns a bash-script step with the same name as the enclosing function.
475 /// (You shouldn't inline this function into the workflow definition, you must
476 /// wrap it in a new function.)
477 pub fn bash(script: impl AsRef<str>) -> Step<Run> {
478 Step::new(function_name(1)).run(script.as_ref())
479 }
480
481 /// Returns a pwsh-script step with the same name as the enclosing function.
482 /// (You shouldn't inline this function into the workflow definition, you must
483 /// wrap it in a new function.)
484 pub fn pwsh(script: &str) -> Step<Run> {
485 Step::new(function_name(1)).run(script).shell(PWSH_SHELL)
486 }
487
488 /// Runs the command in either powershell or bash, depending on platform.
489 /// (You shouldn't inline this function into the workflow definition, you must
490 /// wrap it in a new function.)
491 pub fn run(platform: Platform, script: &str) -> Step<Run> {
492 match platform {
493 Platform::Windows => Step::new(function_name(1)).run(script).shell(PWSH_SHELL),
494 Platform::Linux | Platform::Mac => Step::new(function_name(1)).run(script),
495 }
496 }
497
498 /// Returns a Workflow with the same name as the enclosing module with default
499 /// set for the running shell.
500 pub fn workflow() -> Workflow {
501 Workflow::default()
502 .name(
503 named::function_name(1)
504 .split("::")
505 .collect::<Vec<_>>()
506 .into_iter()
507 .rev()
508 .skip(1)
509 .rev()
510 .collect::<Vec<_>>()
511 .join("::"),
512 )
513 .permissions(Permissions::default())
514 .defaults(Defaults::default().run(RunDefaults::default().shell(BASH_SHELL)))
515 }
516
517 /// Returns a Job with the same name as the enclosing function.
518 /// (note job names may not contain `::`)
519 pub fn job<J: JobType>(job: Job<J>) -> NamedJob<J> {
520 NamedJob {
521 name: function_name(1).split("::").last().unwrap().to_owned(),
522 job,
523 }
524 }
525
526 /// Returns the function name N callers above in the stack
527 /// (typically 1).
528 /// This only works because xtask always runs debug builds.
529 pub fn function_name(i: usize) -> String {
530 let mut name = "<unknown>".to_string();
531 let mut count = 0;
532 backtrace::trace(|frame| {
533 if count < i + 3 {
534 count += 1;
535 return true;
536 }
537 backtrace::resolve_frame(frame, |cb| {
538 if let Some(s) = cb.name() {
539 name = s.to_string()
540 }
541 });
542 false
543 });
544
545 name.split("::")
546 .skip_while(|s| s != &"workflows")
547 .skip(1)
548 .collect::<Vec<_>>()
549 .join("::")
550 }
551}
552
553const ZED_ZIPPY_GIT_USER_NAME: &str = "zed-zippy[bot]";
554const ZED_ZIPPY_GIT_USER_EMAIL: &str = "234243425+zed-zippy[bot]@users.noreply.github.com";
555
556pub(crate) trait ZippyGitIdentity {
557 fn with_zippy_git_identity(self) -> Self;
558}
559
560impl ZippyGitIdentity for Step<Run> {
561 fn with_zippy_git_identity(self) -> Self {
562 self.add_env(("GIT_AUTHOR_NAME", ZED_ZIPPY_GIT_USER_NAME))
563 .add_env(("GIT_AUTHOR_EMAIL", ZED_ZIPPY_GIT_USER_EMAIL))
564 .add_env(("GIT_COMMITTER_NAME", ZED_ZIPPY_GIT_USER_NAME))
565 .add_env(("GIT_COMMITTER_EMAIL", ZED_ZIPPY_GIT_USER_EMAIL))
566 }
567}
568
569const GITHUB_SCRIPT_SHA: &str = "f28e40c7f34bde8b3046d885e986cb6290c5673b"; // v7
570const UPLOAD_ARTIFACT_SHA: &str = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"; // v7.0.1
571const DOWNLOAD_ARTIFACT_SHA: &str = "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"; // v8.0.1
572
573#[allow(unused)]
574pub(crate) enum ResultEncoding {
575 String,
576 Json,
577}
578
579impl ResultEncoding {
580 fn as_str(&self) -> &'static str {
581 match self {
582 ResultEncoding::String => "string",
583 ResultEncoding::Json => "json",
584 }
585 }
586}
587
588#[derive(Default)]
589pub(crate) struct GitHubScriptStep {
590 name: String,
591 id: Option<String>,
592 script: String,
593 result_encoding: Option<ResultEncoding>,
594 token: Option<String>,
595 env: Vec<(String, String)>,
596}
597
598impl GitHubScriptStep {
599 pub fn custom_name(mut self, name: &str) -> Self {
600 self.name = name.to_string();
601 self
602 }
603
604 pub fn id(mut self, id: &str) -> Self {
605 self.id = Some(id.to_string());
606 self
607 }
608
609 pub fn result_encoding(mut self, encoding: ResultEncoding) -> Self {
610 self.result_encoding = Some(encoding);
611 self
612 }
613
614 pub fn token(mut self, token: impl ToString) -> Self {
615 self.token = Some(token.to_string());
616 self
617 }
618
619 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
620 self.env.push((key.into(), value.into()));
621 self
622 }
623}
624
625impl From<GitHubScriptStep> for Step<Use> {
626 fn from(value: GitHubScriptStep) -> Self {
627 let mut step = Step::new(value.name)
628 .uses("actions", "github-script", GITHUB_SCRIPT_SHA)
629 .add_with(("script", value.script))
630 .when_some(value.result_encoding, |step, encoding| {
631 step.add_with(("result-encoding", encoding.as_str()))
632 })
633 .when_some(value.token, |step, token| {
634 step.add_with(("github-token", token))
635 })
636 .when_some(value.id, |step, id| step.id(id));
637 for (key, val) in value.env {
638 step = step.add_env((key, val));
639 }
640 step
641 }
642}
643
644impl FluentBuilder for GitHubScriptStep {}
645
646pub fn github_script(script: impl Into<String>) -> GitHubScriptStep {
647 GitHubScriptStep {
648 name: function_name(1),
649 script: script.into(),
650 ..Default::default()
651 }
652}
653
654pub(crate) enum IfNoFilesFound {
655 #[allow(unused)]
656 Warn,
657 Error,
658 Ignore,
659}
660
661impl IfNoFilesFound {
662 fn as_str(&self) -> &'static str {
663 match self {
664 IfNoFilesFound::Warn => "warn",
665 IfNoFilesFound::Error => "error",
666 IfNoFilesFound::Ignore => "ignore",
667 }
668 }
669}
670
671#[derive(Default)]
672pub(crate) struct UploadArtifactStep {
673 name: String,
674 artifact_name: String,
675 path: String,
676 if_no_files_found: Option<IfNoFilesFound>,
677 retention_days: Option<u32>,
678 if_condition: Option<Expression>,
679 overwrite: bool,
680}
681
682impl UploadArtifactStep {
683 pub fn if_no_files_found(mut self, behavior: IfNoFilesFound) -> Self {
684 self.if_no_files_found = Some(behavior);
685 self
686 }
687
688 pub fn retention_days(mut self, days: u32) -> Self {
689 self.retention_days = Some(days);
690 self
691 }
692
693 pub fn if_condition(mut self, condition: Expression) -> Self {
694 self.if_condition = Some(condition);
695 self
696 }
697
698 pub fn overwrite(mut self, overwrite: bool) -> Self {
699 self.overwrite = overwrite;
700 self
701 }
702}
703
704impl From<UploadArtifactStep> for Step<Use> {
705 fn from(value: UploadArtifactStep) -> Self {
706 Step::new(value.name)
707 .uses("actions", "upload-artifact", UPLOAD_ARTIFACT_SHA)
708 .add_with(("name", value.artifact_name))
709 .add_with(("path", value.path))
710 .when_some(value.if_no_files_found, |step, behavior| {
711 step.add_with(("if-no-files-found", behavior.as_str()))
712 })
713 .when_some(value.retention_days, |step, days| {
714 step.add_with(("retention-days", days.to_string()))
715 })
716 .when_some(value.if_condition, |step, condition| {
717 step.if_condition(condition)
718 })
719 .when(value.overwrite, |step| step.add_with(("overwrite", true)))
720 }
721}
722
723impl FluentBuilder for UploadArtifactStep {}
724
725pub fn upload_artifact(
726 artifact_name: impl Into<String>,
727 path: impl Into<String>,
728) -> UploadArtifactStep {
729 UploadArtifactStep {
730 name: function_name(1),
731 artifact_name: artifact_name.into(),
732 path: path.into(),
733 ..Default::default()
734 }
735}
736
737#[derive(Default)]
738pub(crate) struct DownloadArtifactStep {
739 name: String,
740 artifact_name: Option<String>,
741 path: Option<String>,
742}
743
744impl DownloadArtifactStep {
745 pub fn artifact_name(mut self, artifact_name: impl Into<String>) -> Self {
746 self.artifact_name = Some(artifact_name.into());
747 self
748 }
749
750 pub fn path(mut self, path: &str) -> Self {
751 self.path = Some(path.to_string());
752 self
753 }
754}
755
756impl From<DownloadArtifactStep> for Step<Use> {
757 fn from(value: DownloadArtifactStep) -> Self {
758 Step::new(value.name)
759 .uses("actions", "download-artifact", DOWNLOAD_ARTIFACT_SHA)
760 .when_some(value.artifact_name, |step, artifact_name| {
761 step.add_with(("name", artifact_name))
762 })
763 .when_some(value.path, |step, path| step.add_with(("path", path)))
764 }
765}
766
767impl FluentBuilder for DownloadArtifactStep {}
768
769pub fn download_artifact() -> DownloadArtifactStep {
770 DownloadArtifactStep {
771 name: function_name(1),
772 ..Default::default()
773 }
774}
775
776/// Non-exhaustive list of the permissions to be set for a GitHub app token.
777///
778/// See https://github.com/actions/create-github-app-token?tab=readme-ov-file#permission-permission-name
779/// and beyond for a full list of available permissions.
780#[allow(unused)]
781pub(crate) enum TokenPermissions {
782 Contents,
783 Issues,
784 Members,
785 PullRequests,
786 Workflows,
787}
788
789impl TokenPermissions {
790 pub fn environment_name(&self) -> &'static str {
791 match self {
792 TokenPermissions::Contents => "permission-contents",
793 TokenPermissions::Issues => "permission-issues",
794 TokenPermissions::Members => "permission-members",
795 TokenPermissions::PullRequests => "permission-pull-requests",
796 TokenPermissions::Workflows => "permission-workflows",
797 }
798 }
799}
800
801pub(crate) struct Unset;
802
803pub(crate) struct GenerateAppToken<'a, Target = Unset, Permissions = Unset> {
804 job_name: String,
805 app_id: &'a str,
806 app_secret: &'a str,
807 repository_target: Target,
808 permissions: Permissions,
809}
810
811impl<'a, Permissions> GenerateAppToken<'a, Unset, Permissions> {
812 pub fn for_repository(
813 self,
814 repository_target: RepositoryTarget,
815 ) -> GenerateAppToken<'a, RepositoryTarget, Permissions> {
816 GenerateAppToken {
817 job_name: self.job_name,
818 app_id: self.app_id,
819 app_secret: self.app_secret,
820 repository_target,
821 permissions: self.permissions,
822 }
823 }
824}
825
826impl<'a, Target> GenerateAppToken<'a, Target, Unset> {
827 pub fn with_permissions(
828 self,
829 permissions: impl Into<Vec<(TokenPermissions, Level)>>,
830 ) -> GenerateAppToken<'a, Target, Vec<(TokenPermissions, Level)>> {
831 GenerateAppToken {
832 job_name: self.job_name,
833 app_id: self.app_id,
834 app_secret: self.app_secret,
835 repository_target: self.repository_target,
836 permissions: permissions.into(),
837 }
838 }
839}
840
841impl<'a> From<GenerateAppToken<'a, RepositoryTarget, Vec<(TokenPermissions, Level)>>>
842 for (Step<Use>, StepOutput)
843{
844 fn from(token: GenerateAppToken<'a, RepositoryTarget, Vec<(TokenPermissions, Level)>>) -> Self {
845 let input = token.permissions.into_iter().fold(
846 Input::default()
847 .add("app-id", token.app_id)
848 .add("private-key", token.app_secret)
849 .add("owner", token.repository_target.owner)
850 .add("repositories", token.repository_target.repositories),
851 |input, (permission, level)| {
852 input.add(
853 permission.environment_name(),
854 serde_json::to_value(&level).unwrap_or_default(),
855 )
856 },
857 );
858 let step = Step::new(token.job_name)
859 .uses(
860 "actions",
861 "create-github-app-token",
862 "f8d387b68d61c58ab83c6c016672934102569859",
863 )
864 .id("generate-token")
865 .add_with(input);
866
867 let generated_token = StepOutput::new(&step, "token");
868 (step, generated_token)
869 }
870}
871
872pub(crate) struct RepositoryTarget {
873 owner: String,
874 repositories: String,
875}
876
877impl RepositoryTarget {
878 pub fn new<T: ToString>(owner: T, repositories: &[&str]) -> Self {
879 Self {
880 owner: owner.to_string(),
881 repositories: repositories.join("\n"),
882 }
883 }
884
885 pub fn current() -> Self {
886 Self::new(
887 "${{ github.repository_owner }}",
888 &["${{ github.event.repository.name }}"],
889 )
890 }
891}
892
893pub(crate) fn generate_token<'a>(
894 app_id_source: &'a str,
895 app_secret_source: &'a str,
896) -> GenerateAppToken<'a> {
897 generate_token_with_job_name(app_id_source, app_secret_source)
898}
899
900pub fn authenticate_as_zippy() -> GenerateAppToken<'static> {
901 generate_token_with_job_name(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY)
902}
903
904fn generate_token_with_job_name<'a>(
905 app_id_source: &'a str,
906 app_secret_source: &'a str,
907) -> GenerateAppToken<'a> {
908 GenerateAppToken {
909 job_name: function_name(1),
910 app_id: app_id_source,
911 app_secret: app_secret_source,
912 repository_target: Unset,
913 permissions: Unset,
914 }
915}
916
917pub(crate) struct BotCommitStep {
918 message: String,
919 branch: String,
920 files: String,
921 token: String,
922}
923
924impl BotCommitStep {
925 pub fn new(message: impl ToString, branch: impl ToString, token: &StepOutput) -> Self {
926 Self {
927 message: message.to_string(),
928 branch: branch.to_string(),
929 files: "**".to_string(),
930 token: token.to_string(),
931 }
932 }
933
934 pub fn with_files(self, files: impl ToString) -> Self {
935 Self {
936 files: files.to_string(),
937 ..self
938 }
939 }
940}
941
942impl From<BotCommitStep> for Step<Use> {
943 fn from(step: BotCommitStep) -> Self {
944 Step::new("steps::bot_commit")
945 .uses(
946 "IAreKyleW00t",
947 "verified-bot-commit",
948 "126a6a11889ab05bcff72ec2403c326cd249b84c", // v2.3.0
949 )
950 .id("commit")
951 .add_with(("message", step.message))
952 .add_with(("ref", format!("refs/heads/{}", step.branch)))
953 .add_with(("files", step.files))
954 .add_with(("token", step.token))
955 }
956}
957
958pub(crate) enum GitRef {
959 Tag(String),
960 Branch(String),
961}
962
963impl GitRef {
964 pub fn tag(name: impl ToString) -> Self {
965 Self::Tag(name.to_string())
966 }
967
968 pub fn branch(name: impl ToString) -> Self {
969 Self::Branch(name.to_string())
970 }
971
972 fn create_ref_path(&self) -> String {
973 match self {
974 Self::Tag(name) => format!("refs/tags/{name}"),
975 Self::Branch(name) => format!("refs/heads/{name}"),
976 }
977 }
978
979 fn update_ref_path(&self) -> String {
980 match self {
981 Self::Tag(name) => format!("tags/{name}"),
982 Self::Branch(name) => format!("heads/{name}"),
983 }
984 }
985
986 fn kind(&self) -> &'static str {
987 match self {
988 Self::Tag(_) => "tag",
989 Self::Branch(_) => "branch",
990 }
991 }
992}
993
994enum RefOperation {
995 Create,
996 Update { force: bool },
997}
998
999pub(crate) enum RefSha {
1000 Context,
1001 Custom(String),
1002}
1003
1004struct RefOp {
1005 git_ref: GitRef,
1006 operation: RefOperation,
1007 sha: RefSha,
1008 token: String,
1009}
1010
1011impl<T: ToString> From<T> for RefSha {
1012 fn from(sha: T) -> Self {
1013 RefSha::Custom(sha.to_string())
1014 }
1015}
1016
1017impl From<RefOp> for Step<Use> {
1018 fn from(op: RefOp) -> Self {
1019 let (api_method, ref_path, force_line) = match &op.operation {
1020 RefOperation::Create => ("createRef", op.git_ref.create_ref_path(), String::new()),
1021 RefOperation::Update { force } => (
1022 "updateRef",
1023 op.git_ref.update_ref_path(),
1024 format!(",\n force: {force}"),
1025 ),
1026 };
1027 let step_name = match &op.operation {
1028 RefOperation::Create => format!("steps::create_{}", op.git_ref.kind()),
1029 RefOperation::Update { .. } => format!("steps::update_{}", op.git_ref.kind()),
1030 };
1031 let script = indoc::formatdoc! {r#"
1032 github.rest.git.{api_method}({{
1033 owner: context.repo.owner,
1034 repo: context.repo.repo,
1035 ref: '{ref_path}',
1036 sha: {sha}{force_line}
1037 }})
1038 "#,
1039 sha = match &op.sha {
1040 RefSha::Context => "context.sha".to_string(),
1041 RefSha::Custom(sha) => format!("'{sha}'"),
1042 }
1043 };
1044 github_script(script)
1045 .custom_name(&step_name)
1046 .token(op.token)
1047 .into()
1048 }
1049}
1050
1051pub(crate) fn create_ref(
1052 git_ref: GitRef,
1053 sha: impl Into<RefSha>,
1054 token: &StepOutput,
1055) -> impl Into<Step<Use>> {
1056 RefOp {
1057 git_ref,
1058 operation: RefOperation::Create,
1059 sha: sha.into(),
1060 token: token.to_string(),
1061 }
1062}
1063
1064pub(crate) fn update_ref(
1065 git_ref: GitRef,
1066 sha: impl Into<RefSha>,
1067 token: &StepOutput,
1068 force: bool,
1069) -> impl Into<Step<Use>> {
1070 RefOp {
1071 git_ref,
1072 operation: RefOperation::Update { force },
1073 sha: sha.into(),
1074 token: token.to_string(),
1075 }
1076}
1077
1078const ZED_ZIPPY_COMMITTER: &str =
1079 "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>";
1080
1081pub(crate) struct CreatePrStep {
1082 title: String,
1083 body: String,
1084 branch: String,
1085 base: String,
1086 token: String,
1087 assignees: Option<String>,
1088 labels: Option<String>,
1089 path: Option<String>,
1090}
1091
1092impl CreatePrStep {
1093 pub fn new(title: impl ToString, branch: impl ToString, token: &StepOutput) -> Self {
1094 Self {
1095 title: title.to_string(),
1096 body: "Release Notes:\n\n- N/A".to_string(),
1097 branch: branch.to_string(),
1098 base: "main".to_string(),
1099 token: token.to_string(),
1100 assignees: Some(Context::github().actor().to_string()),
1101 labels: None,
1102 path: None,
1103 }
1104 }
1105
1106 pub fn with_body(self, body: impl ToString) -> Self {
1107 Self {
1108 body: body.to_string(),
1109 ..self
1110 }
1111 }
1112
1113 pub fn with_assignee(self, assignee: impl ToString) -> Self {
1114 Self {
1115 assignees: Some(assignee.to_string()),
1116 ..self
1117 }
1118 }
1119
1120 pub fn with_labels(self, labels: impl ToString) -> Self {
1121 Self {
1122 labels: Some(labels.to_string()),
1123 ..self
1124 }
1125 }
1126
1127 pub fn with_path(self, path: impl ToString) -> Self {
1128 Self {
1129 path: Some(path.to_string()),
1130 ..self
1131 }
1132 }
1133}
1134
1135impl From<CreatePrStep> for Step<Use> {
1136 fn from(step: CreatePrStep) -> Self {
1137 Step::new("steps::create_pull_request")
1138 .uses(
1139 "peter-evans",
1140 "create-pull-request",
1141 "98357b18bf14b5342f975ff684046ec3b2a07725", // v7
1142 )
1143 .add_with(("title", step.title.clone()))
1144 .add_with(("body", step.body))
1145 .add_with(("commit-message", step.title))
1146 .add_with(("branch", step.branch))
1147 .add_with(("committer", ZED_ZIPPY_COMMITTER))
1148 .add_with(("author", ZED_ZIPPY_COMMITTER))
1149 .add_with(("base", step.base))
1150 .add_with(("delete-branch", true))
1151 .add_with(("token", step.token))
1152 .add_with(("sign-commits", true))
1153 .when_some(step.assignees, |s, v| s.add_with(("assignees", v)))
1154 .when_some(step.labels, |s, v| s.add_with(("labels", v)))
1155 .when_some(step.path, |s, v| s.add_with(("path", v)))
1156 }
1157}
1158