use serde_json::Value;
use crate::cargo_package::{self, Answer, POLICY_DESTINATION, Probe};
use crate::detect::Forge;
use crate::error::RkError;
use crate::setup::app_jwt::{self, AppApi};
use crate::setup::context::Ctx;
use crate::setup::process::{Exec, Outcome};
use crate::setup::workflow_jobs;
pub type Runner<'a> = dyn FnMut(&Exec) -> Result<Outcome, RkError> + 'a;
const GITLAB_PRIVATE_REPORTING_LIMITATION: &str = "GitLab has no project-level private reporting switch; the reporter must enable confidentiality; this proves project feature access, not successful submission by every external reporter";
#[derive(Debug)]
pub enum StepState {
Satisfied {
detail: String,
limitation: Option<String>,
},
Unsatisfied {
detail: String,
},
Inapplicable {
detail: String,
},
Unknown {
detail: String,
},
}
impl StepState {
#[must_use]
pub fn detail(&self) -> &str {
match self {
Self::Satisfied { detail, .. }
| Self::Unsatisfied { detail }
| Self::Inapplicable { detail }
| Self::Unknown { detail } => detail,
}
}
#[must_use]
pub const fn satisfied(&self) -> bool {
matches!(self, Self::Satisfied { .. })
}
fn ok(detail: impl Into<String>) -> Self {
Self::Satisfied {
detail: detail.into(),
limitation: None,
}
}
fn ok_with_limitation(detail: impl Into<String>, limitation: impl Into<String>) -> Self {
Self::Satisfied {
detail: detail.into(),
limitation: Some(limitation.into()),
}
}
fn not(detail: impl Into<String>) -> Self {
Self::Unsatisfied {
detail: detail.into(),
}
}
fn inapplicable(detail: impl Into<String>) -> Self {
Self::Inapplicable {
detail: detail.into(),
}
}
fn unknown(detail: impl Into<String>) -> Self {
Self::Unknown {
detail: detail.into(),
}
}
}
pub(crate) enum Api {
Ok(Value),
Missing,
Failed(String),
}
pub fn observe(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
if step == "package-check" {
return package_check(ctx, run);
}
if step == "branch-reminder" {
return Ok(branch_reminder_state(ctx));
}
if step == "forge-version" {
return forge_version(ctx, run);
}
match ctx.forge {
Some(Forge::Github) => github(ctx, step, run),
Some(Forge::Gitlab) => gitlab(ctx, step, run),
None => Ok(StepState::inapplicable(
"the profile names no forge this release drives",
)),
}
}
const PYTHON_LIMITATION: &str = "sdist and wheel policy inclusion is unproved: PEP 517 leaves the file set to the build backend and the two outputs can differ; inspect both before publishing";
const BUILD_CONTRACT: &str = "dist/<name>.tar.gz and dist/<name>.tar.gz.sha256";
fn bash_limitation(command: &str) -> String {
format!(
"the {command} tarball is not inspected: its contents, SECURITY.md inclusion among them, are unproved; run {command} and list the tarball before the first release"
)
}
fn bash_build_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
let command = ctx
.profile
.release
.build
.as_ref()
.and_then(|build| build.command.as_deref())
.unwrap_or(crate::profile::BuildAnswer::COMMAND);
let mut words = command.split_whitespace();
let program = words.next().unwrap_or_default();
let limitation = bash_limitation(command);
let env = ctx.child_env("package-check");
let runnable = |path: &std::path::Path| {
use std::os::unix::fs::PermissionsExt as _;
std::fs::metadata(path)
.is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
};
let on_path = |name: &str| {
env.iter()
.find(|(key, _)| key == "PATH")
.map(|(_, value)| value.clone())
.or_else(|| std::env::var_os("PATH"))
.is_some_and(|paths| std::env::split_paths(&paths).any(|dir| runnable(&dir.join(name))))
};
if program == "make" {
let makefile = ["GNUmakefile", "makefile", "Makefile"]
.iter()
.any(|name| ctx.target.join(name).is_file());
if !makefile {
return Ok(StepState::not(format!(
"the build command is {command}, and the target holds no Makefile, GNUmakefile, or makefile; write one whose target produces {BUILD_CONTRACT}, or set profile.release.build.command to the command that does"
)));
}
if !on_path("make") {
return Ok(StepState::ok_with_limitation(
format!(
"{command} has its makefile; make is not on this host, so the target was not resolved"
),
limitation,
));
}
let mut args: Vec<&str> = vec!["-n"];
args.extend(words);
let outcome = run(&cargo_exec(ctx, "make", &args))?;
if !outcome.success() {
return Ok(StepState::not(format!(
"make -n does not resolve the build command {command}: {}; give the makefile the target that produces {BUILD_CONTRACT}, or set profile.release.build.command",
last_line(&outcome.stderr)
)));
}
return Ok(StepState::ok_with_limitation(
format!("{command} resolves in the target's makefile"),
limitation,
));
}
if program.contains('/') {
if !runnable(ctx.target.join(program).as_std_path()) {
return Ok(StepState::not(format!(
"the build command is {command}, and {program} is not an executable file in the target; make it one that produces {BUILD_CONTRACT}, or set profile.release.build.command"
)));
}
return Ok(StepState::ok_with_limitation(
format!(
"{command} is the project's own build command, and {program} is executable here; it owes {BUILD_CONTRACT}"
),
limitation,
));
}
let resolves = on_path(program);
Ok(StepState::ok_with_limitation(
if resolves {
format!(
"{command} is the project's own build command, and {program} resolves here; it owes {BUILD_CONTRACT}"
)
} else {
format!(
"{command} is the project's own build command, and {program} does not resolve on this host, where profile.release.build.packages need not be installed; it owes {BUILD_CONTRACT}"
)
},
limitation,
))
}
fn package_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
if let Some(reason) = super::steps::no_registry(ctx) {
return Ok(StepState::ok(reason));
}
let (program, args): (&str, &[&str]) = match ctx.tech {
Some("rust") => ("cargo", &["publish", "--dry-run", "--allow-dirty"]),
Some("python") => return python_build_check(ctx, run, ctx.reporting_policy()),
Some("bash") => return bash_build_check(ctx, run),
Some(other) => {
return Ok(StepState::unknown(format!(
"no packaging check is defined for {other}"
)));
}
None => {
return Ok(StepState::unknown(
"no version file names a technology; see rk binding --list",
));
}
};
let outcome = run(&cargo_exec(ctx, program, args))?;
if !outcome.success() {
return Ok(StepState::not(format!(
"the packaging check failed: {}",
last_line(&outcome.stderr)
)));
}
let built = "the package builds and passes the registry's dry run";
policy_in_the_crate(ctx, run, built)
}
fn uv_runs(
program: &std::ffi::OsStr,
env: &[(std::ffi::OsString, std::ffi::OsString)],
cwd: &std::path::Path,
) -> bool {
use std::os::unix::fs::PermissionsExt as _;
let runnable = |path: &std::path::Path| {
std::fs::metadata(path)
.is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
};
let path = std::path::Path::new(program);
if path.components().count() > 1 {
return runnable(&cwd.join(path));
}
env.iter()
.find(|(key, _)| key == "PATH")
.map(|(_, value)| value.clone())
.or_else(|| std::env::var_os("PATH"))
.is_some_and(|paths| {
std::env::split_paths(&paths).any(|dir| runnable(&cwd.join(dir).join(path)))
})
}
fn python_build_check(ctx: &Ctx, run: &mut Runner, policy: bool) -> Result<StepState, RkError> {
let program = crate::probes::uv_bin();
let env = ctx.child_env("package-check");
if !uv_runs(&program, &env, ctx.target.as_std_path()) {
return Ok(StepState::Unknown {
detail: format!(
"uv ({}) is not runnable on this host, so the package build was not checked; install uv, or point RK_UV_BIN at it",
program.to_string_lossy()
),
});
}
let exec = Exec {
program,
args: vec!["build".into()],
env,
cwd: ctx.target.as_std_path().to_path_buf(),
stdin: None,
};
let outcome = run(&exec)?;
if !outcome.success() {
return Ok(StepState::not(format!(
"the packaging check failed: {}",
last_line(&outcome.stderr)
)));
}
let listed = |suffix: &str| -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(ctx.target.join("dist").as_std_path())
.into_iter()
.flatten()
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.ends_with(suffix))
.collect();
names.sort();
names
};
let (sdists, wheels) = (listed(".tar.gz"), listed(".whl"));
if sdists.is_empty() || wheels.is_empty() {
return Ok(StepState::not(
"uv build left no sdist and wheel pair in dist/; the release uploads both",
));
}
let built = format!(
"uv build wrote the sdist {} and the wheel {}",
sdists.join(", "),
wheels.join(", ")
);
Ok(if policy {
StepState::ok_with_limitation(built, PYTHON_LIMITATION)
} else {
StepState::ok(built)
})
}
fn cargo_exec(ctx: &Ctx, program: &str, args: &[&str]) -> Exec {
Exec {
program: program.into(),
args: args.iter().map(Into::into).collect(),
env: ctx.child_env("package-check"),
cwd: ctx.target.as_std_path().to_path_buf(),
stdin: None,
}
}
fn policy_in_the_crate(ctx: &Ctx, run: &mut Runner, built: &str) -> Result<StepState, RkError> {
let policy = ctx.reporting_policy();
let root_manifest = ctx.target.as_std_path().join("Cargo.toml");
let probe = cargo_package::probe(&root_manifest, &[], |args| {
run(&cargo_exec(ctx, "cargo", args)).map(|outcome| Answer {
success: outcome.success(),
stdout: outcome.stdout,
stderr: outcome.stderr,
})
})?;
let listing = match probe {
Probe::Listed(listing) => listing,
Probe::OtherShape => {
let unproved = if policy {
format!(
"{POLICY_DESTINATION} inclusion and the absence of release-kit's own files are"
)
} else {
"the absence of release-kit's own files is".to_owned()
};
return Ok(StepState::ok_with_limitation(
built,
format!(
"{unproved} unproved: the package check lists files only for a single default package rooted at the target, and this workspace selects a different shape; inspect the published archive before releasing"
),
));
}
Probe::MetadataFailed(stderr) => {
return Ok(StepState::unknown(format!(
"{built}, and the listing check could not run: cargo metadata failed: {}",
last_line(&stderr)
)));
}
Probe::ListingFailed(stderr) => {
return Ok(StepState::unknown(format!(
"{built}, and the listing check could not run: cargo package --list failed: {}",
last_line(&stderr)
)));
}
};
let forbidden = cargo_package::release_kit_paths(ctx.landed_destinations());
let shipped = listing.shipped(&forbidden);
Ok(boundary_state(
built,
policy,
!policy || listing.carries(POLICY_DESTINATION),
&shipped,
))
}
fn boundary_state(built: &str, policy: bool, carried: bool, shipped: &[&str]) -> StepState {
const WHY: &str = "and release-plz attributes every commit touching one of them to the package, so a landing that rewrites one asks for a release with no code change";
let policy_fix = format!(
"add /{POLICY_DESTINATION} to [package].include, remove the [package].exclude entry matching it, or stop ignoring the file"
);
let own = || {
let entries = cargo_package::exclude_entries(shipped)
.iter()
.map(|entry| format!("\"{entry}\""))
.collect::<Vec<_>>()
.join(", ");
(
shipped.join(", "),
format!("add {entries} to [package].exclude, or drop them from [package].include"),
)
};
match (carried, shipped.is_empty()) {
(true, true) if policy => StepState::ok(format!(
"{built}, and the published package carries {POLICY_DESTINATION} and no file release-kit lands or owns"
)),
(true, true) => StepState::ok(format!(
"{built}, and the published package ships no file release-kit lands or owns"
)),
(false, true) => StepState::not(format!(
"{built}, but the published package omits {POLICY_DESTINATION}: {policy_fix}"
)),
(true, false) => {
let (list, fix) = own();
StepState::not(format!(
"{built}, but the published package ships files release-kit lands or owns ({list}), {WHY}: {fix}"
))
}
(false, false) => {
let (list, fix) = own();
StepState::not(format!(
"{built}, but the published package omits {POLICY_DESTINATION} and ships files release-kit lands or owns ({list}), {WHY}: {policy_fix}; and {fix}"
))
}
}
}
fn branch_reminder_state(ctx: &Ctx) -> StepState {
use crate::setup::branch_reminder::{HookState, observe_hook};
match observe_hook(&ctx.target) {
HookState::Installed => {
StepState::ok("the post-merge hook carries the release-kit reminder")
}
HookState::Absent => StepState::not("no post-merge hook is installed"),
HookState::Foreign => {
StepState::not("a post-merge hook exists without the release-kit marker")
}
HookState::Drifted => StepState::not("the reminder hook drifted from this binary's body"),
HookState::Unreadable(detail) => StepState::unknown(detail),
}
}
pub const GITLAB_VERSION_FLOOR: (u64, u64) = (18, 2);
const GITLAB_EDITIONS: [&str; 2] = ["ee", "ce"];
fn version_refusal(found: &str, prerelease: Option<&str>, on_gitlab_com: bool) -> String {
let (major, minor) = GITLAB_VERSION_FLOOR;
let mut said = vec![format!(
"this GitLab instance reports {found}; the convention needs {major}.{minor} or newer"
)];
if let Some(suffix) = prerelease {
said.push(format!(
"the -{suffix} suffix is a pre-release, and nothing proves the feature shipped in it, so this step fails closed"
));
}
said.push(format!(
"the merge-request pipeline triggers a child pipeline with `strategy: mirror`, which GitLab added in {major}.{minor}"
));
said.push(
"below it the child's status never reaches the parent pipeline, so a failing project job merges".to_owned(),
);
if on_gitlab_com {
said.push(format!(
"gitlab.com runs below {major}.{minor} only until its next deployment; rerun the check after it"
));
} else {
said.push(format!(
"upgrade the instance to {major}.{minor} or newer, or host the project on gitlab.com"
));
}
said.join("; ")
}
fn forge_version(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
if ctx.forge == Some(Forge::Github) {
return Ok(StepState::ok(
"github.com is a rolling service and declares no version floor",
));
}
let body = match api_get(ctx, run, "version")? {
Api::Ok(body) => body,
Api::Missing => {
return Ok(StepState::unknown(
"this instance answers no GET /version; the floor cannot be read. Check that glab is authenticated against it: glab auth login",
));
}
Api::Failed(err) => {
return Ok(StepState::unknown(format!(
"the version could not be read: {err}. Check that glab is authenticated against this instance: glab auth login"
)));
}
};
let Some(found) = body["version"].as_str() else {
return Ok(StepState::unknown(
"the forge answer carries no version field; the floor cannot be read. Check that glab is authenticated against this instance: glab auth login",
));
};
let (number, suffix) = found
.split_once('-')
.map_or((found, None), |(n, s)| (n, Some(s)));
let mut parts = number.split('.');
let parsed = parts
.next()
.and_then(|major| major.parse::<u64>().ok())
.zip(parts.next().and_then(|minor| minor.parse::<u64>().ok()));
let Some(pair) = parsed else {
return Ok(StepState::unknown(format!(
"the forge reports the version as '{found}', which names no major and minor pair; the floor cannot be read"
)));
};
let on_gitlab_com = ctx.on_gitlab_com();
let prerelease = suffix.filter(|s| !GITLAB_EDITIONS.contains(s));
if let Some(suffix) = prerelease.filter(|_| !on_gitlab_com) {
return Ok(StepState::not(version_refusal(found, Some(suffix), false)));
}
if pair < GITLAB_VERSION_FLOOR {
return Ok(StepState::not(version_refusal(found, None, on_gitlab_com)));
}
let (major, minor) = GITLAB_VERSION_FLOOR;
Ok(StepState::ok(prerelease.map_or_else(
|| format!("this instance reports {found}, at or above the {major}.{minor} floor"),
|suffix| {
format!(
"gitlab.com reports {found}, at or above the {major}.{minor} floor; the -{suffix} suffix is the SaaS deployment's own"
)
},
)))
}
pub fn single_trunk_guard(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
let trunk = ctx.trunk();
for candidate in ctx.retired_branches() {
let candidate = candidate.as_str();
if candidate == trunk {
continue;
}
let state = match ctx.forge {
Some(Forge::Github) => github_candidate_guard(ctx, run, candidate)?,
Some(Forge::Gitlab) => gitlab_candidate_guard(ctx, run, candidate)?,
None => StepState::unknown("the profile names no forge this release drives"),
};
if !state.satisfied() {
return Ok(state);
}
}
Ok(StepState::ok(
"every candidate branch is absent, or an ancestor of the trunk",
))
}
fn github_candidate_guard(
ctx: &Ctx,
run: &mut Runner,
candidate: &str,
) -> Result<StepState, RkError> {
let trunk = ctx.trunk();
match api_get(
ctx,
run,
&format!("repos/{}/git/ref/heads/{candidate}", ctx.repo),
)? {
Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
Api::Failed(err) => return Ok(StepState::unknown(err)),
Api::Ok(_) => {}
}
match api_get(
ctx,
run,
&format!("repos/{}/compare/{candidate}...{trunk}", ctx.repo),
)? {
Api::Ok(body) => {
let status = body["status"].as_str().unwrap_or("");
Ok(if matches!(status, "ahead" | "identical") {
StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
} else {
StepState::not(format!(
"{candidate} is not an ancestor of {trunk} ({status}); deleting it would lose work"
))
})
}
Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
Api::Failed(err) => Ok(StepState::unknown(err)),
}
}
fn gitlab_candidate_guard(
ctx: &Ctx,
run: &mut Runner,
candidate: &str,
) -> Result<StepState, RkError> {
let trunk = ctx.trunk();
let project = ctx.repo.replace('/', "%2F");
match api_get(
ctx,
run,
&format!("projects/{project}/repository/branches/{candidate}"),
)? {
Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
Api::Failed(err) => return Ok(StepState::unknown(err)),
Api::Ok(_) => {}
}
match api_get(
ctx,
run,
&format!("projects/{project}/repository/compare?from={trunk}&to={candidate}"),
)? {
Api::Ok(body) => {
let ahead = body["commits"]
.as_array()
.is_some_and(|list| !list.is_empty());
Ok(if ahead {
StepState::not(format!(
"{candidate} carries commits {trunk} does not; deleting it would lose work"
))
} else {
StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
})
}
Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
Api::Failed(err) => Ok(StepState::unknown(err)),
}
}
pub(crate) fn api_get(ctx: &Ctx, run: &mut Runner, path: &str) -> Result<Api, RkError> {
let exec = Exec {
program: ctx.cli.clone().into_os_string(),
args: vec!["api".into(), path.into()],
env: ctx.child_env("observe"),
cwd: ctx.target.as_std_path().to_path_buf(),
stdin: None,
};
let outcome = run(&exec)?;
if outcome.success() {
return Ok(
serde_json::from_slice::<Value>(&outcome.stdout).map_or_else(
|_| Api::Failed("the forge answer did not parse as JSON".into()),
Api::Ok,
),
);
}
let stderr = String::from_utf8_lossy(&outcome.stderr).into_owned();
if stderr.contains("404") {
Ok(Api::Missing)
} else {
Ok(Api::Failed(last_line(&outcome.stderr)))
}
}
fn last_line(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes)
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.unwrap_or("no output")
.to_owned()
}
#[allow(
clippy::too_many_lines,
reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
)]
fn github(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
let trunk = ctx.trunk();
let repo = &ctx.repo;
match step {
"private-vulnerability-reporting" => {
let visibility_path = format!("repos/{repo}");
match api_get(ctx, run, &visibility_path)? {
Api::Ok(body) => match body["private"].as_bool() {
Some(true) => {
return Ok(StepState::inapplicable(
"private vulnerability reporting is available for public repositories",
));
}
Some(false) => {}
None => {
return Ok(StepState::unknown(format!(
"{visibility_path}: repository visibility is unreadable"
)));
}
},
Api::Missing => {
return Ok(StepState::unknown(format!(
"{visibility_path}: repository visibility is unreadable (404)"
)));
}
Api::Failed(err) => {
return Ok(StepState::unknown(format!("{visibility_path}: {err}")));
}
}
let path = format!("repos/{repo}/private-vulnerability-reporting");
Ok(match api_get(ctx, run, &path)? {
Api::Ok(body) => match body["enabled"].as_bool() {
Some(true) => StepState::ok("private vulnerability reporting is enabled"),
Some(false) => StepState::not("private vulnerability reporting is disabled"),
None => StepState::unknown(format!("{path}: enabled is unreadable")),
},
Api::Missing => {
StepState::unknown(format!("{path}: reporting state is unreadable (404)"))
}
Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
})
}
"default-branch" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
Api::Ok(body) => {
let found = body["default_branch"].as_str().unwrap_or("");
if found == trunk {
StepState::ok(format!("{trunk} is the default branch"))
} else {
StepState::not(format!("the default branch is {found}"))
}
}
Api::Missing => StepState::not(format!("the forge does not know {repo}")),
Api::Failed(err) => StepState::unknown(err),
}),
"single-trunk" => {
for candidate in ctx.retired_branches() {
let candidate = candidate.as_str();
if candidate == trunk {
continue;
}
match api_get(ctx, run, &format!("repos/{repo}/git/ref/heads/{candidate}"))? {
Api::Missing => {}
Api::Ok(_) => {
return Ok(StepState::not(format!("a {candidate} branch still exists")));
}
Api::Failed(err) => return Ok(StepState::unknown(err)),
}
}
Ok(StepState::ok(
"no long-lived branch besides the trunk remains",
))
}
"merge-cleanup" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
Api::Ok(body) => {
if body["delete_branch_on_merge"].as_bool().unwrap_or(false) {
StepState::ok("a merged branch is deleted by the forge")
} else {
StepState::not("a merged branch outlives its merge")
}
}
Api::Missing => StepState::not(format!("the forge does not know {repo}")),
Api::Failed(err) => StepState::unknown(err),
}),
"auto-merge" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
Api::Ok(body) => {
if body["allow_auto_merge"].as_bool().unwrap_or(false) {
StepState::ok("a request may merge itself once its checks pass")
} else {
StepState::not("a request cannot merge itself; the auto-merge switch is off")
}
}
Api::Missing => StepState::not(format!("the forge does not know {repo}")),
Api::Failed(err) => StepState::unknown(err),
}),
"ci-permissions" => Ok(
match api_get(
ctx,
run,
&format!("repos/{repo}/actions/permissions/workflow"),
)? {
Api::Ok(body) => {
let write = body["default_workflow_permissions"] == "write";
let approve = body["can_approve_pull_request_reviews"] == true;
if write && approve {
StepState::ok("CI may write and open requests")
} else {
StepState::not(format!(
"workflow permissions are {} with request approval {}",
body["default_workflow_permissions"],
body["can_approve_pull_request_reviews"]
))
}
}
Api::Missing => StepState::not("no workflow permissions are readable"),
Api::Failed(err) => StepState::unknown(err),
},
),
"bot-secrets" => Ok(
match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
Api::Ok(body) => {
let names: Vec<&str> = body["secrets"]
.as_array()
.map(|list| {
list.iter()
.filter_map(|secret| secret["name"].as_str())
.collect()
})
.unwrap_or_default();
let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
if wanted.iter().all(|name| names.contains(name)) {
StepState::ok("both bot secrets are stored")
} else if names.is_empty() {
StepState::not("no bot secrets are stored")
} else {
StepState::not(format!("stored secrets: {}", names.join(", ")))
}
}
Api::Missing => StepState::not("no secrets are readable"),
Api::Failed(err) => StepState::unknown(err),
},
),
"protect-trunk" => github_trunk_ruleset(ctx, run),
"protect-tags" => github_ruleset(
ctx,
run,
ctx.tag_ruleset(),
"tag",
"refs/tags/v*",
&["deletion", "update"],
),
"protect-release-lines" => {
match github_ruleset_body(ctx, run, ctx.lines_ruleset())? {
RulesetLookup::Absent => {
return Ok(StepState::inapplicable(
"release/* is unprotected; optional — applied only where older lines exist",
));
}
RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
RulesetLookup::Found(_) => {}
}
github_ruleset(
ctx,
run,
ctx.lines_ruleset(),
"branch",
"refs/heads/release/*",
&["deletion", "non_fast_forward"],
)
}
"protections-check" => {
let mut failures = Vec::new();
let mut unknowns = Vec::new();
let mut limitations: Vec<String> = Vec::new();
for owned in owned_protections(ctx) {
match github(ctx, owned, run)? {
StepState::Satisfied {
limitation: found, ..
} => limitations.extend(found),
StepState::Inapplicable { .. } => {}
StepState::Unsatisfied { detail } => {
failures.push(format!("{owned}: {detail}"));
}
StepState::Unknown { detail } => {
unknowns.push(format!("{owned}: {detail}"));
}
}
}
match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
Api::Ok(body) => {
let owned = [
ctx.trunk_ruleset().to_owned(),
ctx.safety_ruleset().to_owned(),
ctx.tag_ruleset().to_owned(),
ctx.lines_ruleset().to_owned(),
];
for ruleset in body.as_array().into_iter().flatten() {
let name = ruleset["name"].as_str().unwrap_or("");
if !owned.iter().any(|expected| expected == name) {
failures.push(format!("a ruleset no step owns: {name}"));
}
}
}
Api::Missing | Api::Failed(_) => {
unknowns.push("the ruleset inventory is not readable".to_owned());
}
}
Ok(if !failures.is_empty() {
StepState::not(failures.join("; "))
} else if !unknowns.is_empty() {
StepState::unknown(unknowns.join("; "))
} else {
StepState::Satisfied {
detail: "exactly the owned protections, with those rules".into(),
limitation: if limitations.is_empty() {
None
} else {
Some(limitations.join("; "))
},
}
})
}
_ => Ok(StepState::unknown(format!("no observation for {step}"))),
}
}
fn owned_protections(ctx: &Ctx) -> Vec<&'static str> {
let mut owned = vec!["protect-trunk"];
if ctx.tag_protection() {
owned.push("protect-tags");
}
owned.push("protect-release-lines");
owned
}
#[must_use]
pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
AppApi::Ok(body) => {
let id = body["id"].as_i64().unwrap_or_default();
let held = &body["permissions"];
let short: Vec<String> = minimum_grant(ctx)
.into_iter()
.filter(|(key, level)| held[key] != *level)
.map(|(key, level)| format!("{key}: {level}"))
.collect();
if short.is_empty() {
StepState::ok(format!("installation {id} covers {}", ctx.repo))
} else {
StepState::not(format!(
"installation {id} covers {} and does not hold [{}]; approve the App's updated permissions on the installation's own settings page",
ctx.repo,
short.join(", ")
))
}
}
AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
}
}
fn minimum_grant(ctx: &Ctx) -> Vec<(&'static str, &'static str)> {
let mut grant = vec![("contents", "write"), ("pull_requests", "write")];
if ctx.integration() == crate::landing::Integration::Local
&& ctx.profile.release.style == Some(crate::landing::Style::Trunk)
{
grant.push(("checks", "read"));
}
grant
}
fn github_ruleset(
ctx: &Ctx,
run: &mut Runner,
name: &str,
target: &str,
include: &str,
rules: &[&str],
) -> Result<StepState, RkError> {
let detail = match github_ruleset_body(ctx, run, name)? {
RulesetLookup::Found(detail) => detail,
RulesetLookup::Absent => {
return Ok(StepState::not(format!("no ruleset named {name}")));
}
RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
};
if detail["enforcement"] != "active" {
return Ok(StepState::not(format!("{name} is not active")));
}
if detail["target"] != target {
return Ok(StepState::not(format!(
"{name} does not target {target} refs"
)));
}
if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
return Ok(StepState::not(format!(
"{name} does not cover {include} alone"
)));
}
if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
return Ok(StepState::not(format!(
"{name} excludes refs from its own coverage"
)));
}
let mut held: Vec<&str> = detail["rules"]
.as_array()
.map(|list| {
list.iter()
.filter_map(|rule| rule["type"].as_str())
.collect()
})
.unwrap_or_default();
held.sort_unstable();
let mut expected: Vec<&str> = rules.to_vec();
expected.sort_unstable();
if held == expected {
Ok(StepState::ok(format!(
"{name} is active with exactly its rules"
)))
} else {
Ok(StepState::not(format!(
"{name} carries the rules [{}] where the setup owns [{}]",
held.join(", "),
expected.join(", ")
)))
}
}
fn unowned_rule_faults(rules: &[Value], owned: &[String]) -> Vec<String> {
rules
.iter()
.filter_map(|rule| rule["type"].as_str())
.filter(|kind| !owned.iter().any(|name| name == kind))
.map(|kind| {
if kind == "merge_queue" {
MERGE_QUEUE_FAULT.to_owned()
} else {
format!("an unowned rule is present: {kind}")
}
})
.collect()
}
fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
let trunk = ctx.trunk();
let name = ctx.trunk_ruleset().to_owned();
let detail = match github_ruleset_body(ctx, run, &name)? {
RulesetLookup::Found(detail) => detail,
RulesetLookup::Absent => {
return Ok(StepState::not(format!("no ruleset named {name}")));
}
RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
};
let rules = detail["rules"].as_array().cloned().unwrap_or_default();
let mut faults = Vec::new();
if detail["enforcement"] != "active" {
faults.push(format!("{name} is not active"));
}
if detail["target"] != "branch" {
faults.push(format!("{name} does not target branches"));
}
let expected_ref = serde_json::json!([format!("refs/heads/{trunk}")]);
if detail["conditions"]["ref_name"]["include"] != expected_ref {
faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
}
if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
faults.push(format!("{name} excludes refs from its own coverage"));
}
let expected_bypass = super::context::github_bypass_actors(&ctx.protection().bypass_actors);
if detail["bypass_actors"] != expected_bypass {
faults.push("the bypass actors do not match the recorded authority".to_owned());
}
faults.extend(trunk_rule_faults(ctx, &rules, &name));
faults.extend(merge_method_fault(ctx, &rules));
if let Some(checks) = rules
.iter()
.find(|rule| rule["type"] == "required_status_checks")
{
if checks["parameters"]["strict_required_status_checks_policy"]
!= ctx.protection().strict_required_status_checks
{
faults.push(STALE_MERGE_FAULT.to_owned());
}
let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
.as_array()
.map(|list| {
list.iter()
.filter_map(|check| check["context"].as_str())
.collect()
})
.unwrap_or_default();
if contexts.is_empty() {
faults.push("no status check is required".to_owned());
} else if let Some(expected) = &ctx.required_check {
let mut held = contexts.clone();
held.sort_unstable();
let title_check = ctx.title_check();
let mut owned_contexts = [expected.as_str(), title_check];
owned_contexts.sort_unstable();
if held != owned_contexts {
faults.push(format!(
"the required checks are [{}] where the setup owns [{}]",
contexts.join(", "),
owned_contexts.join(", ")
));
}
} else if !contexts.contains(&ctx.title_check()) {
faults.push(format!("the {} check is not required", ctx.title_check()));
}
}
match repository_merge_settings(ctx, run)? {
MergeSources::Owned => {}
MergeSources::Faults(proven) => faults.extend(proven),
MergeSources::Unreadable(err) => {
if faults.is_empty() {
return Ok(StepState::unknown(err));
}
}
}
match github_safety_ruleset(ctx, run)? {
SafetyRuleset::Owned => {}
SafetyRuleset::Faults(proven) => faults.extend(proven),
SafetyRuleset::Unreadable(err) => {
if faults.is_empty() {
return Ok(StepState::unknown(err));
}
}
}
if let Some(shape) = gate_faults(ctx) {
faults.push(shape);
}
if !faults.is_empty() {
return Ok(StepState::not(faults.join("; ")));
}
Ok(StepState::ok(format!(
"{name} holds the release-merge shape beside {}",
ctx.safety_ruleset()
)))
}
fn merge_method_fault(ctx: &Ctx, rules: &[Value]) -> Option<String> {
let request = rules.iter().find(|rule| rule["type"] == "pull_request")?;
(request["parameters"]["allowed_merge_methods"]
!= serde_json::json!(ctx.protection().allowed_merge_methods))
.then(|| {
format!(
"the merge method is not exactly a {} merge",
ctx.merge_form().as_str()
)
})
}
fn trunk_rule_faults(ctx: &Ctx, rules: &[Value], name: &str) -> Vec<String> {
let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
let mut faults = Vec::new();
let mut accounted: Vec<String> = ctx
.protection()
.owned_trunk_rules
.iter()
.filter(|rule| crate::config::REQUEST_RULES.contains(&rule.as_str()))
.cloned()
.collect();
for required in &accounted {
if !has(required) {
faults.push(format!("the {required} rule is missing"));
}
}
for stray in crate::config::SAFETY_RULES {
if has(stray) {
faults.push(format!(
"the {stray} rule sits in {name}, where a bypass actor excuses it"
));
}
accounted.push(stray.to_owned());
}
faults.extend(unowned_rule_faults(rules, &accounted));
faults
}
enum SafetyRuleset {
Owned,
Faults(Vec<String>),
Unreadable(String),
}
fn github_safety_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<SafetyRuleset, RkError> {
let trunk = ctx.trunk();
let name = ctx.safety_ruleset().to_owned();
let detail = match github_ruleset_body(ctx, run, &name)? {
RulesetLookup::Found(detail) => detail,
RulesetLookup::Absent => {
return Ok(SafetyRuleset::Faults(vec![format!(
"no ruleset named {name} holds the trunk against deletion and force-push"
)]));
}
RulesetLookup::Unreadable(err) => return Ok(SafetyRuleset::Unreadable(err)),
};
let mut faults = Vec::new();
if detail["enforcement"] != "active" {
faults.push(format!("{name} is not active"));
}
if detail["target"] != "branch" {
faults.push(format!("{name} does not target branches"));
}
if detail["conditions"]["ref_name"]["include"]
!= serde_json::json!([format!("refs/heads/{trunk}")])
{
faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
}
if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
faults.push(format!("{name} excludes refs from its own coverage"));
}
if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
faults.push(format!(
"{name} names a bypass actor, so deletion and force-push hold against nobody"
));
}
let rules = detail["rules"].as_array().cloned().unwrap_or_default();
let safety_rules: Vec<String> = ctx
.protection()
.owned_trunk_rules
.iter()
.filter(|rule| crate::config::SAFETY_RULES.contains(&rule.as_str()))
.cloned()
.collect();
for required in &safety_rules {
if !rules.iter().any(|rule| rule["type"] == required.as_str()) {
faults.push(format!("the {required} rule is missing from {name}"));
}
}
faults.extend(unowned_rule_faults(&rules, &safety_rules));
if faults.is_empty() {
Ok(SafetyRuleset::Owned)
} else {
Ok(SafetyRuleset::Faults(faults))
}
}
fn gate_faults(ctx: &Ctx) -> Option<String> {
if !ctx.forge_gate() {
return None;
}
let check = ctx.required_check.as_deref()?;
let shape = workflow_jobs::faults(
&workflow_jobs::read_gate(&ctx.target, check, ctx.trunk()),
check,
ctx.trunk(),
);
let waking = (ctx.integration() == crate::landing::Integration::Local)
.then_some(ctx.required_workflow.as_deref())
.flatten()
.and_then(|workflow| {
workflow_jobs::waking_workflow_fault(&ctx.target, workflow, check, ctx.trunk())
});
match (shape, waking) {
(None, None) => None,
(Some(one), None) | (None, Some(one)) => Some(one),
(Some(shape), Some(waking)) => Some(format!("{shape}; {waking}")),
}
}
enum MergeSources {
Owned,
Faults(Vec<String>),
Unreadable(String),
}
fn repository_merge_settings(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
let squash = ctx.merge_form() == crate::landing::MergeForm::Squash;
Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
Api::Ok(body) => {
let mut faults = Vec::new();
if squash {
let owned_title = ctx.protection().github.squash_title_source.as_str();
let owned_body = ctx.protection().github.squash_body_source.as_str();
if body["squash_merge_commit_title"] != owned_title {
faults.push(format!(
"the squash title source is {} where the setup owns {owned_title}",
body["squash_merge_commit_title"]
));
}
if body["squash_merge_commit_message"] != owned_body {
faults.push(format!(
"the squash message source is {} where the setup owns {owned_body}",
body["squash_merge_commit_message"]
));
}
} else if let Some(fault) = rebase_switch_fault(body["allow_rebase_merge"].as_bool()) {
faults.push(fault);
}
if faults.is_empty() {
MergeSources::Owned
} else {
MergeSources::Faults(faults)
}
}
Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
Api::Failed(err) => MergeSources::Unreadable(err),
})
}
fn rebase_switch_fault(allowed: Option<bool>) -> Option<String> {
match allowed {
Some(true) => None,
Some(false) => Some(
"the repository disallows rebase merges (allow_rebase_merge is false), so the ruleset's one merge method is refused and no request can merge"
.to_owned(),
),
None => Some("the repository's allow_rebase_merge switch is missing or unreadable".to_owned()),
}
}
enum RulesetLookup {
Found(Value),
Absent,
Unreadable(String),
}
fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
Api::Ok(body) => body,
Api::Missing => {
return Ok(RulesetLookup::Unreadable(
"the ruleset inventory is not readable".into(),
));
}
Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
};
let id = list
.as_array()
.into_iter()
.flatten()
.find(|ruleset| ruleset["name"] == name)
.and_then(|ruleset| ruleset["id"].as_i64());
let Some(id) = id else {
return Ok(RulesetLookup::Absent);
};
match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
Api::Ok(body) => Ok(RulesetLookup::Found(body)),
Api::Missing => Ok(RulesetLookup::Unreadable(format!(
"the {name} detail is not readable"
))),
Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
}
}
const GITLAB_AUTO_MERGE_LIMITATION: &str = "the forge offers no project-level auto-merge switch: availability follows the pipeline requirement protect-trunk asserts, and turning that requirement off removes auto-merge with nothing here reporting it";
const GITLAB_TAG_LIMITATION: &str =
"an Owner or Maintainer can still delete a protected tag through the UI or API";
const MERGE_QUEUE_FAULT: &str = "a merge queue is enabled on the trunk; this convention lands no workflow that triggers on merge_group, so the queue waits on a required check that never reports and drops the request when its CI timeout expires. rk setup step protect-trunk --apply rewrites the ruleset without it";
const STALE_MERGE_FAULT: &str = "the trunk permits a merge from a branch that does not carry the trunk's tip; an armed release request can therefore ship a version computed against a trunk that moved. rk setup step protect-trunk --apply rewrites the ruleset with the freshness requirement";
const GITLAB_TITLE_LIMITATION: &str = "the title gate stops accident, not authority: a merge request runs its own CI configuration, and a title edit starts no new pipeline";
fn pipeline_fault(forge_gate: bool, observed: Option<bool>) -> Option<String> {
match (forge_gate, observed) {
(true, Some(true)) | (false, Some(false)) => None,
(true, Some(false)) => Some("the pipeline requirement is off".to_owned()),
(false, Some(true)) => Some(
"the pipeline requirement is on, and no forge gate lands to run a pipeline, so no merge request can merge"
.to_owned(),
),
(_, None) => Some("the pipeline requirement is missing or unreadable".to_owned()),
}
}
const GITLAB_COMMIT_LIMITATION: &str =
"the commit gate stops accident, not authority: a merge request runs its own CI configuration";
enum GitlabIdentity {
Token,
ServiceAccount(u64),
}
fn gitlab_identity_form(
ctx: &Ctx,
run: &mut Runner,
) -> Result<Result<GitlabIdentity, StepState>, RkError> {
if !ctx.on_gitlab_com() {
return Ok(Ok(GitlabIdentity::Token));
}
let root = ctx.repo.split('/').next().unwrap_or_default();
let namespace = match api_get(ctx, run, &format!("namespaces/{root}"))? {
Api::Ok(body) => body,
Api::Missing => {
return Ok(Err(StepState::not(format!(
"the forge does not know the namespace {root}"
))));
}
Api::Failed(err) => return Ok(Err(StepState::unknown(err))),
};
let plan = namespace["plan"].as_str().unwrap_or_default();
let kind = namespace["kind"].as_str().unwrap_or_default();
let paid = ["premium", "ultimate", "silver", "gold"]
.iter()
.any(|tier| plan.starts_with(tier))
|| plan == "opensource";
Ok(match (plan, kind) {
_ if paid => Ok(GitlabIdentity::Token),
("free", "group") => namespace["id"].as_u64().map_or_else(
|| {
Err(StepState::unknown(format!(
"the namespace {root} answers no id"
)))
},
|id| Ok(GitlabIdentity::ServiceAccount(id)),
),
("free", "user") => Err(StepState::not(format!(
"the personal namespace {root} has no bot identity form on GitLab.com Free; move the project under a group, or use a Premium or Ultimate tier"
))),
_ => Err(StepState::unknown(format!(
"the namespace {root} reads plan {plan:?} and kind {kind:?}, which name no identity form"
))),
})
}
pub fn gitlab_bot_is_a_service_account(
ctx: &Ctx,
run: &mut Runner,
) -> Result<Result<bool, String>, RkError> {
Ok(match gitlab_identity_form(ctx, run)? {
Ok(form) => Ok(matches!(form, GitlabIdentity::ServiceAccount(_))),
Err(state) => Err(state.detail().to_owned()),
})
}
fn gitlab_bot_variable(
ctx: &Ctx,
run: &mut Runner,
project: &str,
holds: &str,
) -> Result<StepState, RkError> {
Ok(
match api_get(
ctx,
run,
&format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
)? {
Api::Ok(_) => StepState::ok(format!("{holds} and its variable is stored")),
Api::Missing => StepState::not(format!(
"{holds} with no stored variable; a rerun replaces its token"
)),
Api::Failed(err) => StepState::unknown(err),
},
)
}
fn gitlab_listing(
ctx: &Ctx,
run: &mut Runner,
path: &str,
found: impl Fn(&serde_json::Value) -> bool,
) -> Result<Result<Option<serde_json::Value>, StepState>, RkError> {
for page in 1..=10u32 {
let list = match api_get(ctx, run, &format!("{path}&per_page=100&page={page}"))? {
Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
Api::Missing => Vec::new(),
Api::Failed(err) => return Ok(Err(StepState::unknown(err))),
};
if let Some(hit) = list.iter().find(|entry| found(entry)) {
return Ok(Ok(Some(hit.clone())));
}
if list.len() < 100 {
return Ok(Ok(None));
}
}
Ok(Err(StepState::unknown(
"the listing did not exhaust within ten pages; nothing was decided",
)))
}
fn gitlab_all(
ctx: &Ctx,
run: &mut Runner,
path: &str,
) -> Result<Result<Vec<serde_json::Value>, StepState>, RkError> {
let mut all = Vec::new();
for page in 1..=10u32 {
let list = match api_get(ctx, run, &format!("{path}&per_page=100&page={page}"))? {
Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
Api::Missing => Vec::new(),
Api::Failed(err) => return Ok(Err(StepState::unknown(err))),
};
let short = list.len() < 100;
all.extend(list);
if short {
return Ok(Ok(all));
}
}
Ok(Err(StepState::unknown(
"the listing did not exhaust within ten pages; nothing was decided",
)))
}
const BOT_MARKER: &str = "release-kit bot identity";
fn gitlab_rename(live: &str, name: &str) -> StepState {
StepState::not(format!(
"the release-kit bot {live} serves this project, and setup.bot.name asks for {name}; a rename strands the first identity, so revoke it by hand or set the name back"
))
}
fn gitlab_live_account(
ctx: &Ctx,
run: &mut Runner,
project: &str,
group: u64,
default: &str,
name: &str,
) -> Result<Option<StepState>, RkError> {
let accounts = match gitlab_all(
ctx,
run,
&format!("groups/{group}/service_accounts?order_by=id"),
)? {
Ok(accounts) => accounts,
Err(state) => return Ok(Some(state)),
};
let members = match gitlab_all(
ctx,
run,
&format!("projects/{project}/members/all?order_by=id"),
)? {
Ok(members) => members,
Err(state) => return Ok(Some(state)),
};
for account in &accounts {
let (Some(id), Some(user)) = (account["id"].as_u64(), account["username"].as_str()) else {
continue;
};
if user == name
|| !members
.iter()
.any(|member| member["id"].as_u64() == Some(id))
{
continue;
}
if user == default {
return Ok(Some(gitlab_rename(user, name)));
}
let path =
format!("groups/{group}/service_accounts/{id}/personal_access_tokens?state=active");
let tokens = match gitlab_all(ctx, run, &path)? {
Ok(tokens) => tokens,
Err(state) => return Ok(Some(state)),
};
if tokens
.iter()
.any(|token| token["description"] == BOT_MARKER && token["active"] != false)
{
return Ok(Some(gitlab_rename(user, name)));
}
}
Ok(None)
}
fn gitlab_install_bot(ctx: &Ctx, run: &mut Runner, project: &str) -> Result<StepState, RkError> {
let form = match gitlab_identity_form(ctx, run)? {
Ok(form) => form,
Err(state) => return Ok(state),
};
match form {
GitlabIdentity::Token => {
let name = ctx.bot_name().unwrap_or("release-bot");
let path = format!("projects/{project}/access_tokens?state=active");
let tokens = match gitlab_all(ctx, run, &path)? {
Ok(tokens) => tokens,
Err(state) => return Ok(state),
};
let active: Vec<_> = tokens
.iter()
.filter(|token| token["revoked"] == false && token["active"] != false)
.collect();
let live = active.iter().find(|token| {
token["name"] != name
&& (token["description"] == BOT_MARKER || token["name"] == "release-bot")
});
if let Some(live) = live.and_then(|token| token["name"].as_str()) {
return Ok(gitlab_rename(live, name));
}
if !active.iter().any(|token| token["name"] == name) {
return Ok(StepState::not(format!("no active {name} token exists")));
}
gitlab_bot_variable(ctx, run, project, &format!("an active {name} token exists"))
}
GitlabIdentity::ServiceAccount(group) => {
let project_id = match api_get(ctx, run, &format!("projects/{project}"))? {
Api::Ok(body) => body["id"].as_u64(),
Api::Missing => {
return Ok(StepState::not(format!(
"the forge does not know {}",
ctx.repo
)));
}
Api::Failed(err) => return Ok(StepState::unknown(err)),
};
let Some(project_id) = project_id else {
return Ok(StepState::unknown("the project answers no id"));
};
let name = ctx
.bot_name()
.map_or_else(|| format!("release-bot-{project_id}"), str::to_owned);
let path = format!("groups/{group}/service_accounts?order_by=id");
let account = match gitlab_listing(ctx, run, &path, |account| {
account["username"] == name.as_str()
})? {
Ok(account) => account,
Err(state) => return Ok(state),
};
let default = format!("release-bot-{project_id}");
if let Some(state) = gitlab_live_account(ctx, run, project, group, &default, &name)? {
return Ok(state);
}
let Some(id) = account.and_then(|account| account["id"].as_u64()) else {
return Ok(StepState::not(format!(
"no service account named {name} exists"
)));
};
let level = match api_get(ctx, run, &format!("projects/{project}/members/all/{id}"))? {
Api::Ok(body) => body["access_level"].as_u64().unwrap_or(0),
Api::Missing => {
return Ok(StepState::not(format!(
"the service account {name} is not a member of this project"
)));
}
Api::Failed(err) => return Ok(StepState::unknown(err)),
};
if level < 40 {
return Ok(StepState::not(format!(
"the service account {name} is a member of this project below Maintainer"
)));
}
gitlab_bot_variable(
ctx,
run,
project,
&format!("a service account named {name} is a Maintainer of this project"),
)
}
}
}
#[allow(
clippy::too_many_lines,
reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
)]
fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
let trunk = ctx.trunk();
let project = ctx.repo.replace('/', "%2F");
match step {
"private-vulnerability-reporting" => {
let path = format!("projects/{project}");
Ok(match api_get(ctx, run, &path)? {
Api::Ok(body) => {
let access = body["issues_access_level"].as_str();
if !matches!(access, Some("enabled" | "private" | "disabled")) {
StepState::unknown("issue intake access is unreadable")
} else if body
.get("issues_enabled")
.is_some_and(|flag| !flag.is_boolean())
{
StepState::unknown("legacy issue intake flag is unreadable")
} else if body["issues_enabled"] == false || access == Some("disabled") {
StepState::not("issue intake is disabled; see setup guide step 3g")
} else if access == Some("private") {
StepState::not("issue intake is restricted; see setup guide step 3g")
} else {
StepState::ok_with_limitation(
"issue intake is enabled",
GITLAB_PRIVATE_REPORTING_LIMITATION,
)
}
}
Api::Missing => {
StepState::unknown(format!("{path}: issue intake is unreadable (404)"))
}
Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
})
}
"default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
Api::Ok(body) => {
let found = body["default_branch"].as_str().unwrap_or("");
if found == trunk {
StepState::ok(format!("{trunk} is the default branch"))
} else {
StepState::not(format!("the default branch is {found}"))
}
}
Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
Api::Failed(err) => StepState::unknown(err),
}),
"single-trunk" => {
for candidate in ctx.retired_branches() {
let candidate = candidate.as_str();
if candidate == trunk {
continue;
}
match api_get(
ctx,
run,
&format!("projects/{project}/repository/branches/{candidate}"),
)? {
Api::Missing => {}
Api::Ok(_) => {
return Ok(StepState::not(format!("a {candidate} branch still exists")));
}
Api::Failed(err) => return Ok(StepState::unknown(err)),
}
}
Ok(StepState::ok(
"no long-lived branch besides the trunk remains",
))
}
"merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
Api::Ok(body) => {
if body["remove_source_branch_after_merge"]
.as_bool()
.unwrap_or(false)
{
StepState::ok("a merged branch is deleted by the forge")
} else {
StepState::not("a merged branch outlives its merge")
}
}
Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
Api::Failed(err) => StepState::unknown(err),
}),
"auto-merge" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
Api::Ok(body) => {
if body["only_allow_merge_if_pipeline_succeeds"]
.as_bool()
.unwrap_or(false)
{
StepState::ok_with_limitation(
"a request may merge itself once its pipeline passes",
GITLAB_AUTO_MERGE_LIMITATION,
)
} else {
StepState::not(
"the pipeline requirement auto-merge rides on is off; protect-trunk asserts it",
)
}
}
Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
Api::Failed(err) => StepState::unknown(err),
}),
"ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
Api::Ok(body) => {
if body["jobs_enabled"] == true {
StepState::ok("pipelines are enabled")
} else {
StepState::not("pipelines are disabled")
}
}
Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
Api::Failed(err) => StepState::unknown(err),
}),
"install-bot" => gitlab_install_bot(ctx, run, &project),
"bot-secrets" => Ok(
match api_get(
ctx,
run,
&format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
)? {
Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
Api::Failed(err) => StepState::unknown(err),
},
),
"protect-trunk" => {
let protection = match api_get(
ctx,
run,
&format!("projects/{project}/protected_branches/{trunk}"),
)? {
Api::Ok(body) => body,
Api::Missing => {
return Ok(StepState::not(format!("{trunk} is not protected")));
}
Api::Failed(err) => return Ok(StepState::unknown(err)),
};
let grants = protection["push_access_levels"]
.as_array()
.cloned()
.unwrap_or_default();
let policy = ctx.protection();
let no_push =
grants.len() == 1 && grants[0]["access_level"] == policy.gitlab.push_access_level;
let merges = protection["merge_access_levels"]
.as_array()
.cloned()
.unwrap_or_default();
let can_merge =
merges.len() == 1 && merges[0]["access_level"] == policy.gitlab.merge_access_level;
let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
Api::Ok(body) => body,
Api::Missing | Api::Failed(_) => Value::Null,
};
let mut faults = Vec::new();
if !no_push {
faults.push(format!(
"{trunk} still takes a direct push: the forge honors the most permissive of {} push grants",
grants.len()
));
}
if !can_merge {
faults.push(format!(
"{trunk} merge grants are not exactly the one owned maintainer level"
));
}
if protection["allow_force_push"] != false {
faults.push(format!("{trunk} allows force pushes"));
}
if let Some(fault) = pipeline_fault(
ctx.forge_gate(),
settings["only_allow_merge_if_pipeline_succeeds"].as_bool(),
) {
faults.push(fault);
}
if settings["merge_method"] != policy.gitlab.merge_method.as_str() {
faults.push("the merge method is not fast-forward".to_owned());
}
let squash = ctx.merge_form() == crate::landing::MergeForm::Squash;
if settings["squash_option"] != policy.gitlab.squash_option.as_str() {
faults.push(if squash {
"merge requests do not always squash".to_owned()
} else {
"merge requests may squash, so a request's commits would not keep their own messages".to_owned()
});
}
if squash
&& settings["squash_commit_template"]
!= policy.gitlab.squash_commit_template.as_str()
{
faults.push("the squash template is not the merge request's title".to_owned());
}
Ok(if faults.is_empty() {
let detail = format!("{trunk} holds the release-merge shape");
if ctx.forge_gate() {
StepState::ok_with_limitation(
detail,
if squash {
GITLAB_TITLE_LIMITATION
} else {
GITLAB_COMMIT_LIMITATION
},
)
} else {
StepState::ok(detail)
}
} else {
StepState::not(faults.join("; "))
})
}
"protect-tags" => Ok(
match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
Api::Ok(_) => {
StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
}
Api::Missing => StepState::not("v* is not protected"),
Api::Failed(err) => StepState::unknown(err),
},
),
"protect-release-lines" => Ok(
match api_get(
ctx,
run,
&format!("projects/{project}/protected_branches/release%2F%2A"),
)? {
Api::Ok(body) => {
let level_ok = |levels: &Value| {
levels
.as_array()
.is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
};
if body["allow_force_push"] != false {
StepState::not("release/* allows force pushes")
} else if !level_ok(&body["push_access_levels"])
|| !level_ok(&body["merge_access_levels"])
{
StepState::not(
"release/* grants are not exactly the owned maintainer levels",
)
} else {
StepState::ok("release/* refuses force pushes and deletion by git clients")
}
}
Api::Missing => StepState::inapplicable(
"release/* is unprotected; optional — applied only where older lines exist",
),
Api::Failed(err) => StepState::unknown(err),
},
),
"protections-check" => {
let mut failures = Vec::new();
let mut unknowns = Vec::new();
let mut limitations: Vec<String> = Vec::new();
for owned in owned_protections(ctx) {
match gitlab(ctx, owned, run)? {
StepState::Satisfied {
limitation: found, ..
} => limitations.extend(found),
StepState::Inapplicable { .. } => {}
StepState::Unsatisfied { detail } => {
failures.push(format!("{owned}: {detail}"));
}
StepState::Unknown { detail } => {
unknowns.push(format!("{owned}: {detail}"));
}
}
}
Ok(if !failures.is_empty() {
StepState::not(failures.join("; "))
} else if !unknowns.is_empty() {
StepState::unknown(unknowns.join("; "))
} else {
StepState::Satisfied {
detail: "the protections hold, as far as this forge enforces them".into(),
limitation: if limitations.is_empty() {
None
} else {
Some(limitations.join("; "))
},
}
})
}
_ => Ok(StepState::unknown(format!("no observation for {step}"))),
}
}
#[cfg(test)]
mod tests {
#[test]
fn merge_form_rebase_switch_is_judged() {
assert_eq!(super::rebase_switch_fault(Some(true)), None);
assert!(
super::rebase_switch_fault(Some(false))
.is_some_and(|fault| fault.contains("allow_rebase_merge is false"))
);
assert!(
super::rebase_switch_fault(None)
.is_some_and(|fault| fault.contains("missing or unreadable"))
);
}
#[test]
fn release_less_pipeline_requirement_is_judged_both_ways() {
assert_eq!(super::pipeline_fault(true, Some(true)), None);
assert_eq!(super::pipeline_fault(false, Some(false)), None);
for (gate, observed) in [
(true, Some(false)),
(false, Some(true)),
(true, None),
(false, None),
] {
assert!(
super::pipeline_fault(gate, observed).is_some(),
"gate {gate}, observed {observed:?}"
);
}
}
}