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
25fn canonical<T: serde::Serialize>(value: &T) -> String {
35 serde_json::to_string(value).unwrap_or_default()
36}
37
38const fn bool_word(value: bool) -> &'static str {
39 if value { "true" } else { "false" }
40}
41
42fn json_list(values: &[String]) -> String {
45 let inner: Vec<String> = values.iter().map(|value| format!("\"{value}\"")).collect();
46 format!("[{}]", inner.join(", "))
47}
48
49pub(super) fn github_bypass_actors(values: &[String]) -> serde_json::Value {
55 serde_json::Value::Array(
56 values
57 .iter()
58 .filter_map(|value| match value.as_str() {
59 crate::config::LOCAL_GITHUB_BYPASS => Some(serde_json::json!({
60 "actor_id": 5,
61 "actor_type": "RepositoryRole",
62 "bypass_mode": "always",
63 })),
64 _ => None,
65 })
66 .collect(),
67 )
68}
69
70fn compose_rules(
83 protection: &crate::config::Protection,
84 wanted: &[&str],
85 required_check: &str,
86 title_check: &str,
87) -> String {
88 let rules: Vec<serde_json::Value> = protection
89 .owned_trunk_rules
90 .iter()
91 .filter(|rule| wanted.iter().any(|kind| kind == &rule.as_str()))
92 .map(|rule| match rule.as_str() {
93 "pull_request" => serde_json::json!({
94 "type": "pull_request",
95 "parameters": {
96 "required_approving_review_count": protection.required_approving_review_count,
97 "dismiss_stale_reviews_on_push": protection.dismiss_stale_reviews_on_push,
98 "require_code_owner_review": protection.require_code_owner_review,
99 "require_last_push_approval": protection.require_last_push_approval,
100 "required_review_thread_resolution": false,
101 "require_extra_approval_for_unattributed_changes": false,
102 "allowed_merge_methods": protection.allowed_merge_methods,
103 }
104 }),
105 "required_status_checks" => serde_json::json!({
106 "type": "required_status_checks",
107 "parameters": {
108 "do_not_enforce_on_create": true,
109 "strict_required_status_checks_policy":
110 protection.strict_required_status_checks,
111 "required_status_checks": [
112 { "context": required_check },
113 { "context": title_check },
114 ],
115 }
116 }),
117 other => serde_json::json!({ "type": other }),
118 })
119 .collect();
120 serde_json::to_string_pretty(&rules).unwrap_or_else(|_| "[]".to_owned())
123}
124
125fn effective_protection(
167 stated: Option<&crate::config::Protection>,
168 integration: crate::landing::Integration,
169 form: crate::landing::MergeForm,
170 mode: ReleaseMode,
171) -> Effective {
172 let authority = stated.map_or_else(
173 || {
174 if integration == crate::landing::Integration::Local {
175 crate::config::local_protection()
176 } else {
177 crate::config::Protection::default()
178 }
179 },
180 |stated| {
181 if integration == crate::landing::Integration::Local
182 && crate::config::legacy_local_protection(stated)
183 {
184 let mut migrated = stated.clone();
185 let current = crate::config::local_protection();
186 migrated.bypass_actors = current.bypass_actors;
187 migrated.owned_trunk_rules = current.owned_trunk_rules;
188 migrated.gitlab.push_access_level = current.gitlab.push_access_level;
189 migrated
190 } else {
191 stated.clone()
192 }
193 },
194 );
195 let mut policy = crate::config::with_merge_form(&authority, form);
196 if form == crate::landing::MergeForm::Rebase
197 && !policy
198 .owned_trunk_rules
199 .iter()
200 .any(|rule| rule == crate::config::LINEAR_HISTORY_RULE)
201 {
202 policy
203 .owned_trunk_rules
204 .push(crate::config::LINEAR_HISTORY_RULE.to_owned());
205 }
206 let forge_gate = crate::profile::catalog::forge_gate_lands(integration, mode);
207 if !forge_gate {
208 policy
209 .owned_trunk_rules
210 .retain(|rule| rule != REQUIRED_CHECK_RULE);
211 }
212 Effective {
213 policy,
214 tag_protection: mode != ReleaseMode::None,
215 forge_gate,
216 }
217}
218
219const REQUIRED_CHECK_RULE: &str = "required_status_checks";
222
223struct Effective {
226 policy: crate::config::Protection,
228 tag_protection: bool,
231 forge_gate: bool,
234}
235
236fn recorded_workflow(
244 record: Option<&crate::landing::manifest::Manifest>,
245) -> (crate::landing::Integration, crate::landing::MergeForm) {
246 record.map_or_else(
247 || {
248 (
249 crate::landing::manifest::integration_forge(),
250 crate::landing::manifest::merge_form_squash(),
251 )
252 },
253 |held| (held.git.integration, held.git.merge_form),
254 )
255}
256
257fn gate_check(protection: &crate::config::Protection, form: crate::landing::MergeForm) -> String {
264 match form {
265 crate::landing::MergeForm::Squash => protection.title_check.clone(),
266 crate::landing::MergeForm::Rebase => crate::config::COMMIT_GATE_CHECK.to_owned(),
267 }
268}
269
270const PASSTHROUGH: [&str; 11] = [
274 "PATH",
275 "HOME",
276 "XDG_CONFIG_HOME",
277 "GH_TOKEN",
278 "GITHUB_TOKEN",
279 "GH_HOST",
280 "GH_CONFIG_DIR",
281 "GLAB_TOKEN",
282 "GITLAB_TOKEN",
283 "GITLAB_HOST",
284 "GLAB_CONFIG_DIR",
285];
286
287pub use super::secrets::VALUE_VARS as SECRET_VARS;
293
294#[derive(Debug, Clone)]
296pub struct Ctx {
297 pub target: Utf8PathBuf,
299 pub repo: String,
301 pub forge: Option<Forge>,
306 pub declared_forge: Option<String>,
309 pub profile: ProfileSnapshot,
311 pub capabilities: CapabilityRequests,
313 pub host: Option<String>,
315 pub required_check: Option<String>,
317 pub required_workflow: Option<String>,
322 pub cli: PathBuf,
324 pub tech: Option<&'static str>,
326 trunk: String,
329 line_prefix: String,
332 retired_branches: Vec<String>,
335 release_lines: bool,
338 excluded_steps: std::collections::BTreeMap<String, String>,
341 bot_app_id: Option<String>,
344 trunk_ruleset: String,
346 safety_ruleset: String,
349 tag_ruleset: String,
351 lines_ruleset: String,
353 title_check: String,
356 protection: crate::config::Protection,
364 landed: Option<Vec<String>>,
367 integration: crate::landing::Integration,
372 merge_form: crate::landing::MergeForm,
376 pub(super) tag_protection: bool,
379 pub(super) forge_gate: bool,
382}
383
384impl Ctx {
385 pub fn resolve(
394 target: &Utf8PathBuf,
395 repo_flag: Option<&str>,
396 forge_flag: Option<&str>,
397 required_check: Option<&str>,
398 ) -> Result<Self, RkError> {
399 if !target.is_dir() {
400 return Err(RkError::missing(
401 Diagnostic::new(
402 Reason::TargetNotFound,
403 format!("target {target} is not a directory; nothing was run"),
404 )
405 .expected("an existing repository to set up"),
406 ));
407 }
408 let forge_flag = forge_flag
409 .map(|name| {
410 detect::Forge::parse(name).ok_or_else(|| {
411 RkError::Usage(format!(
412 "unknown forge '{name}'; the forges are: github, gitlab"
413 ))
414 })
415 })
416 .transpose()?;
417 let detected = detect::detect(target.as_std_path());
418 let config = crate::config::load(target.as_std_path())?;
419 let record = crate::landing::manifest::load(target)?;
420 let resolved = crate::profile::Params::resolve(
424 target,
425 &crate::profile::Inputs {
426 forge: forge_flag.map(Forge::as_str),
427 repo: repo_flag,
428 ..crate::profile::Inputs::default()
429 },
430 config.as_ref(),
431 record.as_ref(),
432 crate::profile::Purpose::Preview,
433 )?;
434 let declared_forge = resolved.forge().map(str::to_owned);
435 let forge = declared_forge.as_deref().and_then(Forge::parse);
436 let repo = resolved.repo().to_owned();
437 let repo = if repo == crate::projection::REPO_PLACEHOLDER {
438 String::new()
439 } else {
440 repo
441 };
442 let cli = PathBuf::new();
446 let answers = config
447 .as_ref()
448 .map_or_else(crate::config::Setup::default, |held| held.setup.clone());
449 let required_check = required_check.map(str::to_owned).or_else(|| {
453 Some(answers.required_check.clone())
454 .filter(|name| !name.is_empty() && forge == Some(Forge::Github))
455 });
456 let required_workflow = Some(answers.required_workflow.clone())
457 .filter(|name| !name.is_empty() && forge == Some(Forge::Github));
458 let bot_app_id = Some(answers.bot.app_id.clone()).filter(|id| !id.is_empty());
459 let trunk = resolved.trunk().to_owned();
460 let (integration, merge_form) = recorded_workflow(record.as_ref());
461 let landed = record.as_ref().map(|held| {
462 held.files
463 .iter()
464 .map(|file| file.destination.clone())
465 .collect()
466 });
467 let stated = config.as_ref().map(|held| &held.protection);
468 let effective =
469 effective_protection(stated, integration, merge_form, resolved.release_mode());
470 let protection = effective.policy;
471 Ok(Self {
472 target: target.clone(),
473 repo,
474 forge,
475 host: detected.host,
476 required_check,
477 required_workflow,
478 cli,
479 tech: resolved.driver().and_then(|driver| {
480 ["rust", "python", "bash"]
481 .into_iter()
482 .find(|known| *known == driver)
483 }),
484 trunk_ruleset: protection.trunk_ruleset(&trunk),
485 safety_ruleset: protection.safety_ruleset(&trunk),
486 tag_ruleset: protection.tag_ruleset.clone(),
487 lines_ruleset: protection.lines_ruleset.clone(),
488 title_check: gate_check(&protection, merge_form),
489 protection,
490 tag_protection: effective.tag_protection,
491 forge_gate: effective.forge_gate,
492 integration,
493 merge_form,
494 landed,
495 trunk,
496 line_prefix: resolved.line_prefix().to_owned(),
497 profile: resolved.profile().clone(),
498 capabilities: resolved.capabilities().clone(),
499 declared_forge,
500 retired_branches: answers.retired_branches,
501 release_lines: answers.release_lines,
502 excluded_steps: answers.excluded_steps,
503 bot_app_id,
504 })
505 }
506
507 pub fn require_cli(&mut self, steps: &[&crate::setup::steps::StepSpec]) -> Result<(), RkError> {
522 let Some(forge) = self.forge else {
523 return Ok(());
524 };
525 let calls = steps.iter().any(|step| {
526 step.forge_cli.contains(&forge) && crate::setup::report::stance(self, step).acts()
527 });
528 if calls && self.cli.as_os_str().is_empty() {
529 self.cli = resolve_cli(forge)?;
530 }
531 Ok(())
532 }
533
534 #[doc(hidden)]
539 #[must_use]
540 pub fn for_tests(
541 target: Utf8PathBuf,
542 repo: String,
543 forge: Forge,
544 cli: PathBuf,
545 tech: Option<&'static str>,
546 ) -> Self {
547 let defaults = crate::config::Protection::default();
548 Self {
549 integration: crate::landing::Integration::Forge,
552 merge_form: crate::landing::MergeForm::Squash,
553 tag_protection: true,
554 forge_gate: true,
555 landed: None,
556 target,
557 repo,
558 forge: Some(forge),
559 declared_forge: Some(forge.as_str().to_owned()),
560 profile: ProfileSnapshot {
561 technologies: tech.into_iter().map(str::to_owned).collect(),
562 forge: Some(forge.as_str().to_owned()),
563 release: crate::profile::ReleaseIntent {
564 mode: ReleaseMode::Automatic,
565 driver: tech.map(str::to_owned),
566 style: Some(crate::landing::Style::Trunk),
567 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
568 },
569 },
570 capabilities: CapabilityRequests {
571 nix_packaging: false,
572 reporting_policy: true,
573 scorecard: false,
574 code_scanning: None,
575 workflow_guards: Some(true),
576 },
577 host: None,
578 required_check: None,
579 required_workflow: None,
580 cli,
581 tech,
582 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
583 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
584 retired_branches: crate::config::Setup::default().retired_branches,
585 release_lines: false,
586 excluded_steps: std::collections::BTreeMap::new(),
587 bot_app_id: None,
588 trunk_ruleset: format!("{}-protection", crate::config::TRUNK_DEFAULT),
589 safety_ruleset: format!("{}-safety", crate::config::TRUNK_DEFAULT),
590 tag_ruleset: defaults.tag_ruleset.clone(),
591 lines_ruleset: defaults.lines_ruleset.clone(),
592 title_check: defaults.title_check.clone(),
593 protection: defaults,
594 }
595 }
596
597 #[must_use]
600 pub fn landed_destinations(&self) -> Option<&[String]> {
601 self.landed.as_deref()
602 }
603
604 #[must_use]
606 pub const fn has_adapter(&self) -> bool {
607 self.forge.is_some()
608 }
609
610 pub fn adapter(&self) -> Result<Forge, RkError> {
618 self.forge.ok_or_else(|| {
619 let named = self.declared_forge.as_deref();
620 let message = named.map_or_else(
621 || "the profile names no forge, and this operation acts on one".to_owned(),
622 |name| {
623 format!(
624 "the profile names the forge {name}, which this release has no adapter for"
625 )
626 },
627 );
628 RkError::refusal(
629 Diagnostic::new(Reason::PrerequisiteUnmet, message)
630 .expected("a profile naming github or gitlab")
631 .action("set profile.forge in .release-kit/config.toml, or pass --forge <github|gitlab>")
632 .target_state("unchanged"),
633 )
634 })
635 }
636
637 #[must_use]
639 pub const fn automatic_release(&self) -> bool {
640 matches!(self.profile.release.mode, ReleaseMode::Automatic)
641 }
642
643 #[must_use]
645 pub fn driver(&self) -> Option<&str> {
646 self.profile.release.driver.as_deref()
647 }
648
649 #[must_use]
651 pub fn declared_forge(&self) -> Option<&str> {
652 self.declared_forge.as_deref()
653 }
654
655 #[must_use]
657 pub const fn reporting_policy(&self) -> bool {
658 self.capabilities.reporting_policy
659 }
660
661 #[must_use]
663 pub fn trunk(&self) -> &str {
664 &self.trunk
665 }
666
667 #[must_use]
669 pub fn line_prefix(&self) -> &str {
670 &self.line_prefix
671 }
672
673 #[must_use]
675 pub fn retired_branches(&self) -> &[String] {
676 &self.retired_branches
677 }
678
679 #[must_use]
681 pub const fn release_lines(&self) -> bool {
682 self.release_lines
683 }
684
685 #[must_use]
689 pub fn excluded(&self, step: &str) -> Option<&str> {
690 self.excluded_steps.get(step).map(String::as_str)
691 }
692
693 #[must_use]
695 pub fn excluded_count(&self) -> usize {
696 self.excluded_steps.len()
697 }
698
699 #[must_use]
701 pub fn bot_app_id(&self) -> Option<&str> {
702 self.bot_app_id.as_deref()
703 }
704
705 #[must_use]
707 pub fn trunk_ruleset(&self) -> &str {
708 &self.trunk_ruleset
709 }
710
711 #[must_use]
713 pub fn safety_ruleset(&self) -> &str {
714 &self.safety_ruleset
715 }
716
717 #[must_use]
719 pub fn tag_ruleset(&self) -> &str {
720 &self.tag_ruleset
721 }
722
723 #[must_use]
725 pub fn lines_ruleset(&self) -> &str {
726 &self.lines_ruleset
727 }
728
729 #[must_use]
732 pub fn title_check(&self) -> &str {
733 &self.title_check
734 }
735
736 #[must_use]
738 pub const fn integration(&self) -> crate::landing::Integration {
739 self.integration
740 }
741
742 #[must_use]
744 pub const fn merge_form(&self) -> crate::landing::MergeForm {
745 self.merge_form
746 }
747
748 #[must_use]
751 pub const fn tag_protection(&self) -> bool {
752 self.tag_protection
753 }
754
755 #[must_use]
758 pub const fn forge_gate(&self) -> bool {
759 self.forge_gate
760 }
761
762 fn safety_rule_types(&self) -> String {
765 let mut types: Vec<&str> = self
766 .protection
767 .owned_trunk_rules
768 .iter()
769 .map(String::as_str)
770 .filter(|rule| crate::config::SAFETY_RULES.contains(rule))
771 .collect();
772 types.sort_unstable();
773 types.dedup();
774 serde_json::to_string(&types).unwrap_or_else(|_| "[]".to_owned())
775 }
776
777 fn trunk_rules(&self) -> String {
780 compose_rules(
781 &self.protection,
782 &crate::config::REQUEST_RULES,
783 self.required_check.as_deref().unwrap_or_default(),
784 &self.title_check,
785 )
786 }
787
788 fn safety_rules(&self) -> String {
790 compose_rules(
791 &self.protection,
792 &crate::config::SAFETY_RULES,
793 self.required_check.as_deref().unwrap_or_default(),
794 &self.title_check,
795 )
796 }
797
798 #[must_use]
806 pub fn proof_fields(&self) -> std::collections::BTreeMap<String, String> {
807 let mut fields = std::collections::BTreeMap::new();
808 let mut put = |key: &str, value: String| {
809 fields.insert(key.to_owned(), value);
810 };
811 put("forge", self.declared_forge.clone().unwrap_or_default());
812 put("host", self.host.clone().unwrap_or_default());
813 put("repo", self.repo.clone());
814 put("profile", canonical(&self.profile));
815 put("capabilities", canonical(&self.capabilities));
816 put("trunk", self.trunk.clone());
817 put("line_prefix", self.line_prefix.clone());
818 put("retired_branches", canonical(&self.retired_branches));
819 put("release_lines", bool_word(self.release_lines).to_owned());
820 put("integration", canonical(&self.integration));
821 put(
822 "required_check",
823 self.required_check.clone().unwrap_or_default(),
824 );
825 put(
826 "required_workflow",
827 self.required_workflow.clone().unwrap_or_default(),
828 );
829 put("bot_app_id", self.bot_app_id.clone().unwrap_or_default());
830 put("trunk_ruleset", self.trunk_ruleset.clone());
831 put("safety_ruleset", self.safety_ruleset.clone());
832 put("tag_ruleset", self.tag_ruleset.clone());
833 put("lines_ruleset", self.lines_ruleset.clone());
834 put("title_check", self.title_check.clone());
835 put("protection", canonical(&self.protection));
836 fields
837 }
838
839 #[must_use]
841 pub const fn protection(&self) -> &crate::config::Protection {
842 &self.protection
843 }
844
845 #[must_use]
848 pub fn self_hosted_gitlab(&self) -> bool {
849 self.forge == Some(Forge::Gitlab)
850 && self
851 .host
852 .as_deref()
853 .is_some_and(|host| host != "gitlab.com")
854 }
855
856 #[must_use]
862 pub fn on_gitlab_com(&self) -> bool {
863 self.forge == Some(Forge::Gitlab) && self.host.as_deref() == Some("gitlab.com")
864 }
865
866 #[must_use]
869 #[allow(
870 clippy::too_many_lines,
871 reason = "one pass builds the whole environment a step receives, and splitting it would separate a variable from the value it carries"
872 )]
873 pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
874 let mut env: Vec<(OsString, OsString)> = vec![
875 (
876 "RK_FORGE".into(),
877 self.forge.map_or("", Forge::as_str).into(),
878 ),
879 ("RK_REPO".into(), self.repo.clone().into()),
880 ("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
881 ("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
882 ("RK_TRUNK_RULESET".into(), self.trunk_ruleset.clone().into()),
883 (
884 "RK_SAFETY_RULESET".into(),
885 self.safety_ruleset.clone().into(),
886 ),
887 ("RK_TAG_RULESET".into(), self.tag_ruleset.clone().into()),
888 ("RK_LINES_RULESET".into(), self.lines_ruleset.clone().into()),
889 ("RK_TITLE_CHECK".into(), self.title_check.clone().into()),
893 ("RK_MERGE_FORM".into(), self.merge_form.as_str().into()),
896 (
901 "RK_TAG_PROTECTION".into(),
902 bool_word(self.tag_protection).into(),
903 ),
904 ("RK_FORGE_GATE".into(), bool_word(self.forge_gate).into()),
905 (
909 "RK_TAG_PATTERN".into(),
910 self.protection.tag_pattern.clone().into(),
911 ),
912 (
913 "RK_REVIEW_COUNT".into(),
914 self.protection
915 .required_approving_review_count
916 .to_string()
917 .into(),
918 ),
919 (
920 "RK_DISMISS_STALE_REVIEWS".into(),
921 bool_word(self.protection.dismiss_stale_reviews_on_push).into(),
922 ),
923 (
924 "RK_CODE_OWNER_REVIEW".into(),
925 bool_word(self.protection.require_code_owner_review).into(),
926 ),
927 (
928 "RK_LAST_PUSH_APPROVAL".into(),
929 bool_word(self.protection.require_last_push_approval).into(),
930 ),
931 (
932 "RK_MERGE_METHODS".into(),
933 json_list(&self.protection.allowed_merge_methods).into(),
934 ),
935 (
936 "RK_STRICT_CHECKS".into(),
937 bool_word(self.protection.strict_required_status_checks).into(),
938 ),
939 (
940 "RK_BYPASS_ACTORS".into(),
941 github_bypass_actors(&self.protection.bypass_actors)
942 .to_string()
943 .into(),
944 ),
945 (
946 "RK_SQUASH_TITLE_SOURCE".into(),
947 self.protection.github.squash_title_source.clone().into(),
948 ),
949 (
950 "RK_SQUASH_BODY_SOURCE".into(),
951 self.protection.github.squash_body_source.clone().into(),
952 ),
953 (
954 "RK_GITLAB_MERGE_METHOD".into(),
955 self.protection.gitlab.merge_method.clone().into(),
956 ),
957 (
958 "RK_GITLAB_SQUASH_OPTION".into(),
959 self.protection.gitlab.squash_option.clone().into(),
960 ),
961 (
962 "RK_GITLAB_SQUASH_TEMPLATE".into(),
963 self.protection.gitlab.squash_commit_template.clone().into(),
964 ),
965 (
966 "RK_GITLAB_PUSH_LEVEL".into(),
967 self.protection.gitlab.push_access_level.to_string().into(),
968 ),
969 ("RK_TRUNK_RULES".into(), self.trunk_rules().into()),
977 ("RK_SAFETY_RULES".into(), self.safety_rules().into()),
978 (
979 "RK_SAFETY_RULE_TYPES".into(),
980 self.safety_rule_types().into(),
981 ),
982 (
983 "RK_GITLAB_MERGE_LEVEL".into(),
984 self.protection.gitlab.merge_access_level.to_string().into(),
985 ),
986 ("GH_PAGER".into(), "".into()),
987 ("GLAB_PAGER".into(), "".into()),
988 ];
989 if let Some(check) = &self.required_check
990 && self.forge == Some(Forge::Github)
991 && self.forge_gate
992 && matches!(step, "protect-trunk" | "protections-check")
993 {
994 env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
995 }
996 for name in PASSTHROUGH {
997 if let Some(value) = std::env::var_os(name) {
998 env.push((name.into(), value));
999 }
1000 }
1001 if let Some(dir) = self.cli_override_dir() {
1005 let mut paths: Vec<PathBuf> = vec![dir];
1006 if let Some(existing) = std::env::var_os("PATH") {
1007 paths.extend(std::env::split_paths(&existing));
1008 }
1009 if let Ok(joined) = std::env::join_paths(paths) {
1010 env.retain(|(name, _)| name != "PATH");
1011 env.push(("PATH".into(), joined));
1012 }
1013 }
1014 if step == "bot-secrets" {
1015 for name in SECRET_VARS {
1016 if let Some(value) = secrets::value_of(name) {
1017 env.push((name.into(), value));
1018 }
1019 }
1020 }
1021 env
1022 }
1023
1024 fn cli_override_dir(&self) -> Option<PathBuf> {
1026 let overridden = std::env::var_os(match self.forge? {
1027 Forge::Github => "RK_GH_BIN",
1028 Forge::Gitlab => "RK_GLAB_BIN",
1029 })?;
1030 Path::new(&overridden).parent().map(Path::to_path_buf)
1031 }
1032
1033 #[must_use]
1041 pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
1042 SECRET_VARS
1043 .iter()
1044 .filter_map(|name| secrets::value_of(name))
1045 .map(|value| Zeroizing::new(value.into_encoded_bytes()))
1046 .collect()
1047 }
1048}
1049
1050pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
1060 let override_var = match forge {
1061 Forge::Github => "RK_GH_BIN",
1062 Forge::Gitlab => "RK_GLAB_BIN",
1063 };
1064 if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
1065 let path = PathBuf::from(&overridden);
1066 if !path.is_file() {
1067 return Err(RkError::refusal(
1068 Diagnostic::new(
1069 Reason::PrerequisiteUnmet,
1070 format!(
1071 "{override_var} names {}, which does not exist",
1072 path.display()
1073 ),
1074 )
1075 .expected("the override to name the forge CLI binary"),
1076 ));
1077 }
1078 if path.file_name().is_none_or(|name| name != forge.cli()) {
1083 return Err(RkError::refusal(
1084 Diagnostic::new(
1085 Reason::PrerequisiteUnmet,
1086 format!(
1087 "{override_var} must name a binary called {}, and {} is not one",
1088 forge.cli(),
1089 path.display()
1090 ),
1091 )
1092 .expected(format!(
1093 "an override whose file name is {}, so scripts and observations run one binary",
1094 forge.cli()
1095 )),
1096 ));
1097 }
1098 return Ok(path);
1099 }
1100 let name = forge.cli();
1101 let found = std::env::var_os("PATH").and_then(|path| {
1102 std::env::split_paths(&path)
1103 .map(|dir| dir.join(name))
1104 .find(|candidate| candidate.is_file())
1105 });
1106 found.ok_or_else(|| {
1107 RkError::refusal(
1108 Diagnostic::new(
1109 Reason::PrerequisiteUnmet,
1110 format!(
1111 "{name} is not on PATH, and a step this run acts on calls it on {}",
1112 forge.as_str()
1113 ),
1114 )
1115 .expected(format!("the {name} CLI installed and authenticated"))
1116 .action(format!("install {name}, then run {name} auth login")),
1117 )
1118 })
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123 #[test]
1130 fn each_ruleset_composes_its_half_of_the_owned_rule_key() {
1131 let kinds = |text: &str| -> Vec<String> {
1132 let parsed: Vec<serde_json::Value> =
1133 serde_json::from_str(text).expect("the rules parse");
1134 parsed
1135 .iter()
1136 .filter_map(|rule| rule["type"].as_str())
1137 .map(str::to_owned)
1138 .collect()
1139 };
1140 let mut policy = crate::config::Protection::default();
1141 let request =
1142 super::compose_rules(&policy, &crate::config::REQUEST_RULES, "gate", "pr-title");
1143 assert_eq!(kinds(&request), ["pull_request", "required_status_checks"]);
1144 let safety =
1145 super::compose_rules(&policy, &crate::config::SAFETY_RULES, "gate", "pr-title");
1146 assert_eq!(kinds(&safety), ["deletion", "non_fast_forward"]);
1147
1148 let parsed: Vec<serde_json::Value> =
1149 serde_json::from_str(&request).expect("the rules parse");
1150 let checks = parsed
1151 .iter()
1152 .find(|rule| rule["type"] == "required_status_checks")
1153 .expect("the check rule");
1154 assert_eq!(
1155 checks["parameters"]["required_status_checks"],
1156 serde_json::json!([{ "context": "gate" }, { "context": "pr-title" }])
1157 );
1158
1159 policy.bypass_actors = vec![crate::config::LOCAL_GITHUB_BYPASS.into()];
1162 assert_eq!(
1163 kinds(&super::compose_rules(
1164 &policy,
1165 &crate::config::REQUEST_RULES,
1166 "gate",
1167 "pr-title"
1168 )),
1169 ["pull_request", "required_status_checks"]
1170 );
1171 assert_eq!(
1172 kinds(&super::compose_rules(
1173 &policy,
1174 &crate::config::SAFETY_RULES,
1175 "gate",
1176 "pr-title"
1177 )),
1178 ["deletion", "non_fast_forward"]
1179 );
1180 }
1181
1182 #[test]
1195 fn the_effective_policy_follows_the_recorded_authority() {
1196 use crate::landing::{Integration, MergeForm};
1197 let silent = super::effective_protection(
1198 None,
1199 Integration::Forge,
1200 MergeForm::Squash,
1201 crate::profile::ReleaseMode::Automatic,
1202 )
1203 .policy;
1204 assert!(
1205 silent
1206 .owned_trunk_rules
1207 .contains(&"pull_request".to_owned())
1208 );
1209 assert_eq!(silent.gitlab.push_access_level, 0);
1210
1211 let silent = super::effective_protection(
1212 None,
1213 Integration::Local,
1214 MergeForm::Squash,
1215 crate::profile::ReleaseMode::Automatic,
1216 )
1217 .policy;
1218 assert_eq!(
1219 silent.owned_trunk_rules,
1220 crate::config::Protection::default().owned_trunk_rules,
1221 "local integration retains the release request's atomic check"
1222 );
1223 assert_eq!(
1224 silent.bypass_actors,
1225 [crate::config::LOCAL_GITHUB_BYPASS.to_owned()]
1226 );
1227 assert_eq!(
1228 silent.gitlab.push_access_level, 40,
1229 "zero would close the trunk to the push this mode ends in"
1230 );
1231
1232 let mut stated = crate::config::Protection::default();
1233 stated.gitlab.push_access_level = 0;
1234 stated.owned_trunk_rules = vec!["deletion".into()];
1235 let held = super::effective_protection(
1236 Some(&stated),
1237 Integration::Local,
1238 MergeForm::Squash,
1239 crate::profile::ReleaseMode::Automatic,
1240 )
1241 .policy;
1242 assert_eq!(held.gitlab.push_access_level, 0, "a stated value wins");
1243 assert_eq!(held.owned_trunk_rules, ["deletion".to_owned()]);
1244
1245 let legacy = crate::config::Protection {
1246 owned_trunk_rules: vec!["deletion".into(), "non_fast_forward".into()],
1247 gitlab: crate::config::Gitlab {
1248 push_access_level: 40,
1249 ..crate::config::Gitlab::default()
1250 },
1251 ..crate::config::Protection::default()
1252 };
1253 let migrated = super::effective_protection(
1254 Some(&legacy),
1255 Integration::Local,
1256 MergeForm::Squash,
1257 crate::profile::ReleaseMode::Automatic,
1258 )
1259 .policy;
1260 assert_eq!(
1261 migrated,
1262 crate::config::local_protection(),
1263 "setup cannot reinstall the legacy policy while upgrade remains able to read it"
1264 );
1265 }
1266
1267 #[test]
1273 fn merge_form_decides_the_effective_merge_values() {
1274 use crate::landing::{Integration, MergeForm};
1275 let kinds = |text: &str| -> Vec<String> {
1276 let parsed: Vec<serde_json::Value> =
1277 serde_json::from_str(text).expect("the rules parse");
1278 parsed
1279 .iter()
1280 .filter_map(|rule| rule["type"].as_str())
1281 .map(str::to_owned)
1282 .collect()
1283 };
1284 let stated = crate::config::Protection::default();
1285 for integration in [Integration::Forge, Integration::Local] {
1286 for held in [None, Some(&stated)] {
1287 let squash = super::effective_protection(
1288 held,
1289 integration,
1290 MergeForm::Squash,
1291 crate::profile::ReleaseMode::Automatic,
1292 )
1293 .policy;
1294 assert_eq!(squash.allowed_merge_methods, ["squash".to_owned()]);
1295 assert_eq!(squash.gitlab.squash_option, "always");
1296 assert!(
1297 !squash
1298 .owned_trunk_rules
1299 .contains(&crate::config::LINEAR_HISTORY_RULE.to_owned())
1300 );
1301 assert_eq!(
1302 kinds(&super::compose_rules(
1303 &squash,
1304 &crate::config::SAFETY_RULES,
1305 "gate",
1306 "pr-title"
1307 )),
1308 ["deletion", "non_fast_forward"]
1309 );
1310
1311 let rebase = super::effective_protection(
1312 held,
1313 integration,
1314 MergeForm::Rebase,
1315 crate::profile::ReleaseMode::Automatic,
1316 )
1317 .policy;
1318 assert_eq!(rebase.allowed_merge_methods, ["rebase".to_owned()]);
1319 assert_eq!(rebase.gitlab.squash_option, "never");
1320 assert_eq!(rebase.gitlab.merge_method, "ff");
1321 assert_eq!(
1322 kinds(&super::compose_rules(
1323 &rebase,
1324 &crate::config::SAFETY_RULES,
1325 "gate",
1326 "pr-commits"
1327 )),
1328 ["deletion", "non_fast_forward", "required_linear_history"]
1329 );
1330 assert_eq!(
1331 kinds(&super::compose_rules(
1332 &rebase,
1333 &crate::config::REQUEST_RULES,
1334 "gate",
1335 "pr-commits"
1336 )),
1337 ["pull_request", "required_status_checks"],
1338 "the linear-history rule never sits where a bypass excuses it"
1339 );
1340 }
1341 }
1342 }
1343
1344 #[test]
1346 fn merge_form_names_the_required_gate() {
1347 use crate::landing::MergeForm;
1348 let policy = crate::config::Protection::default();
1349 assert_eq!(super::gate_check(&policy, MergeForm::Squash), "pr-title");
1350 assert_eq!(super::gate_check(&policy, MergeForm::Rebase), "pr-commits");
1351 }
1352 #[test]
1359 fn release_less_effective_protection_follows_the_profile() {
1360 use crate::landing::{Integration, MergeForm};
1361 use crate::profile::ReleaseMode;
1362 for integration in [Integration::Forge, Integration::Local] {
1363 for form in [MergeForm::Squash, MergeForm::Rebase] {
1364 for mode in [
1365 ReleaseMode::Automatic,
1366 ReleaseMode::External,
1367 ReleaseMode::None,
1368 ] {
1369 let effective = super::effective_protection(None, integration, form, mode);
1370 let at = format!("{integration:?} {form:?} {mode:?}");
1371 let gate = integration == Integration::Forge || mode == ReleaseMode::Automatic;
1372 assert_eq!(effective.tag_protection, mode != ReleaseMode::None, "{at}");
1373 assert_eq!(effective.forge_gate, gate, "{at}");
1374 let rules = &effective.policy.owned_trunk_rules;
1375 assert_eq!(
1376 rules.iter().any(|rule| rule == "required_status_checks"),
1377 gate,
1378 "{at}: {rules:?}"
1379 );
1380 for kept in ["deletion", "non_fast_forward", "pull_request"] {
1381 assert!(rules.iter().any(|rule| rule == kept), "{at}: {kept}");
1382 }
1383 assert_eq!(
1384 effective.policy.allowed_merge_methods,
1385 [form.as_str().to_owned()],
1386 "{at}"
1387 );
1388 }
1389 }
1390 }
1391 }
1392}