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_tags(ctx: &Ctx) -> Option<String> {
81 needs_adapter(ctx).or_else(|| {
82 (!ctx.tag_protection()).then(|| {
83 format!(
84 "profile.release.mode is {}, so no release tag is minted to protect",
85 ctx.profile.release.mode.as_str()
86 )
87 })
88 })
89}
90
91fn needs_driver(ctx: &Ctx) -> Option<String> {
94 if !ctx.automatic_release() {
95 return Some(format!(
96 "profile.release.mode is {}, so no package is published from here",
97 ctx.profile.release.mode.as_str()
98 ));
99 }
100 ctx.driver().is_none().then(|| {
101 "profile.release.driver names no technology, so no packaging command is known".to_owned()
102 })
103}
104
105fn needs_reporting_policy(ctx: &Ctx) -> Option<String> {
108 needs_adapter(ctx).or_else(|| {
109 (!ctx.reporting_policy()).then(|| "capabilities.reporting_policy is false".to_owned())
110 })
111}
112
113pub struct StepSpec {
115 pub name: &'static str,
118 pub chapter: &'static str,
120 pub mutates: Mutates,
122 pub proves: &'static str,
124 pub destructive: bool,
127 pub optional: bool,
130 pub forge_cli: &'static [Forge],
139 pub prereqs: &'static [&'static str],
141 pub applies: Applies,
143}
144
145impl std::fmt::Debug for StepSpec {
146 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 f.debug_struct("StepSpec")
148 .field("name", &self.name)
149 .field("chapter", &self.chapter)
150 .field("mutates", &self.mutates)
151 .field("destructive", &self.destructive)
152 .field("optional", &self.optional)
153 .field("prereqs", &self.prereqs)
154 .finish_non_exhaustive()
155 }
156}
157
158pub const STEPS: [StepSpec; 15] = [
166 StepSpec {
167 name: "package-check",
168 chapter: "§0",
169 mutates: Mutates::Nothing,
170 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",
171 destructive: false,
172 optional: false,
173 forge_cli: &[],
174 prereqs: &[],
175 applies: needs_driver,
176 },
177 StepSpec {
178 name: "default-branch",
179 chapter: "§1",
180 mutates: Mutates::Forge,
181 proves: "the trunk is the default branch",
182 destructive: false,
183 optional: false,
184 forge_cli: &[Forge::Github, Forge::Gitlab],
185 prereqs: &[],
186 applies: needs_adapter,
187 },
188 StepSpec {
189 name: "single-trunk",
190 chapter: "§1",
191 mutates: Mutates::Forge,
192 proves: "no long-lived branch besides the trunk remains",
193 destructive: true,
194 optional: false,
195 forge_cli: &[Forge::Github, Forge::Gitlab],
196 prereqs: &["default-branch"],
197 applies: needs_adapter,
198 },
199 StepSpec {
200 name: "merge-cleanup",
201 chapter: "§1",
202 mutates: Mutates::Forge,
203 proves: "the forge deletes a branch when its merge lands",
204 destructive: false,
205 optional: false,
206 forge_cli: &[Forge::Github, Forge::Gitlab],
207 prereqs: &["default-branch"],
208 applies: needs_adapter_and_guards,
209 },
210 StepSpec {
211 name: "branch-reminder",
212 chapter: "§1",
213 mutates: Mutates::Local,
214 proves: "a pull reminds the operator when a merged branch lingers locally",
215 destructive: false,
216 optional: false,
217 forge_cli: &[],
218 prereqs: &[],
219 applies: needs_workflow_guards,
220 },
221 StepSpec {
222 name: "ci-permissions",
223 chapter: "§2",
224 mutates: Mutates::Forge,
225 proves: "CI may write and open requests",
226 destructive: false,
227 optional: false,
228 forge_cli: &[Forge::Github, Forge::Gitlab],
229 prereqs: &[],
230 applies: needs_release,
231 },
232 StepSpec {
233 name: "install-bot",
234 chapter: "§2",
235 mutates: Mutates::Forge,
236 proves: "the bot identity can act on this project",
237 destructive: false,
238 optional: false,
239 forge_cli: &[Forge::Github, Forge::Gitlab],
240 prereqs: &[],
241 applies: needs_release,
242 },
243 StepSpec {
244 name: "bot-secrets",
245 chapter: "§2",
246 mutates: Mutates::Forge,
247 proves: "the bot credentials are stored on the project",
248 destructive: false,
249 optional: false,
250 forge_cli: &[Forge::Github, Forge::Gitlab],
251 prereqs: &[],
252 applies: needs_release,
253 },
254 StepSpec {
255 name: "forge-version",
256 chapter: "§3",
257 mutates: Mutates::Nothing,
258 proves: "the forge meets the convention's minimum version",
259 destructive: false,
260 optional: false,
261 forge_cli: &[Forge::Gitlab],
262 prereqs: &[],
263 applies: needs_adapter,
264 },
265 StepSpec {
266 name: "protect-trunk",
267 chapter: "§3",
268 mutates: Mutates::Forge,
269 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",
270 destructive: false,
271 optional: false,
272 forge_cli: &[Forge::Github, Forge::Gitlab],
273 prereqs: &["default-branch", "forge-version"],
274 applies: needs_adapter,
275 },
276 StepSpec {
277 name: "protect-tags",
278 chapter: "§3",
279 mutates: Mutates::Forge,
280 proves: "v* is protected as far as the forge allows",
281 destructive: false,
282 optional: false,
283 forge_cli: &[Forge::Github, Forge::Gitlab],
284 prereqs: &[],
285 applies: needs_tags,
286 },
287 StepSpec {
288 name: "protect-release-lines",
289 chapter: "§3",
290 mutates: Mutates::Forge,
291 proves: "release/* cannot be force-pushed or deleted",
292 destructive: false,
293 optional: true,
294 forge_cli: &[Forge::Github, Forge::Gitlab],
295 prereqs: &[],
296 applies: needs_release,
297 },
298 StepSpec {
299 name: "auto-merge",
300 chapter: "§3",
301 mutates: Mutates::Forge,
302 proves: "a request may merge itself once its required checks pass",
303 destructive: false,
304 optional: false,
305 forge_cli: &[Forge::Github, Forge::Gitlab],
306 prereqs: &["default-branch"],
307 applies: needs_release,
308 },
309 StepSpec {
310 name: "protections-check",
311 chapter: "§3",
312 mutates: Mutates::Nothing,
313 proves: "exactly the owned protections, with those rules",
314 destructive: false,
315 optional: false,
316 forge_cli: &[Forge::Github, Forge::Gitlab],
317 prereqs: &[],
318 applies: needs_adapter,
319 },
320 StepSpec {
321 name: "private-vulnerability-reporting",
322 chapter: "§3",
323 mutates: Mutates::Forge,
324 proves: "a vulnerability report has a private intake path, with the forge's limits named",
325 destructive: false,
326 optional: false,
327 forge_cli: &[Forge::Github, Forge::Gitlab],
328 prereqs: &[],
329 applies: needs_reporting_policy,
330 },
331];
332
333impl StepSpec {
334 #[must_use]
342 pub fn proves_for(&self, ctx: &Ctx) -> &'static str {
343 if self.name != "protect-trunk" {
344 return self.proves;
345 }
346 match (ctx.merge_form(), ctx.forge_gate()) {
347 (crate::landing::MergeForm::Squash, true) => self.proves,
348 (crate::landing::MergeForm::Squash, false) => {
349 "the trunk takes no direct push and merges only by squash with the request's title and body as the message"
350 }
351 (crate::landing::MergeForm::Rebase, true) => {
352 "the trunk takes no direct push, merges only by rebase with every commit keeping its own message, and requires the named check"
353 }
354 (crate::landing::MergeForm::Rebase, false) => {
355 "the trunk takes no direct push and merges only by rebase with every commit keeping its own message"
356 }
357 }
358 }
359}
360
361#[must_use]
363pub fn spec(name: &str) -> Option<&'static StepSpec> {
364 STEPS.iter().find(|step| step.name == name)
365}
366
367#[cfg(test)]
368mod tests {
369 use super::{Ctx, STEPS, spec};
370 use crate::profile::ReleaseMode;
371
372 fn ctx(forge: Option<&str>, mode: ReleaseMode) -> Ctx {
376 let mut ctx = Ctx::for_tests(
377 camino::Utf8PathBuf::from("/tmp/target"),
378 "acme/widget".to_owned(),
379 crate::detect::Forge::Github,
380 std::path::PathBuf::from("gh"),
381 Some("rust"),
382 );
383 ctx.declared_forge = forge.map(str::to_owned);
384 ctx.forge = forge.and_then(crate::detect::Forge::parse);
385 ctx.profile.forge = ctx.declared_forge.clone();
386 ctx.profile.release.mode = mode;
387 ctx.tag_protection = mode != ReleaseMode::None;
388 if mode != ReleaseMode::Automatic {
389 ctx.profile.release.driver = None;
390 ctx.profile.release.style = None;
391 ctx.profile.release.line_prefix = None;
392 }
393 ctx
394 }
395
396 fn applicable(forge: Option<&str>, mode: ReleaseMode) -> Vec<&'static str> {
398 let ctx = ctx(forge, mode);
399 STEPS
400 .iter()
401 .filter(|step| (step.applies)(&ctx).is_none())
402 .map(|step| step.name)
403 .collect()
404 }
405
406 #[test]
410 fn the_workflow_steps_follow_the_workflow_guards() {
411 let mut ctx = ctx(Some("github"), ReleaseMode::Automatic);
412 ctx.capabilities.workflow_guards = Some(false);
413 for name in ["merge-cleanup", "branch-reminder"] {
414 let step = spec(name).expect("the step exists");
415 let reason = (step.applies)(&ctx).expect("the step does not apply");
416 assert!(reason.contains("capabilities.workflow_guards"), "{reason}");
417 }
418 for name in [
419 "default-branch",
420 "single-trunk",
421 "protect-trunk",
422 "auto-merge",
423 ] {
424 let step = spec(name).expect("the step exists");
425 assert_eq!((step.applies)(&ctx), None, "{name}");
426 }
427 }
428
429 #[test]
433 fn the_applicability_matrix_follows_the_profile() {
434 for mode in [
436 ReleaseMode::Automatic,
437 ReleaseMode::External,
438 ReleaseMode::None,
439 ] {
440 let steps = applicable(None, mode);
441 assert_eq!(
442 steps,
443 if mode == ReleaseMode::Automatic {
444 vec!["package-check", "branch-reminder"]
445 } else {
446 vec!["branch-reminder"]
447 },
448 "no forge, {mode:?}"
449 );
450 }
451 assert_eq!(
453 applicable(Some("codeberg"), ReleaseMode::None),
454 ["branch-reminder"]
455 );
456
457 for forge in ["github", "gitlab"] {
458 let automatic = applicable(Some(forge), ReleaseMode::Automatic);
459 for step in STEPS.iter().map(|step| step.name) {
460 assert!(automatic.contains(&step), "{forge} automatic: {step}");
461 }
462 for mode in [ReleaseMode::External, ReleaseMode::None] {
467 let steps = applicable(Some(forge), mode);
468 assert_eq!(
469 steps.contains(&"protect-tags"),
470 mode == ReleaseMode::External,
471 "{forge} {mode:?}: protect-tags"
472 );
473 for step in [
474 "default-branch",
475 "single-trunk",
476 "merge-cleanup",
477 "branch-reminder",
478 "forge-version",
479 "protect-trunk",
480 "protections-check",
481 "private-vulnerability-reporting",
482 ] {
483 assert!(steps.contains(&step), "{forge} {mode:?}: {step}");
484 }
485 for step in [
486 "package-check",
487 "ci-permissions",
488 "install-bot",
489 "bot-secrets",
490 "auto-merge",
491 "protect-release-lines",
492 ] {
493 assert!(!steps.contains(&step), "{forge} {mode:?}: {step} applies");
494 }
495 }
496 }
497 }
498
499 #[test]
504 fn release_less_protect_tags_does_not_apply() {
505 for forge in ["github", "gitlab"] {
506 let none = ctx(Some(forge), ReleaseMode::None);
507 let reason = (spec("protect-tags").expect("the step exists").applies)(&none)
508 .expect("a release-less target protects no tag");
509 assert!(reason.contains("profile.release.mode is none"), "{reason}");
510 for mode in [ReleaseMode::Automatic, ReleaseMode::External] {
511 let releasing = ctx(Some(forge), mode);
512 assert_eq!(
513 (spec("protect-tags").expect("the step exists").applies)(&releasing),
514 None,
515 "{forge} {mode:?}"
516 );
517 }
518 }
519 }
520
521 #[test]
524 fn the_reporting_channel_follows_the_requested_policy() {
525 let mut ctx = ctx(Some("github"), ReleaseMode::None);
526 assert!(
527 (spec("private-vulnerability-reporting")
528 .expect("the step exists")
529 .applies)(&ctx)
530 .is_none()
531 );
532 ctx.capabilities.reporting_policy = false;
533 let reason = (spec("private-vulnerability-reporting")
534 .expect("the step exists")
535 .applies)(&ctx)
536 .expect("an unrequested policy names itself");
537 assert!(reason.contains("capabilities.reporting_policy"), "{reason}");
538 }
539
540 #[test]
543 fn the_packaging_gate_needs_a_named_driver() {
544 let mut ctx = ctx(Some("github"), ReleaseMode::Automatic);
545 ctx.profile.release.driver = None;
546 let reason = (spec("package-check").expect("the step exists").applies)(&ctx)
547 .expect("a driverless release names itself");
548 assert!(reason.contains("profile.release.driver"), "{reason}");
549 }
550
551 #[test]
554 fn every_prereq_is_an_earlier_step() {
555 for (idx, step) in STEPS.iter().enumerate() {
556 for prereq in step.prereqs {
557 let position = STEPS
558 .iter()
559 .position(|other| other.name == *prereq)
560 .unwrap_or(usize::MAX);
561 assert!(
562 position < idx,
563 "{}: prereq {prereq} is not an earlier step",
564 step.name
565 );
566 }
567 }
568 let reporting = &STEPS[STEPS.len() - 1];
569 assert_eq!(reporting.name, "private-vulnerability-reporting");
570 assert_eq!(reporting.chapter, "§3");
571 assert_eq!(reporting.mutates, super::Mutates::Forge);
572 assert!(!reporting.optional && !reporting.destructive);
573 assert!(reporting.prereqs.is_empty());
574 assert_eq!(STEPS[STEPS.len() - 2].name, "protections-check");
575 assert!(spec("package-check").is_some());
576 assert!(spec("no-such-step").is_none());
577 }
578}