use crate::detect::Forge;
use super::context::Ctx;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mutates {
Nothing,
Forge,
Local,
}
pub type Applies = fn(&Ctx) -> Option<String>;
const fn always(_: &Ctx) -> Option<String> {
None
}
fn needs_adapter(ctx: &Ctx) -> Option<String> {
if ctx.has_adapter() {
return None;
}
Some(ctx.declared_forge().map_or_else(
|| "the profile names no forge".to_owned(),
|name| format!("the profile names the forge {name}, which this release has no adapter for"),
))
}
fn needs_release(ctx: &Ctx) -> Option<String> {
needs_adapter(ctx).or_else(|| {
(!ctx.automatic_release()).then(|| {
format!(
"profile.release.mode is {}, so no release automation is selected",
ctx.profile.release.mode.as_str()
)
})
})
}
fn needs_driver(ctx: &Ctx) -> Option<String> {
if !ctx.automatic_release() {
return Some(format!(
"profile.release.mode is {}, so no package is published from here",
ctx.profile.release.mode.as_str()
));
}
ctx.driver().is_none().then(|| {
"profile.release.driver names no technology, so no packaging command is known".to_owned()
})
}
fn needs_reporting_policy(ctx: &Ctx) -> Option<String> {
needs_adapter(ctx).or_else(|| {
(!ctx.reporting_policy()).then(|| "capabilities.reporting_policy is false".to_owned())
})
}
pub struct StepSpec {
pub name: &'static str,
pub chapter: &'static str,
pub mutates: Mutates,
pub proves: &'static str,
pub destructive: bool,
pub optional: bool,
pub forge_cli: &'static [Forge],
pub prereqs: &'static [&'static str],
pub applies: Applies,
}
impl std::fmt::Debug for StepSpec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StepSpec")
.field("name", &self.name)
.field("chapter", &self.chapter)
.field("mutates", &self.mutates)
.field("destructive", &self.destructive)
.field("optional", &self.optional)
.field("prereqs", &self.prereqs)
.finish_non_exhaustive()
}
}
pub const STEPS: [StepSpec; 15] = [
StepSpec {
name: "package-check",
chapter: "ยง0",
mutates: Mutates::Nothing,
proves: "the package is publishable with no credentials, and carries the reporting policy and none of release-kit's own files where the binding can list it",
destructive: false,
optional: false,
forge_cli: &[],
prereqs: &[],
applies: needs_driver,
},
StepSpec {
name: "default-branch",
chapter: "ยง1",
mutates: Mutates::Forge,
proves: "the trunk is the default branch",
destructive: false,
optional: false,
forge_cli: &[Forge::Github, Forge::Gitlab],
prereqs: &[],
applies: needs_adapter,
},
StepSpec {
name: "single-trunk",
chapter: "ยง1",
mutates: Mutates::Forge,
proves: "no long-lived branch besides the trunk remains",
destructive: true,
optional: false,
forge_cli: &[Forge::Github, Forge::Gitlab],
prereqs: &["default-branch"],
applies: needs_adapter,
},
StepSpec {
name: "merge-cleanup",
chapter: "ยง1",
mutates: Mutates::Forge,
proves: "the forge deletes a branch when its merge lands",
destructive: false,
optional: false,
forge_cli: &[Forge::Github, Forge::Gitlab],
prereqs: &["default-branch"],
applies: needs_adapter,
},
StepSpec {
name: "branch-reminder",
chapter: "ยง1",
mutates: Mutates::Local,
proves: "a pull reminds the operator when a merged branch lingers locally",
destructive: false,
optional: false,
forge_cli: &[],
prereqs: &[],
applies: always,
},
StepSpec {
name: "ci-permissions",
chapter: "ยง2",
mutates: Mutates::Forge,
proves: "CI may write and open requests",
destructive: false,
optional: false,
forge_cli: &[Forge::Github, Forge::Gitlab],
prereqs: &[],
applies: needs_release,
},
StepSpec {
name: "install-bot",
chapter: "ยง2",
mutates: Mutates::Forge,
proves: "the bot identity can act on this project",
destructive: false,
optional: false,
forge_cli: &[Forge::Github, Forge::Gitlab],
prereqs: &[],
applies: needs_release,
},
StepSpec {
name: "bot-secrets",
chapter: "ยง2",
mutates: Mutates::Forge,
proves: "the bot credentials are stored on the project",
destructive: false,
optional: false,
forge_cli: &[Forge::Github, Forge::Gitlab],
prereqs: &[],
applies: needs_release,
},
StepSpec {
name: "forge-version",
chapter: "ยง3",
mutates: Mutates::Nothing,
proves: "the forge meets the convention's minimum version",
destructive: false,
optional: false,
forge_cli: &[Forge::Gitlab],
prereqs: &[],
applies: needs_adapter,
},
StepSpec {
name: "protect-trunk",
chapter: "ยง3",
mutates: Mutates::Forge,
proves: "the trunk takes no direct push, merges only by squash with the request's title and body as the message, and requires the named check",
destructive: false,
optional: false,
forge_cli: &[Forge::Github, Forge::Gitlab],
prereqs: &["default-branch", "forge-version"],
applies: needs_adapter,
},
StepSpec {
name: "protect-tags",
chapter: "ยง3",
mutates: Mutates::Forge,
proves: "v* is protected as far as the forge allows",
destructive: false,
optional: false,
forge_cli: &[Forge::Github, Forge::Gitlab],
prereqs: &[],
applies: needs_adapter,
},
StepSpec {
name: "protect-release-lines",
chapter: "ยง3",
mutates: Mutates::Forge,
proves: "release/* cannot be force-pushed or deleted",
destructive: false,
optional: true,
forge_cli: &[Forge::Github, Forge::Gitlab],
prereqs: &[],
applies: needs_release,
},
StepSpec {
name: "auto-merge",
chapter: "ยง3",
mutates: Mutates::Forge,
proves: "a request may merge itself once its required checks pass",
destructive: false,
optional: false,
forge_cli: &[Forge::Github, Forge::Gitlab],
prereqs: &["default-branch"],
applies: needs_release,
},
StepSpec {
name: "protections-check",
chapter: "ยง3",
mutates: Mutates::Nothing,
proves: "exactly the owned protections, with those rules",
destructive: false,
optional: false,
forge_cli: &[Forge::Github, Forge::Gitlab],
prereqs: &[],
applies: needs_adapter,
},
StepSpec {
name: "private-vulnerability-reporting",
chapter: "ยง3",
mutates: Mutates::Forge,
proves: "a vulnerability report has a private intake path, with the forge's limits named",
destructive: false,
optional: false,
forge_cli: &[Forge::Github, Forge::Gitlab],
prereqs: &[],
applies: needs_reporting_policy,
},
];
#[must_use]
pub fn spec(name: &str) -> Option<&'static StepSpec> {
STEPS.iter().find(|step| step.name == name)
}
#[cfg(test)]
mod tests {
use super::{Ctx, STEPS, spec};
use crate::profile::ReleaseMode;
fn ctx(forge: Option<&str>, mode: ReleaseMode) -> Ctx {
let mut ctx = Ctx::for_tests(
camino::Utf8PathBuf::from("/tmp/target"),
"acme/widget".to_owned(),
crate::detect::Forge::Github,
std::path::PathBuf::from("gh"),
Some("rust"),
);
ctx.declared_forge = forge.map(str::to_owned);
ctx.forge = forge.and_then(crate::detect::Forge::parse);
ctx.profile.forge = ctx.declared_forge.clone();
ctx.profile.release.mode = mode;
if mode != ReleaseMode::Automatic {
ctx.profile.release.driver = None;
ctx.profile.release.style = None;
ctx.profile.release.line_prefix = None;
}
ctx
}
fn applicable(forge: Option<&str>, mode: ReleaseMode) -> Vec<&'static str> {
let ctx = ctx(forge, mode);
STEPS
.iter()
.filter(|step| (step.applies)(&ctx).is_none())
.map(|step| step.name)
.collect()
}
#[test]
fn the_applicability_matrix_follows_the_profile() {
for mode in [
ReleaseMode::Automatic,
ReleaseMode::External,
ReleaseMode::None,
] {
let steps = applicable(None, mode);
assert_eq!(
steps,
if mode == ReleaseMode::Automatic {
vec!["package-check", "branch-reminder"]
} else {
vec!["branch-reminder"]
},
"no forge, {mode:?}"
);
}
assert_eq!(
applicable(Some("codeberg"), ReleaseMode::None),
["branch-reminder"]
);
for forge in ["github", "gitlab"] {
let automatic = applicable(Some(forge), ReleaseMode::Automatic);
for step in STEPS.iter().map(|step| step.name) {
assert!(automatic.contains(&step), "{forge} automatic: {step}");
}
for mode in [ReleaseMode::External, ReleaseMode::None] {
let steps = applicable(Some(forge), mode);
for step in [
"default-branch",
"single-trunk",
"merge-cleanup",
"branch-reminder",
"forge-version",
"protect-trunk",
"protect-tags",
"protections-check",
"private-vulnerability-reporting",
] {
assert!(steps.contains(&step), "{forge} {mode:?}: {step}");
}
for step in [
"package-check",
"ci-permissions",
"install-bot",
"bot-secrets",
"auto-merge",
"protect-release-lines",
] {
assert!(!steps.contains(&step), "{forge} {mode:?}: {step} applies");
}
}
}
}
#[test]
fn the_reporting_channel_follows_the_requested_policy() {
let mut ctx = ctx(Some("github"), ReleaseMode::None);
assert!(
(spec("private-vulnerability-reporting")
.expect("the step exists")
.applies)(&ctx)
.is_none()
);
ctx.capabilities.reporting_policy = false;
let reason = (spec("private-vulnerability-reporting")
.expect("the step exists")
.applies)(&ctx)
.expect("an unrequested policy names itself");
assert!(reason.contains("capabilities.reporting_policy"), "{reason}");
}
#[test]
fn the_packaging_gate_needs_a_named_driver() {
let mut ctx = ctx(Some("github"), ReleaseMode::Automatic);
ctx.profile.release.driver = None;
let reason = (spec("package-check").expect("the step exists").applies)(&ctx)
.expect("a driverless release names itself");
assert!(reason.contains("profile.release.driver"), "{reason}");
}
#[test]
fn every_prereq_is_an_earlier_step() {
for (idx, step) in STEPS.iter().enumerate() {
for prereq in step.prereqs {
let position = STEPS
.iter()
.position(|other| other.name == *prereq)
.unwrap_or(usize::MAX);
assert!(
position < idx,
"{}: prereq {prereq} is not an earlier step",
step.name
);
}
}
let reporting = &STEPS[STEPS.len() - 1];
assert_eq!(reporting.name, "private-vulnerability-reporting");
assert_eq!(reporting.chapter, "ยง3");
assert_eq!(reporting.mutates, super::Mutates::Forge);
assert!(!reporting.optional && !reporting.destructive);
assert!(reporting.prereqs.is_empty());
assert_eq!(STEPS[STEPS.len() - 2].name, "protections-check");
assert!(spec("package-check").is_some());
assert!(spec("no-such-step").is_none());
}
}