1use std::ffi::OsString;
14use std::path::{Path, PathBuf};
15
16use camino::Utf8PathBuf;
17use zeroize::Zeroizing;
18
19use super::secrets;
20use crate::detect::{self, Forge};
21use crate::diagnostic::{Diagnostic, Reason};
22use crate::error::RkError;
23use crate::profile::{CapabilityRequests, ProfileSnapshot, ReleaseMode};
24
25const fn bool_word(value: bool) -> &'static str {
32 if value { "true" } else { "false" }
33}
34
35fn json_list(values: &[String]) -> String {
38 let inner: Vec<String> = values.iter().map(|value| format!("\"{value}\"")).collect();
39 format!("[{}]", inner.join(", "))
40}
41
42fn compose_trunk_rules(
50 protection: &crate::config::Protection,
51 required_check: &str,
52 title_check: &str,
53) -> String {
54 let rules: Vec<serde_json::Value> = protection
55 .owned_trunk_rules
56 .iter()
57 .map(|rule| match rule.as_str() {
58 "pull_request" => serde_json::json!({
59 "type": "pull_request",
60 "parameters": {
61 "required_approving_review_count": protection.required_approving_review_count,
62 "dismiss_stale_reviews_on_push": protection.dismiss_stale_reviews_on_push,
63 "require_code_owner_review": protection.require_code_owner_review,
64 "require_last_push_approval": protection.require_last_push_approval,
65 "required_review_thread_resolution": false,
66 "require_extra_approval_for_unattributed_changes": false,
67 "allowed_merge_methods": protection.allowed_merge_methods,
68 }
69 }),
70 "required_status_checks" => serde_json::json!({
71 "type": "required_status_checks",
72 "parameters": {
73 "do_not_enforce_on_create": true,
74 "strict_required_status_checks_policy":
75 protection.strict_required_status_checks,
76 "required_status_checks": [
77 { "context": required_check },
78 { "context": title_check },
79 ],
80 }
81 }),
82 other => serde_json::json!({ "type": other }),
83 })
84 .collect();
85 serde_json::to_string_pretty(&rules).unwrap_or_else(|_| "[]".to_owned())
88}
89
90fn effective_protection(
110 stated: Option<&crate::config::Protection>,
111 integration: crate::landing::Integration,
112) -> crate::config::Protection {
113 if let Some(stated) = stated {
114 return stated.clone();
115 }
116 let mut policy = crate::config::Protection::default();
117 if integration == crate::landing::Integration::Local {
118 const MAINTAINER: i64 = 40;
120 policy
121 .owned_trunk_rules
122 .retain(|rule| rule != "pull_request" && rule != "required_status_checks");
123 policy.gitlab.push_access_level = MAINTAINER;
124 }
125 policy
126}
127
128const PASSTHROUGH: [&str; 11] = [
132 "PATH",
133 "HOME",
134 "XDG_CONFIG_HOME",
135 "GH_TOKEN",
136 "GITHUB_TOKEN",
137 "GH_HOST",
138 "GH_CONFIG_DIR",
139 "GLAB_TOKEN",
140 "GITLAB_TOKEN",
141 "GITLAB_HOST",
142 "GLAB_CONFIG_DIR",
143];
144
145pub use super::secrets::VALUE_VARS as SECRET_VARS;
151
152#[derive(Debug, Clone)]
154pub struct Ctx {
155 pub target: Utf8PathBuf,
157 pub repo: String,
159 pub forge: Option<Forge>,
164 pub declared_forge: Option<String>,
167 pub profile: ProfileSnapshot,
169 pub capabilities: CapabilityRequests,
171 pub host: Option<String>,
173 pub required_check: Option<String>,
175 pub cli: PathBuf,
177 pub tech: Option<&'static str>,
179 trunk: String,
182 line_prefix: String,
185 retired_branches: Vec<String>,
188 release_lines: bool,
191 excluded_steps: std::collections::BTreeMap<String, String>,
194 bot_app_id: Option<String>,
197 trunk_ruleset: String,
199 tag_ruleset: String,
201 lines_ruleset: String,
203 title_check: String,
205 protection: crate::config::Protection,
213 integration: crate::landing::Integration,
218}
219
220impl Ctx {
221 pub fn resolve(
230 target: &Utf8PathBuf,
231 repo_flag: Option<&str>,
232 forge_flag: Option<&str>,
233 required_check: Option<&str>,
234 ) -> Result<Self, RkError> {
235 if !target.is_dir() {
236 return Err(RkError::missing(
237 Diagnostic::new(
238 Reason::TargetNotFound,
239 format!("target {target} is not a directory; nothing was run"),
240 )
241 .expected("an existing repository to set up"),
242 ));
243 }
244 let forge_flag = forge_flag
245 .map(|name| {
246 detect::Forge::parse(name).ok_or_else(|| {
247 RkError::Usage(format!(
248 "unknown forge '{name}'; the forges are: github, gitlab"
249 ))
250 })
251 })
252 .transpose()?;
253 let detected = detect::detect(target.as_std_path());
254 let config = crate::config::load(target.as_std_path())?;
255 let record = crate::landing::manifest::load(target)?;
256 let resolved = crate::profile::Params::resolve(
260 target,
261 &crate::profile::Inputs {
262 forge: forge_flag.map(Forge::as_str),
263 repo: repo_flag,
264 ..crate::profile::Inputs::default()
265 },
266 config.as_ref(),
267 record.as_ref(),
268 crate::profile::Purpose::Preview,
269 )?;
270 let declared_forge = resolved.forge().map(str::to_owned);
271 let forge = declared_forge.as_deref().and_then(Forge::parse);
272 let repo = resolved.repo().to_owned();
273 let repo = if repo == crate::projection::REPO_PLACEHOLDER {
274 String::new()
275 } else {
276 repo
277 };
278 let cli = PathBuf::new();
282 let answers = config
283 .as_ref()
284 .map_or_else(crate::config::Setup::default, |held| held.setup.clone());
285 let required_check = required_check.map(str::to_owned).or_else(|| {
289 Some(answers.required_check.clone())
290 .filter(|name| !name.is_empty() && forge == Some(Forge::Github))
291 });
292 let bot_app_id = Some(answers.bot.app_id.clone()).filter(|id| !id.is_empty());
293 let trunk = resolved.trunk().to_owned();
294 let integration = record
299 .as_ref()
300 .map_or_else(crate::landing::manifest::integration_forge, |held| {
301 held.git.integration
302 });
303 let stated = config.as_ref().map(|held| &held.protection);
304 let protection = effective_protection(stated, integration);
305 Ok(Self {
306 target: target.clone(),
307 repo,
308 forge,
309 host: detected.host,
310 required_check,
311 cli,
312 tech: resolved.driver().and_then(|driver| {
313 ["rust", "python", "bash"]
314 .into_iter()
315 .find(|known| *known == driver)
316 }),
317 trunk_ruleset: protection.trunk_ruleset(&trunk),
318 tag_ruleset: protection.tag_ruleset.clone(),
319 lines_ruleset: protection.lines_ruleset.clone(),
320 title_check: protection.title_check.clone(),
321 protection,
322 integration,
323 trunk,
324 line_prefix: resolved.line_prefix().to_owned(),
325 profile: resolved.profile().clone(),
326 capabilities: resolved.capabilities().clone(),
327 declared_forge,
328 retired_branches: answers.retired_branches,
329 release_lines: answers.release_lines,
330 excluded_steps: answers.excluded_steps,
331 bot_app_id,
332 })
333 }
334
335 pub fn require_cli(&mut self, steps: &[&crate::setup::steps::StepSpec]) -> Result<(), RkError> {
350 let Some(forge) = self.forge else {
351 return Ok(());
352 };
353 let calls = steps.iter().any(|step| {
354 step.forge_cli.contains(&forge) && crate::commands::setup::stance(self, step).acts()
355 });
356 if calls && self.cli.as_os_str().is_empty() {
357 self.cli = resolve_cli(forge)?;
358 }
359 Ok(())
360 }
361
362 #[doc(hidden)]
367 #[must_use]
368 pub fn for_tests(
369 target: Utf8PathBuf,
370 repo: String,
371 forge: Forge,
372 cli: PathBuf,
373 tech: Option<&'static str>,
374 ) -> Self {
375 let defaults = crate::config::Protection::default();
376 Self {
377 integration: crate::landing::Integration::Forge,
380 target,
381 repo,
382 forge: Some(forge),
383 declared_forge: Some(forge.as_str().to_owned()),
384 profile: ProfileSnapshot {
385 technologies: tech.into_iter().map(str::to_owned).collect(),
386 forge: Some(forge.as_str().to_owned()),
387 release: crate::profile::ReleaseIntent {
388 mode: ReleaseMode::Automatic,
389 driver: tech.map(str::to_owned),
390 style: Some(crate::landing::Style::Trunk),
391 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
392 },
393 },
394 capabilities: CapabilityRequests {
395 nix_packaging: false,
396 reporting_policy: true,
397 scorecard: false,
398 code_scanning: None,
399 },
400 host: None,
401 required_check: None,
402 cli,
403 tech,
404 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
405 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
406 retired_branches: crate::config::Setup::default().retired_branches,
407 release_lines: false,
408 excluded_steps: std::collections::BTreeMap::new(),
409 bot_app_id: None,
410 trunk_ruleset: format!("{}-protection", crate::config::TRUNK_DEFAULT),
411 tag_ruleset: defaults.tag_ruleset.clone(),
412 lines_ruleset: defaults.lines_ruleset.clone(),
413 title_check: defaults.title_check.clone(),
414 protection: defaults,
415 }
416 }
417
418 #[must_use]
420 pub const fn has_adapter(&self) -> bool {
421 self.forge.is_some()
422 }
423
424 pub fn adapter(&self) -> Result<Forge, RkError> {
432 self.forge.ok_or_else(|| {
433 let named = self.declared_forge.as_deref();
434 let message = named.map_or_else(
435 || "the profile names no forge, and this operation acts on one".to_owned(),
436 |name| {
437 format!(
438 "the profile names the forge {name}, which this release has no adapter for"
439 )
440 },
441 );
442 RkError::refusal(
443 Diagnostic::new(Reason::PrerequisiteUnmet, message)
444 .expected("a profile naming github or gitlab")
445 .action("set profile.forge in .release-kit/config.toml, or pass --forge <github|gitlab>")
446 .target_state("unchanged"),
447 )
448 })
449 }
450
451 #[must_use]
453 pub const fn automatic_release(&self) -> bool {
454 matches!(self.profile.release.mode, ReleaseMode::Automatic)
455 }
456
457 #[must_use]
459 pub fn driver(&self) -> Option<&str> {
460 self.profile.release.driver.as_deref()
461 }
462
463 #[must_use]
465 pub fn declared_forge(&self) -> Option<&str> {
466 self.declared_forge.as_deref()
467 }
468
469 #[must_use]
471 pub const fn reporting_policy(&self) -> bool {
472 self.capabilities.reporting_policy
473 }
474
475 #[must_use]
477 pub fn trunk(&self) -> &str {
478 &self.trunk
479 }
480
481 #[must_use]
483 pub fn line_prefix(&self) -> &str {
484 &self.line_prefix
485 }
486
487 #[must_use]
489 pub fn retired_branches(&self) -> &[String] {
490 &self.retired_branches
491 }
492
493 #[must_use]
495 pub const fn release_lines(&self) -> bool {
496 self.release_lines
497 }
498
499 #[must_use]
503 pub fn excluded(&self, step: &str) -> Option<&str> {
504 self.excluded_steps.get(step).map(String::as_str)
505 }
506
507 #[must_use]
509 pub fn excluded_count(&self) -> usize {
510 self.excluded_steps.len()
511 }
512
513 #[must_use]
515 pub fn bot_app_id(&self) -> Option<&str> {
516 self.bot_app_id.as_deref()
517 }
518
519 #[must_use]
521 pub fn trunk_ruleset(&self) -> &str {
522 &self.trunk_ruleset
523 }
524
525 #[must_use]
527 pub fn tag_ruleset(&self) -> &str {
528 &self.tag_ruleset
529 }
530
531 #[must_use]
533 pub fn lines_ruleset(&self) -> &str {
534 &self.lines_ruleset
535 }
536
537 #[must_use]
539 pub fn title_check(&self) -> &str {
540 &self.title_check
541 }
542
543 #[must_use]
545 pub const fn integration(&self) -> crate::landing::Integration {
546 self.integration
547 }
548
549 fn trunk_rules(&self) -> String {
551 compose_trunk_rules(
552 &self.protection,
553 self.required_check.as_deref().unwrap_or_default(),
554 &self.title_check,
555 )
556 }
557
558 #[must_use]
560 pub const fn protection(&self) -> &crate::config::Protection {
561 &self.protection
562 }
563
564 #[must_use]
567 pub fn self_hosted_gitlab(&self) -> bool {
568 self.forge == Some(Forge::Gitlab)
569 && self
570 .host
571 .as_deref()
572 .is_some_and(|host| host != "gitlab.com")
573 }
574
575 #[must_use]
578 #[allow(
579 clippy::too_many_lines,
580 reason = "one pass builds the whole environment a step receives, and splitting it would separate a variable from the value it carries"
581 )]
582 pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
583 let mut env: Vec<(OsString, OsString)> = vec![
584 (
585 "RK_FORGE".into(),
586 self.forge.map_or("", Forge::as_str).into(),
587 ),
588 ("RK_REPO".into(), self.repo.clone().into()),
589 ("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
590 ("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
591 ("RK_TRUNK_RULESET".into(), self.trunk_ruleset.clone().into()),
592 ("RK_TAG_RULESET".into(), self.tag_ruleset.clone().into()),
593 ("RK_LINES_RULESET".into(), self.lines_ruleset.clone().into()),
594 ("RK_TITLE_CHECK".into(), self.title_check.clone().into()),
595 (
599 "RK_TAG_PATTERN".into(),
600 self.protection.tag_pattern.clone().into(),
601 ),
602 (
603 "RK_REVIEW_COUNT".into(),
604 self.protection
605 .required_approving_review_count
606 .to_string()
607 .into(),
608 ),
609 (
610 "RK_DISMISS_STALE_REVIEWS".into(),
611 bool_word(self.protection.dismiss_stale_reviews_on_push).into(),
612 ),
613 (
614 "RK_CODE_OWNER_REVIEW".into(),
615 bool_word(self.protection.require_code_owner_review).into(),
616 ),
617 (
618 "RK_LAST_PUSH_APPROVAL".into(),
619 bool_word(self.protection.require_last_push_approval).into(),
620 ),
621 (
622 "RK_MERGE_METHODS".into(),
623 json_list(&self.protection.allowed_merge_methods).into(),
624 ),
625 (
626 "RK_STRICT_CHECKS".into(),
627 bool_word(self.protection.strict_required_status_checks).into(),
628 ),
629 (
630 "RK_SQUASH_TITLE_SOURCE".into(),
631 self.protection.github.squash_title_source.clone().into(),
632 ),
633 (
634 "RK_SQUASH_BODY_SOURCE".into(),
635 self.protection.github.squash_body_source.clone().into(),
636 ),
637 (
638 "RK_GITLAB_MERGE_METHOD".into(),
639 self.protection.gitlab.merge_method.clone().into(),
640 ),
641 (
642 "RK_GITLAB_SQUASH_OPTION".into(),
643 self.protection.gitlab.squash_option.clone().into(),
644 ),
645 (
646 "RK_GITLAB_SQUASH_TEMPLATE".into(),
647 self.protection.gitlab.squash_commit_template.clone().into(),
648 ),
649 (
650 "RK_GITLAB_PUSH_LEVEL".into(),
651 self.protection.gitlab.push_access_level.to_string().into(),
652 ),
653 ("RK_TRUNK_RULES".into(), self.trunk_rules().into()),
660 (
661 "RK_GITLAB_MERGE_LEVEL".into(),
662 self.protection.gitlab.merge_access_level.to_string().into(),
663 ),
664 ("GH_PAGER".into(), "".into()),
665 ("GLAB_PAGER".into(), "".into()),
666 ];
667 if let Some(check) = &self.required_check
668 && self.forge == Some(Forge::Github)
669 && matches!(step, "protect-trunk" | "protections-check")
670 {
671 env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
672 }
673 for name in PASSTHROUGH {
674 if let Some(value) = std::env::var_os(name) {
675 env.push((name.into(), value));
676 }
677 }
678 if let Some(dir) = self.cli_override_dir() {
682 let mut paths: Vec<PathBuf> = vec![dir];
683 if let Some(existing) = std::env::var_os("PATH") {
684 paths.extend(std::env::split_paths(&existing));
685 }
686 if let Ok(joined) = std::env::join_paths(paths) {
687 env.retain(|(name, _)| name != "PATH");
688 env.push(("PATH".into(), joined));
689 }
690 }
691 if step == "bot-secrets" {
692 for name in SECRET_VARS {
693 if let Some(value) = secrets::value_of(name) {
694 env.push((name.into(), value));
695 }
696 }
697 }
698 env
699 }
700
701 fn cli_override_dir(&self) -> Option<PathBuf> {
703 let overridden = std::env::var_os(match self.forge? {
704 Forge::Github => "RK_GH_BIN",
705 Forge::Gitlab => "RK_GLAB_BIN",
706 })?;
707 Path::new(&overridden).parent().map(Path::to_path_buf)
708 }
709
710 #[must_use]
718 pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
719 SECRET_VARS
720 .iter()
721 .filter_map(|name| secrets::value_of(name))
722 .map(|value| Zeroizing::new(value.into_encoded_bytes()))
723 .collect()
724 }
725}
726
727pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
737 let override_var = match forge {
738 Forge::Github => "RK_GH_BIN",
739 Forge::Gitlab => "RK_GLAB_BIN",
740 };
741 if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
742 let path = PathBuf::from(&overridden);
743 if !path.is_file() {
744 return Err(RkError::refusal(
745 Diagnostic::new(
746 Reason::PrerequisiteUnmet,
747 format!(
748 "{override_var} names {}, which does not exist",
749 path.display()
750 ),
751 )
752 .expected("the override to name the forge CLI binary"),
753 ));
754 }
755 if path.file_name().is_none_or(|name| name != forge.cli()) {
760 return Err(RkError::refusal(
761 Diagnostic::new(
762 Reason::PrerequisiteUnmet,
763 format!(
764 "{override_var} must name a binary called {}, and {} is not one",
765 forge.cli(),
766 path.display()
767 ),
768 )
769 .expected(format!(
770 "an override whose file name is {}, so scripts and observations run one binary",
771 forge.cli()
772 )),
773 ));
774 }
775 return Ok(path);
776 }
777 let name = forge.cli();
778 let found = std::env::var_os("PATH").and_then(|path| {
779 std::env::split_paths(&path)
780 .map(|dir| dir.join(name))
781 .find(|candidate| candidate.is_file())
782 });
783 found.ok_or_else(|| {
784 RkError::refusal(
785 Diagnostic::new(
786 Reason::PrerequisiteUnmet,
787 format!(
788 "{name} is not on PATH, and a step this run acts on calls it on {}",
789 forge.as_str()
790 ),
791 )
792 .expected(format!("the {name} CLI installed and authenticated"))
793 .action(format!("install {name}, then run {name} auth login")),
794 )
795 })
796}
797
798#[cfg(test)]
799mod tests {
800 #[test]
807 fn the_trunk_rules_follow_the_owned_rule_key() {
808 let mut policy = crate::config::Protection::default();
809 let forge = super::compose_trunk_rules(&policy, "gate", "pr-title");
810 let parsed: Vec<serde_json::Value> = serde_json::from_str(&forge).expect("the rules parse");
811 let kinds: Vec<&str> = parsed
812 .iter()
813 .filter_map(|rule| rule["type"].as_str())
814 .collect();
815 assert_eq!(
816 kinds,
817 [
818 "deletion",
819 "non_fast_forward",
820 "pull_request",
821 "required_status_checks"
822 ]
823 );
824 let checks = parsed
825 .iter()
826 .find(|rule| rule["type"] == "required_status_checks")
827 .expect("the check rule");
828 assert_eq!(
829 checks["parameters"]["required_status_checks"],
830 serde_json::json!([{ "context": "gate" }, { "context": "pr-title" }])
831 );
832
833 policy.owned_trunk_rules = vec!["deletion".into(), "non_fast_forward".into()];
834 let local = super::compose_trunk_rules(&policy, "gate", "pr-title");
835 let parsed: Vec<serde_json::Value> = serde_json::from_str(&local).expect("the rules parse");
836 let kinds: Vec<&str> = parsed
837 .iter()
838 .filter_map(|rule| rule["type"].as_str())
839 .collect();
840 assert_eq!(kinds, ["deletion", "non_fast_forward"]);
841 assert!(!local.contains("pull_request"), "{local}");
842 assert!(!local.contains("required_status_checks"), "{local}");
843 }
844
845 #[test]
857 fn the_effective_policy_follows_the_recorded_authority() {
858 use crate::landing::Integration;
859 let silent = super::effective_protection(None, Integration::Forge);
860 assert!(
861 silent
862 .owned_trunk_rules
863 .contains(&"pull_request".to_owned())
864 );
865 assert_eq!(silent.gitlab.push_access_level, 0);
866
867 let silent = super::effective_protection(None, Integration::Local);
868 assert_eq!(
869 silent.owned_trunk_rules,
870 ["deletion".to_owned(), "non_fast_forward".to_owned()],
871 "no forge can apply a request rule to a push"
872 );
873 assert_eq!(
874 silent.gitlab.push_access_level, 40,
875 "zero would close the trunk to the push this mode ends in"
876 );
877
878 let mut stated = crate::config::Protection::default();
879 stated.gitlab.push_access_level = 0;
880 stated.owned_trunk_rules = vec!["deletion".into()];
881 let held = super::effective_protection(Some(&stated), Integration::Local);
882 assert_eq!(held.gitlab.push_access_level, 0, "a stated value wins");
883 assert_eq!(held.owned_trunk_rules, ["deletion".to_owned()]);
884 }
885}