1use 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#[derive(Debug, Serialize)]
49pub struct GithubCiInfo {
50 #[serde(skip_serializing)]
52 pub github_ci_workflow_dir: Utf8PathBuf,
53 pub rust_version: Option<String>,
55 pub dist_install_for_coordinator: GhaRunStep,
57 pub dist_install_strategy: DistInstallStrategy,
59 pub fail_fast: bool,
61 pub cache_builds: bool,
63 pub build_local_artifacts: bool,
65 pub dispatch_releases: bool,
67 pub release_branch: Option<String>,
69 pub artifacts_matrix: cargo_dist_schema::GithubMatrix,
71 pub pr_run_mode: cargo_dist_schema::PrRunMode,
73 pub global_task: GithubGlobalJobConfig,
75 pub tap: Option<String>,
77 pub plan_jobs: Vec<GithubCiJob>,
79 pub local_artifacts_jobs: Vec<GithubCiJob>,
81 pub global_artifacts_jobs: Vec<GithubCiJob>,
83 pub host_jobs: Vec<GithubCiJob>,
85 pub publish_jobs: Vec<String>,
87 pub user_publish_jobs: Vec<GithubCiJob>,
89 pub post_announce_jobs: Vec<GithubCiJob>,
91 pub ssldotcom_windows_sign: Option<ProductionMode>,
93 pub macos_sign: bool,
95 pub hosting_providers: Vec<HostingStyle>,
97 pub tag_namespace: Option<String>,
99 pub root_permissions: Option<GithubPermissionMap>,
101 pub github_build_setup: Vec<GithubJobStep>,
103 #[serde(flatten)]
105 pub github_release: Option<GithubReleaseInfo>,
106 pub actions: SortedMap<String, String>,
108 pub need_cargo_auditable: bool,
110 pub need_cargo_cyclonedx: bool,
112 pub need_omnibor: bool,
114}
115
116#[derive(Debug, Serialize)]
118pub struct GithubReleaseInfo {
119 pub create_release: bool,
121 pub github_releases_repo: Option<JinjaGithubRepoPair>,
123 pub external_repo_commit: Option<String>,
125 pub github_attestations: bool,
127 pub github_attestations_filters: GithubAttestationsFilters,
129 pub github_attestations_phase: GithubAttestationsPhase,
131 pub release_command: String,
133 pub release_phase: GithubReleasePhase,
135}
136
137#[derive(Debug, Clone, Serialize, Default, Deserialize)]
139#[serde(rename_all = "kebab-case")]
140pub struct GithubJobStep {
141 #[serde(default)]
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub id: Option<String>,
145
146 #[serde(default)]
148 #[serde(rename = "if")]
149 #[serde(skip_serializing_if = "Option::is_none")]
150 pub if_expr: Option<serde_json::Value>,
151
152 #[serde(default)]
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub name: Option<String>,
156
157 #[serde(default)]
159 #[serde(skip_serializing_if = "Option::is_none")]
160 pub uses: Option<String>,
161
162 #[serde(default)]
164 #[serde(skip_serializing_if = "Option::is_none")]
165 pub run: Option<String>,
166
167 #[serde(default)]
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub working_directory: Option<String>,
171
172 #[serde(default)]
174 #[serde(skip_serializing_if = "Option::is_none")]
175 pub shell: Option<String>,
176
177 #[serde(default)]
179 pub with: BTreeMap<String, serde_json::Value>,
180
181 #[serde(default)]
183 pub env: BTreeMap<String, serde_json::Value>,
184
185 #[serde(default)]
187 #[serde(skip_serializing_if = "Option::is_none")]
188 pub continue_on_error: Option<serde_json::Value>,
189
190 #[serde(default)]
192 #[serde(skip_serializing_if = "Option::is_none")]
193 pub timeout_minutes: Option<serde_json::Value>,
194}
195
196#[derive(Debug, Serialize)]
198pub struct GithubCiJob {
199 pub name: String,
201 pub permissions: Option<GithubPermissionMap>,
203}
204
205impl GithubCiInfo {
206 pub fn new(dist: &DistGraph, ci_config: &GithubCiConfig) -> DistResult<GithubCiInfo> {
208 let rust_version = dist.config.builds.cargo.rust_toolchain_version.clone();
210
211 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 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 let mut tasks = vec![];
266
267 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 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 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 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 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 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 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 } else {
488 host_config.during
489 };
490
491 let mut release_args = vec![];
492 let action;
493 release_args.push("\"${{ needs.plan.outputs.tag }}\"");
495
496 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 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
531fn 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 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 output.push(GithubCiJob {
572 name: name.clone(),
573 permissions: perms_for_job.cloned(),
574 });
575 }
576 Ok(output)
577}
578
579fn 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
590fn 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 Ok(groups.into_iter().collect::<Vec<_>>().into_iter())
621}
622
623fn 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
645pub 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
660fn 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 let result = Some(match target_triple.operating_system {
675 OperatingSystem::Linux => {
676 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 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 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 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 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 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 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 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 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 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
906pub struct GithubJobStepsBuilder {
909 steps: Vec<GithubJobStep>,
910 path: Utf8PathBuf,
911}
912
913impl GithubJobStepsBuilder {
914 #[cfg(test)]
915 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 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 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 fn validate_step(idx: usize, step: &GithubJobStep) -> Option<String> {
955 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 fn validate_step_uses_keys(step: &GithubJobStep) -> Option<&'static str> {
980 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 fn validate_step_run_keys(step: &GithubJobStep) -> Option<&'static str> {
999 step.run.as_ref()?;
1001 if !step.with.is_empty() {
1002 return Some("with");
1003 }
1004 None
1005 }
1006
1007 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}