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;
23
24// The trunk every setup asserts is the one permanent branch the target
25// states in its own committed configuration, read through `Ctx::trunk`.
26// A target that names none keeps the compiled default, so a landing
27// predating the key behaves exactly as it did.
28
29/// A policy boolean as the JSON word a forge body carries.
30const fn bool_word(value: bool) -> &'static str {
31    if value { "true" } else { "false" }
32}
33
34/// A policy list as the JSON array a forge body carries. Every element
35/// passed the floor table, so this quotes rather than escapes.
36fn json_list(values: &[String]) -> String {
37    let inner: Vec<String> = values.iter().map(|value| format!("\"{value}\"")).collect();
38    format!("[{}]", inner.join(", "))
39}
40
41/// The variables that pass through from the operator's environment to a
42/// step: the interpreter's search path, the forge CLI's configuration and
43/// authentication, and nothing else.
44const PASSTHROUGH: [&str; 11] = [
45    "PATH",
46    "HOME",
47    "XDG_CONFIG_HOME",
48    "GH_TOKEN",
49    "GITHUB_TOKEN",
50    "GH_HOST",
51    "GH_CONFIG_DIR",
52    "GLAB_TOKEN",
53    "GITLAB_TOKEN",
54    "GITLAB_HOST",
55    "GLAB_CONFIG_DIR",
56];
57
58/// The value-bearing bot variables, forwarded only to the steps that
59/// consume them and recorded in the journal as handling, never as value.
60/// The key is in no list here: it reaches its step as bytes on standard
61/// input, and neither it nor its path is ever put in an environment.
62/// [`secrets`] owns that.
63pub use super::secrets::VALUE_VARS as SECRET_VARS;
64
65/// One resolved run context.
66#[derive(Debug, Clone)]
67pub struct Ctx {
68    /// The repository being set up.
69    pub target: Utf8PathBuf,
70    /// The project path on the forge.
71    pub repo: String,
72    /// The forge the run acts on.
73    pub forge: Forge,
74    /// The remote host, where one was detected.
75    pub host: Option<String>,
76    /// The value of `--required-check`, where given.
77    pub required_check: Option<String>,
78    /// The resolved forge CLI binary.
79    pub cli: PathBuf,
80    /// The detected technology, where the version file names one.
81    pub tech: Option<&'static str>,
82    /// The one permanent branch this target states, or the compiled
83    /// default where it states none.
84    trunk: String,
85    /// The release-line prefix this target states, or the compiled
86    /// default where it states none.
87    line_prefix: String,
88    /// The long-lived branches a single trunk retires, as this target
89    /// names them.
90    retired_branches: Vec<String>,
91    /// Whether a full apply runs the release-line protection, which a
92    /// project that keeps no line does not want run at all.
93    release_lines: bool,
94    /// The steps this target declared it does not run, each against its
95    /// stated reason. A run reports them and judges none of them.
96    excluded_steps: std::collections::BTreeMap<String, String>,
97    /// The bot App's public identifier where this target states one; the
98    /// environment still wins over it, and no private credential is here.
99    bot_app_id: Option<String>,
100    /// The ruleset that protects the trunk, as this target names it.
101    trunk_ruleset: String,
102    /// The ruleset that makes published tags immutable.
103    tag_ruleset: String,
104    /// The ruleset that protects the release lines.
105    lines_ruleset: String,
106    /// The context the landed title job reports under.
107    title_check: String,
108    /// The floored policy this target states. Every value here passed the
109    /// floor table at load, so a run can pass it to a step without
110    /// judging it again.
111    protection: crate::config::Protection,
112}
113
114impl Ctx {
115    /// Resolve detection, overrides, and the forge CLI in one pass, before
116    /// any step runs.
117    ///
118    /// # Errors
119    ///
120    /// Refuses when the target is missing, when no remote resolves and no
121    /// override covers the gap, when the host is unrecognized, and when the
122    /// forge CLI is not on `PATH`.
123    pub fn resolve(
124        target: &Utf8PathBuf,
125        repo_flag: Option<&str>,
126        forge_flag: Option<&str>,
127        required_check: Option<&str>,
128    ) -> Result<Self, RkError> {
129        if !target.is_dir() {
130            return Err(RkError::missing(
131                Diagnostic::new(
132                    Reason::TargetNotFound,
133                    format!("target {target} is not a directory; nothing was run"),
134                )
135                .expected("an existing repository to set up"),
136            ));
137        }
138        let forge_flag = forge_flag
139            .map(|name| {
140                detect::Forge::parse(name).ok_or_else(|| {
141                    RkError::Usage(format!(
142                        "unknown forge '{name}'; the forges are: github, gitlab"
143                    ))
144                })
145            })
146            .transpose()?;
147        let detected = detect::detect(target.as_std_path());
148        let Some(forge) = forge_flag.or(detected.forge) else {
149            let diagnostic = detected.host.as_ref().map_or_else(
150                || {
151                    Diagnostic::new(
152                        Reason::ForgeUndetected,
153                        "no forge detected: the target has no origin remote",
154                    )
155                },
156                |host| {
157                    Diagnostic::new(
158                        Reason::ForgeUndetected,
159                        format!("no forge detected: the host {host} is not recognized"),
160                    )
161                },
162            );
163            let diagnostic = diagnostic
164                .expected("a github.com or gitlab remote, or an override")
165                .action("pass --forge <github|gitlab>, and --repo <path> if the remote is absent");
166            // An unrecognized host is a refusal, never a default; a
167            // missing remote is absent input, in the sysexits sense.
168            return Err(if detected.host.is_some() {
169                RkError::refusal(diagnostic)
170            } else {
171                RkError::missing(diagnostic)
172            });
173        };
174
175        let Some(repo) = repo_flag.map(str::to_owned).or(detected.repo) else {
176            return Err(RkError::missing(
177                Diagnostic::new(
178                    Reason::ForgeUndetected,
179                    "no repository detected: the target has no origin remote",
180                )
181                .expected("an origin remote naming the project")
182                .action("pass --repo <owner/name>"),
183            ));
184        };
185        let cli = resolve_cli(forge)?;
186        let config = crate::config::load(target.as_std_path())?;
187        let answers = config
188            .as_ref()
189            .map_or_else(crate::config::Setup::default, |held| held.setup.clone());
190        // The flag wins, and the committed answer fills the gap on GitHub
191        // alone: GitLab names no individual check and refuses a supplied
192        // one, so a shared configuration must not make that refusal fire.
193        let required_check = required_check.map(str::to_owned).or_else(|| {
194            Some(answers.required_check.clone())
195                .filter(|name| !name.is_empty() && forge == Forge::Github)
196        });
197        let bot_app_id = Some(answers.bot.app_id.clone()).filter(|id| !id.is_empty());
198        let trunk = crate::config::trunk_of(target.as_std_path())?;
199        let protection = config
200            .as_ref()
201            .map_or_else(crate::config::Protection::default, |held| {
202                held.protection.clone()
203            });
204        Ok(Self {
205            target: target.clone(),
206            repo,
207            forge,
208            host: detected.host,
209            required_check,
210            cli,
211            tech: detect::tech_of(target.as_std_path()),
212            trunk_ruleset: protection.trunk_ruleset(&trunk),
213            tag_ruleset: protection.tag_ruleset.clone(),
214            lines_ruleset: protection.lines_ruleset.clone(),
215            title_check: protection.title_check.clone(),
216            protection,
217            trunk,
218            line_prefix: crate::config::line_prefix_of(target.as_std_path())?,
219            retired_branches: answers.retired_branches,
220            release_lines: answers.release_lines,
221            excluded_steps: answers.excluded_steps,
222            bot_app_id,
223        })
224    }
225
226    /// A context the integration tests build directly, for an observer
227    /// exercised against recorded forge answers rather than a repository.
228    /// The trunk and the prefix take their compiled defaults, because such
229    /// a test reads no target configuration.
230    #[doc(hidden)]
231    #[must_use]
232    pub fn for_tests(
233        target: Utf8PathBuf,
234        repo: String,
235        forge: Forge,
236        cli: PathBuf,
237        tech: Option<&'static str>,
238    ) -> Self {
239        let defaults = crate::config::Protection::default();
240        Self {
241            target,
242            repo,
243            forge,
244            host: None,
245            required_check: None,
246            cli,
247            tech,
248            trunk: crate::config::TRUNK_DEFAULT.to_owned(),
249            line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
250            retired_branches: crate::config::Setup::default().retired_branches,
251            release_lines: false,
252            excluded_steps: std::collections::BTreeMap::new(),
253            bot_app_id: None,
254            trunk_ruleset: format!("{}-protection", crate::config::TRUNK_DEFAULT),
255            tag_ruleset: defaults.tag_ruleset.clone(),
256            lines_ruleset: defaults.lines_ruleset.clone(),
257            title_check: defaults.title_check.clone(),
258            protection: defaults,
259        }
260    }
261
262    /// The one permanent branch this run asserts.
263    #[must_use]
264    pub fn trunk(&self) -> &str {
265        &self.trunk
266    }
267
268    /// The release-line prefix this run asserts.
269    #[must_use]
270    pub fn line_prefix(&self) -> &str {
271        &self.line_prefix
272    }
273
274    /// The long-lived branches this run's single-trunk step retires.
275    #[must_use]
276    pub fn retired_branches(&self) -> &[String] {
277        &self.retired_branches
278    }
279
280    /// Whether a full apply runs the release-line protection.
281    #[must_use]
282    pub const fn release_lines(&self) -> bool {
283        self.release_lines
284    }
285
286    /// Why this target does not run the named step, where it declared an
287    /// exclusion for it. The reason is what the report prints, so an
288    /// excluded step is always visible with the answer behind it.
289    #[must_use]
290    pub fn excluded(&self, step: &str) -> Option<&str> {
291        self.excluded_steps.get(step).map(String::as_str)
292    }
293
294    /// How many steps this target declared it does not run.
295    #[must_use]
296    pub fn excluded_count(&self) -> usize {
297        self.excluded_steps.len()
298    }
299
300    /// The bot App's public identifier this target states, where it does.
301    #[must_use]
302    pub fn bot_app_id(&self) -> Option<&str> {
303        self.bot_app_id.as_deref()
304    }
305
306    /// The ruleset that protects the trunk.
307    #[must_use]
308    pub fn trunk_ruleset(&self) -> &str {
309        &self.trunk_ruleset
310    }
311
312    /// The ruleset that makes published tags immutable.
313    #[must_use]
314    pub fn tag_ruleset(&self) -> &str {
315        &self.tag_ruleset
316    }
317
318    /// The ruleset that protects the release lines.
319    #[must_use]
320    pub fn lines_ruleset(&self) -> &str {
321        &self.lines_ruleset
322    }
323
324    /// The context the landed title job reports under.
325    #[must_use]
326    pub fn title_check(&self) -> &str {
327        &self.title_check
328    }
329
330    /// The floored policy this target states, already judged at load.
331    #[must_use]
332    pub const fn protection(&self) -> &crate::config::Protection {
333        &self.protection
334    }
335
336    /// Whether this run targets a GitLab instance that is not gitlab.com,
337    /// where registry trusted publishing cannot reach.
338    #[must_use]
339    pub fn self_hosted_gitlab(&self) -> bool {
340        self.forge == Forge::Gitlab
341            && self
342                .host
343                .as_deref()
344                .is_some_and(|host| host != "gitlab.com")
345    }
346
347    /// The constructed environment a step receives. Secrets enter only for
348    /// the step that consumes them; the caller records their handling.
349    #[must_use]
350    pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
351        let mut env: Vec<(OsString, OsString)> = vec![
352            ("RK_FORGE".into(), self.forge.as_str().into()),
353            ("RK_REPO".into(), self.repo.clone().into()),
354            ("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
355            ("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
356            ("RK_TRUNK_RULESET".into(), self.trunk_ruleset.clone().into()),
357            ("RK_TAG_RULESET".into(), self.tag_ruleset.clone().into()),
358            ("RK_LINES_RULESET".into(), self.lines_ruleset.clone().into()),
359            ("RK_TITLE_CHECK".into(), self.title_check.clone().into()),
360            // The floored policy, already judged against the floor table
361            // at load. A step receives values, never a judgment: one
362            // owner decides what passes, and it is not a shell script.
363            (
364                "RK_TAG_PATTERN".into(),
365                self.protection.tag_pattern.clone().into(),
366            ),
367            (
368                "RK_REVIEW_COUNT".into(),
369                self.protection
370                    .required_approving_review_count
371                    .to_string()
372                    .into(),
373            ),
374            (
375                "RK_DISMISS_STALE_REVIEWS".into(),
376                bool_word(self.protection.dismiss_stale_reviews_on_push).into(),
377            ),
378            (
379                "RK_CODE_OWNER_REVIEW".into(),
380                bool_word(self.protection.require_code_owner_review).into(),
381            ),
382            (
383                "RK_LAST_PUSH_APPROVAL".into(),
384                bool_word(self.protection.require_last_push_approval).into(),
385            ),
386            (
387                "RK_MERGE_METHODS".into(),
388                json_list(&self.protection.allowed_merge_methods).into(),
389            ),
390            (
391                "RK_STRICT_CHECKS".into(),
392                bool_word(self.protection.strict_required_status_checks).into(),
393            ),
394            (
395                "RK_SQUASH_TITLE_SOURCE".into(),
396                self.protection.github.squash_title_source.clone().into(),
397            ),
398            (
399                "RK_SQUASH_BODY_SOURCE".into(),
400                self.protection.github.squash_body_source.clone().into(),
401            ),
402            (
403                "RK_GITLAB_MERGE_METHOD".into(),
404                self.protection.gitlab.merge_method.clone().into(),
405            ),
406            (
407                "RK_GITLAB_SQUASH_OPTION".into(),
408                self.protection.gitlab.squash_option.clone().into(),
409            ),
410            (
411                "RK_GITLAB_SQUASH_TEMPLATE".into(),
412                self.protection.gitlab.squash_commit_template.clone().into(),
413            ),
414            (
415                "RK_GITLAB_PUSH_LEVEL".into(),
416                self.protection.gitlab.push_access_level.to_string().into(),
417            ),
418            (
419                "RK_GITLAB_MERGE_LEVEL".into(),
420                self.protection.gitlab.merge_access_level.to_string().into(),
421            ),
422            ("GH_PAGER".into(), "".into()),
423            ("GLAB_PAGER".into(), "".into()),
424        ];
425        if let Some(check) = &self.required_check {
426            if self.forge == Forge::Github && matches!(step, "protect-trunk" | "protections-check")
427            {
428                env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
429            }
430        }
431        for name in PASSTHROUGH {
432            if let Some(value) = std::env::var_os(name) {
433                env.push((name.into(), value));
434            }
435        }
436        // The forge CLI override substitutes the binary for the run's own
437        // calls; a step resolves the CLI by name, so the override's
438        // directory leads the child's search path.
439        if let Some(dir) = self.cli_override_dir() {
440            let mut paths: Vec<PathBuf> = vec![dir];
441            if let Some(existing) = std::env::var_os("PATH") {
442                paths.extend(std::env::split_paths(&existing));
443            }
444            if let Ok(joined) = std::env::join_paths(paths) {
445                env.retain(|(name, _)| name != "PATH");
446                env.push(("PATH".into(), joined));
447            }
448        }
449        if step == "bot-secrets" {
450            for name in SECRET_VARS {
451                if let Some(value) = secrets::value_of(name) {
452                    env.push((name.into(), value));
453                }
454            }
455        }
456        env
457    }
458
459    /// The directory of an explicitly overridden forge CLI, where one is set.
460    fn cli_override_dir(&self) -> Option<PathBuf> {
461        let overridden = std::env::var_os(match self.forge {
462            Forge::Github => "RK_GH_BIN",
463            Forge::Gitlab => "RK_GLAB_BIN",
464        })?;
465        Path::new(&overridden).parent().map(Path::to_path_buf)
466    }
467
468    /// The secret bytes a run must keep out of its own output: the values
469    /// the environment carries. Every buffer is scrubbed on drop; none is
470    /// ever logged or echoed.
471    ///
472    /// Key material is not read here. The step that transmits a key adds
473    /// the very bytes it sends, so the needle cannot describe one file
474    /// while the child receives another.
475    #[must_use]
476    pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
477        SECRET_VARS
478            .iter()
479            .filter_map(|name| secrets::value_of(name))
480            .map(|value| Zeroizing::new(value.into_encoded_bytes()))
481            .collect()
482    }
483}
484
485/// Resolve the forge CLI once, at context time: the `RK_GH_BIN` and
486/// `RK_GLAB_BIN` overrides first, then a `PATH` search.
487///
488/// Not found and not executable are distinct failures, in the shell
489/// convention. `rk branches prune` shares it for the verify path.
490///
491/// # Errors
492///
493/// Refuses when the override or the search resolves no usable binary.
494pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
495    let override_var = match forge {
496        Forge::Github => "RK_GH_BIN",
497        Forge::Gitlab => "RK_GLAB_BIN",
498    };
499    if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
500        let path = PathBuf::from(&overridden);
501        if !path.is_file() {
502            return Err(RkError::refusal(
503                Diagnostic::new(
504                    Reason::PrerequisiteUnmet,
505                    format!(
506                        "{override_var} names {}, which does not exist",
507                        path.display()
508                    ),
509                )
510                .expected("the override to name the forge CLI binary"),
511            ));
512        }
513        // The scripts invoke the CLI by its canonical name through the
514        // child's search path, so an override under any other name would
515        // split one lifecycle across two binaries: observed through the
516        // override, applied through whatever the name resolves to.
517        if path.file_name().is_none_or(|name| name != forge.cli()) {
518            return Err(RkError::refusal(
519                Diagnostic::new(
520                    Reason::PrerequisiteUnmet,
521                    format!(
522                        "{override_var} must name a binary called {}, and {} is not one",
523                        forge.cli(),
524                        path.display()
525                    ),
526                )
527                .expected(format!(
528                    "an override whose file name is {}, so scripts and observations run one binary",
529                    forge.cli()
530                )),
531            ));
532        }
533        return Ok(path);
534    }
535    let name = forge.cli();
536    let found = std::env::var_os("PATH").and_then(|path| {
537        std::env::split_paths(&path)
538            .map(|dir| dir.join(name))
539            .find(|candidate| candidate.is_file())
540    });
541    found.ok_or_else(|| {
542        RkError::refusal(
543            Diagnostic::new(
544                Reason::PrerequisiteUnmet,
545                format!(
546                    "{name} is not on PATH, and every {} step calls it",
547                    forge.as_str()
548                ),
549            )
550            .expected(format!("the {name} CLI installed and authenticated"))
551            .action(format!("install {name}, then run {name} auth login")),
552        )
553    })
554}