Skip to main content

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