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}
77
78impl Ctx {
79    /// Resolve detection, overrides, and the forge CLI in one pass, before
80    /// any step runs.
81    ///
82    /// # Errors
83    ///
84    /// Refuses when the target is missing, when no remote resolves and no
85    /// override covers the gap, when the host is unrecognized, and when the
86    /// forge CLI is not on `PATH`.
87    pub fn resolve(
88        target: &Utf8PathBuf,
89        repo_flag: Option<&str>,
90        forge_flag: Option<&str>,
91        required_check: Option<&str>,
92    ) -> Result<Self, RkError> {
93        if !target.is_dir() {
94            return Err(RkError::missing(
95                Diagnostic::new(
96                    Reason::TargetNotFound,
97                    format!("target {target} is not a directory; nothing was run"),
98                )
99                .expected("an existing repository to set up"),
100            ));
101        }
102        let forge_flag = forge_flag
103            .map(|name| {
104                detect::Forge::parse(name).ok_or_else(|| {
105                    RkError::Usage(format!(
106                        "unknown forge '{name}'; the forges are: github, gitlab"
107                    ))
108                })
109            })
110            .transpose()?;
111        let detected = detect::detect(target.as_std_path());
112        let Some(forge) = forge_flag.or(detected.forge) else {
113            let diagnostic = detected.host.as_ref().map_or_else(
114                || {
115                    Diagnostic::new(
116                        Reason::ForgeUndetected,
117                        "no forge detected: the target has no origin remote",
118                    )
119                },
120                |host| {
121                    Diagnostic::new(
122                        Reason::ForgeUndetected,
123                        format!("no forge detected: the host {host} is not recognized"),
124                    )
125                },
126            );
127            let diagnostic = diagnostic
128                .expected("a github.com or gitlab remote, or an override")
129                .action("pass --forge <github|gitlab>, and --repo <path> if the remote is absent");
130            // An unrecognized host is a refusal, never a default; a
131            // missing remote is absent input, in the sysexits sense.
132            return Err(if detected.host.is_some() {
133                RkError::refusal(diagnostic)
134            } else {
135                RkError::missing(diagnostic)
136            });
137        };
138
139        let Some(repo) = repo_flag.map(str::to_owned).or(detected.repo) else {
140            return Err(RkError::missing(
141                Diagnostic::new(
142                    Reason::ForgeUndetected,
143                    "no repository detected: the target has no origin remote",
144                )
145                .expected("an origin remote naming the project")
146                .action("pass --repo <owner/name>"),
147            ));
148        };
149        let cli = resolve_cli(forge)?;
150        Ok(Self {
151            target: target.clone(),
152            repo,
153            forge,
154            host: detected.host,
155            required_check: required_check.map(str::to_owned),
156            cli,
157            tech: detect::tech_of(target.as_std_path()),
158            trunk: crate::config::trunk_of(target.as_std_path())?,
159            line_prefix: crate::config::line_prefix_of(target.as_std_path())?,
160        })
161    }
162
163    /// A context the integration tests build directly, for an observer
164    /// exercised against recorded forge answers rather than a repository.
165    /// The trunk and the prefix take their compiled defaults, because such
166    /// a test reads no target configuration.
167    #[doc(hidden)]
168    #[must_use]
169    pub fn for_tests(
170        target: Utf8PathBuf,
171        repo: String,
172        forge: Forge,
173        cli: PathBuf,
174        tech: Option<&'static str>,
175    ) -> Self {
176        Self {
177            target,
178            repo,
179            forge,
180            host: None,
181            required_check: None,
182            cli,
183            tech,
184            trunk: crate::config::TRUNK_DEFAULT.to_owned(),
185            line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
186        }
187    }
188
189    /// The one permanent branch this run asserts.
190    #[must_use]
191    pub fn trunk(&self) -> &str {
192        &self.trunk
193    }
194
195    /// The release-line prefix this run asserts.
196    #[must_use]
197    pub fn line_prefix(&self) -> &str {
198        &self.line_prefix
199    }
200
201    /// Whether this run targets a GitLab instance that is not gitlab.com,
202    /// where registry trusted publishing cannot reach.
203    #[must_use]
204    pub fn self_hosted_gitlab(&self) -> bool {
205        self.forge == Forge::Gitlab
206            && self
207                .host
208                .as_deref()
209                .is_some_and(|host| host != "gitlab.com")
210    }
211
212    /// The constructed environment a step receives. Secrets enter only for
213    /// the step that consumes them; the caller records their handling.
214    #[must_use]
215    pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
216        let mut env: Vec<(OsString, OsString)> = vec![
217            ("RK_FORGE".into(), self.forge.as_str().into()),
218            ("RK_REPO".into(), self.repo.clone().into()),
219            ("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
220            ("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
221            ("GH_PAGER".into(), "".into()),
222            ("GLAB_PAGER".into(), "".into()),
223        ];
224        if let Some(check) = &self.required_check {
225            if self.forge == Forge::Github && matches!(step, "protect-trunk" | "protections-check")
226            {
227                env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
228            }
229        }
230        for name in PASSTHROUGH {
231            if let Some(value) = std::env::var_os(name) {
232                env.push((name.into(), value));
233            }
234        }
235        // The forge CLI override substitutes the binary for the run's own
236        // calls; a step resolves the CLI by name, so the override's
237        // directory leads the child's search path.
238        if let Some(dir) = self.cli_override_dir() {
239            let mut paths: Vec<PathBuf> = vec![dir];
240            if let Some(existing) = std::env::var_os("PATH") {
241                paths.extend(std::env::split_paths(&existing));
242            }
243            if let Ok(joined) = std::env::join_paths(paths) {
244                env.retain(|(name, _)| name != "PATH");
245                env.push(("PATH".into(), joined));
246            }
247        }
248        if step == "bot-secrets" {
249            for name in SECRET_VARS {
250                if let Some(value) = secrets::value_of(name) {
251                    env.push((name.into(), value));
252                }
253            }
254        }
255        env
256    }
257
258    /// The directory of an explicitly overridden forge CLI, where one is set.
259    fn cli_override_dir(&self) -> Option<PathBuf> {
260        let overridden = std::env::var_os(match self.forge {
261            Forge::Github => "RK_GH_BIN",
262            Forge::Gitlab => "RK_GLAB_BIN",
263        })?;
264        Path::new(&overridden).parent().map(Path::to_path_buf)
265    }
266
267    /// The secret bytes a run must keep out of its own output: the values
268    /// the environment carries. Every buffer is scrubbed on drop; none is
269    /// ever logged or echoed.
270    ///
271    /// Key material is not read here. The step that transmits a key adds
272    /// the very bytes it sends, so the needle cannot describe one file
273    /// while the child receives another.
274    #[must_use]
275    pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
276        SECRET_VARS
277            .iter()
278            .filter_map(|name| secrets::value_of(name))
279            .map(|value| Zeroizing::new(value.into_encoded_bytes()))
280            .collect()
281    }
282}
283
284/// Resolve the forge CLI once, at context time: the `RK_GH_BIN` and
285/// `RK_GLAB_BIN` overrides first, then a `PATH` search.
286///
287/// Not found and not executable are distinct failures, in the shell
288/// convention. `rk branches prune` shares it for the verify path.
289///
290/// # Errors
291///
292/// Refuses when the override or the search resolves no usable binary.
293pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
294    let override_var = match forge {
295        Forge::Github => "RK_GH_BIN",
296        Forge::Gitlab => "RK_GLAB_BIN",
297    };
298    if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
299        let path = PathBuf::from(&overridden);
300        if !path.is_file() {
301            return Err(RkError::refusal(
302                Diagnostic::new(
303                    Reason::PrerequisiteUnmet,
304                    format!(
305                        "{override_var} names {}, which does not exist",
306                        path.display()
307                    ),
308                )
309                .expected("the override to name the forge CLI binary"),
310            ));
311        }
312        // The scripts invoke the CLI by its canonical name through the
313        // child's search path, so an override under any other name would
314        // split one lifecycle across two binaries: observed through the
315        // override, applied through whatever the name resolves to.
316        if path.file_name().is_none_or(|name| name != forge.cli()) {
317            return Err(RkError::refusal(
318                Diagnostic::new(
319                    Reason::PrerequisiteUnmet,
320                    format!(
321                        "{override_var} must name a binary called {}, and {} is not one",
322                        forge.cli(),
323                        path.display()
324                    ),
325                )
326                .expected(format!(
327                    "an override whose file name is {}, so scripts and observations run one binary",
328                    forge.cli()
329                )),
330            ));
331        }
332        return Ok(path);
333    }
334    let name = forge.cli();
335    let found = std::env::var_os("PATH").and_then(|path| {
336        std::env::split_paths(&path)
337            .map(|dir| dir.join(name))
338            .find(|candidate| candidate.is_file())
339    });
340    found.ok_or_else(|| {
341        RkError::refusal(
342            Diagnostic::new(
343                Reason::PrerequisiteUnmet,
344                format!(
345                    "{name} is not on PATH, and every {} step calls it",
346                    forge.as_str()
347                ),
348            )
349            .expected(format!("the {name} CLI installed and authenticated"))
350            .action(format!("install {name}, then run {name} auth login")),
351        )
352    })
353}