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