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: the one permanent branch; named so a
25/// later option can change it.
26pub const TRUNK_BRANCH: &str = "master";
27
28/// The variables that pass through from the operator's environment to a
29/// step: the interpreter's search path, the forge CLI's configuration and
30/// authentication, and nothing else.
31const PASSTHROUGH: [&str; 11] = [
32    "PATH",
33    "HOME",
34    "XDG_CONFIG_HOME",
35    "GH_TOKEN",
36    "GITHUB_TOKEN",
37    "GH_HOST",
38    "GH_CONFIG_DIR",
39    "GLAB_TOKEN",
40    "GITLAB_TOKEN",
41    "GITLAB_HOST",
42    "GLAB_CONFIG_DIR",
43];
44
45/// The value-bearing bot variables, forwarded only to the steps that
46/// consume them and recorded in the journal as handling, never as value.
47/// The key is in no list here: it reaches its step as bytes on standard
48/// input, and neither it nor its path is ever put in an environment.
49/// [`secrets`] owns that.
50pub use super::secrets::VALUE_VARS as SECRET_VARS;
51
52/// One resolved run context.
53#[derive(Debug)]
54pub struct Ctx {
55    /// The repository being set up.
56    pub target: Utf8PathBuf,
57    /// The project path on the forge.
58    pub repo: String,
59    /// The forge the run acts on.
60    pub forge: Forge,
61    /// The remote host, where one was detected.
62    pub host: Option<String>,
63    /// The value of `--required-check`, where given.
64    pub required_check: Option<String>,
65    /// The resolved forge CLI binary.
66    pub cli: PathBuf,
67    /// The detected technology, where the version file names one.
68    pub tech: Option<&'static str>,
69}
70
71impl Ctx {
72    /// Resolve detection, overrides, and the forge CLI in one pass, before
73    /// any step runs.
74    ///
75    /// # Errors
76    ///
77    /// Refuses when the target is missing, when no remote resolves and no
78    /// override covers the gap, when the host is unrecognized, and when the
79    /// forge CLI is not on `PATH`.
80    pub fn resolve(
81        target: &Utf8PathBuf,
82        repo_flag: Option<&str>,
83        forge_flag: Option<&str>,
84        required_check: Option<&str>,
85    ) -> Result<Self, RkError> {
86        if !target.is_dir() {
87            return Err(RkError::missing(
88                Diagnostic::new(
89                    Reason::TargetNotFound,
90                    format!("target {target} is not a directory; nothing was run"),
91                )
92                .expected("an existing repository to set up"),
93            ));
94        }
95        let forge_flag = forge_flag
96            .map(|name| {
97                detect::Forge::parse(name).ok_or_else(|| {
98                    RkError::Usage(format!(
99                        "unknown forge '{name}'; the forges are: github, gitlab"
100                    ))
101                })
102            })
103            .transpose()?;
104        let detected = detect::detect(target.as_std_path());
105        let Some(forge) = forge_flag.or(detected.forge) else {
106            let diagnostic = detected.host.as_ref().map_or_else(
107                || {
108                    Diagnostic::new(
109                        Reason::ForgeUndetected,
110                        "no forge detected: the target has no origin remote",
111                    )
112                },
113                |host| {
114                    Diagnostic::new(
115                        Reason::ForgeUndetected,
116                        format!("no forge detected: the host {host} is not recognized"),
117                    )
118                },
119            );
120            let diagnostic = diagnostic
121                .expected("a github.com or gitlab remote, or an override")
122                .action("pass --forge <github|gitlab>, and --repo <path> if the remote is absent");
123            // An unrecognized host is a refusal, never a default; a
124            // missing remote is absent input, in the sysexits sense.
125            return Err(if detected.host.is_some() {
126                RkError::refusal(diagnostic)
127            } else {
128                RkError::missing(diagnostic)
129            });
130        };
131
132        let Some(repo) = repo_flag.map(str::to_owned).or(detected.repo) else {
133            return Err(RkError::missing(
134                Diagnostic::new(
135                    Reason::ForgeUndetected,
136                    "no repository detected: the target has no origin remote",
137                )
138                .expected("an origin remote naming the project")
139                .action("pass --repo <owner/name>"),
140            ));
141        };
142        let cli = resolve_cli(forge)?;
143        Ok(Self {
144            target: target.clone(),
145            repo,
146            forge,
147            host: detected.host,
148            required_check: required_check.map(str::to_owned),
149            cli,
150            tech: detect::tech_of(target.as_std_path()),
151        })
152    }
153
154    /// Whether this run targets a GitLab instance that is not gitlab.com,
155    /// where registry trusted publishing cannot reach.
156    #[must_use]
157    pub fn self_hosted_gitlab(&self) -> bool {
158        self.forge == Forge::Gitlab
159            && self
160                .host
161                .as_deref()
162                .is_some_and(|host| host != "gitlab.com")
163    }
164
165    /// The constructed environment a step receives. Secrets enter only for
166    /// the step that consumes them; the caller records their handling.
167    #[must_use]
168    pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
169        let mut env: Vec<(OsString, OsString)> = vec![
170            ("RK_FORGE".into(), self.forge.as_str().into()),
171            ("RK_REPO".into(), self.repo.clone().into()),
172            ("RK_TRUNK_BRANCH".into(), TRUNK_BRANCH.into()),
173            ("GH_PAGER".into(), "".into()),
174            ("GLAB_PAGER".into(), "".into()),
175        ];
176        if let Some(check) = &self.required_check {
177            if self.forge == Forge::Github && matches!(step, "protect-trunk" | "protections-check")
178            {
179                env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
180            }
181        }
182        for name in PASSTHROUGH {
183            if let Some(value) = std::env::var_os(name) {
184                env.push((name.into(), value));
185            }
186        }
187        // The forge CLI override substitutes the binary for the run's own
188        // calls; a step resolves the CLI by name, so the override's
189        // directory leads the child's search path.
190        if let Some(dir) = self.cli_override_dir() {
191            let mut paths: Vec<PathBuf> = vec![dir];
192            if let Some(existing) = std::env::var_os("PATH") {
193                paths.extend(std::env::split_paths(&existing));
194            }
195            if let Ok(joined) = std::env::join_paths(paths) {
196                env.retain(|(name, _)| name != "PATH");
197                env.push(("PATH".into(), joined));
198            }
199        }
200        if step == "bot-secrets" {
201            for name in SECRET_VARS {
202                if let Some(value) = secrets::value_of(name) {
203                    env.push((name.into(), value));
204                }
205            }
206        }
207        env
208    }
209
210    /// The directory of an explicitly overridden forge CLI, where one is set.
211    fn cli_override_dir(&self) -> Option<PathBuf> {
212        let overridden = std::env::var_os(match self.forge {
213            Forge::Github => "RK_GH_BIN",
214            Forge::Gitlab => "RK_GLAB_BIN",
215        })?;
216        Path::new(&overridden).parent().map(Path::to_path_buf)
217    }
218
219    /// The secret bytes a run must keep out of its own output: the values
220    /// the environment carries. Every buffer is scrubbed on drop; none is
221    /// ever logged or echoed.
222    ///
223    /// Key material is not read here. The step that transmits a key adds
224    /// the very bytes it sends, so the needle cannot describe one file
225    /// while the child receives another.
226    #[must_use]
227    pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
228        SECRET_VARS
229            .iter()
230            .filter_map(|name| secrets::value_of(name))
231            .map(|value| Zeroizing::new(value.into_encoded_bytes()))
232            .collect()
233    }
234}
235
236/// Resolve the forge CLI once, at context time: the `RK_GH_BIN` and
237/// `RK_GLAB_BIN` overrides first, then a `PATH` search. Not found and not
238/// executable are distinct failures, in the shell convention.
239fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
240    let override_var = match forge {
241        Forge::Github => "RK_GH_BIN",
242        Forge::Gitlab => "RK_GLAB_BIN",
243    };
244    if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
245        let path = PathBuf::from(&overridden);
246        if !path.is_file() {
247            return Err(RkError::refusal(
248                Diagnostic::new(
249                    Reason::PrerequisiteUnmet,
250                    format!(
251                        "{override_var} names {}, which does not exist",
252                        path.display()
253                    ),
254                )
255                .expected("the override to name the forge CLI binary"),
256            ));
257        }
258        // The scripts invoke the CLI by its canonical name through the
259        // child's search path, so an override under any other name would
260        // split one lifecycle across two binaries: observed through the
261        // override, applied through whatever the name resolves to.
262        if path.file_name().is_none_or(|name| name != forge.cli()) {
263            return Err(RkError::refusal(
264                Diagnostic::new(
265                    Reason::PrerequisiteUnmet,
266                    format!(
267                        "{override_var} must name a binary called {}, and {} is not one",
268                        forge.cli(),
269                        path.display()
270                    ),
271                )
272                .expected(format!(
273                    "an override whose file name is {}, so scripts and observations run one binary",
274                    forge.cli()
275                )),
276            ));
277        }
278        return Ok(path);
279    }
280    let name = forge.cli();
281    let found = std::env::var_os("PATH").and_then(|path| {
282        std::env::split_paths(&path)
283            .map(|dir| dir.join(name))
284            .find(|candidate| candidate.is_file())
285    });
286    found.ok_or_else(|| {
287        RkError::refusal(
288            Diagnostic::new(
289                Reason::PrerequisiteUnmet,
290                format!(
291                    "{name} is not on PATH, and every {} step calls it",
292                    forge.as_str()
293                ),
294            )
295            .expected(format!("the {name} CLI installed and authenticated"))
296            .action(format!("install {name}, then run {name} auth login")),
297        )
298    })
299}