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