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