release_kit/setup/context.rs
1//! The resolved context one setup run works in: target, repository, forge,
2//! the forge CLI binary, and the environment a step receives.
3//!
4//! The environment is constructed, not inherited: `env_clear` plus exactly
5//! the declared variables, the forge CLI's own configuration and
6//! authentication variables, and — only for the steps that need them — the
7//! bot credentials. The parent's environment does not leak into a
8//! privileged child, no secret is ever an argv value, and key material
9//! reaches no environment at all: `rk` reads the key the operator named and
10//! writes it to the step's standard input. [`super::secrets`] owns that
11//! boundary.
12
13use 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
25// The trunk every setup asserts is the one permanent branch the target
26// states in its own committed configuration, read through `Ctx::trunk`.
27// A target that names none keeps the compiled default, so a landing
28// predating the key behaves exactly as it did.
29
30/// A policy boolean as the JSON word a forge body carries.
31const fn bool_word(value: bool) -> &'static str {
32 if value { "true" } else { "false" }
33}
34
35/// A policy list as the JSON array a forge body carries. Every element
36/// passed the floor table, so this quotes rather than escapes.
37fn json_list(values: &[String]) -> String {
38 let inner: Vec<String> = values.iter().map(|value| format!("\"{value}\"")).collect();
39 format!("[{}]", inner.join(", "))
40}
41
42/// The GitHub trunk ruleset's `rules` array, as JSON.
43///
44/// Every rule comes from `protection.owned_trunk_rules`, which the floor
45/// table judges per integration mode and the observer checks against, so
46/// one key decides what a run installs and what the check expects. A rule
47/// this convention parameterizes carries its parameters; the rest are
48/// bare type entries.
49fn 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 // Pretty rather than compact: the body a run sends is what an
86 // operator reads back off the forge when a protection is in doubt.
87 serde_json::to_string_pretty(&rules).unwrap_or_else(|_| "[]".to_owned())
88}
89
90/// One target's effective protection policy: what it stated, resolved
91/// against the authority that carries an implementation onto its trunk.
92///
93/// A target that stated a policy gets its own values: the floor table
94/// already judged them under the same mode, and an operator who narrowed
95/// or widened something meant it. A target that stated nothing gets the
96/// compiled defaults, which describe forge integration because that is
97/// the shape this convention had before the axis existed — so under
98/// local integration they are adjusted rather than installed.
99///
100/// Two adjustments, and both only where the target stated nothing. The
101/// owned rules drop the request rule and the required-check rule, which
102/// no forge can apply to a push. The GitLab push level moves off zero to
103/// the narrowest level that still admits a push, because zero closes the
104/// trunk to the very act that mode's integrations end in.
105///
106/// One resolution serves the body a step sends, the observer that reads
107/// the answer back, and the prerequisites, so a canonical apply cannot
108/// install one shape and then fault its own result for not being another.
109fn 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 /// The narrowest GitLab access level that still admits a push.
119 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
128/// The variables that pass through from the operator's environment to a
129/// step: the interpreter's search path, the forge CLI's configuration and
130/// authentication, and nothing else.
131const 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
145/// The value-bearing bot variables, forwarded only to the steps that
146/// consume them and recorded in the journal as handling, never as value.
147/// The key is in no list here: it reaches its step as bytes on standard
148/// input, and neither it nor its path is ever put in an environment.
149/// [`secrets`] owns that.
150pub use super::secrets::VALUE_VARS as SECRET_VARS;
151
152/// One resolved run context.
153#[derive(Debug, Clone)]
154pub struct Ctx {
155 /// The repository being set up.
156 pub target: Utf8PathBuf,
157 /// The project path on the forge, empty where the profile names none.
158 pub repo: String,
159 /// The forge adapter the run acts through, where the profile names a
160 /// forge this release drives. A target with no forge, or one this
161 /// release has no adapter for, carries none: its local steps still
162 /// run and its forge steps report as not applicable.
163 pub forge: Option<Forge>,
164 /// The forge the profile declares, preserved whatever the adapter
165 /// says, so an unknown name stays readable.
166 pub declared_forge: Option<String>,
167 /// What the project is, as the target configuration resolves it.
168 pub profile: ProfileSnapshot,
169 /// Which optional products the target requests.
170 pub capabilities: CapabilityRequests,
171 /// The remote host, where one was detected.
172 pub host: Option<String>,
173 /// The value of `--required-check`, where given.
174 pub required_check: Option<String>,
175 /// The committed `setup.required_workflow`, on GitHub alone. No flag
176 /// answers it: the setup never writes it, it reads it to prove that
177 /// the workflow the release gate waits on is the workflow that carries
178 /// the check the gate judges.
179 pub required_workflow: Option<String>,
180 /// The resolved forge CLI binary.
181 pub cli: PathBuf,
182 /// The detected technology, where the version file names one.
183 pub tech: Option<&'static str>,
184 /// The one permanent branch this target states, or the compiled
185 /// default where it states none.
186 trunk: String,
187 /// The release-line prefix this target states, or the compiled
188 /// default where it states none.
189 line_prefix: String,
190 /// The long-lived branches a single trunk retires, as this target
191 /// names them.
192 retired_branches: Vec<String>,
193 /// Whether a full apply runs the release-line protection, which a
194 /// project that keeps no line does not want run at all.
195 release_lines: bool,
196 /// The steps this target declared it does not run, each against its
197 /// stated reason. A run reports them and judges none of them.
198 excluded_steps: std::collections::BTreeMap<String, String>,
199 /// The bot App's public identifier where this target states one; the
200 /// environment still wins over it, and no private credential is here.
201 bot_app_id: Option<String>,
202 /// The ruleset that protects the trunk, as this target names it.
203 trunk_ruleset: String,
204 /// The ruleset that makes published tags immutable.
205 tag_ruleset: String,
206 /// The ruleset that protects the release lines.
207 lines_ruleset: String,
208 /// The context the landed title job reports under.
209 title_check: String,
210 /// The effective policy this target runs under: what the target
211 /// stated, resolved against the recorded integration mode. Every
212 /// stated value passed the floor table at load, so a run passes this
213 /// to a step without judging it again, and one resolution serves the
214 /// body a step sends, the observer that reads the answer back, and
215 /// every prerequisite — so a run cannot install one shape and then
216 /// fault its own result for not being another.
217 protection: crate::config::Protection,
218 /// Which authority carries an implementation onto this target's trunk.
219 /// A local-integration trunk takes the direct push that mode's
220 /// integrations end in, so the protection a run installs is not the
221 /// forge-integration one.
222 integration: crate::landing::Integration,
223}
224
225impl Ctx {
226 /// Resolve detection, overrides, and the forge CLI in one pass, before
227 /// any step runs.
228 ///
229 /// # Errors
230 ///
231 /// Refuses when the target is missing, when no remote resolves and no
232 /// override covers the gap, when the host is unrecognized, and when the
233 /// forge CLI is not on `PATH`.
234 pub fn resolve(
235 target: &Utf8PathBuf,
236 repo_flag: Option<&str>,
237 forge_flag: Option<&str>,
238 required_check: Option<&str>,
239 ) -> Result<Self, RkError> {
240 if !target.is_dir() {
241 return Err(RkError::missing(
242 Diagnostic::new(
243 Reason::TargetNotFound,
244 format!("target {target} is not a directory; nothing was run"),
245 )
246 .expected("an existing repository to set up"),
247 ));
248 }
249 let forge_flag = forge_flag
250 .map(|name| {
251 detect::Forge::parse(name).ok_or_else(|| {
252 RkError::Usage(format!(
253 "unknown forge '{name}'; the forges are: github, gitlab"
254 ))
255 })
256 })
257 .transpose()?;
258 let detected = detect::detect(target.as_std_path());
259 let config = crate::config::load(target.as_std_path())?;
260 let record = crate::landing::manifest::load(target)?;
261 // The setup reads the same target configuration a landing records,
262 // so which steps apply follows the profile rather than a second
263 // detection of its own.
264 let resolved = crate::profile::Params::resolve(
265 target,
266 &crate::profile::Inputs {
267 forge: forge_flag.map(Forge::as_str),
268 repo: repo_flag,
269 ..crate::profile::Inputs::default()
270 },
271 config.as_ref(),
272 record.as_ref(),
273 crate::profile::Purpose::Preview,
274 )?;
275 let declared_forge = resolved.forge().map(str::to_owned);
276 let forge = declared_forge.as_deref().and_then(Forge::parse);
277 let repo = resolved.repo().to_owned();
278 let repo = if repo == crate::projection::REPO_PLACEHOLDER {
279 String::new()
280 } else {
281 repo
282 };
283 // The forge CLI is not a prerequisite of building a context: it is
284 // a prerequisite of the steps a run actually calls the forge for,
285 // which `require_cli` resolves at that point.
286 let cli = PathBuf::new();
287 let answers = config
288 .as_ref()
289 .map_or_else(crate::config::Setup::default, |held| held.setup.clone());
290 // The flag wins, and the committed answer fills the gap on GitHub
291 // alone: GitLab names no individual check and refuses a supplied
292 // one, so a shared configuration must not make that refusal fire.
293 let required_check = required_check.map(str::to_owned).or_else(|| {
294 Some(answers.required_check.clone())
295 .filter(|name| !name.is_empty() && forge == Some(Forge::Github))
296 });
297 let required_workflow = Some(answers.required_workflow.clone())
298 .filter(|name| !name.is_empty() && forge == Some(Forge::Github));
299 let bot_app_id = Some(answers.bot.app_id.clone()).filter(|id| !id.is_empty());
300 let trunk = resolved.trunk().to_owned();
301 // The record, not the resolution's compiled default: a setup
302 // configures a forge to match what landed, and a target with no
303 // record has landed nothing here. Forge is what such a target
304 // carries, the same compatibility answer `rk integrate` reads.
305 let integration = record
306 .as_ref()
307 .map_or_else(crate::landing::manifest::integration_forge, |held| {
308 held.git.integration
309 });
310 let stated = config.as_ref().map(|held| &held.protection);
311 let protection = effective_protection(stated, integration);
312 Ok(Self {
313 target: target.clone(),
314 repo,
315 forge,
316 host: detected.host,
317 required_check,
318 required_workflow,
319 cli,
320 tech: resolved.driver().and_then(|driver| {
321 ["rust", "python", "bash"]
322 .into_iter()
323 .find(|known| *known == driver)
324 }),
325 trunk_ruleset: protection.trunk_ruleset(&trunk),
326 tag_ruleset: protection.tag_ruleset.clone(),
327 lines_ruleset: protection.lines_ruleset.clone(),
328 title_check: protection.title_check.clone(),
329 protection,
330 integration,
331 trunk,
332 line_prefix: resolved.line_prefix().to_owned(),
333 profile: resolved.profile().clone(),
334 capabilities: resolved.capabilities().clone(),
335 declared_forge,
336 retired_branches: answers.retired_branches,
337 release_lines: answers.release_lines,
338 excluded_steps: answers.excluded_steps,
339 bot_app_id,
340 })
341 }
342
343 /// Resolve the forge CLI where this run will call the forge for one of
344 /// `steps`, and refuse where it cannot be found.
345 ///
346 /// The prerequisite belongs to the call, not to the command. A step is
347 /// only a caller when the target's configuration selects it, the
348 /// target has not excluded it, and the step reaches the forge at this
349 /// particular forge. A preview writes nothing, and asks anyway for the
350 /// steps it would act on, because it names the command it would run
351 /// and a CLI nothing could find is worth saying then.
352 ///
353 /// # Errors
354 /// Propagates the refusal when the forge CLI cannot be resolved.
355 ///
356 /// SATISFIES forge-setup:applicability-follows-the-target-configuration
357 pub fn require_cli(&mut self, steps: &[&crate::setup::steps::StepSpec]) -> Result<(), RkError> {
358 let Some(forge) = self.forge else {
359 return Ok(());
360 };
361 let calls = steps.iter().any(|step| {
362 step.forge_cli.contains(&forge) && crate::commands::setup::stance(self, step).acts()
363 });
364 if calls && self.cli.as_os_str().is_empty() {
365 self.cli = resolve_cli(forge)?;
366 }
367 Ok(())
368 }
369
370 /// A context the integration tests build directly, for an observer
371 /// exercised against recorded forge answers rather than a repository.
372 /// The trunk and the prefix take their compiled defaults, because such
373 /// a test reads no target configuration.
374 #[doc(hidden)]
375 #[must_use]
376 pub fn for_tests(
377 target: Utf8PathBuf,
378 repo: String,
379 forge: Forge,
380 cli: PathBuf,
381 tech: Option<&'static str>,
382 ) -> Self {
383 let defaults = crate::config::Protection::default();
384 Self {
385 // The forge-integration shape, which is what every observer
386 // and request-body test here asserts; a local-mode case states it.
387 integration: crate::landing::Integration::Forge,
388 target,
389 repo,
390 forge: Some(forge),
391 declared_forge: Some(forge.as_str().to_owned()),
392 profile: ProfileSnapshot {
393 technologies: tech.into_iter().map(str::to_owned).collect(),
394 forge: Some(forge.as_str().to_owned()),
395 release: crate::profile::ReleaseIntent {
396 mode: ReleaseMode::Automatic,
397 driver: tech.map(str::to_owned),
398 style: Some(crate::landing::Style::Trunk),
399 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
400 },
401 },
402 capabilities: CapabilityRequests {
403 nix_packaging: false,
404 reporting_policy: true,
405 scorecard: false,
406 code_scanning: None,
407 },
408 host: None,
409 required_check: None,
410 required_workflow: None,
411 cli,
412 tech,
413 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
414 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
415 retired_branches: crate::config::Setup::default().retired_branches,
416 release_lines: false,
417 excluded_steps: std::collections::BTreeMap::new(),
418 bot_app_id: None,
419 trunk_ruleset: format!("{}-protection", crate::config::TRUNK_DEFAULT),
420 tag_ruleset: defaults.tag_ruleset.clone(),
421 lines_ruleset: defaults.lines_ruleset.clone(),
422 title_check: defaults.title_check.clone(),
423 protection: defaults,
424 }
425 }
426
427 /// Whether the run has a forge adapter to act through.
428 #[must_use]
429 pub const fn has_adapter(&self) -> bool {
430 self.forge.is_some()
431 }
432
433 /// The forge adapter, or the refusal a forge operation answers where
434 /// the profile names no forge this release drives.
435 ///
436 /// # Errors
437 ///
438 /// A `prerequisite-unmet` refusal naming the declared forge and the
439 /// ones this release drives.
440 pub fn adapter(&self) -> Result<Forge, RkError> {
441 self.forge.ok_or_else(|| {
442 let named = self.declared_forge.as_deref();
443 let message = named.map_or_else(
444 || "the profile names no forge, and this operation acts on one".to_owned(),
445 |name| {
446 format!(
447 "the profile names the forge {name}, which this release has no adapter for"
448 )
449 },
450 );
451 RkError::refusal(
452 Diagnostic::new(Reason::PrerequisiteUnmet, message)
453 .expected("a profile naming github or gitlab")
454 .action("set profile.forge in .release-kit/config.toml, or pass --forge <github|gitlab>")
455 .target_state("unchanged"),
456 )
457 })
458 }
459
460 /// Whether this target's release is one release-kit drives.
461 #[must_use]
462 pub const fn automatic_release(&self) -> bool {
463 matches!(self.profile.release.mode, ReleaseMode::Automatic)
464 }
465
466 /// The release driver, where the profile names one.
467 #[must_use]
468 pub fn driver(&self) -> Option<&str> {
469 self.profile.release.driver.as_deref()
470 }
471
472 /// The forge the profile declares, whatever the adapter says.
473 #[must_use]
474 pub fn declared_forge(&self) -> Option<&str> {
475 self.declared_forge.as_deref()
476 }
477
478 /// Whether the target requested the landed reporting policy.
479 #[must_use]
480 pub const fn reporting_policy(&self) -> bool {
481 self.capabilities.reporting_policy
482 }
483
484 /// The one permanent branch this run asserts.
485 #[must_use]
486 pub fn trunk(&self) -> &str {
487 &self.trunk
488 }
489
490 /// The release-line prefix this run asserts.
491 #[must_use]
492 pub fn line_prefix(&self) -> &str {
493 &self.line_prefix
494 }
495
496 /// The long-lived branches this run's single-trunk step retires.
497 #[must_use]
498 pub fn retired_branches(&self) -> &[String] {
499 &self.retired_branches
500 }
501
502 /// Whether a full apply runs the release-line protection.
503 #[must_use]
504 pub const fn release_lines(&self) -> bool {
505 self.release_lines
506 }
507
508 /// Why this target does not run the named step, where it declared an
509 /// exclusion for it. The reason is what the report prints, so an
510 /// excluded step is always visible with the answer behind it.
511 #[must_use]
512 pub fn excluded(&self, step: &str) -> Option<&str> {
513 self.excluded_steps.get(step).map(String::as_str)
514 }
515
516 /// How many steps this target declared it does not run.
517 #[must_use]
518 pub fn excluded_count(&self) -> usize {
519 self.excluded_steps.len()
520 }
521
522 /// The bot App's public identifier this target states, where it does.
523 #[must_use]
524 pub fn bot_app_id(&self) -> Option<&str> {
525 self.bot_app_id.as_deref()
526 }
527
528 /// The ruleset that protects the trunk.
529 #[must_use]
530 pub fn trunk_ruleset(&self) -> &str {
531 &self.trunk_ruleset
532 }
533
534 /// The ruleset that makes published tags immutable.
535 #[must_use]
536 pub fn tag_ruleset(&self) -> &str {
537 &self.tag_ruleset
538 }
539
540 /// The ruleset that protects the release lines.
541 #[must_use]
542 pub fn lines_ruleset(&self) -> &str {
543 &self.lines_ruleset
544 }
545
546 /// The context the landed title job reports under.
547 #[must_use]
548 pub fn title_check(&self) -> &str {
549 &self.title_check
550 }
551
552 /// Which authority carries an implementation onto this trunk.
553 #[must_use]
554 pub const fn integration(&self) -> crate::landing::Integration {
555 self.integration
556 }
557
558 /// The trunk ruleset's rules, for the body a run sends the forge.
559 fn trunk_rules(&self) -> String {
560 compose_trunk_rules(
561 &self.protection,
562 self.required_check.as_deref().unwrap_or_default(),
563 &self.title_check,
564 )
565 }
566
567 /// The floored policy this target states, already judged at load.
568 #[must_use]
569 pub const fn protection(&self) -> &crate::config::Protection {
570 &self.protection
571 }
572
573 /// Whether this run targets a GitLab instance that is not gitlab.com,
574 /// where registry trusted publishing cannot reach.
575 #[must_use]
576 pub fn self_hosted_gitlab(&self) -> bool {
577 self.forge == Some(Forge::Gitlab)
578 && self
579 .host
580 .as_deref()
581 .is_some_and(|host| host != "gitlab.com")
582 }
583
584 /// The constructed environment a step receives. Secrets enter only for
585 /// the step that consumes them; the caller records their handling.
586 #[must_use]
587 #[allow(
588 clippy::too_many_lines,
589 reason = "one pass builds the whole environment a step receives, and splitting it would separate a variable from the value it carries"
590 )]
591 pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
592 let mut env: Vec<(OsString, OsString)> = vec![
593 (
594 "RK_FORGE".into(),
595 self.forge.map_or("", Forge::as_str).into(),
596 ),
597 ("RK_REPO".into(), self.repo.clone().into()),
598 ("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
599 ("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
600 ("RK_TRUNK_RULESET".into(), self.trunk_ruleset.clone().into()),
601 ("RK_TAG_RULESET".into(), self.tag_ruleset.clone().into()),
602 ("RK_LINES_RULESET".into(), self.lines_ruleset.clone().into()),
603 ("RK_TITLE_CHECK".into(), self.title_check.clone().into()),
604 // The floored policy, already judged against the floor table
605 // at load. A step receives values, never a judgment: one
606 // owner decides what passes, and it is not a shell script.
607 (
608 "RK_TAG_PATTERN".into(),
609 self.protection.tag_pattern.clone().into(),
610 ),
611 (
612 "RK_REVIEW_COUNT".into(),
613 self.protection
614 .required_approving_review_count
615 .to_string()
616 .into(),
617 ),
618 (
619 "RK_DISMISS_STALE_REVIEWS".into(),
620 bool_word(self.protection.dismiss_stale_reviews_on_push).into(),
621 ),
622 (
623 "RK_CODE_OWNER_REVIEW".into(),
624 bool_word(self.protection.require_code_owner_review).into(),
625 ),
626 (
627 "RK_LAST_PUSH_APPROVAL".into(),
628 bool_word(self.protection.require_last_push_approval).into(),
629 ),
630 (
631 "RK_MERGE_METHODS".into(),
632 json_list(&self.protection.allowed_merge_methods).into(),
633 ),
634 (
635 "RK_STRICT_CHECKS".into(),
636 bool_word(self.protection.strict_required_status_checks).into(),
637 ),
638 (
639 "RK_SQUASH_TITLE_SOURCE".into(),
640 self.protection.github.squash_title_source.clone().into(),
641 ),
642 (
643 "RK_SQUASH_BODY_SOURCE".into(),
644 self.protection.github.squash_body_source.clone().into(),
645 ),
646 (
647 "RK_GITLAB_MERGE_METHOD".into(),
648 self.protection.gitlab.merge_method.clone().into(),
649 ),
650 (
651 "RK_GITLAB_SQUASH_OPTION".into(),
652 self.protection.gitlab.squash_option.clone().into(),
653 ),
654 (
655 "RK_GITLAB_SQUASH_TEMPLATE".into(),
656 self.protection.gitlab.squash_commit_template.clone().into(),
657 ),
658 (
659 "RK_GITLAB_PUSH_LEVEL".into(),
660 self.protection.gitlab.push_access_level.to_string().into(),
661 ),
662 // The trunk ruleset's rules, built from the one key the floor
663 // table judges and the observer reads, so the body a run
664 // sends cannot install a rule the check does not expect, or
665 // omit one it does. A local-integration target's key names the two rules
666 // that still hold against a direct push, and the request and
667 // required-check rules are simply absent.
668 ("RK_TRUNK_RULES".into(), self.trunk_rules().into()),
669 (
670 "RK_GITLAB_MERGE_LEVEL".into(),
671 self.protection.gitlab.merge_access_level.to_string().into(),
672 ),
673 ("GH_PAGER".into(), "".into()),
674 ("GLAB_PAGER".into(), "".into()),
675 ];
676 if let Some(check) = &self.required_check
677 && self.forge == Some(Forge::Github)
678 && matches!(step, "protect-trunk" | "protections-check")
679 {
680 env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
681 }
682 for name in PASSTHROUGH {
683 if let Some(value) = std::env::var_os(name) {
684 env.push((name.into(), value));
685 }
686 }
687 // The forge CLI override substitutes the binary for the run's own
688 // calls; a step resolves the CLI by name, so the override's
689 // directory leads the child's search path.
690 if let Some(dir) = self.cli_override_dir() {
691 let mut paths: Vec<PathBuf> = vec![dir];
692 if let Some(existing) = std::env::var_os("PATH") {
693 paths.extend(std::env::split_paths(&existing));
694 }
695 if let Ok(joined) = std::env::join_paths(paths) {
696 env.retain(|(name, _)| name != "PATH");
697 env.push(("PATH".into(), joined));
698 }
699 }
700 if step == "bot-secrets" {
701 for name in SECRET_VARS {
702 if let Some(value) = secrets::value_of(name) {
703 env.push((name.into(), value));
704 }
705 }
706 }
707 env
708 }
709
710 /// The directory of an explicitly overridden forge CLI, where one is set.
711 fn cli_override_dir(&self) -> Option<PathBuf> {
712 let overridden = std::env::var_os(match self.forge? {
713 Forge::Github => "RK_GH_BIN",
714 Forge::Gitlab => "RK_GLAB_BIN",
715 })?;
716 Path::new(&overridden).parent().map(Path::to_path_buf)
717 }
718
719 /// The secret bytes a run must keep out of its own output: the values
720 /// the environment carries. Every buffer is scrubbed on drop; none is
721 /// ever logged or echoed.
722 ///
723 /// Key material is not read here. The step that transmits a key adds
724 /// the very bytes it sends, so the needle cannot describe one file
725 /// while the child receives another.
726 #[must_use]
727 pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
728 SECRET_VARS
729 .iter()
730 .filter_map(|name| secrets::value_of(name))
731 .map(|value| Zeroizing::new(value.into_encoded_bytes()))
732 .collect()
733 }
734}
735
736/// Resolve the forge CLI once, at context time: the `RK_GH_BIN` and
737/// `RK_GLAB_BIN` overrides first, then a `PATH` search.
738///
739/// Not found and not executable are distinct failures, in the shell
740/// convention. `rk branches prune` shares it for the verify path.
741///
742/// # Errors
743///
744/// Refuses when the override or the search resolves no usable binary.
745pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
746 let override_var = match forge {
747 Forge::Github => "RK_GH_BIN",
748 Forge::Gitlab => "RK_GLAB_BIN",
749 };
750 if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
751 let path = PathBuf::from(&overridden);
752 if !path.is_file() {
753 return Err(RkError::refusal(
754 Diagnostic::new(
755 Reason::PrerequisiteUnmet,
756 format!(
757 "{override_var} names {}, which does not exist",
758 path.display()
759 ),
760 )
761 .expected("the override to name the forge CLI binary"),
762 ));
763 }
764 // The scripts invoke the CLI by its canonical name through the
765 // child's search path, so an override under any other name would
766 // split one lifecycle across two binaries: observed through the
767 // override, applied through whatever the name resolves to.
768 if path.file_name().is_none_or(|name| name != forge.cli()) {
769 return Err(RkError::refusal(
770 Diagnostic::new(
771 Reason::PrerequisiteUnmet,
772 format!(
773 "{override_var} must name a binary called {}, and {} is not one",
774 forge.cli(),
775 path.display()
776 ),
777 )
778 .expected(format!(
779 "an override whose file name is {}, so scripts and observations run one binary",
780 forge.cli()
781 )),
782 ));
783 }
784 return Ok(path);
785 }
786 let name = forge.cli();
787 let found = std::env::var_os("PATH").and_then(|path| {
788 std::env::split_paths(&path)
789 .map(|dir| dir.join(name))
790 .find(|candidate| candidate.is_file())
791 });
792 found.ok_or_else(|| {
793 RkError::refusal(
794 Diagnostic::new(
795 Reason::PrerequisiteUnmet,
796 format!(
797 "{name} is not on PATH, and a step this run acts on calls it on {}",
798 forge.as_str()
799 ),
800 )
801 .expected(format!("the {name} CLI installed and authenticated"))
802 .action(format!("install {name}, then run {name} auth login")),
803 )
804 })
805}
806
807#[cfg(test)]
808mod tests {
809 /// The trunk ruleset's rules come from the one owned-rules key, so
810 /// what a run installs, what the floor table judges, and what the
811 /// check expects cannot disagree. A local-integration target names
812 /// the two rules that still hold against a direct push, and the
813 /// request and required-check rules are absent rather than installed
814 /// against the mode that needs the push.
815 #[test]
816 fn the_trunk_rules_follow_the_owned_rule_key() {
817 let mut policy = crate::config::Protection::default();
818 let forge = super::compose_trunk_rules(&policy, "gate", "pr-title");
819 let parsed: Vec<serde_json::Value> = serde_json::from_str(&forge).expect("the rules parse");
820 let kinds: Vec<&str> = parsed
821 .iter()
822 .filter_map(|rule| rule["type"].as_str())
823 .collect();
824 assert_eq!(
825 kinds,
826 [
827 "deletion",
828 "non_fast_forward",
829 "pull_request",
830 "required_status_checks"
831 ]
832 );
833 let checks = parsed
834 .iter()
835 .find(|rule| rule["type"] == "required_status_checks")
836 .expect("the check rule");
837 assert_eq!(
838 checks["parameters"]["required_status_checks"],
839 serde_json::json!([{ "context": "gate" }, { "context": "pr-title" }])
840 );
841
842 policy.owned_trunk_rules = vec!["deletion".into(), "non_fast_forward".into()];
843 let local = super::compose_trunk_rules(&policy, "gate", "pr-title");
844 let parsed: Vec<serde_json::Value> = serde_json::from_str(&local).expect("the rules parse");
845 let kinds: Vec<&str> = parsed
846 .iter()
847 .filter_map(|rule| rule["type"].as_str())
848 .collect();
849 assert_eq!(kinds, ["deletion", "non_fast_forward"]);
850 assert!(!local.contains("pull_request"), "{local}");
851 assert!(!local.contains("required_status_checks"), "{local}");
852 }
853
854 /// One effective policy serves the body a step sends, the observer
855 /// that reads the answer back, and every prerequisite.
856 ///
857 /// A target that stated nothing gets the compiled defaults, which
858 /// describe forge integration because that is the shape this
859 /// convention had before the axis existed — so under local
860 /// integration they are adjusted: the two rules no forge can apply
861 /// to a push are dropped, and the GitLab level moves off the zero
862 /// that would close the trunk to the push that mode ends in. A
863 /// target that stated a policy keeps every value it stated, because
864 /// the floor table already judged it under the same mode.
865 #[test]
866 fn the_effective_policy_follows_the_recorded_authority() {
867 use crate::landing::Integration;
868 let silent = super::effective_protection(None, Integration::Forge);
869 assert!(
870 silent
871 .owned_trunk_rules
872 .contains(&"pull_request".to_owned())
873 );
874 assert_eq!(silent.gitlab.push_access_level, 0);
875
876 let silent = super::effective_protection(None, Integration::Local);
877 assert_eq!(
878 silent.owned_trunk_rules,
879 ["deletion".to_owned(), "non_fast_forward".to_owned()],
880 "no forge can apply a request rule to a push"
881 );
882 assert_eq!(
883 silent.gitlab.push_access_level, 40,
884 "zero would close the trunk to the push this mode ends in"
885 );
886
887 let mut stated = crate::config::Protection::default();
888 stated.gitlab.push_access_level = 0;
889 stated.owned_trunk_rules = vec!["deletion".into()];
890 let held = super::effective_protection(Some(&stated), Integration::Local);
891 assert_eq!(held.gitlab.push_access_level, 0, "a stated value wins");
892 assert_eq!(held.owned_trunk_rules, ["deletion".to_owned()]);
893 }
894}