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;
23use crate::profile::{CapabilityRequests, ProfileSnapshot, ReleaseMode};
24
25// The trunk every setup asserts is the one permanent branch the target
26// states in its own committed configuration, read through `Ctx::trunk`.
27// A target that names none keeps the compiled default, so a landing
28// predating the key behaves exactly as it did.
29
30/// A policy boolean as the JSON word a forge body carries.
31const fn bool_word(value: bool) -> &'static str {
32    if value { "true" } else { "false" }
33}
34
35/// A policy list as the JSON array a forge body carries. Every element
36/// passed the floor table, so this quotes rather than escapes.
37fn json_list(values: &[String]) -> String {
38    let inner: Vec<String> = values.iter().map(|value| format!("\"{value}\"")).collect();
39    format!("[{}]", inner.join(", "))
40}
41
42/// The variables that pass through from the operator's environment to a
43/// step: the interpreter's search path, the forge CLI's configuration and
44/// authentication, and nothing else.
45const PASSTHROUGH: [&str; 11] = [
46    "PATH",
47    "HOME",
48    "XDG_CONFIG_HOME",
49    "GH_TOKEN",
50    "GITHUB_TOKEN",
51    "GH_HOST",
52    "GH_CONFIG_DIR",
53    "GLAB_TOKEN",
54    "GITLAB_TOKEN",
55    "GITLAB_HOST",
56    "GLAB_CONFIG_DIR",
57];
58
59/// The value-bearing bot variables, forwarded only to the steps that
60/// consume them and recorded in the journal as handling, never as value.
61/// The key is in no list here: it reaches its step as bytes on standard
62/// input, and neither it nor its path is ever put in an environment.
63/// [`secrets`] owns that.
64pub use super::secrets::VALUE_VARS as SECRET_VARS;
65
66/// One resolved run context.
67#[derive(Debug, Clone)]
68pub struct Ctx {
69    /// The repository being set up.
70    pub target: Utf8PathBuf,
71    /// The project path on the forge, empty where the profile names none.
72    pub repo: String,
73    /// The forge adapter the run acts through, where the profile names a
74    /// forge this release drives. A target with no forge, or one this
75    /// release has no adapter for, carries none: its local steps still
76    /// run and its forge steps report as not applicable.
77    pub forge: Option<Forge>,
78    /// The forge the profile declares, preserved whatever the adapter
79    /// says, so an unknown name stays readable.
80    pub declared_forge: Option<String>,
81    /// What the project is, as the target configuration resolves it.
82    pub profile: ProfileSnapshot,
83    /// Which optional products the target requests.
84    pub capabilities: CapabilityRequests,
85    /// The remote host, where one was detected.
86    pub host: Option<String>,
87    /// The value of `--required-check`, where given.
88    pub required_check: Option<String>,
89    /// The resolved forge CLI binary.
90    pub cli: PathBuf,
91    /// The detected technology, where the version file names one.
92    pub tech: Option<&'static str>,
93    /// The one permanent branch this target states, or the compiled
94    /// default where it states none.
95    trunk: String,
96    /// The release-line prefix this target states, or the compiled
97    /// default where it states none.
98    line_prefix: String,
99    /// The long-lived branches a single trunk retires, as this target
100    /// names them.
101    retired_branches: Vec<String>,
102    /// Whether a full apply runs the release-line protection, which a
103    /// project that keeps no line does not want run at all.
104    release_lines: bool,
105    /// The steps this target declared it does not run, each against its
106    /// stated reason. A run reports them and judges none of them.
107    excluded_steps: std::collections::BTreeMap<String, String>,
108    /// The bot App's public identifier where this target states one; the
109    /// environment still wins over it, and no private credential is here.
110    bot_app_id: Option<String>,
111    /// The ruleset that protects the trunk, as this target names it.
112    trunk_ruleset: String,
113    /// The ruleset that makes published tags immutable.
114    tag_ruleset: String,
115    /// The ruleset that protects the release lines.
116    lines_ruleset: String,
117    /// The context the landed title job reports under.
118    title_check: String,
119    /// The floored policy this target states. Every value here passed the
120    /// floor table at load, so a run can pass it to a step without
121    /// judging it again.
122    protection: crate::config::Protection,
123}
124
125impl Ctx {
126    /// Resolve detection, overrides, and the forge CLI in one pass, before
127    /// any step runs.
128    ///
129    /// # Errors
130    ///
131    /// Refuses when the target is missing, when no remote resolves and no
132    /// override covers the gap, when the host is unrecognized, and when the
133    /// forge CLI is not on `PATH`.
134    pub fn resolve(
135        target: &Utf8PathBuf,
136        repo_flag: Option<&str>,
137        forge_flag: Option<&str>,
138        required_check: Option<&str>,
139    ) -> Result<Self, RkError> {
140        if !target.is_dir() {
141            return Err(RkError::missing(
142                Diagnostic::new(
143                    Reason::TargetNotFound,
144                    format!("target {target} is not a directory; nothing was run"),
145                )
146                .expected("an existing repository to set up"),
147            ));
148        }
149        let forge_flag = forge_flag
150            .map(|name| {
151                detect::Forge::parse(name).ok_or_else(|| {
152                    RkError::Usage(format!(
153                        "unknown forge '{name}'; the forges are: github, gitlab"
154                    ))
155                })
156            })
157            .transpose()?;
158        let detected = detect::detect(target.as_std_path());
159        let config = crate::config::load(target.as_std_path())?;
160        let record = crate::landing::manifest::load(target)?;
161        // The setup reads the same target configuration a landing records,
162        // so which steps apply follows the profile rather than a second
163        // detection of its own.
164        let resolved = crate::profile::Params::resolve(
165            target,
166            &crate::profile::Inputs {
167                forge: forge_flag.map(Forge::as_str),
168                repo: repo_flag,
169                ..crate::profile::Inputs::default()
170            },
171            config.as_ref(),
172            record.as_ref(),
173            crate::profile::Purpose::Preview,
174        )?;
175        let declared_forge = resolved.forge().map(str::to_owned);
176        let forge = declared_forge.as_deref().and_then(Forge::parse);
177        let repo = resolved.repo().to_owned();
178        let repo = if repo == crate::projection::REPO_PLACEHOLDER {
179            String::new()
180        } else {
181            repo
182        };
183        // The forge CLI is not a prerequisite of building a context: it is
184        // a prerequisite of the steps a run actually calls the forge for,
185        // which `require_cli` resolves at that point.
186        let cli = PathBuf::new();
187        let answers = config
188            .as_ref()
189            .map_or_else(crate::config::Setup::default, |held| held.setup.clone());
190        // The flag wins, and the committed answer fills the gap on GitHub
191        // alone: GitLab names no individual check and refuses a supplied
192        // one, so a shared configuration must not make that refusal fire.
193        let required_check = required_check.map(str::to_owned).or_else(|| {
194            Some(answers.required_check.clone())
195                .filter(|name| !name.is_empty() && forge == Some(Forge::Github))
196        });
197        let bot_app_id = Some(answers.bot.app_id.clone()).filter(|id| !id.is_empty());
198        let trunk = resolved.trunk().to_owned();
199        let protection = config
200            .as_ref()
201            .map_or_else(crate::config::Protection::default, |held| {
202                held.protection.clone()
203            });
204        Ok(Self {
205            target: target.clone(),
206            repo,
207            forge,
208            host: detected.host,
209            required_check,
210            cli,
211            tech: resolved.driver().and_then(|driver| {
212                ["rust", "python", "bash"]
213                    .into_iter()
214                    .find(|known| *known == driver)
215            }),
216            trunk_ruleset: protection.trunk_ruleset(&trunk),
217            tag_ruleset: protection.tag_ruleset.clone(),
218            lines_ruleset: protection.lines_ruleset.clone(),
219            title_check: protection.title_check.clone(),
220            protection,
221            trunk,
222            line_prefix: resolved.line_prefix().to_owned(),
223            profile: resolved.profile().clone(),
224            capabilities: resolved.capabilities().clone(),
225            declared_forge,
226            retired_branches: answers.retired_branches,
227            release_lines: answers.release_lines,
228            excluded_steps: answers.excluded_steps,
229            bot_app_id,
230        })
231    }
232
233    /// Resolve the forge CLI where this run will call the forge for one of
234    /// `steps`, and refuse where it cannot be found.
235    ///
236    /// The prerequisite belongs to the call, not to the command. A step is
237    /// only a caller when the target's configuration selects it, the
238    /// target has not excluded it, and the step reaches the forge at this
239    /// particular forge. A preview writes nothing, and asks anyway for the
240    /// steps it would act on, because it names the command it would run
241    /// and a CLI nothing could find is worth saying then.
242    ///
243    /// # Errors
244    /// Propagates the refusal when the forge CLI cannot be resolved.
245    ///
246    /// SATISFIES forge-setup:applicability-follows-the-target-configuration
247    pub fn require_cli(&mut self, steps: &[&crate::setup::steps::StepSpec]) -> Result<(), RkError> {
248        let Some(forge) = self.forge else {
249            return Ok(());
250        };
251        let calls = steps.iter().any(|step| {
252            step.forge_cli.contains(&forge) && crate::commands::setup::stance(self, step).acts()
253        });
254        if calls && self.cli.as_os_str().is_empty() {
255            self.cli = resolve_cli(forge)?;
256        }
257        Ok(())
258    }
259
260    /// A context the integration tests build directly, for an observer
261    /// exercised against recorded forge answers rather than a repository.
262    /// The trunk and the prefix take their compiled defaults, because such
263    /// a test reads no target configuration.
264    #[doc(hidden)]
265    #[must_use]
266    pub fn for_tests(
267        target: Utf8PathBuf,
268        repo: String,
269        forge: Forge,
270        cli: PathBuf,
271        tech: Option<&'static str>,
272    ) -> Self {
273        let defaults = crate::config::Protection::default();
274        Self {
275            target,
276            repo,
277            forge: Some(forge),
278            declared_forge: Some(forge.as_str().to_owned()),
279            profile: ProfileSnapshot {
280                technologies: tech.into_iter().map(str::to_owned).collect(),
281                forge: Some(forge.as_str().to_owned()),
282                release: crate::profile::ReleaseIntent {
283                    mode: ReleaseMode::Automatic,
284                    driver: tech.map(str::to_owned),
285                    style: Some(crate::landing::Style::Trunk),
286                    line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
287                },
288            },
289            capabilities: CapabilityRequests {
290                nix_packaging: false,
291                reporting_policy: true,
292                scorecard: false,
293                code_scanning: None,
294            },
295            host: None,
296            required_check: None,
297            cli,
298            tech,
299            trunk: crate::config::TRUNK_DEFAULT.to_owned(),
300            line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
301            retired_branches: crate::config::Setup::default().retired_branches,
302            release_lines: false,
303            excluded_steps: std::collections::BTreeMap::new(),
304            bot_app_id: None,
305            trunk_ruleset: format!("{}-protection", crate::config::TRUNK_DEFAULT),
306            tag_ruleset: defaults.tag_ruleset.clone(),
307            lines_ruleset: defaults.lines_ruleset.clone(),
308            title_check: defaults.title_check.clone(),
309            protection: defaults,
310        }
311    }
312
313    /// Whether the run has a forge adapter to act through.
314    #[must_use]
315    pub const fn has_adapter(&self) -> bool {
316        self.forge.is_some()
317    }
318
319    /// The forge adapter, or the refusal a forge operation answers where
320    /// the profile names no forge this release drives.
321    ///
322    /// # Errors
323    ///
324    /// A `prerequisite-unmet` refusal naming the declared forge and the
325    /// ones this release drives.
326    pub fn adapter(&self) -> Result<Forge, RkError> {
327        self.forge.ok_or_else(|| {
328            let named = self.declared_forge.as_deref();
329            let message = named.map_or_else(
330                || "the profile names no forge, and this operation acts on one".to_owned(),
331                |name| {
332                    format!(
333                        "the profile names the forge {name}, which this release has no adapter for"
334                    )
335                },
336            );
337            RkError::refusal(
338                Diagnostic::new(Reason::PrerequisiteUnmet, message)
339                    .expected("a profile naming github or gitlab")
340                    .action("set profile.forge in .release-kit/config.toml, or pass --forge <github|gitlab>")
341                    .target_state("unchanged"),
342            )
343        })
344    }
345
346    /// Whether this target's release is one release-kit drives.
347    #[must_use]
348    pub const fn automatic_release(&self) -> bool {
349        matches!(self.profile.release.mode, ReleaseMode::Automatic)
350    }
351
352    /// The release driver, where the profile names one.
353    #[must_use]
354    pub fn driver(&self) -> Option<&str> {
355        self.profile.release.driver.as_deref()
356    }
357
358    /// The forge the profile declares, whatever the adapter says.
359    #[must_use]
360    pub fn declared_forge(&self) -> Option<&str> {
361        self.declared_forge.as_deref()
362    }
363
364    /// Whether the target requested the landed reporting policy.
365    #[must_use]
366    pub const fn reporting_policy(&self) -> bool {
367        self.capabilities.reporting_policy
368    }
369
370    /// The one permanent branch this run asserts.
371    #[must_use]
372    pub fn trunk(&self) -> &str {
373        &self.trunk
374    }
375
376    /// The release-line prefix this run asserts.
377    #[must_use]
378    pub fn line_prefix(&self) -> &str {
379        &self.line_prefix
380    }
381
382    /// The long-lived branches this run's single-trunk step retires.
383    #[must_use]
384    pub fn retired_branches(&self) -> &[String] {
385        &self.retired_branches
386    }
387
388    /// Whether a full apply runs the release-line protection.
389    #[must_use]
390    pub const fn release_lines(&self) -> bool {
391        self.release_lines
392    }
393
394    /// Why this target does not run the named step, where it declared an
395    /// exclusion for it. The reason is what the report prints, so an
396    /// excluded step is always visible with the answer behind it.
397    #[must_use]
398    pub fn excluded(&self, step: &str) -> Option<&str> {
399        self.excluded_steps.get(step).map(String::as_str)
400    }
401
402    /// How many steps this target declared it does not run.
403    #[must_use]
404    pub fn excluded_count(&self) -> usize {
405        self.excluded_steps.len()
406    }
407
408    /// The bot App's public identifier this target states, where it does.
409    #[must_use]
410    pub fn bot_app_id(&self) -> Option<&str> {
411        self.bot_app_id.as_deref()
412    }
413
414    /// The ruleset that protects the trunk.
415    #[must_use]
416    pub fn trunk_ruleset(&self) -> &str {
417        &self.trunk_ruleset
418    }
419
420    /// The ruleset that makes published tags immutable.
421    #[must_use]
422    pub fn tag_ruleset(&self) -> &str {
423        &self.tag_ruleset
424    }
425
426    /// The ruleset that protects the release lines.
427    #[must_use]
428    pub fn lines_ruleset(&self) -> &str {
429        &self.lines_ruleset
430    }
431
432    /// The context the landed title job reports under.
433    #[must_use]
434    pub fn title_check(&self) -> &str {
435        &self.title_check
436    }
437
438    /// The floored policy this target states, already judged at load.
439    #[must_use]
440    pub const fn protection(&self) -> &crate::config::Protection {
441        &self.protection
442    }
443
444    /// Whether this run targets a GitLab instance that is not gitlab.com,
445    /// where registry trusted publishing cannot reach.
446    #[must_use]
447    pub fn self_hosted_gitlab(&self) -> bool {
448        self.forge == Some(Forge::Gitlab)
449            && self
450                .host
451                .as_deref()
452                .is_some_and(|host| host != "gitlab.com")
453    }
454
455    /// The constructed environment a step receives. Secrets enter only for
456    /// the step that consumes them; the caller records their handling.
457    #[must_use]
458    #[allow(
459        clippy::too_many_lines,
460        reason = "one pass builds the whole environment a step receives, and splitting it would separate a variable from the value it carries"
461    )]
462    pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
463        let mut env: Vec<(OsString, OsString)> = vec![
464            (
465                "RK_FORGE".into(),
466                self.forge.map_or("", Forge::as_str).into(),
467            ),
468            ("RK_REPO".into(), self.repo.clone().into()),
469            ("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
470            ("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
471            ("RK_TRUNK_RULESET".into(), self.trunk_ruleset.clone().into()),
472            ("RK_TAG_RULESET".into(), self.tag_ruleset.clone().into()),
473            ("RK_LINES_RULESET".into(), self.lines_ruleset.clone().into()),
474            ("RK_TITLE_CHECK".into(), self.title_check.clone().into()),
475            // The floored policy, already judged against the floor table
476            // at load. A step receives values, never a judgment: one
477            // owner decides what passes, and it is not a shell script.
478            (
479                "RK_TAG_PATTERN".into(),
480                self.protection.tag_pattern.clone().into(),
481            ),
482            (
483                "RK_REVIEW_COUNT".into(),
484                self.protection
485                    .required_approving_review_count
486                    .to_string()
487                    .into(),
488            ),
489            (
490                "RK_DISMISS_STALE_REVIEWS".into(),
491                bool_word(self.protection.dismiss_stale_reviews_on_push).into(),
492            ),
493            (
494                "RK_CODE_OWNER_REVIEW".into(),
495                bool_word(self.protection.require_code_owner_review).into(),
496            ),
497            (
498                "RK_LAST_PUSH_APPROVAL".into(),
499                bool_word(self.protection.require_last_push_approval).into(),
500            ),
501            (
502                "RK_MERGE_METHODS".into(),
503                json_list(&self.protection.allowed_merge_methods).into(),
504            ),
505            (
506                "RK_STRICT_CHECKS".into(),
507                bool_word(self.protection.strict_required_status_checks).into(),
508            ),
509            (
510                "RK_SQUASH_TITLE_SOURCE".into(),
511                self.protection.github.squash_title_source.clone().into(),
512            ),
513            (
514                "RK_SQUASH_BODY_SOURCE".into(),
515                self.protection.github.squash_body_source.clone().into(),
516            ),
517            (
518                "RK_GITLAB_MERGE_METHOD".into(),
519                self.protection.gitlab.merge_method.clone().into(),
520            ),
521            (
522                "RK_GITLAB_SQUASH_OPTION".into(),
523                self.protection.gitlab.squash_option.clone().into(),
524            ),
525            (
526                "RK_GITLAB_SQUASH_TEMPLATE".into(),
527                self.protection.gitlab.squash_commit_template.clone().into(),
528            ),
529            (
530                "RK_GITLAB_PUSH_LEVEL".into(),
531                self.protection.gitlab.push_access_level.to_string().into(),
532            ),
533            (
534                "RK_GITLAB_MERGE_LEVEL".into(),
535                self.protection.gitlab.merge_access_level.to_string().into(),
536            ),
537            ("GH_PAGER".into(), "".into()),
538            ("GLAB_PAGER".into(), "".into()),
539        ];
540        if let Some(check) = &self.required_check
541            && self.forge == Some(Forge::Github)
542            && matches!(step, "protect-trunk" | "protections-check")
543        {
544            env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
545        }
546        for name in PASSTHROUGH {
547            if let Some(value) = std::env::var_os(name) {
548                env.push((name.into(), value));
549            }
550        }
551        // The forge CLI override substitutes the binary for the run's own
552        // calls; a step resolves the CLI by name, so the override's
553        // directory leads the child's search path.
554        if let Some(dir) = self.cli_override_dir() {
555            let mut paths: Vec<PathBuf> = vec![dir];
556            if let Some(existing) = std::env::var_os("PATH") {
557                paths.extend(std::env::split_paths(&existing));
558            }
559            if let Ok(joined) = std::env::join_paths(paths) {
560                env.retain(|(name, _)| name != "PATH");
561                env.push(("PATH".into(), joined));
562            }
563        }
564        if step == "bot-secrets" {
565            for name in SECRET_VARS {
566                if let Some(value) = secrets::value_of(name) {
567                    env.push((name.into(), value));
568                }
569            }
570        }
571        env
572    }
573
574    /// The directory of an explicitly overridden forge CLI, where one is set.
575    fn cli_override_dir(&self) -> Option<PathBuf> {
576        let overridden = std::env::var_os(match self.forge? {
577            Forge::Github => "RK_GH_BIN",
578            Forge::Gitlab => "RK_GLAB_BIN",
579        })?;
580        Path::new(&overridden).parent().map(Path::to_path_buf)
581    }
582
583    /// The secret bytes a run must keep out of its own output: the values
584    /// the environment carries. Every buffer is scrubbed on drop; none is
585    /// ever logged or echoed.
586    ///
587    /// Key material is not read here. The step that transmits a key adds
588    /// the very bytes it sends, so the needle cannot describe one file
589    /// while the child receives another.
590    #[must_use]
591    pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
592        SECRET_VARS
593            .iter()
594            .filter_map(|name| secrets::value_of(name))
595            .map(|value| Zeroizing::new(value.into_encoded_bytes()))
596            .collect()
597    }
598}
599
600/// Resolve the forge CLI once, at context time: the `RK_GH_BIN` and
601/// `RK_GLAB_BIN` overrides first, then a `PATH` search.
602///
603/// Not found and not executable are distinct failures, in the shell
604/// convention. `rk branches prune` shares it for the verify path.
605///
606/// # Errors
607///
608/// Refuses when the override or the search resolves no usable binary.
609pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
610    let override_var = match forge {
611        Forge::Github => "RK_GH_BIN",
612        Forge::Gitlab => "RK_GLAB_BIN",
613    };
614    if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
615        let path = PathBuf::from(&overridden);
616        if !path.is_file() {
617            return Err(RkError::refusal(
618                Diagnostic::new(
619                    Reason::PrerequisiteUnmet,
620                    format!(
621                        "{override_var} names {}, which does not exist",
622                        path.display()
623                    ),
624                )
625                .expected("the override to name the forge CLI binary"),
626            ));
627        }
628        // The scripts invoke the CLI by its canonical name through the
629        // child's search path, so an override under any other name would
630        // split one lifecycle across two binaries: observed through the
631        // override, applied through whatever the name resolves to.
632        if path.file_name().is_none_or(|name| name != forge.cli()) {
633            return Err(RkError::refusal(
634                Diagnostic::new(
635                    Reason::PrerequisiteUnmet,
636                    format!(
637                        "{override_var} must name a binary called {}, and {} is not one",
638                        forge.cli(),
639                        path.display()
640                    ),
641                )
642                .expected(format!(
643                    "an override whose file name is {}, so scripts and observations run one binary",
644                    forge.cli()
645                )),
646            ));
647        }
648        return Ok(path);
649    }
650    let name = forge.cli();
651    let found = std::env::var_os("PATH").and_then(|path| {
652        std::env::split_paths(&path)
653            .map(|dir| dir.join(name))
654            .find(|candidate| candidate.is_file())
655    });
656    found.ok_or_else(|| {
657        RkError::refusal(
658            Diagnostic::new(
659                Reason::PrerequisiteUnmet,
660                format!(
661                    "{name} is not on PATH, and a step this run acts on calls it on {}",
662                    forge.as_str()
663                ),
664            )
665            .expected(format!("the {name} CLI installed and authenticated"))
666            .action(format!("install {name}, then run {name} auth login")),
667        )
668    })
669}