1use crate::detect::Forge;
17
18use super::context::Ctx;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Mutates {
23 Nothing,
25 Forge,
27 Local,
29}
30
31pub type Applies = fn(&Ctx) -> Option<String>;
37
38fn needs_workflow_guards(ctx: &Ctx) -> Option<String> {
41 (!ctx.capabilities.workflow_guards()).then(|| {
42 "capabilities.workflow_guards is false, so the workflow guards are not selected".to_owned()
43 })
44}
45
46fn needs_adapter_and_guards(ctx: &Ctx) -> Option<String> {
49 needs_adapter(ctx).or_else(|| needs_workflow_guards(ctx))
50}
51
52fn needs_adapter(ctx: &Ctx) -> Option<String> {
55 if ctx.has_adapter() {
56 return None;
57 }
58 Some(ctx.declared_forge().map_or_else(
59 || "the profile names no forge".to_owned(),
60 |name| format!("the profile names the forge {name}, which this release has no adapter for"),
61 ))
62}
63
64fn needs_release(ctx: &Ctx) -> Option<String> {
67 needs_adapter(ctx).or_else(|| {
68 (!ctx.automatic_release()).then(|| {
69 format!(
70 "profile.release.mode is {}, so no release automation is selected",
71 ctx.profile.release.mode.as_str()
72 )
73 })
74 })
75}
76
77fn needs_driver(ctx: &Ctx) -> Option<String> {
80 if !ctx.automatic_release() {
81 return Some(format!(
82 "profile.release.mode is {}, so no package is published from here",
83 ctx.profile.release.mode.as_str()
84 ));
85 }
86 ctx.driver().is_none().then(|| {
87 "profile.release.driver names no technology, so no packaging command is known".to_owned()
88 })
89}
90
91fn needs_reporting_policy(ctx: &Ctx) -> Option<String> {
94 needs_adapter(ctx).or_else(|| {
95 (!ctx.reporting_policy()).then(|| "capabilities.reporting_policy is false".to_owned())
96 })
97}
98
99pub struct StepSpec {
101 pub name: &'static str,
104 pub chapter: &'static str,
106 pub mutates: Mutates,
108 pub proves: &'static str,
110 pub destructive: bool,
113 pub optional: bool,
116 pub forge_cli: &'static [Forge],
125 pub prereqs: &'static [&'static str],
127 pub applies: Applies,
129}
130
131impl std::fmt::Debug for StepSpec {
132 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133 f.debug_struct("StepSpec")
134 .field("name", &self.name)
135 .field("chapter", &self.chapter)
136 .field("mutates", &self.mutates)
137 .field("destructive", &self.destructive)
138 .field("optional", &self.optional)
139 .field("prereqs", &self.prereqs)
140 .finish_non_exhaustive()
141 }
142}
143
144pub const STEPS: [StepSpec; 15] = [
152 StepSpec {
153 name: "package-check",
154 chapter: "§0",
155 mutates: Mutates::Nothing,
156 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",
157 destructive: false,
158 optional: false,
159 forge_cli: &[],
160 prereqs: &[],
161 applies: needs_driver,
162 },
163 StepSpec {
164 name: "default-branch",
165 chapter: "§1",
166 mutates: Mutates::Forge,
167 proves: "the trunk is the default branch",
168 destructive: false,
169 optional: false,
170 forge_cli: &[Forge::Github, Forge::Gitlab],
171 prereqs: &[],
172 applies: needs_adapter,
173 },
174 StepSpec {
175 name: "single-trunk",
176 chapter: "§1",
177 mutates: Mutates::Forge,
178 proves: "no long-lived branch besides the trunk remains",
179 destructive: true,
180 optional: false,
181 forge_cli: &[Forge::Github, Forge::Gitlab],
182 prereqs: &["default-branch"],
183 applies: needs_adapter,
184 },
185 StepSpec {
186 name: "merge-cleanup",
187 chapter: "§1",
188 mutates: Mutates::Forge,
189 proves: "the forge deletes a branch when its merge lands",
190 destructive: false,
191 optional: false,
192 forge_cli: &[Forge::Github, Forge::Gitlab],
193 prereqs: &["default-branch"],
194 applies: needs_adapter_and_guards,
195 },
196 StepSpec {
197 name: "branch-reminder",
198 chapter: "§1",
199 mutates: Mutates::Local,
200 proves: "a pull reminds the operator when a merged branch lingers locally",
201 destructive: false,
202 optional: false,
203 forge_cli: &[],
204 prereqs: &[],
205 applies: needs_workflow_guards,
206 },
207 StepSpec {
208 name: "ci-permissions",
209 chapter: "§2",
210 mutates: Mutates::Forge,
211 proves: "CI may write and open requests",
212 destructive: false,
213 optional: false,
214 forge_cli: &[Forge::Github, Forge::Gitlab],
215 prereqs: &[],
216 applies: needs_release,
217 },
218 StepSpec {
219 name: "install-bot",
220 chapter: "§2",
221 mutates: Mutates::Forge,
222 proves: "the bot identity can act on this project",
223 destructive: false,
224 optional: false,
225 forge_cli: &[Forge::Github, Forge::Gitlab],
226 prereqs: &[],
227 applies: needs_release,
228 },
229 StepSpec {
230 name: "bot-secrets",
231 chapter: "§2",
232 mutates: Mutates::Forge,
233 proves: "the bot credentials are stored on the project",
234 destructive: false,
235 optional: false,
236 forge_cli: &[Forge::Github, Forge::Gitlab],
237 prereqs: &[],
238 applies: needs_release,
239 },
240 StepSpec {
241 name: "forge-version",
242 chapter: "§3",
243 mutates: Mutates::Nothing,
244 proves: "the forge meets the convention's minimum version",
245 destructive: false,
246 optional: false,
247 forge_cli: &[Forge::Gitlab],
248 prereqs: &[],
249 applies: needs_adapter,
250 },
251 StepSpec {
252 name: "protect-trunk",
253 chapter: "§3",
254 mutates: Mutates::Forge,
255 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",
256 destructive: false,
257 optional: false,
258 forge_cli: &[Forge::Github, Forge::Gitlab],
259 prereqs: &["default-branch", "forge-version"],
260 applies: needs_adapter,
261 },
262 StepSpec {
263 name: "protect-tags",
264 chapter: "§3",
265 mutates: Mutates::Forge,
266 proves: "v* is protected as far as the forge allows",
267 destructive: false,
268 optional: false,
269 forge_cli: &[Forge::Github, Forge::Gitlab],
270 prereqs: &[],
271 applies: needs_adapter,
272 },
273 StepSpec {
274 name: "protect-release-lines",
275 chapter: "§3",
276 mutates: Mutates::Forge,
277 proves: "release/* cannot be force-pushed or deleted",
278 destructive: false,
279 optional: true,
280 forge_cli: &[Forge::Github, Forge::Gitlab],
281 prereqs: &[],
282 applies: needs_release,
283 },
284 StepSpec {
285 name: "auto-merge",
286 chapter: "§3",
287 mutates: Mutates::Forge,
288 proves: "a request may merge itself once its required checks pass",
289 destructive: false,
290 optional: false,
291 forge_cli: &[Forge::Github, Forge::Gitlab],
292 prereqs: &["default-branch"],
293 applies: needs_release,
294 },
295 StepSpec {
296 name: "protections-check",
297 chapter: "§3",
298 mutates: Mutates::Nothing,
299 proves: "exactly the owned protections, with those rules",
300 destructive: false,
301 optional: false,
302 forge_cli: &[Forge::Github, Forge::Gitlab],
303 prereqs: &[],
304 applies: needs_adapter,
305 },
306 StepSpec {
307 name: "private-vulnerability-reporting",
308 chapter: "§3",
309 mutates: Mutates::Forge,
310 proves: "a vulnerability report has a private intake path, with the forge's limits named",
311 destructive: false,
312 optional: false,
313 forge_cli: &[Forge::Github, Forge::Gitlab],
314 prereqs: &[],
315 applies: needs_reporting_policy,
316 },
317];
318
319#[must_use]
321pub fn spec(name: &str) -> Option<&'static StepSpec> {
322 STEPS.iter().find(|step| step.name == name)
323}
324
325#[cfg(test)]
326mod tests {
327 use super::{Ctx, STEPS, spec};
328 use crate::profile::ReleaseMode;
329
330 fn ctx(forge: Option<&str>, mode: ReleaseMode) -> Ctx {
334 let mut ctx = Ctx::for_tests(
335 camino::Utf8PathBuf::from("/tmp/target"),
336 "acme/widget".to_owned(),
337 crate::detect::Forge::Github,
338 std::path::PathBuf::from("gh"),
339 Some("rust"),
340 );
341 ctx.declared_forge = forge.map(str::to_owned);
342 ctx.forge = forge.and_then(crate::detect::Forge::parse);
343 ctx.profile.forge = ctx.declared_forge.clone();
344 ctx.profile.release.mode = mode;
345 if mode != ReleaseMode::Automatic {
346 ctx.profile.release.driver = None;
347 ctx.profile.release.style = None;
348 ctx.profile.release.line_prefix = None;
349 }
350 ctx
351 }
352
353 fn applicable(forge: Option<&str>, mode: ReleaseMode) -> Vec<&'static str> {
355 let ctx = ctx(forge, mode);
356 STEPS
357 .iter()
358 .filter(|step| (step.applies)(&ctx).is_none())
359 .map(|step| step.name)
360 .collect()
361 }
362
363 #[test]
367 fn the_workflow_steps_follow_the_workflow_guards() {
368 let mut ctx = ctx(Some("github"), ReleaseMode::Automatic);
369 ctx.capabilities.workflow_guards = Some(false);
370 for name in ["merge-cleanup", "branch-reminder"] {
371 let step = spec(name).expect("the step exists");
372 let reason = (step.applies)(&ctx).expect("the step does not apply");
373 assert!(reason.contains("capabilities.workflow_guards"), "{reason}");
374 }
375 for name in [
376 "default-branch",
377 "single-trunk",
378 "protect-trunk",
379 "auto-merge",
380 ] {
381 let step = spec(name).expect("the step exists");
382 assert_eq!((step.applies)(&ctx), None, "{name}");
383 }
384 }
385
386 #[test]
390 fn the_applicability_matrix_follows_the_profile() {
391 for mode in [
393 ReleaseMode::Automatic,
394 ReleaseMode::External,
395 ReleaseMode::None,
396 ] {
397 let steps = applicable(None, mode);
398 assert_eq!(
399 steps,
400 if mode == ReleaseMode::Automatic {
401 vec!["package-check", "branch-reminder"]
402 } else {
403 vec!["branch-reminder"]
404 },
405 "no forge, {mode:?}"
406 );
407 }
408 assert_eq!(
410 applicable(Some("codeberg"), ReleaseMode::None),
411 ["branch-reminder"]
412 );
413
414 for forge in ["github", "gitlab"] {
415 let automatic = applicable(Some(forge), ReleaseMode::Automatic);
416 for step in STEPS.iter().map(|step| step.name) {
417 assert!(automatic.contains(&step), "{forge} automatic: {step}");
418 }
419 for mode in [ReleaseMode::External, ReleaseMode::None] {
422 let steps = applicable(Some(forge), mode);
423 for step in [
424 "default-branch",
425 "single-trunk",
426 "merge-cleanup",
427 "branch-reminder",
428 "forge-version",
429 "protect-trunk",
430 "protect-tags",
431 "protections-check",
432 "private-vulnerability-reporting",
433 ] {
434 assert!(steps.contains(&step), "{forge} {mode:?}: {step}");
435 }
436 for step in [
437 "package-check",
438 "ci-permissions",
439 "install-bot",
440 "bot-secrets",
441 "auto-merge",
442 "protect-release-lines",
443 ] {
444 assert!(!steps.contains(&step), "{forge} {mode:?}: {step} applies");
445 }
446 }
447 }
448 }
449
450 #[test]
453 fn the_reporting_channel_follows_the_requested_policy() {
454 let mut ctx = ctx(Some("github"), ReleaseMode::None);
455 assert!(
456 (spec("private-vulnerability-reporting")
457 .expect("the step exists")
458 .applies)(&ctx)
459 .is_none()
460 );
461 ctx.capabilities.reporting_policy = false;
462 let reason = (spec("private-vulnerability-reporting")
463 .expect("the step exists")
464 .applies)(&ctx)
465 .expect("an unrequested policy names itself");
466 assert!(reason.contains("capabilities.reporting_policy"), "{reason}");
467 }
468
469 #[test]
472 fn the_packaging_gate_needs_a_named_driver() {
473 let mut ctx = ctx(Some("github"), ReleaseMode::Automatic);
474 ctx.profile.release.driver = None;
475 let reason = (spec("package-check").expect("the step exists").applies)(&ctx)
476 .expect("a driverless release names itself");
477 assert!(reason.contains("profile.release.driver"), "{reason}");
478 }
479
480 #[test]
483 fn every_prereq_is_an_earlier_step() {
484 for (idx, step) in STEPS.iter().enumerate() {
485 for prereq in step.prereqs {
486 let position = STEPS
487 .iter()
488 .position(|other| other.name == *prereq)
489 .unwrap_or(usize::MAX);
490 assert!(
491 position < idx,
492 "{}: prereq {prereq} is not an earlier step",
493 step.name
494 );
495 }
496 }
497 let reporting = &STEPS[STEPS.len() - 1];
498 assert_eq!(reporting.name, "private-vulnerability-reporting");
499 assert_eq!(reporting.chapter, "§3");
500 assert_eq!(reporting.mutates, super::Mutates::Forge);
501 assert!(!reporting.optional && !reporting.destructive);
502 assert!(reporting.prereqs.is_empty());
503 assert_eq!(STEPS[STEPS.len() - 2].name, "protections-check");
504 assert!(spec("package-check").is_some());
505 assert!(spec("no-such-step").is_none());
506 }
507}