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 offline(target: &Utf8PathBuf) -> Result<Self, RkError> {
515 Self::resolve(target, None, None, None)
516 }
517
518 pub fn require_cli(&mut self, steps: &[&crate::setup::steps::StepSpec]) -> Result<(), RkError> {
533 let Some(forge) = self.forge else {
534 return Ok(());
535 };
536 let calls = steps.iter().any(|step| {
537 step.forge_cli.contains(&forge) && crate::setup::report::stance(self, step).acts()
538 });
539 if calls && self.cli.as_os_str().is_empty() {
540 self.cli = resolve_cli(forge)?;
541 }
542 Ok(())
543 }
544
545 #[doc(hidden)]
550 #[must_use]
551 pub fn for_tests(
552 target: Utf8PathBuf,
553 repo: String,
554 forge: Forge,
555 cli: PathBuf,
556 tech: Option<&'static str>,
557 ) -> Self {
558 let defaults = crate::config::Protection::default();
559 Self {
560 integration: crate::landing::Integration::Forge,
563 merge_form: crate::landing::MergeForm::Squash,
564 tag_protection: true,
565 forge_gate: true,
566 landed: None,
567 target,
568 repo,
569 forge: Some(forge),
570 declared_forge: Some(forge.as_str().to_owned()),
571 profile: ProfileSnapshot {
572 technologies: tech.into_iter().map(str::to_owned).collect(),
573 forge: Some(forge.as_str().to_owned()),
574 release: crate::profile::ReleaseIntent {
575 mode: ReleaseMode::Automatic,
576 driver: tech.map(str::to_owned),
577 style: Some(crate::landing::Style::Trunk),
578 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
579 registry: None,
580 },
581 },
582 capabilities: CapabilityRequests {
583 nix_packaging: false,
584 reporting_policy: true,
585 scorecard: false,
586 code_scanning: None,
587 workflow_guards: Some(true),
588 },
589 host: None,
590 required_check: None,
591 required_workflow: None,
592 cli,
593 tech,
594 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
595 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
596 retired_branches: crate::config::Setup::default().retired_branches,
597 release_lines: false,
598 excluded_steps: std::collections::BTreeMap::new(),
599 bot_app_id: None,
600 trunk_ruleset: format!("{}-protection", crate::config::TRUNK_DEFAULT),
601 safety_ruleset: format!("{}-safety", crate::config::TRUNK_DEFAULT),
602 tag_ruleset: defaults.tag_ruleset.clone(),
603 lines_ruleset: defaults.lines_ruleset.clone(),
604 title_check: defaults.title_check.clone(),
605 protection: defaults,
606 }
607 }
608
609 #[must_use]
612 pub fn landed_destinations(&self) -> Option<&[String]> {
613 self.landed.as_deref()
614 }
615
616 #[must_use]
618 pub const fn has_adapter(&self) -> bool {
619 self.forge.is_some()
620 }
621
622 pub fn adapter(&self) -> Result<Forge, RkError> {
630 self.forge.ok_or_else(|| {
631 let named = self.declared_forge.as_deref();
632 let message = named.map_or_else(
633 || "the profile names no forge, and this operation acts on one".to_owned(),
634 |name| {
635 format!(
636 "the profile names the forge {name}, which this release has no adapter for"
637 )
638 },
639 );
640 RkError::refusal(
641 Diagnostic::new(Reason::PrerequisiteUnmet, message)
642 .expected("a profile naming github or gitlab")
643 .action("set profile.forge in .release-kit/config.toml, or pass --forge <github|gitlab>")
644 .target_state("unchanged"),
645 )
646 })
647 }
648
649 #[must_use]
651 pub const fn automatic_release(&self) -> bool {
652 matches!(self.profile.release.mode, ReleaseMode::Automatic)
653 }
654
655 #[must_use]
657 pub fn driver(&self) -> Option<&str> {
658 self.profile.release.driver.as_deref()
659 }
660
661 #[must_use]
663 pub fn declared_forge(&self) -> Option<&str> {
664 self.declared_forge.as_deref()
665 }
666
667 #[must_use]
669 pub const fn reporting_policy(&self) -> bool {
670 self.capabilities.reporting_policy
671 }
672
673 #[must_use]
675 pub fn trunk(&self) -> &str {
676 &self.trunk
677 }
678
679 #[must_use]
681 pub fn line_prefix(&self) -> &str {
682 &self.line_prefix
683 }
684
685 #[must_use]
687 pub fn retired_branches(&self) -> &[String] {
688 &self.retired_branches
689 }
690
691 #[must_use]
693 pub const fn release_lines(&self) -> bool {
694 self.release_lines
695 }
696
697 #[must_use]
701 pub fn excluded(&self, step: &str) -> Option<&str> {
702 self.excluded_steps.get(step).map(String::as_str)
703 }
704
705 #[must_use]
707 pub fn excluded_count(&self) -> usize {
708 self.excluded_steps.len()
709 }
710
711 #[must_use]
713 pub fn bot_app_id(&self) -> Option<&str> {
714 self.bot_app_id.as_deref()
715 }
716
717 #[must_use]
719 pub fn trunk_ruleset(&self) -> &str {
720 &self.trunk_ruleset
721 }
722
723 #[must_use]
725 pub fn safety_ruleset(&self) -> &str {
726 &self.safety_ruleset
727 }
728
729 #[must_use]
731 pub fn tag_ruleset(&self) -> &str {
732 &self.tag_ruleset
733 }
734
735 #[must_use]
737 pub fn lines_ruleset(&self) -> &str {
738 &self.lines_ruleset
739 }
740
741 #[must_use]
744 pub fn title_check(&self) -> &str {
745 &self.title_check
746 }
747
748 #[must_use]
750 pub const fn integration(&self) -> crate::landing::Integration {
751 self.integration
752 }
753
754 #[must_use]
756 pub const fn merge_form(&self) -> crate::landing::MergeForm {
757 self.merge_form
758 }
759
760 #[must_use]
763 pub const fn tag_protection(&self) -> bool {
764 self.tag_protection
765 }
766
767 #[must_use]
770 pub const fn forge_gate(&self) -> bool {
771 self.forge_gate
772 }
773
774 fn safety_rule_types(&self) -> String {
777 let mut types: Vec<&str> = self
778 .protection
779 .owned_trunk_rules
780 .iter()
781 .map(String::as_str)
782 .filter(|rule| crate::config::SAFETY_RULES.contains(rule))
783 .collect();
784 types.sort_unstable();
785 types.dedup();
786 serde_json::to_string(&types).unwrap_or_else(|_| "[]".to_owned())
787 }
788
789 fn trunk_rules(&self) -> String {
792 compose_rules(
793 &self.protection,
794 &crate::config::REQUEST_RULES,
795 self.required_check.as_deref().unwrap_or_default(),
796 &self.title_check,
797 )
798 }
799
800 fn safety_rules(&self) -> String {
802 compose_rules(
803 &self.protection,
804 &crate::config::SAFETY_RULES,
805 self.required_check.as_deref().unwrap_or_default(),
806 &self.title_check,
807 )
808 }
809
810 #[must_use]
818 pub fn proof_fields(&self) -> std::collections::BTreeMap<String, String> {
819 let mut fields = std::collections::BTreeMap::new();
820 let mut put = |key: &str, value: String| {
821 fields.insert(key.to_owned(), value);
822 };
823 put("forge", self.declared_forge.clone().unwrap_or_default());
824 put("host", self.host.clone().unwrap_or_default());
825 put("repo", self.repo.clone());
826 put("profile", canonical(&self.profile));
827 put("capabilities", canonical(&self.capabilities));
828 put("trunk", self.trunk.clone());
829 put("line_prefix", self.line_prefix.clone());
830 put("retired_branches", canonical(&self.retired_branches));
831 put("release_lines", bool_word(self.release_lines).to_owned());
832 put("integration", canonical(&self.integration));
833 put(
834 "required_check",
835 self.required_check.clone().unwrap_or_default(),
836 );
837 put(
838 "required_workflow",
839 self.required_workflow.clone().unwrap_or_default(),
840 );
841 put("bot_app_id", self.bot_app_id.clone().unwrap_or_default());
842 put("trunk_ruleset", self.trunk_ruleset.clone());
843 put("safety_ruleset", self.safety_ruleset.clone());
844 put("tag_ruleset", self.tag_ruleset.clone());
845 put("lines_ruleset", self.lines_ruleset.clone());
846 put("title_check", self.title_check.clone());
847 put("protection", canonical(&self.protection));
848 fields
849 }
850
851 #[must_use]
853 pub const fn protection(&self) -> &crate::config::Protection {
854 &self.protection
855 }
856
857 #[must_use]
860 pub fn self_hosted_gitlab(&self) -> bool {
861 self.forge == Some(Forge::Gitlab)
862 && self
863 .host
864 .as_deref()
865 .is_some_and(|host| host != "gitlab.com")
866 }
867
868 #[must_use]
874 pub fn on_gitlab_com(&self) -> bool {
875 self.forge == Some(Forge::Gitlab) && self.host.as_deref() == Some("gitlab.com")
876 }
877
878 #[must_use]
881 #[allow(
882 clippy::too_many_lines,
883 reason = "one pass builds the whole environment a step receives, and splitting it would separate a variable from the value it carries"
884 )]
885 pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
886 let mut env: Vec<(OsString, OsString)> = vec![
887 (
888 "RK_FORGE".into(),
889 self.forge.map_or("", Forge::as_str).into(),
890 ),
891 ("RK_REPO".into(), self.repo.clone().into()),
892 ("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
893 ("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
894 ("RK_TRUNK_RULESET".into(), self.trunk_ruleset.clone().into()),
895 (
896 "RK_SAFETY_RULESET".into(),
897 self.safety_ruleset.clone().into(),
898 ),
899 ("RK_TAG_RULESET".into(), self.tag_ruleset.clone().into()),
900 ("RK_LINES_RULESET".into(), self.lines_ruleset.clone().into()),
901 ("RK_TITLE_CHECK".into(), self.title_check.clone().into()),
905 ("RK_MERGE_FORM".into(), self.merge_form.as_str().into()),
908 (
913 "RK_TAG_PROTECTION".into(),
914 bool_word(self.tag_protection).into(),
915 ),
916 ("RK_FORGE_GATE".into(), bool_word(self.forge_gate).into()),
917 (
921 "RK_TAG_PATTERN".into(),
922 self.protection.tag_pattern.clone().into(),
923 ),
924 (
925 "RK_REVIEW_COUNT".into(),
926 self.protection
927 .required_approving_review_count
928 .to_string()
929 .into(),
930 ),
931 (
932 "RK_DISMISS_STALE_REVIEWS".into(),
933 bool_word(self.protection.dismiss_stale_reviews_on_push).into(),
934 ),
935 (
936 "RK_CODE_OWNER_REVIEW".into(),
937 bool_word(self.protection.require_code_owner_review).into(),
938 ),
939 (
940 "RK_LAST_PUSH_APPROVAL".into(),
941 bool_word(self.protection.require_last_push_approval).into(),
942 ),
943 (
944 "RK_MERGE_METHODS".into(),
945 json_list(&self.protection.allowed_merge_methods).into(),
946 ),
947 (
948 "RK_STRICT_CHECKS".into(),
949 bool_word(self.protection.strict_required_status_checks).into(),
950 ),
951 (
952 "RK_BYPASS_ACTORS".into(),
953 github_bypass_actors(&self.protection.bypass_actors)
954 .to_string()
955 .into(),
956 ),
957 (
958 "RK_SQUASH_TITLE_SOURCE".into(),
959 self.protection.github.squash_title_source.clone().into(),
960 ),
961 (
962 "RK_SQUASH_BODY_SOURCE".into(),
963 self.protection.github.squash_body_source.clone().into(),
964 ),
965 (
966 "RK_GITLAB_MERGE_METHOD".into(),
967 self.protection.gitlab.merge_method.clone().into(),
968 ),
969 (
970 "RK_GITLAB_SQUASH_OPTION".into(),
971 self.protection.gitlab.squash_option.clone().into(),
972 ),
973 (
974 "RK_GITLAB_SQUASH_TEMPLATE".into(),
975 self.protection.gitlab.squash_commit_template.clone().into(),
976 ),
977 (
978 "RK_GITLAB_PUSH_LEVEL".into(),
979 self.protection.gitlab.push_access_level.to_string().into(),
980 ),
981 ("RK_TRUNK_RULES".into(), self.trunk_rules().into()),
989 ("RK_SAFETY_RULES".into(), self.safety_rules().into()),
990 (
991 "RK_SAFETY_RULE_TYPES".into(),
992 self.safety_rule_types().into(),
993 ),
994 (
995 "RK_GITLAB_MERGE_LEVEL".into(),
996 self.protection.gitlab.merge_access_level.to_string().into(),
997 ),
998 ("GH_PAGER".into(), "".into()),
999 ("GLAB_PAGER".into(), "".into()),
1000 ];
1001 if let Some(check) = &self.required_check
1002 && self.forge == Some(Forge::Github)
1003 && self.forge_gate
1004 && matches!(step, "protect-trunk" | "protections-check")
1005 {
1006 env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
1007 }
1008 for name in PASSTHROUGH {
1009 if let Some(value) = std::env::var_os(name) {
1010 env.push((name.into(), value));
1011 }
1012 }
1013 if let Some(dir) = self.cli_override_dir() {
1017 let mut paths: Vec<PathBuf> = vec![dir];
1018 if let Some(existing) = std::env::var_os("PATH") {
1019 paths.extend(std::env::split_paths(&existing));
1020 }
1021 if let Ok(joined) = std::env::join_paths(paths) {
1022 env.retain(|(name, _)| name != "PATH");
1023 env.push(("PATH".into(), joined));
1024 }
1025 }
1026 if step == "bot-secrets" {
1027 for name in SECRET_VARS {
1028 if let Some(value) = secrets::value_of(name) {
1029 env.push((name.into(), value));
1030 }
1031 }
1032 }
1033 env
1034 }
1035
1036 fn cli_override_dir(&self) -> Option<PathBuf> {
1038 let overridden = std::env::var_os(match self.forge? {
1039 Forge::Github => "RK_GH_BIN",
1040 Forge::Gitlab => "RK_GLAB_BIN",
1041 })?;
1042 Path::new(&overridden).parent().map(Path::to_path_buf)
1043 }
1044
1045 #[must_use]
1053 pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
1054 SECRET_VARS
1055 .iter()
1056 .filter_map(|name| secrets::value_of(name))
1057 .map(|value| Zeroizing::new(value.into_encoded_bytes()))
1058 .collect()
1059 }
1060}
1061
1062pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
1072 let override_var = match forge {
1073 Forge::Github => "RK_GH_BIN",
1074 Forge::Gitlab => "RK_GLAB_BIN",
1075 };
1076 if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
1077 let path = PathBuf::from(&overridden);
1078 if !path.is_file() {
1079 return Err(RkError::refusal(
1080 Diagnostic::new(
1081 Reason::PrerequisiteUnmet,
1082 format!(
1083 "{override_var} names {}, which does not exist",
1084 path.display()
1085 ),
1086 )
1087 .expected("the override to name the forge CLI binary"),
1088 ));
1089 }
1090 if path.file_name().is_none_or(|name| name != forge.cli()) {
1095 return Err(RkError::refusal(
1096 Diagnostic::new(
1097 Reason::PrerequisiteUnmet,
1098 format!(
1099 "{override_var} must name a binary called {}, and {} is not one",
1100 forge.cli(),
1101 path.display()
1102 ),
1103 )
1104 .expected(format!(
1105 "an override whose file name is {}, so scripts and observations run one binary",
1106 forge.cli()
1107 )),
1108 ));
1109 }
1110 return Ok(path);
1111 }
1112 let name = forge.cli();
1113 let found = std::env::var_os("PATH").and_then(|path| {
1114 std::env::split_paths(&path)
1115 .map(|dir| dir.join(name))
1116 .find(|candidate| candidate.is_file())
1117 });
1118 found.ok_or_else(|| {
1119 RkError::refusal(
1120 Diagnostic::new(
1121 Reason::PrerequisiteUnmet,
1122 format!(
1123 "{name} is not on PATH, and a step this run acts on calls it on {}",
1124 forge.as_str()
1125 ),
1126 )
1127 .expected(format!("the {name} CLI installed and authenticated"))
1128 .action(format!("install {name}, then run {name} auth login")),
1129 )
1130 })
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135 #[test]
1142 fn each_ruleset_composes_its_half_of_the_owned_rule_key() {
1143 let kinds = |text: &str| -> Vec<String> {
1144 let parsed: Vec<serde_json::Value> =
1145 serde_json::from_str(text).expect("the rules parse");
1146 parsed
1147 .iter()
1148 .filter_map(|rule| rule["type"].as_str())
1149 .map(str::to_owned)
1150 .collect()
1151 };
1152 let mut policy = crate::config::Protection::default();
1153 let request =
1154 super::compose_rules(&policy, &crate::config::REQUEST_RULES, "gate", "pr-title");
1155 assert_eq!(kinds(&request), ["pull_request", "required_status_checks"]);
1156 let safety =
1157 super::compose_rules(&policy, &crate::config::SAFETY_RULES, "gate", "pr-title");
1158 assert_eq!(kinds(&safety), ["deletion", "non_fast_forward"]);
1159
1160 let parsed: Vec<serde_json::Value> =
1161 serde_json::from_str(&request).expect("the rules parse");
1162 let checks = parsed
1163 .iter()
1164 .find(|rule| rule["type"] == "required_status_checks")
1165 .expect("the check rule");
1166 assert_eq!(
1167 checks["parameters"]["required_status_checks"],
1168 serde_json::json!([{ "context": "gate" }, { "context": "pr-title" }])
1169 );
1170
1171 policy.bypass_actors = vec![crate::config::LOCAL_GITHUB_BYPASS.into()];
1174 assert_eq!(
1175 kinds(&super::compose_rules(
1176 &policy,
1177 &crate::config::REQUEST_RULES,
1178 "gate",
1179 "pr-title"
1180 )),
1181 ["pull_request", "required_status_checks"]
1182 );
1183 assert_eq!(
1184 kinds(&super::compose_rules(
1185 &policy,
1186 &crate::config::SAFETY_RULES,
1187 "gate",
1188 "pr-title"
1189 )),
1190 ["deletion", "non_fast_forward"]
1191 );
1192 }
1193
1194 #[test]
1207 fn the_effective_policy_follows_the_recorded_authority() {
1208 use crate::landing::{Integration, MergeForm};
1209 let silent = super::effective_protection(
1210 None,
1211 Integration::Forge,
1212 MergeForm::Squash,
1213 crate::profile::ReleaseMode::Automatic,
1214 )
1215 .policy;
1216 assert!(
1217 silent
1218 .owned_trunk_rules
1219 .contains(&"pull_request".to_owned())
1220 );
1221 assert_eq!(silent.gitlab.push_access_level, 0);
1222
1223 let silent = super::effective_protection(
1224 None,
1225 Integration::Local,
1226 MergeForm::Squash,
1227 crate::profile::ReleaseMode::Automatic,
1228 )
1229 .policy;
1230 assert_eq!(
1231 silent.owned_trunk_rules,
1232 crate::config::Protection::default().owned_trunk_rules,
1233 "local integration retains the release request's atomic check"
1234 );
1235 assert_eq!(
1236 silent.bypass_actors,
1237 [crate::config::LOCAL_GITHUB_BYPASS.to_owned()]
1238 );
1239 assert_eq!(
1240 silent.gitlab.push_access_level, 40,
1241 "zero would close the trunk to the push this mode ends in"
1242 );
1243
1244 let mut stated = crate::config::Protection::default();
1245 stated.gitlab.push_access_level = 0;
1246 stated.owned_trunk_rules = vec!["deletion".into()];
1247 let held = super::effective_protection(
1248 Some(&stated),
1249 Integration::Local,
1250 MergeForm::Squash,
1251 crate::profile::ReleaseMode::Automatic,
1252 )
1253 .policy;
1254 assert_eq!(held.gitlab.push_access_level, 0, "a stated value wins");
1255 assert_eq!(held.owned_trunk_rules, ["deletion".to_owned()]);
1256
1257 let legacy = crate::config::Protection {
1258 owned_trunk_rules: vec!["deletion".into(), "non_fast_forward".into()],
1259 gitlab: crate::config::Gitlab {
1260 push_access_level: 40,
1261 ..crate::config::Gitlab::default()
1262 },
1263 ..crate::config::Protection::default()
1264 };
1265 let migrated = super::effective_protection(
1266 Some(&legacy),
1267 Integration::Local,
1268 MergeForm::Squash,
1269 crate::profile::ReleaseMode::Automatic,
1270 )
1271 .policy;
1272 assert_eq!(
1273 migrated,
1274 crate::config::local_protection(),
1275 "setup cannot reinstall the legacy policy while upgrade remains able to read it"
1276 );
1277 }
1278
1279 #[test]
1285 fn merge_form_decides_the_effective_merge_values() {
1286 use crate::landing::{Integration, MergeForm};
1287 let kinds = |text: &str| -> Vec<String> {
1288 let parsed: Vec<serde_json::Value> =
1289 serde_json::from_str(text).expect("the rules parse");
1290 parsed
1291 .iter()
1292 .filter_map(|rule| rule["type"].as_str())
1293 .map(str::to_owned)
1294 .collect()
1295 };
1296 let stated = crate::config::Protection::default();
1297 for integration in [Integration::Forge, Integration::Local] {
1298 for held in [None, Some(&stated)] {
1299 let squash = super::effective_protection(
1300 held,
1301 integration,
1302 MergeForm::Squash,
1303 crate::profile::ReleaseMode::Automatic,
1304 )
1305 .policy;
1306 assert_eq!(squash.allowed_merge_methods, ["squash".to_owned()]);
1307 assert_eq!(squash.gitlab.squash_option, "always");
1308 assert!(
1309 !squash
1310 .owned_trunk_rules
1311 .contains(&crate::config::LINEAR_HISTORY_RULE.to_owned())
1312 );
1313 assert_eq!(
1314 kinds(&super::compose_rules(
1315 &squash,
1316 &crate::config::SAFETY_RULES,
1317 "gate",
1318 "pr-title"
1319 )),
1320 ["deletion", "non_fast_forward"]
1321 );
1322
1323 let rebase = super::effective_protection(
1324 held,
1325 integration,
1326 MergeForm::Rebase,
1327 crate::profile::ReleaseMode::Automatic,
1328 )
1329 .policy;
1330 assert_eq!(rebase.allowed_merge_methods, ["rebase".to_owned()]);
1331 assert_eq!(rebase.gitlab.squash_option, "never");
1332 assert_eq!(rebase.gitlab.merge_method, "ff");
1333 assert_eq!(
1334 kinds(&super::compose_rules(
1335 &rebase,
1336 &crate::config::SAFETY_RULES,
1337 "gate",
1338 "pr-commits"
1339 )),
1340 ["deletion", "non_fast_forward", "required_linear_history"]
1341 );
1342 assert_eq!(
1343 kinds(&super::compose_rules(
1344 &rebase,
1345 &crate::config::REQUEST_RULES,
1346 "gate",
1347 "pr-commits"
1348 )),
1349 ["pull_request", "required_status_checks"],
1350 "the linear-history rule never sits where a bypass excuses it"
1351 );
1352 }
1353 }
1354 }
1355
1356 #[test]
1358 fn merge_form_names_the_required_gate() {
1359 use crate::landing::MergeForm;
1360 let policy = crate::config::Protection::default();
1361 assert_eq!(super::gate_check(&policy, MergeForm::Squash), "pr-title");
1362 assert_eq!(super::gate_check(&policy, MergeForm::Rebase), "pr-commits");
1363 }
1364 #[test]
1371 fn release_less_effective_protection_follows_the_profile() {
1372 use crate::landing::{Integration, MergeForm};
1373 use crate::profile::ReleaseMode;
1374 for integration in [Integration::Forge, Integration::Local] {
1375 for form in [MergeForm::Squash, MergeForm::Rebase] {
1376 for mode in [
1377 ReleaseMode::Automatic,
1378 ReleaseMode::External,
1379 ReleaseMode::None,
1380 ] {
1381 let effective = super::effective_protection(None, integration, form, mode);
1382 let at = format!("{integration:?} {form:?} {mode:?}");
1383 let gate = integration == Integration::Forge || mode == ReleaseMode::Automatic;
1384 assert_eq!(effective.tag_protection, mode != ReleaseMode::None, "{at}");
1385 assert_eq!(effective.forge_gate, gate, "{at}");
1386 let rules = &effective.policy.owned_trunk_rules;
1387 assert_eq!(
1388 rules.iter().any(|rule| rule == "required_status_checks"),
1389 gate,
1390 "{at}: {rules:?}"
1391 );
1392 for kept in ["deletion", "non_fast_forward", "pull_request"] {
1393 assert!(rules.iter().any(|rule| rule == kept), "{at}: {kept}");
1394 }
1395 assert_eq!(
1396 effective.policy.allowed_merge_methods,
1397 [form.as_str().to_owned()],
1398 "{at}"
1399 );
1400 }
1401 }
1402 }
1403 }
1404}