Skip to main content

cargo_dist/backend/ci/
github.rs

1//! CI script generation
2//!
3//! In the future this may get split up into submodules.
4
5use std::collections::BTreeMap;
6
7use axoasset::{LocalAsset, SourceFile};
8use axoprocess::Cmd;
9use camino::{Utf8Path, Utf8PathBuf};
10use cargo_dist_schema::{
11    target_lexicon::{self, Architecture, OperatingSystem, Triple},
12    AptPackageName, ChocolateyPackageName, ContainerImageRef, GhaRunStep,
13    GithubAttestationsFilters, GithubAttestationsPhase, GithubGlobalJobConfig,
14    GithubLocalJobConfig, GithubMatrix, GithubRunnerConfig, GithubRunnerRef, GithubRunners,
15    HomebrewPackageName, PackageInstallScript, PackageVersion, PipPackageName, TripleNameRef,
16};
17use itertools::Itertools;
18use serde::{Deserialize, Serialize};
19use tracing::warn;
20
21use crate::{
22    backend::{diff_files, templates::TEMPLATE_CI_GITHUB},
23    build_wrapper_for_cross,
24    config::{
25        v1::{ci::github::GithubCiConfig, publishers::PublisherConfig},
26        DependencyKind, GithubPermission, GithubPermissionMap, GithubReleasePhase, HostingStyle,
27        JinjaGithubRepoPair, JobStyle, ProductionMode, PublishStyle, SystemDependencies,
28    },
29    errors::DistResult,
30    platform::{github_runners::target_for_github_runner_or_default, targets},
31    CargoBuildWrapper, DistError, DistGraph, SortedMap, SortedSet,
32};
33
34use super::{
35    CargoAuditableInstallStrategy, CargoCyclonedxInstallStrategy, DistInstallSettings,
36    DistInstallStrategy, InstallStrategy, OmniborInstallStrategy,
37};
38
39#[cfg(not(windows))]
40const GITHUB_CI_DIR: &str = ".github/workflows/";
41#[cfg(windows)]
42const GITHUB_CI_DIR: &str = r".github\workflows\";
43const GITHUB_CI_FILE: &str = "release.yml";
44
45/// Info about running dist in Github CI
46///
47/// THESE FIELDS ARE LOAD-BEARING because they're used in the templates.
48#[derive(Debug, Serialize)]
49pub struct GithubCiInfo {
50    /// Cached path to github CI workflows dir
51    #[serde(skip_serializing)]
52    pub github_ci_workflow_dir: Utf8PathBuf,
53    /// Version of rust toolchain to install (deprecated)
54    pub rust_version: Option<String>,
55    /// How to install dist when "coordinating" (plan, global build, etc.)
56    pub dist_install_for_coordinator: GhaRunStep,
57    /// Our install strategy for dist itself
58    pub dist_install_strategy: DistInstallStrategy,
59    /// Whether to fail-fast
60    pub fail_fast: bool,
61    /// Whether to cache builds
62    pub cache_builds: bool,
63    /// Whether to include builtin local artifacts tasks
64    pub build_local_artifacts: bool,
65    /// Whether to make CI get dispatched manually instead of by tag
66    pub dispatch_releases: bool,
67    /// Trigger releases on pushes to this branch instead of ci
68    pub release_branch: Option<String>,
69    /// Matrix for upload-local-artifacts
70    pub artifacts_matrix: cargo_dist_schema::GithubMatrix,
71    /// What kind of job to run on pull request
72    pub pr_run_mode: cargo_dist_schema::PrRunMode,
73    /// global task
74    pub global_task: GithubGlobalJobConfig,
75    /// homebrew tap
76    pub tap: Option<String>,
77    /// plan jobs
78    pub plan_jobs: Vec<GithubCiJob>,
79    /// local artifacts jobs
80    pub local_artifacts_jobs: Vec<GithubCiJob>,
81    /// global artifacts jobs
82    pub global_artifacts_jobs: Vec<GithubCiJob>,
83    /// host jobs
84    pub host_jobs: Vec<GithubCiJob>,
85    /// publish jobs
86    pub publish_jobs: Vec<String>,
87    /// user-specified publish jobs
88    pub user_publish_jobs: Vec<GithubCiJob>,
89    /// post-announce jobs
90    pub post_announce_jobs: Vec<GithubCiJob>,
91    /// \[unstable\] whether to add ssl.com windows binary signing
92    pub ssldotcom_windows_sign: Option<ProductionMode>,
93    /// Whether to enable macOS codesigning
94    pub macos_sign: bool,
95    /// what hosting provider we're using
96    pub hosting_providers: Vec<HostingStyle>,
97    /// whether to prefix release.yml and the tag pattern
98    pub tag_namespace: Option<String>,
99    /// Extra permissions the workflow file should have
100    pub root_permissions: Option<GithubPermissionMap>,
101    /// Extra build steps
102    pub github_build_setup: Vec<GithubJobStep>,
103    /// Info about making a GitHub Release (if we're making one)
104    #[serde(flatten)]
105    pub github_release: Option<GithubReleaseInfo>,
106    /// Action versions to use
107    pub actions: SortedMap<String, String>,
108    /// Whether to install cargo-auditable
109    pub need_cargo_auditable: bool,
110    /// Whether to run cargo-cyclonedx
111    pub need_cargo_cyclonedx: bool,
112    /// Whether to install and run omnibor-cli
113    pub need_omnibor: bool,
114}
115
116/// Details for github releases
117#[derive(Debug, Serialize)]
118pub struct GithubReleaseInfo {
119    /// whether to create the release or assume an existing one
120    pub create_release: bool,
121    /// external repo to release to
122    pub github_releases_repo: Option<JinjaGithubRepoPair>,
123    /// commit to use for github_release_repo
124    pub external_repo_commit: Option<String>,
125    /// Whether to enable GitHub Attestations
126    pub github_attestations: bool,
127    /// Patterns to attest when creating attestations for release artifacts
128    pub github_attestations_filters: GithubAttestationsFilters,
129    /// When to generate GitHub Attestations
130    pub github_attestations_phase: GithubAttestationsPhase,
131    /// `gh` command to run to create the release
132    pub release_command: String,
133    /// Which phase to create the release at
134    pub release_phase: GithubReleasePhase,
135}
136
137/// A github action workflow step
138#[derive(Debug, Clone, Serialize, Default, Deserialize)]
139#[serde(rename_all = "kebab-case")]
140pub struct GithubJobStep {
141    /// A step's ID for looking up any outputs in a later step
142    #[serde(default)]
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub id: Option<String>,
145
146    /// If this step should run
147    #[serde(default)]
148    #[serde(rename = "if")]
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub if_expr: Option<serde_json::Value>,
151
152    /// The name of this step
153    #[serde(default)]
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub name: Option<String>,
156
157    /// The identifier for a marketplace action or relative path for a repo hosted action
158    #[serde(default)]
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub uses: Option<String>,
161
162    /// A script to run
163    #[serde(default)]
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub run: Option<String>,
166
167    /// The working directory this action should run
168    #[serde(default)]
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub working_directory: Option<String>,
171
172    /// The shell name to run the `run` property in e.g. bash or powershell
173    #[serde(default)]
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub shell: Option<String>,
176
177    /// A map of action arguments
178    #[serde(default)]
179    pub with: BTreeMap<String, serde_json::Value>,
180
181    /// Environment variables for this step
182    #[serde(default)]
183    pub env: BTreeMap<String, serde_json::Value>,
184
185    /// If this job should continue if this step errors
186    #[serde(default)]
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub continue_on_error: Option<serde_json::Value>,
189
190    /// Maximum number of minutes this step should take
191    #[serde(default)]
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub timeout_minutes: Option<serde_json::Value>,
194}
195
196/// A custom ci job
197#[derive(Debug, Serialize)]
198pub struct GithubCiJob {
199    /// Name of the job
200    pub name: String,
201    /// Permissions to give the job
202    pub permissions: Option<GithubPermissionMap>,
203}
204
205impl GithubCiInfo {
206    /// Compute the Github CI stuff
207    pub fn new(dist: &DistGraph, ci_config: &GithubCiConfig) -> DistResult<GithubCiInfo> {
208        // Legacy deprecated support
209        let rust_version = dist.config.builds.cargo.rust_toolchain_version.clone();
210
211        // If they don't specify a dist version, use this one
212        let self_dist_version = super::SELF_DIST_VERSION.parse().unwrap();
213        let dist_version = dist
214            .config
215            .dist_version
216            .as_ref()
217            .unwrap_or(&self_dist_version);
218        let fail_fast = ci_config.fail_fast;
219        let cache_builds = ci_config.cache_builds;
220        let build_local_artifacts = ci_config.build_local_artifacts;
221        let dispatch_releases = ci_config.dispatch_releases;
222        let release_branch = ci_config.release_branch.clone();
223        let ssldotcom_windows_sign = dist.config.builds.ssldotcom_windows_sign.clone();
224        let macos_sign = dist.config.builds.macos_sign;
225        let tag_namespace = ci_config.tag_namespace.clone();
226        let pr_run_mode = ci_config.pr_run_mode;
227
228        let github_release = GithubReleaseInfo::new(dist)?;
229        let mut dependencies = SystemDependencies::default();
230
231        let caching_could_be_profitable =
232            release_branch.is_some() || pr_run_mode == cargo_dist_schema::PrRunMode::Upload;
233        let cache_builds = cache_builds.unwrap_or(caching_could_be_profitable);
234
235        let need_cargo_auditable = dist.config.builds.cargo.cargo_auditable;
236        let need_cargo_cyclonedx = dist.config.builds.cargo.cargo_cyclonedx;
237        let need_omnibor = dist.config.builds.omnibor;
238
239        // Figure out what builds we need to do
240        let mut local_targets: SortedSet<&TripleNameRef> = SortedSet::new();
241        for release in &dist.releases {
242            for target in &release.targets {
243                local_targets.insert(target);
244            }
245            dependencies.append(&mut release.config.builds.system_dependencies.clone());
246        }
247
248        let dist_install_strategy = (DistInstallSettings {
249            version: dist_version,
250            url_override: dist.config.dist_url_override.as_deref(),
251        })
252        .install_strategy();
253        let cargo_auditable_install_strategy = CargoAuditableInstallStrategy;
254        let cargo_cyclonedx_install_strategy = CargoCyclonedxInstallStrategy;
255        let omnibor_install_strategy = OmniborInstallStrategy;
256
257        let hosting_providers = dist
258            .hosting
259            .as_ref()
260            .expect("should not be possible to have the Github CI backend without hosting!?")
261            .hosts
262            .clone();
263
264        // Build up the task matrix for building Artifacts
265        let mut tasks = vec![];
266
267        // The global task is responsible for:
268        //
269        // 1. building "global artifacts" like platform-agnostic installers
270        // 2. stitching together dist-manifests from all builds to produce a final one
271        //
272        // If we've done a Good Job, then these artifacts should be possible to build on *any*
273        // platform. Linux is usually fast/cheap, so that's a reasonable choice.
274        let global_runner = ci_config
275            .runners
276            .get("global")
277            .cloned()
278            .unwrap_or_else(default_global_runner_config);
279        let global_task = GithubGlobalJobConfig {
280            runner: global_runner.to_owned(),
281            dist_args: "--artifacts=global".into(),
282            install_dist: dist_install_strategy.dash(),
283            install_cargo_cyclonedx: Some(cargo_cyclonedx_install_strategy.dash()),
284            install_omnibor: need_omnibor.then_some(omnibor_install_strategy.dash()),
285        };
286
287        let tap = dist.global_homebrew_tap.clone();
288
289        let mut job_permissions = ci_config.permissions.clone();
290        // user publish jobs default to elevated privileges
291        for JobStyle::User(name) in &ci_config.publish_jobs {
292            job_permissions.entry(name.clone()).or_insert_with(|| {
293                GithubPermissionMap::from_iter([
294                    ("id-token".to_owned(), GithubPermission::Write),
295                    ("packages".to_owned(), GithubPermission::Write),
296                ])
297            });
298        }
299
300        let mut root_permissions = GithubPermissionMap::new();
301        root_permissions.insert("contents".to_owned(), GithubPermission::Write);
302
303        let mut publish_jobs = vec![];
304        if let Some(PublisherConfig { homebrew, npm, .. }) = &dist.global_publishers {
305            if homebrew.is_some() {
306                publish_jobs.push(PublishStyle::Homebrew.to_string());
307            }
308            if npm.is_some() {
309                publish_jobs.push(PublishStyle::Npm.to_string());
310            }
311        }
312
313        let plan_jobs = build_jobs(&ci_config.plan_jobs, &job_permissions)?;
314        let local_artifacts_jobs = build_jobs(&ci_config.build_local_jobs, &job_permissions)?;
315        let global_artifacts_jobs = build_jobs(&ci_config.build_global_jobs, &job_permissions)?;
316        let host_jobs = build_jobs(&ci_config.host_jobs, &job_permissions)?;
317        let user_publish_jobs = build_jobs(&ci_config.publish_jobs, &job_permissions)?;
318        let post_announce_jobs = build_jobs(&ci_config.post_announce_jobs, &job_permissions)?;
319
320        let root_permissions = (!root_permissions.is_empty()).then_some(root_permissions);
321
322        // Figure out what Local Artifact tasks we need
323        let local_runs = if ci_config.merge_tasks {
324            distribute_targets_to_runners_merged(local_targets, &ci_config.runners)?
325        } else {
326            distribute_targets_to_runners_split(local_targets, &ci_config.runners)?
327        };
328        for (runner, targets) in local_runs {
329            use std::fmt::Write;
330            let real_triple = runner.real_triple();
331            let install_dist = dist_install_strategy.for_triple(&real_triple);
332            let install_cargo_auditable =
333                cargo_auditable_install_strategy.for_triple(&runner.real_triple());
334            let install_omnibor = omnibor_install_strategy.for_triple(&real_triple);
335
336            let mut dist_args = String::from("--artifacts=local");
337            for target in &targets {
338                write!(dist_args, " --target={target}").unwrap();
339            }
340            let packages_install = system_deps_install_script(&runner, &targets, &dependencies)?;
341            tasks.push(GithubLocalJobConfig {
342                targets: Some(targets.iter().copied().map(|s| s.to_owned()).collect()),
343                cache_provider: cache_provider_for_runner(&runner),
344                runner,
345                dist_args,
346                install_dist: install_dist.to_owned(),
347                install_cargo_auditable: need_cargo_auditable
348                    .then_some(install_cargo_auditable.to_owned()),
349                install_omnibor: need_omnibor.then_some(install_omnibor.to_owned()),
350                packages_install,
351            });
352        }
353
354        let github_ci_workflow_dir = dist.repo_dir.join(GITHUB_CI_DIR);
355        let github_build_setup = ci_config
356            .build_setup
357            .as_ref()
358            .map(|local| {
359                crate::backend::ci::github::GithubJobStepsBuilder::new(
360                    &github_ci_workflow_dir,
361                    local,
362                )?
363                .validate()
364            })
365            .transpose()?
366            .unwrap_or_default();
367
368        let default_action_versions = [
369            ("actions/checkout", "v6"),
370            ("actions/upload-artifact", "v6"),
371            ("actions/download-artifact", "v7"),
372            ("actions/attest-build-provenance", "v3"),
373            ("swatinem/rust-cache", "v2"),
374            ("actions/setup-node", "v6"),
375        ];
376        let actions = default_action_versions
377            .iter()
378            .map(|(name, version)| {
379                let version = ci_config
380                    .action_commits
381                    .get(*name)
382                    .map(|c| &**c)
383                    .unwrap_or(*version);
384                (name.to_string(), format!("{name}@{version}"))
385            })
386            .collect::<SortedMap<_, _>>();
387
388        Ok(GithubCiInfo {
389            github_ci_workflow_dir,
390            tag_namespace,
391            rust_version,
392            dist_install_for_coordinator: dist_install_strategy.dash(),
393            dist_install_strategy,
394            fail_fast,
395            cache_builds,
396            build_local_artifacts,
397            dispatch_releases,
398            release_branch,
399            tap,
400            plan_jobs,
401            local_artifacts_jobs,
402            global_artifacts_jobs,
403            host_jobs,
404            publish_jobs,
405            user_publish_jobs,
406            post_announce_jobs,
407            artifacts_matrix: GithubMatrix { include: tasks },
408            pr_run_mode,
409            global_task,
410            ssldotcom_windows_sign,
411            macos_sign,
412            hosting_providers,
413            root_permissions,
414            github_build_setup,
415            github_release,
416            actions,
417            need_cargo_auditable,
418            need_cargo_cyclonedx,
419            need_omnibor,
420        })
421    }
422
423    fn github_ci_release_yml_path(&self) -> Utf8PathBuf {
424        // If tag-namespace is set, apply the prefix to the filename to emphasize it's
425        // just one of many workflows in this project
426        let prefix = self
427            .tag_namespace
428            .as_deref()
429            .map(|p| format!("{p}-"))
430            .unwrap_or_default();
431        self.github_ci_workflow_dir
432            .join(format!("{prefix}{GITHUB_CI_FILE}"))
433    }
434
435    /// Generate the requested configuration and returns it as a string.
436    pub fn generate_github_ci(&self, dist: &DistGraph) -> DistResult<String> {
437        let rendered = dist
438            .templates
439            .render_file_to_clean_string(TEMPLATE_CI_GITHUB, self)?;
440
441        Ok(rendered)
442    }
443
444    /// Write release.yml to disk
445    pub fn write_to_disk(&self, dist: &DistGraph) -> DistResult<()> {
446        let ci_file = self.github_ci_release_yml_path();
447        let rendered = self.generate_github_ci(dist)?;
448
449        LocalAsset::write_new_all(&rendered, &ci_file)?;
450        eprintln!("generated Github CI to {}", ci_file);
451
452        Ok(())
453    }
454
455    /// Check whether the new configuration differs from the config on disk
456    /// writhout actually writing the result.
457    pub fn check(&self, dist: &DistGraph) -> DistResult<()> {
458        let ci_file = self.github_ci_release_yml_path();
459
460        let rendered = self.generate_github_ci(dist)?;
461        diff_files(&ci_file, &rendered)
462    }
463}
464
465impl GithubReleaseInfo {
466    fn new(dist: &DistGraph) -> DistResult<Option<Self>> {
467        let Some(host_config) = &dist.config.hosts.github else {
468            return Ok(None);
469        };
470
471        let create_release = host_config.create;
472        let github_releases_repo = host_config.repo.clone().map(|r| r.into_jinja());
473        let github_attestations = host_config.attestations;
474        let github_attestations_filters = host_config.attestations_filters.clone();
475        let github_attestations_phase = host_config.attestations_phase;
476
477        let github_releases_submodule_path = host_config.submodule_path.clone();
478        let external_repo_commit = github_releases_submodule_path
479            .as_ref()
480            .map(submodule_head)
481            .transpose()?
482            .flatten();
483
484        let release_phase = if host_config.during == GithubReleasePhase::Auto {
485            GithubReleasePhase::Host
486        // If the user chose a non-auto option, respect that.
487        } else {
488            host_config.during
489        };
490
491        let mut release_args = vec![];
492        let action;
493        // Always need to use the tag flag
494        release_args.push("\"${{ needs.plan.outputs.tag }}\"");
495
496        // If using remote repos, specify the repo
497        if github_releases_repo.is_some() {
498            release_args.push("--repo");
499            release_args.push("\"$REPO\"")
500        }
501        release_args.push("--target");
502        release_args.push("\"$RELEASE_COMMIT\"");
503        release_args.push("$PRERELEASE_FLAG");
504        if host_config.create {
505            action = "create";
506            release_args.push("--title");
507            release_args.push("\"$ANNOUNCEMENT_TITLE\"");
508            release_args.push("--notes-file");
509            release_args.push("\"$RUNNER_TEMP/notes.txt\"");
510            // When creating release, upload artifacts transactionally
511            release_args.push("artifacts/*");
512        } else {
513            action = "edit";
514            release_args.push("--draft=false");
515        }
516        let release_command = format!("gh release {action} {}", release_args.join(" "));
517
518        Ok(Some(Self {
519            create_release,
520            github_releases_repo,
521            external_repo_commit,
522            github_attestations,
523            github_attestations_filters,
524            github_attestations_phase,
525            release_command,
526            release_phase,
527        }))
528    }
529}
530
531// Determines the *cached* HEAD for a submodule within the workspace.
532// Note that any unstaged commits, and any local changes to commit
533// history that aren't reflected by the submodule commit history,
534// won't be reflected here.
535fn submodule_head(submodule_path: &Utf8PathBuf) -> DistResult<Option<String>> {
536    let output = Cmd::new("git", "fetch cached commit for a submodule")
537        .arg("submodule")
538        .arg("status")
539        .arg("--cached")
540        .arg(submodule_path)
541        .output()
542        .map_err(|_| DistError::GitSubmoduleCommitError {
543            path: submodule_path.to_string(),
544        })?;
545
546    let line = String::from_utf8_lossy(&output.stdout);
547    // Format: one status character, commit, a space, repo name
548    let line = line.trim_start_matches([' ', '-', '+']);
549    let Some((commit, _)) = line.split_once(' ') else {
550        return Err(DistError::GitSubmoduleCommitError {
551            path: submodule_path.to_string(),
552        });
553    };
554
555    if commit.is_empty() {
556        Ok(None)
557    } else {
558        Ok(Some(commit.to_owned()))
559    }
560}
561
562fn build_jobs(
563    jobs: &[JobStyle],
564    perms: &SortedMap<String, GithubPermissionMap>,
565) -> DistResult<Vec<GithubCiJob>> {
566    let mut output = vec![];
567    for JobStyle::User(name) in jobs {
568        let perms_for_job = perms.get(name);
569
570        // Create the job
571        output.push(GithubCiJob {
572            name: name.clone(),
573            permissions: perms_for_job.cloned(),
574        });
575    }
576    Ok(output)
577}
578
579/// Get the best `cache-provider` key to use for <https://github.com/Swatinem/rust-cache>.
580///
581/// In the future we might make "None" here be a way to say "disable the cache".
582fn cache_provider_for_runner(rc: &GithubRunnerConfig) -> Option<String> {
583    if rc.runner.is_buildjet() {
584        Some("buildjet".into())
585    } else {
586        Some("github".into())
587    }
588}
589
590/// Given a set of targets we want to build local artifacts for, map them to Github Runners
591/// while preferring to merge builds that can happen on the same machine.
592///
593/// This optimizes for machine-hours, at the cost of latency and fault-isolation.
594///
595/// Typically this will result in both x64 macos and arm64 macos getting shoved onto
596/// the same runner, making the entire release process get bottlenecked on the twice-as-long
597/// macos builds. It also makes it impossible to have one macos build fail and the other
598/// succeed (uploading itself to the draft release).
599///
600/// In principle it does remove some duplicated setup work, so this is ostensibly "cheaper".
601fn distribute_targets_to_runners_merged<'a>(
602    targets: SortedSet<&'a TripleNameRef>,
603    custom_runners: &GithubRunners,
604) -> DistResult<std::vec::IntoIter<(GithubRunnerConfig, Vec<&'a TripleNameRef>)>> {
605    let mut groups = SortedMap::<GithubRunnerConfig, Vec<&TripleNameRef>>::new();
606    for target in targets {
607        let runner_conf = github_runner_for_target(target, custom_runners)?;
608        let runner_conf = runner_conf.unwrap_or_else(|| {
609            let fallback = default_global_runner_config();
610            warn!(
611                "not sure which github runner should be used for {target}, assuming {}",
612                fallback.runner
613            );
614            fallback.to_owned()
615        });
616        groups.entry(runner_conf).or_default().push(target);
617    }
618    // This extra into_iter+collect is needed to make this have the same
619    // return type as distribute_targets_to_runners_split
620    Ok(groups.into_iter().collect::<Vec<_>>().into_iter())
621}
622
623/// Given a set of targets we want to build local artifacts for, map them to Github Runners
624/// while preferring each target gets its own runner for latency and fault-isolation.
625fn distribute_targets_to_runners_split<'a>(
626    targets: SortedSet<&'a TripleNameRef>,
627    custom_runners: &GithubRunners,
628) -> DistResult<std::vec::IntoIter<(GithubRunnerConfig, Vec<&'a TripleNameRef>)>> {
629    let mut groups = vec![];
630    for target in targets {
631        let runner = github_runner_for_target(target, custom_runners)?;
632        let runner = runner.unwrap_or_else(|| {
633            let fallback = default_global_runner_config();
634            warn!(
635                "not sure which github runner should be used for {target}, assuming {}",
636                fallback.runner
637            );
638            fallback.to_owned()
639        });
640        groups.push((runner, vec![target]));
641    }
642    Ok(groups.into_iter())
643}
644
645/// Generates a [`GithubRunnerConfig`] from a given github runner name
646pub fn runner_to_config(runner: &GithubRunnerRef) -> GithubRunnerConfig {
647    GithubRunnerConfig {
648        runner: runner.to_owned(),
649        host: target_for_github_runner_or_default(runner).to_owned(),
650        container: None,
651    }
652}
653
654const DEFAULT_LINUX_RUNNER: &GithubRunnerRef = GithubRunnerRef::from_str("ubuntu-22.04");
655
656fn default_global_runner_config() -> GithubRunnerConfig {
657    runner_to_config(DEFAULT_LINUX_RUNNER)
658}
659
660/// Get the appropriate Github Runner for building a target
661fn github_runner_for_target(
662    target: &TripleNameRef,
663    custom_runners: &GithubRunners,
664) -> DistResult<Option<GithubRunnerConfig>> {
665    if let Some(runner) = custom_runners.get(target) {
666        return Ok(Some(runner.clone()));
667    }
668
669    let target_triple: Triple = target.parse()?;
670
671    // We want to default to older runners to minimize the places
672    // where random system dependencies can creep in and be very
673    // recent. This helps with portability!
674    let result = Some(match target_triple.operating_system {
675        OperatingSystem::Linux => {
676            // Use ARM-specific runner for aarch64 Linux targets
677            if matches!(target_triple.architecture, Architecture::Aarch64(_)) {
678                runner_to_config(GithubRunnerRef::from_str("ubuntu-22.04-arm"))
679            } else {
680                runner_to_config(GithubRunnerRef::from_str("ubuntu-22.04"))
681            }
682        }
683        OperatingSystem::Darwin(_) => {
684            if matches!(target_triple.architecture, Architecture::Aarch64(_)) {
685                runner_to_config(GithubRunnerRef::from_str("macos-14"))
686            } else {
687                runner_to_config(GithubRunnerRef::from_str("macos-15-intel"))
688            }
689        }
690        OperatingSystem::Windows => {
691            // Default to cargo-xwin for Windows cross-compiles
692            if target_triple.architecture != Architecture::X86_64 {
693                cargo_xwin()
694            } else {
695                runner_to_config(GithubRunnerRef::from_str("windows-2022"))
696            }
697        }
698        _ => return Ok(None),
699    });
700
701    Ok(result)
702}
703
704fn cargo_xwin() -> GithubRunnerConfig {
705    GithubRunnerConfig {
706        runner: GithubRunnerRef::from_str("ubuntu-22.04").to_owned(),
707        host: targets::TARGET_X64_LINUX_GNU.to_owned(),
708        container: Some(cargo_dist_schema::ContainerConfig {
709            image: ContainerImageRef::from_str("messense/cargo-xwin").to_owned(),
710            host: targets::TARGET_X64_LINUX_MUSL.to_owned(),
711            package_manager: Some(cargo_dist_schema::PackageManager::Apt),
712        }),
713    }
714}
715
716fn brewfile_from<'a>(packages: impl Iterator<Item = &'a HomebrewPackageName>) -> String {
717    packages
718        .map(|p| {
719            let lower = p.as_str().to_ascii_lowercase();
720            // Although `brew install` can take either a formula or a cask,
721            // Brewfiles require you to use the `cask` verb for casks and `brew`
722            // for formulas.
723            if lower.starts_with("homebrew/cask") || lower.starts_with("homebrew/homebrew-cask") {
724                format!(r#"cask "{p}""#).to_owned()
725            } else {
726                format!(r#"brew "{p}""#).to_owned()
727            }
728        })
729        .join("\n")
730}
731
732fn brew_bundle_command<'a>(packages: impl Iterator<Item = &'a HomebrewPackageName>) -> String {
733    format!(
734        r#"cat << EOF >Brewfile
735{}
736EOF
737
738brew bundle install"#,
739        brewfile_from(packages)
740    )
741}
742
743fn system_deps_install_script(
744    rc: &GithubRunnerConfig,
745    targets: &[&TripleNameRef],
746    packages: &SystemDependencies,
747) -> DistResult<Option<PackageInstallScript>> {
748    let mut brew_packages: SortedSet<HomebrewPackageName> = Default::default();
749    let mut apt_packages: SortedSet<(AptPackageName, Option<PackageVersion>)> = Default::default();
750    let mut chocolatey_packages: SortedSet<(ChocolateyPackageName, Option<PackageVersion>)> =
751        Default::default();
752
753    let host = rc.real_triple();
754    match host.operating_system {
755        OperatingSystem::Darwin(_) => {
756            for (name, pkg) in &packages.homebrew {
757                if !pkg.0.stage_wanted(&DependencyKind::Build) {
758                    continue;
759                }
760                if !targets.iter().any(|target| pkg.0.wanted_for_target(target)) {
761                    continue;
762                }
763                brew_packages.insert(name.clone());
764            }
765        }
766        OperatingSystem::Linux => {
767            // We currently don't support non-apt package managers on Linux
768            // is_none() means a native build, probably on GitHub's
769            // apt-using runners.
770            if rc.container.is_none()
771                || rc.container.as_ref().and_then(|c| c.package_manager)
772                    == Some(cargo_dist_schema::PackageManager::Apt)
773            {
774                for (name, pkg) in &packages.apt {
775                    if !pkg.0.stage_wanted(&DependencyKind::Build) {
776                        continue;
777                    }
778                    if !targets.iter().any(|target| pkg.0.wanted_for_target(target)) {
779                        continue;
780                    }
781                    apt_packages.insert((name.clone(), pkg.0.version.clone()));
782                }
783
784                let has_musl_target = targets.iter().any(|target| {
785                    target.parse().unwrap().environment == target_lexicon::Environment::Musl
786                });
787                if has_musl_target {
788                    // musl builds may require musl-tools to build;
789                    // necessary for more complex software
790                    apt_packages.insert((AptPackageName::new("musl-tools".to_owned()), None));
791                }
792            }
793        }
794        OperatingSystem::Windows => {
795            for (name, pkg) in &packages.chocolatey {
796                if !pkg.0.stage_wanted(&DependencyKind::Build) {
797                    continue;
798                }
799                if !targets.iter().any(|target| pkg.0.wanted_for_target(target)) {
800                    continue;
801                }
802                chocolatey_packages.insert((name.clone(), pkg.0.version.clone()));
803            }
804        }
805        _ => {
806            panic!(
807                "unsupported host operating system: {:?}",
808                host.operating_system
809            )
810        }
811    }
812
813    let mut lines = vec![];
814    if !brew_packages.is_empty() {
815        lines.push(brew_bundle_command(brew_packages.iter()))
816    }
817
818    // If we're crossing, we'll most likely be running from a container with
819    // no sudo. We should avoid calling sudo in that case.
820    let sudo = if rc.container.is_some() { "" } else { "sudo " };
821    if !apt_packages.is_empty() {
822        lines.push(format!("{sudo}apt-get update"));
823        let args = apt_packages
824            .iter()
825            .map(|(pkg, version)| {
826                if let Some(v) = version {
827                    format!("{pkg}={v}")
828                } else {
829                    pkg.to_string()
830                }
831            })
832            .join(" ");
833        lines.push(format!("{sudo}apt-get install {args}"));
834    }
835
836    for (pkg, version) in &chocolatey_packages {
837        lines.push(if let Some(v) = version {
838            format!("choco install {pkg} --version={v} --yes")
839        } else {
840            format!("choco install {pkg} --yes")
841        });
842    }
843
844    // Regardless of what we're doing, we might need build wrappers!
845    let mut required_wrappers: SortedSet<CargoBuildWrapper> = Default::default();
846    for target in targets {
847        let target = target.parse().unwrap();
848        if let Some(wrapper) = build_wrapper_for_cross(&host, &target)? {
849            required_wrappers.insert(wrapper);
850        }
851    }
852
853    let mut pip_pkgs: SortedSet<PipPackageName> = Default::default();
854    if required_wrappers.contains(&CargoBuildWrapper::ZigBuild) {
855        pip_pkgs.insert(PipPackageName::new("cargo-zigbuild".to_owned()));
856    }
857    if required_wrappers.contains(&CargoBuildWrapper::Xwin) {
858        pip_pkgs.insert(PipPackageName::new("cargo-xwin".to_owned()));
859    }
860
861    if !pip_pkgs.is_empty() {
862        let push_pip_install_lines = |lines: &mut Vec<String>| {
863            if host.operating_system == OperatingSystem::Linux {
864                // make sure pip is installed — on dnf-based distros we might need to install
865                // it (true for e.g. the `quay.io/pypa/manylinux_2_28_x86_64` image)
866                //
867                // this doesn't work for all distros of course — others might need to be added
868                // later. there's no universal way to install tooling in dist right now anyway.
869                lines.push("  if ! command -v pip3 > /dev/null 2>&1; then".to_owned());
870                lines.push("    dnf install --assumeyes python3-pip".to_owned());
871                lines.push("    pip3 install --upgrade pip".to_owned());
872                lines.push("  fi".to_owned());
873            }
874        };
875
876        for pip_pkg in pip_pkgs {
877            match pip_pkg.as_str() {
878                "cargo-xwin" => {
879                    // that one could already be installed
880                    lines.push("if ! command -v cargo-xwin > /dev/null 2>&1; then".to_owned());
881                    push_pip_install_lines(&mut lines);
882                    lines.push("  pip3 install cargo-xwin".to_owned());
883                    lines.push("fi".to_owned());
884                }
885                "cargo-zigbuild" => {
886                    // that one could already be installed
887                    lines.push("if ! command -v cargo-zigbuild > /dev/null 2>&1; then".to_owned());
888                    push_pip_install_lines(&mut lines);
889                    lines.push("  pip3 install cargo-zigbuild".to_owned());
890                    lines.push("fi".to_owned());
891                }
892                _ => {
893                    lines.push(format!("pip3 install {pip_pkg}"));
894                }
895            }
896        }
897    }
898
899    Ok(if lines.is_empty() {
900        None
901    } else {
902        Some(PackageInstallScript::new(lines.join("\n")))
903    })
904}
905
906/// Builder for looking up and reporting errors in the steps provided by the
907/// `github-build-setup` configuration
908pub struct GithubJobStepsBuilder {
909    steps: Vec<GithubJobStep>,
910    path: Utf8PathBuf,
911}
912
913impl GithubJobStepsBuilder {
914    #[cfg(test)]
915    /// Test only ctor for skipping the fs lookup
916    pub fn from_values(
917        steps: impl IntoIterator<Item = GithubJobStep>,
918        path: impl Into<Utf8PathBuf>,
919    ) -> Self {
920        Self {
921            steps: Vec::from_iter(steps),
922            path: path.into(),
923        }
924    }
925
926    /// Create a new validator
927    pub fn new(
928        base_path: impl AsRef<Utf8Path>,
929        cfg_value: impl AsRef<Utf8Path>,
930    ) -> Result<Self, DistError> {
931        let path = base_path.as_ref().join(cfg_value.as_ref());
932        let src = SourceFile::load_local(&path)
933            .map_err(|e| DistError::GithubBuildSetupNotFound { details: e })?;
934        let steps = src
935            .deserialize_yaml()
936            .map_err(|e| DistError::GithubBuildSetupParse { details: e })?;
937        Ok(Self { steps, path })
938    }
939
940    /// Validate the whole list of build setup steps
941    pub fn validate(self) -> Result<Vec<GithubJobStep>, DistError> {
942        for (i, step) in self.steps.iter().enumerate() {
943            if let Some(message) = Self::validate_step(i, step) {
944                return Err(DistError::GithubBuildSetupNotValid {
945                    file_path: self.path.to_path_buf(),
946                    message,
947                });
948            }
949        }
950        Ok(self.steps)
951    }
952
953    /// validate a single step in the list of steps, returns `Some` if an error is detected
954    fn validate_step(idx: usize, step: &GithubJobStep) -> Option<String> {
955        //github-build-step {step_name} is invalid, cannot have both `uses` and `{conflict_step_name}` defined
956        let key_mismatch = |lhs: &str, rhs: &str| {
957            let step_name = Self::get_name_id_or_idx(idx, step);
958            format!("github-build-step {step_name} is invalid, cannot have both `{lhs}` and `{rhs}` defined")
959        };
960        let invalid_object = |prop: &str, msg: &str| {
961            let step_name = Self::get_name_id_or_idx(idx, step);
962            format!("github-build-step {step_name} has an invalid `{prop}` entry: {msg}")
963        };
964        if let Some(key) = Self::validate_step_uses_keys(step) {
965            return Some(key_mismatch("uses", key));
966        }
967        if let Some(key) = Self::validate_step_run_keys(step) {
968            return Some(key_mismatch("run", key));
969        }
970        if let Some(message) = Self::validate_with_shape(step) {
971            return Some(invalid_object("with", &message));
972        }
973        None
974    }
975
976    /// Validate there are no conflicting keys in this workflow that defines the `uses` keyword
977    ///
978    /// returns `Some` if an error is detected
979    fn validate_step_uses_keys(step: &GithubJobStep) -> Option<&'static str> {
980        // if uses is None, return early
981        step.uses.as_ref()?;
982        if step.run.is_some() {
983            return Some("run");
984        }
985        if step.shell.is_some() {
986            return Some("shell");
987        }
988        if step.working_directory.is_some() {
989            return Some("working-directory");
990        }
991        None
992    }
993
994    /// Validate there are no conflicting keys in this workflow that defines the `run` keyword
995    /// this is called _after_ `Self::validate_step_uses_keys`
996    ///
997    /// returns `Some` if an error is detected
998    fn validate_step_run_keys(step: &GithubJobStep) -> Option<&'static str> {
999        // if run is None, return early
1000        step.run.as_ref()?;
1001        if !step.with.is_empty() {
1002            return Some("with");
1003        }
1004        None
1005    }
1006
1007    /// Validate the with mapping only contains key/value pairs with the values being either
1008    /// strings, booleans, or numbers
1009    ///
1010    /// returns `Some` if an error is detected
1011    fn validate_with_shape(step: &GithubJobStep) -> Option<String> {
1012        for (k, v) in &step.with {
1013            let invalid_type = match v {
1014                serde_json::Value::Null => "null",
1015                serde_json::Value::Array(_) => "array",
1016                serde_json::Value::Object(_) => "object",
1017                _ => continue,
1018            };
1019            return Some(format!("key `{k}` has the type of `{invalid_type}` only `string`, `number` or `boolean` are supported"));
1020        }
1021        None
1022    }
1023
1024    fn get_name_id_or_idx(idx: usize, step: &GithubJobStep) -> String {
1025        step.name
1026            .clone()
1027            .or_else(|| step.id.clone())
1028            .unwrap_or_else(|| idx.to_string())
1029    }
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034    use serde_json::Value;
1035
1036    use super::*;
1037
1038    #[test]
1039    fn validator_works() {
1040        let steps = [GithubJobStep {
1041            uses: Some("".to_string()),
1042            with: BTreeMap::from_iter([
1043                ("key".to_string(), Value::from("value")),
1044                ("key2".to_string(), Value::from(2)),
1045                ("key2".to_string(), Value::from(false)),
1046            ]),
1047            timeout_minutes: Some("8".into()),
1048            continue_on_error: Some("true".into()),
1049            ..Default::default()
1050        }];
1051        let path = Utf8PathBuf::from(std::thread::current().name().unwrap_or(""));
1052        GithubJobStepsBuilder::from_values(steps, path)
1053            .validate()
1054            .expect("validation to pass");
1055    }
1056
1057    #[test]
1058    #[should_panic = "cannot have both `uses` and `run` defined"]
1059    fn validator_catches_run_and_uses() {
1060        let steps = [GithubJobStep {
1061            uses: Some("".to_string()),
1062            run: Some("".to_string()),
1063            ..Default::default()
1064        }];
1065        let path = Utf8PathBuf::from(std::thread::current().name().unwrap_or(""));
1066        GithubJobStepsBuilder::from_values(steps, path)
1067            .validate()
1068            .unwrap();
1069    }
1070
1071    #[test]
1072    #[should_panic = "cannot have both `uses` and `shell` defined"]
1073    fn validator_catches_run_and_shell() {
1074        let steps = [GithubJobStep {
1075            uses: Some("".to_string()),
1076            shell: Some("".to_string()),
1077            ..Default::default()
1078        }];
1079        let path = Utf8PathBuf::from(std::thread::current().name().unwrap_or(""));
1080        GithubJobStepsBuilder::from_values(steps, path)
1081            .validate()
1082            .unwrap();
1083    }
1084
1085    #[test]
1086    #[should_panic = "cannot have both `uses` and `working-directory` defined"]
1087    fn validator_catches_run_and_cwd() {
1088        let steps = [GithubJobStep {
1089            uses: Some("".to_string()),
1090            working_directory: Some("".to_string()),
1091            ..Default::default()
1092        }];
1093        let path = Utf8PathBuf::from(std::thread::current().name().unwrap_or(""));
1094        GithubJobStepsBuilder::from_values(steps, path)
1095            .validate()
1096            .unwrap();
1097    }
1098
1099    #[test]
1100    #[should_panic = "cannot have both `run` and `with` defined"]
1101    fn validator_catches_run_and_with() {
1102        let steps = [GithubJobStep {
1103            run: Some("".to_string()),
1104            with: BTreeMap::from_iter([("key".to_string(), Value::from("value"))]),
1105            ..Default::default()
1106        }];
1107        let path = Utf8PathBuf::from(std::thread::current().name().unwrap_or(""));
1108        GithubJobStepsBuilder::from_values(steps, path)
1109            .validate()
1110            .unwrap();
1111    }
1112
1113    #[test]
1114    #[should_panic = "has an invalid `with` entry: key `key` has the type of `object` only `string`, `number` or `boolean` are supported"]
1115    fn validator_catches_invalid_with() {
1116        let steps = [GithubJobStep {
1117            uses: Some("".to_string()),
1118            with: BTreeMap::from_iter([(
1119                "key".to_string(),
1120                serde_json::json!({
1121                    "obj-key": "obj-value"
1122                }),
1123            )]),
1124            ..Default::default()
1125        }];
1126        let path = Utf8PathBuf::from(std::thread::current().name().unwrap_or(""));
1127        GithubJobStepsBuilder::from_values(steps, path)
1128            .validate()
1129            .unwrap();
1130    }
1131
1132    #[test]
1133    #[should_panic = "step-name"]
1134    fn validator_errors_with_name() {
1135        let steps = [GithubJobStep {
1136            name: Some("step-name".to_string()),
1137            uses: Some(String::new()),
1138            run: Some(String::new()),
1139            ..Default::default()
1140        }];
1141        let path = Utf8PathBuf::from(std::thread::current().name().unwrap_or(""));
1142        GithubJobStepsBuilder::from_values(steps, path)
1143            .validate()
1144            .unwrap();
1145    }
1146
1147    #[test]
1148    #[should_panic = "step-name"]
1149    fn validator_errors_with_name_over_id() {
1150        let steps = [GithubJobStep {
1151            name: Some("step-name".to_string()),
1152            id: Some("step-id".to_string()),
1153            uses: Some(String::new()),
1154            run: Some(String::new()),
1155            ..Default::default()
1156        }];
1157        let path = Utf8PathBuf::from(std::thread::current().name().unwrap_or(""));
1158        GithubJobStepsBuilder::from_values(steps, path)
1159            .validate()
1160            .unwrap();
1161    }
1162
1163    #[test]
1164    #[should_panic = "step-id"]
1165    fn validator_errors_with_id() {
1166        let steps = [GithubJobStep {
1167            id: Some("step-id".to_string()),
1168            uses: Some(String::new()),
1169            run: Some(String::new()),
1170            ..Default::default()
1171        }];
1172        let path = Utf8PathBuf::from(std::thread::current().name().unwrap_or(""));
1173        GithubJobStepsBuilder::from_values(steps, path)
1174            .validate()
1175            .unwrap();
1176    }
1177
1178    #[test]
1179    fn build_setup_can_read() {
1180        let tmp = temp_dir::TempDir::new().unwrap();
1181        let base = Utf8PathBuf::from_path_buf(tmp.path().to_owned())
1182            .expect("temp_dir made non-utf8 path!?");
1183        let cfg = "build-setup.yml".to_string();
1184        std::fs::write(
1185            base.join(&cfg),
1186            r#"
1187- uses: some-action-user/some-action
1188  continue-on-error: ${{ some.expression }}
1189  timeout-minutes: ${{ matrix.timeout }}
1190"#,
1191        )
1192        .unwrap();
1193        GithubJobStepsBuilder::new(&base, &cfg).unwrap();
1194    }
1195
1196    #[test]
1197    fn build_setup_with_if() {
1198        let tmp = temp_dir::TempDir::new().unwrap();
1199        let base = Utf8PathBuf::from_path_buf(tmp.path().to_owned())
1200            .expect("temp_dir made non-utf8 path!?");
1201        let cfg = "build-setup.yml".to_string();
1202        std::fs::write(
1203            base.join(&cfg),
1204            r#"
1205- uses: some-action-user/some-action
1206  if: false
1207"#,
1208        )
1209        .unwrap();
1210        let out = GithubJobStepsBuilder::new(&base, &cfg)
1211            .unwrap()
1212            .validate()
1213            .unwrap()
1214            .pop()
1215            .unwrap();
1216        assert_eq!(out.if_expr, Some(false.into()));
1217    }
1218}