Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:40:01.706Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

steps.rs

1158 lines · 34.2 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.