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.
238///
239/// Not found and not executable are distinct failures, in the shell
240/// convention. `rk branches prune` shares it for the verify path.
241///
242/// # Errors
243///
244/// Refuses when the override or the search resolves no usable binary.
245pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
246    let override_var = match forge {
247        Forge::Github => "RK_GH_BIN",
248        Forge::Gitlab => "RK_GLAB_BIN",
249    };
250    if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
251        let path = PathBuf::from(&overridden);
252        if !path.is_file() {
253            return Err(RkError::refusal(
254                Diagnostic::new(
255                    Reason::PrerequisiteUnmet,
256                    format!(
257                        "{override_var} names {}, which does not exist",
258                        path.display()
259                    ),
260                )
261                .expected("the override to name the forge CLI binary"),
262            ));
263        }
264        // The scripts invoke the CLI by its canonical name through the
265        // child's search path, so an override under any other name would
266        // split one lifecycle across two binaries: observed through the
267        // override, applied through whatever the name resolves to.
268        if path.file_name().is_none_or(|name| name != forge.cli()) {
269            return Err(RkError::refusal(
270                Diagnostic::new(
271                    Reason::PrerequisiteUnmet,
272                    format!(
273                        "{override_var} must name a binary called {}, and {} is not one",
274                        forge.cli(),
275                        path.display()
276                    ),
277                )
278                .expected(format!(
279                    "an override whose file name is {}, so scripts and observations run one binary",
280                    forge.cli()
281                )),
282            ));
283        }
284        return Ok(path);
285    }
286    let name = forge.cli();
287    let found = std::env::var_os("PATH").and_then(|path| {
288        std::env::split_paths(&path)
289            .map(|dir| dir.join(name))
290            .find(|candidate| candidate.is_file())
291    });
292    found.ok_or_else(|| {
293        RkError::refusal(
294            Diagnostic::new(
295                Reason::PrerequisiteUnmet,
296                format!(
297                    "{name} is not on PATH, and every {} step calls it",
298                    forge.as_str()
299                ),
300            )
301            .expected(format!("the {name} CLI installed and authenticated"))
302            .action(format!("install {name}, then run {name} auth login")),
303        )
304    })
305}