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