use std::ffi::OsString;
use std::path::{Path, PathBuf};
use camino::Utf8PathBuf;
use zeroize::Zeroizing;
use super::secrets;
use crate::detect::{self, Forge};
use crate::diagnostic::{Diagnostic, Reason};
use crate::error::RkError;
use crate::profile::{CapabilityRequests, ProfileSnapshot, ReleaseMode};
const fn bool_word(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn json_list(values: &[String]) -> String {
let inner: Vec<String> = values.iter().map(|value| format!("\"{value}\"")).collect();
format!("[{}]", inner.join(", "))
}
fn compose_trunk_rules(
protection: &crate::config::Protection,
required_check: &str,
title_check: &str,
) -> String {
let rules: Vec<serde_json::Value> = protection
.owned_trunk_rules
.iter()
.map(|rule| match rule.as_str() {
"pull_request" => serde_json::json!({
"type": "pull_request",
"parameters": {
"required_approving_review_count": protection.required_approving_review_count,
"dismiss_stale_reviews_on_push": protection.dismiss_stale_reviews_on_push,
"require_code_owner_review": protection.require_code_owner_review,
"require_last_push_approval": protection.require_last_push_approval,
"required_review_thread_resolution": false,
"require_extra_approval_for_unattributed_changes": false,
"allowed_merge_methods": protection.allowed_merge_methods,
}
}),
"required_status_checks" => serde_json::json!({
"type": "required_status_checks",
"parameters": {
"do_not_enforce_on_create": true,
"strict_required_status_checks_policy":
protection.strict_required_status_checks,
"required_status_checks": [
{ "context": required_check },
{ "context": title_check },
],
}
}),
other => serde_json::json!({ "type": other }),
})
.collect();
serde_json::to_string_pretty(&rules).unwrap_or_else(|_| "[]".to_owned())
}
fn effective_protection(
stated: Option<&crate::config::Protection>,
integration: crate::landing::Integration,
) -> crate::config::Protection {
if let Some(stated) = stated {
return stated.clone();
}
let mut policy = crate::config::Protection::default();
if integration == crate::landing::Integration::Local {
const MAINTAINER: i64 = 40;
policy
.owned_trunk_rules
.retain(|rule| rule != "pull_request" && rule != "required_status_checks");
policy.gitlab.push_access_level = MAINTAINER;
}
policy
}
const PASSTHROUGH: [&str; 11] = [
"PATH",
"HOME",
"XDG_CONFIG_HOME",
"GH_TOKEN",
"GITHUB_TOKEN",
"GH_HOST",
"GH_CONFIG_DIR",
"GLAB_TOKEN",
"GITLAB_TOKEN",
"GITLAB_HOST",
"GLAB_CONFIG_DIR",
];
pub use super::secrets::VALUE_VARS as SECRET_VARS;
#[derive(Debug, Clone)]
pub struct Ctx {
pub target: Utf8PathBuf,
pub repo: String,
pub forge: Option<Forge>,
pub declared_forge: Option<String>,
pub profile: ProfileSnapshot,
pub capabilities: CapabilityRequests,
pub host: Option<String>,
pub required_check: Option<String>,
pub cli: PathBuf,
pub tech: Option<&'static str>,
trunk: String,
line_prefix: String,
retired_branches: Vec<String>,
release_lines: bool,
excluded_steps: std::collections::BTreeMap<String, String>,
bot_app_id: Option<String>,
trunk_ruleset: String,
tag_ruleset: String,
lines_ruleset: String,
title_check: String,
protection: crate::config::Protection,
integration: crate::landing::Integration,
}
impl Ctx {
pub fn resolve(
target: &Utf8PathBuf,
repo_flag: Option<&str>,
forge_flag: Option<&str>,
required_check: Option<&str>,
) -> Result<Self, RkError> {
if !target.is_dir() {
return Err(RkError::missing(
Diagnostic::new(
Reason::TargetNotFound,
format!("target {target} is not a directory; nothing was run"),
)
.expected("an existing repository to set up"),
));
}
let forge_flag = forge_flag
.map(|name| {
detect::Forge::parse(name).ok_or_else(|| {
RkError::Usage(format!(
"unknown forge '{name}'; the forges are: github, gitlab"
))
})
})
.transpose()?;
let detected = detect::detect(target.as_std_path());
let config = crate::config::load(target.as_std_path())?;
let record = crate::landing::manifest::load(target)?;
let resolved = crate::profile::Params::resolve(
target,
&crate::profile::Inputs {
forge: forge_flag.map(Forge::as_str),
repo: repo_flag,
..crate::profile::Inputs::default()
},
config.as_ref(),
record.as_ref(),
crate::profile::Purpose::Preview,
)?;
let declared_forge = resolved.forge().map(str::to_owned);
let forge = declared_forge.as_deref().and_then(Forge::parse);
let repo = resolved.repo().to_owned();
let repo = if repo == crate::projection::REPO_PLACEHOLDER {
String::new()
} else {
repo
};
let cli = PathBuf::new();
let answers = config
.as_ref()
.map_or_else(crate::config::Setup::default, |held| held.setup.clone());
let required_check = required_check.map(str::to_owned).or_else(|| {
Some(answers.required_check.clone())
.filter(|name| !name.is_empty() && forge == Some(Forge::Github))
});
let bot_app_id = Some(answers.bot.app_id.clone()).filter(|id| !id.is_empty());
let trunk = resolved.trunk().to_owned();
let integration = record
.as_ref()
.map_or_else(crate::landing::manifest::integration_forge, |held| {
held.git.integration
});
let stated = config.as_ref().map(|held| &held.protection);
let protection = effective_protection(stated, integration);
Ok(Self {
target: target.clone(),
repo,
forge,
host: detected.host,
required_check,
cli,
tech: resolved.driver().and_then(|driver| {
["rust", "python", "bash"]
.into_iter()
.find(|known| *known == driver)
}),
trunk_ruleset: protection.trunk_ruleset(&trunk),
tag_ruleset: protection.tag_ruleset.clone(),
lines_ruleset: protection.lines_ruleset.clone(),
title_check: protection.title_check.clone(),
protection,
integration,
trunk,
line_prefix: resolved.line_prefix().to_owned(),
profile: resolved.profile().clone(),
capabilities: resolved.capabilities().clone(),
declared_forge,
retired_branches: answers.retired_branches,
release_lines: answers.release_lines,
excluded_steps: answers.excluded_steps,
bot_app_id,
})
}
pub fn require_cli(&mut self, steps: &[&crate::setup::steps::StepSpec]) -> Result<(), RkError> {
let Some(forge) = self.forge else {
return Ok(());
};
let calls = steps.iter().any(|step| {
step.forge_cli.contains(&forge) && crate::commands::setup::stance(self, step).acts()
});
if calls && self.cli.as_os_str().is_empty() {
self.cli = resolve_cli(forge)?;
}
Ok(())
}
#[doc(hidden)]
#[must_use]
pub fn for_tests(
target: Utf8PathBuf,
repo: String,
forge: Forge,
cli: PathBuf,
tech: Option<&'static str>,
) -> Self {
let defaults = crate::config::Protection::default();
Self {
integration: crate::landing::Integration::Forge,
target,
repo,
forge: Some(forge),
declared_forge: Some(forge.as_str().to_owned()),
profile: ProfileSnapshot {
technologies: tech.into_iter().map(str::to_owned).collect(),
forge: Some(forge.as_str().to_owned()),
release: crate::profile::ReleaseIntent {
mode: ReleaseMode::Automatic,
driver: tech.map(str::to_owned),
style: Some(crate::landing::Style::Trunk),
line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
},
},
capabilities: CapabilityRequests {
nix_packaging: false,
reporting_policy: true,
scorecard: false,
code_scanning: None,
},
host: None,
required_check: None,
cli,
tech,
trunk: crate::config::TRUNK_DEFAULT.to_owned(),
line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
retired_branches: crate::config::Setup::default().retired_branches,
release_lines: false,
excluded_steps: std::collections::BTreeMap::new(),
bot_app_id: None,
trunk_ruleset: format!("{}-protection", crate::config::TRUNK_DEFAULT),
tag_ruleset: defaults.tag_ruleset.clone(),
lines_ruleset: defaults.lines_ruleset.clone(),
title_check: defaults.title_check.clone(),
protection: defaults,
}
}
#[must_use]
pub const fn has_adapter(&self) -> bool {
self.forge.is_some()
}
pub fn adapter(&self) -> Result<Forge, RkError> {
self.forge.ok_or_else(|| {
let named = self.declared_forge.as_deref();
let message = named.map_or_else(
|| "the profile names no forge, and this operation acts on one".to_owned(),
|name| {
format!(
"the profile names the forge {name}, which this release has no adapter for"
)
},
);
RkError::refusal(
Diagnostic::new(Reason::PrerequisiteUnmet, message)
.expected("a profile naming github or gitlab")
.action("set profile.forge in .release-kit/config.toml, or pass --forge <github|gitlab>")
.target_state("unchanged"),
)
})
}
#[must_use]
pub const fn automatic_release(&self) -> bool {
matches!(self.profile.release.mode, ReleaseMode::Automatic)
}
#[must_use]
pub fn driver(&self) -> Option<&str> {
self.profile.release.driver.as_deref()
}
#[must_use]
pub fn declared_forge(&self) -> Option<&str> {
self.declared_forge.as_deref()
}
#[must_use]
pub const fn reporting_policy(&self) -> bool {
self.capabilities.reporting_policy
}
#[must_use]
pub fn trunk(&self) -> &str {
&self.trunk
}
#[must_use]
pub fn line_prefix(&self) -> &str {
&self.line_prefix
}
#[must_use]
pub fn retired_branches(&self) -> &[String] {
&self.retired_branches
}
#[must_use]
pub const fn release_lines(&self) -> bool {
self.release_lines
}
#[must_use]
pub fn excluded(&self, step: &str) -> Option<&str> {
self.excluded_steps.get(step).map(String::as_str)
}
#[must_use]
pub fn excluded_count(&self) -> usize {
self.excluded_steps.len()
}
#[must_use]
pub fn bot_app_id(&self) -> Option<&str> {
self.bot_app_id.as_deref()
}
#[must_use]
pub fn trunk_ruleset(&self) -> &str {
&self.trunk_ruleset
}
#[must_use]
pub fn tag_ruleset(&self) -> &str {
&self.tag_ruleset
}
#[must_use]
pub fn lines_ruleset(&self) -> &str {
&self.lines_ruleset
}
#[must_use]
pub fn title_check(&self) -> &str {
&self.title_check
}
#[must_use]
pub const fn integration(&self) -> crate::landing::Integration {
self.integration
}
fn trunk_rules(&self) -> String {
compose_trunk_rules(
&self.protection,
self.required_check.as_deref().unwrap_or_default(),
&self.title_check,
)
}
#[must_use]
pub const fn protection(&self) -> &crate::config::Protection {
&self.protection
}
#[must_use]
pub fn self_hosted_gitlab(&self) -> bool {
self.forge == Some(Forge::Gitlab)
&& self
.host
.as_deref()
.is_some_and(|host| host != "gitlab.com")
}
#[must_use]
#[allow(
clippy::too_many_lines,
reason = "one pass builds the whole environment a step receives, and splitting it would separate a variable from the value it carries"
)]
pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
let mut env: Vec<(OsString, OsString)> = vec![
(
"RK_FORGE".into(),
self.forge.map_or("", Forge::as_str).into(),
),
("RK_REPO".into(), self.repo.clone().into()),
("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
("RK_TRUNK_RULESET".into(), self.trunk_ruleset.clone().into()),
("RK_TAG_RULESET".into(), self.tag_ruleset.clone().into()),
("RK_LINES_RULESET".into(), self.lines_ruleset.clone().into()),
("RK_TITLE_CHECK".into(), self.title_check.clone().into()),
(
"RK_TAG_PATTERN".into(),
self.protection.tag_pattern.clone().into(),
),
(
"RK_REVIEW_COUNT".into(),
self.protection
.required_approving_review_count
.to_string()
.into(),
),
(
"RK_DISMISS_STALE_REVIEWS".into(),
bool_word(self.protection.dismiss_stale_reviews_on_push).into(),
),
(
"RK_CODE_OWNER_REVIEW".into(),
bool_word(self.protection.require_code_owner_review).into(),
),
(
"RK_LAST_PUSH_APPROVAL".into(),
bool_word(self.protection.require_last_push_approval).into(),
),
(
"RK_MERGE_METHODS".into(),
json_list(&self.protection.allowed_merge_methods).into(),
),
(
"RK_STRICT_CHECKS".into(),
bool_word(self.protection.strict_required_status_checks).into(),
),
(
"RK_SQUASH_TITLE_SOURCE".into(),
self.protection.github.squash_title_source.clone().into(),
),
(
"RK_SQUASH_BODY_SOURCE".into(),
self.protection.github.squash_body_source.clone().into(),
),
(
"RK_GITLAB_MERGE_METHOD".into(),
self.protection.gitlab.merge_method.clone().into(),
),
(
"RK_GITLAB_SQUASH_OPTION".into(),
self.protection.gitlab.squash_option.clone().into(),
),
(
"RK_GITLAB_SQUASH_TEMPLATE".into(),
self.protection.gitlab.squash_commit_template.clone().into(),
),
(
"RK_GITLAB_PUSH_LEVEL".into(),
self.protection.gitlab.push_access_level.to_string().into(),
),
("RK_TRUNK_RULES".into(), self.trunk_rules().into()),
(
"RK_GITLAB_MERGE_LEVEL".into(),
self.protection.gitlab.merge_access_level.to_string().into(),
),
("GH_PAGER".into(), "".into()),
("GLAB_PAGER".into(), "".into()),
];
if let Some(check) = &self.required_check
&& self.forge == Some(Forge::Github)
&& matches!(step, "protect-trunk" | "protections-check")
{
env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
}
for name in PASSTHROUGH {
if let Some(value) = std::env::var_os(name) {
env.push((name.into(), value));
}
}
if let Some(dir) = self.cli_override_dir() {
let mut paths: Vec<PathBuf> = vec![dir];
if let Some(existing) = std::env::var_os("PATH") {
paths.extend(std::env::split_paths(&existing));
}
if let Ok(joined) = std::env::join_paths(paths) {
env.retain(|(name, _)| name != "PATH");
env.push(("PATH".into(), joined));
}
}
if step == "bot-secrets" {
for name in SECRET_VARS {
if let Some(value) = secrets::value_of(name) {
env.push((name.into(), value));
}
}
}
env
}
fn cli_override_dir(&self) -> Option<PathBuf> {
let overridden = std::env::var_os(match self.forge? {
Forge::Github => "RK_GH_BIN",
Forge::Gitlab => "RK_GLAB_BIN",
})?;
Path::new(&overridden).parent().map(Path::to_path_buf)
}
#[must_use]
pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
SECRET_VARS
.iter()
.filter_map(|name| secrets::value_of(name))
.map(|value| Zeroizing::new(value.into_encoded_bytes()))
.collect()
}
}
pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
let override_var = match forge {
Forge::Github => "RK_GH_BIN",
Forge::Gitlab => "RK_GLAB_BIN",
};
if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
let path = PathBuf::from(&overridden);
if !path.is_file() {
return Err(RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
format!(
"{override_var} names {}, which does not exist",
path.display()
),
)
.expected("the override to name the forge CLI binary"),
));
}
if path.file_name().is_none_or(|name| name != forge.cli()) {
return Err(RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
format!(
"{override_var} must name a binary called {}, and {} is not one",
forge.cli(),
path.display()
),
)
.expected(format!(
"an override whose file name is {}, so scripts and observations run one binary",
forge.cli()
)),
));
}
return Ok(path);
}
let name = forge.cli();
let found = std::env::var_os("PATH").and_then(|path| {
std::env::split_paths(&path)
.map(|dir| dir.join(name))
.find(|candidate| candidate.is_file())
});
found.ok_or_else(|| {
RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
format!(
"{name} is not on PATH, and a step this run acts on calls it on {}",
forge.as_str()
),
)
.expected(format!("the {name} CLI installed and authenticated"))
.action(format!("install {name}, then run {name} auth login")),
)
})
}
#[cfg(test)]
mod tests {
#[test]
fn the_trunk_rules_follow_the_owned_rule_key() {
let mut policy = crate::config::Protection::default();
let forge = super::compose_trunk_rules(&policy, "gate", "pr-title");
let parsed: Vec<serde_json::Value> = serde_json::from_str(&forge).expect("the rules parse");
let kinds: Vec<&str> = parsed
.iter()
.filter_map(|rule| rule["type"].as_str())
.collect();
assert_eq!(
kinds,
[
"deletion",
"non_fast_forward",
"pull_request",
"required_status_checks"
]
);
let checks = parsed
.iter()
.find(|rule| rule["type"] == "required_status_checks")
.expect("the check rule");
assert_eq!(
checks["parameters"]["required_status_checks"],
serde_json::json!([{ "context": "gate" }, { "context": "pr-title" }])
);
policy.owned_trunk_rules = vec!["deletion".into(), "non_fast_forward".into()];
let local = super::compose_trunk_rules(&policy, "gate", "pr-title");
let parsed: Vec<serde_json::Value> = serde_json::from_str(&local).expect("the rules parse");
let kinds: Vec<&str> = parsed
.iter()
.filter_map(|rule| rule["type"].as_str())
.collect();
assert_eq!(kinds, ["deletion", "non_fast_forward"]);
assert!(!local.contains("pull_request"), "{local}");
assert!(!local.contains("required_status_checks"), "{local}");
}
#[test]
fn the_effective_policy_follows_the_recorded_authority() {
use crate::landing::Integration;
let silent = super::effective_protection(None, Integration::Forge);
assert!(
silent
.owned_trunk_rules
.contains(&"pull_request".to_owned())
);
assert_eq!(silent.gitlab.push_access_level, 0);
let silent = super::effective_protection(None, Integration::Local);
assert_eq!(
silent.owned_trunk_rules,
["deletion".to_owned(), "non_fast_forward".to_owned()],
"no forge can apply a request rule to a push"
);
assert_eq!(
silent.gitlab.push_access_level, 40,
"zero would close the trunk to the push this mode ends in"
);
let mut stated = crate::config::Protection::default();
stated.gitlab.push_access_level = 0;
stated.owned_trunk_rules = vec!["deletion".into()];
let held = super::effective_protection(Some(&stated), Integration::Local);
assert_eq!(held.gitlab.push_access_level, 0, "a stated value wins");
assert_eq!(held.owned_trunk_rules, ["deletion".to_owned()]);
}
}