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
105pub(crate) fn no_registry(ctx: &Ctx) -> Option<String> {
111 (ctx.automatic_release()
112 && ctx
113 .driver()
114 .and_then(crate::profile::RegistryAnswer::default_for)
115 .is_some()
116 && ctx.profile.release.registry == Some(crate::profile::RegistryAnswer::None))
117 .then(|| "the release publishes to no registry, so nothing is packaged".to_owned())
118}
119
120fn needs_packaging(ctx: &Ctx) -> Option<String> {
123 needs_driver(ctx).or_else(|| no_registry(ctx))
124}
125
126fn needs_reporting_policy(ctx: &Ctx) -> Option<String> {
129 needs_adapter(ctx).or_else(|| {
130 (!ctx.reporting_policy()).then(|| "capabilities.reporting_policy is false".to_owned())
131 })
132}
133
134pub struct StepSpec {
136 pub name: &'static str,
139 pub chapter: &'static str,
141 pub mutates: Mutates,
143 pub proves: &'static str,
145 pub destructive: bool,
148 pub optional: bool,
151 pub forge_cli: &'static [Forge],
160 pub prereqs: &'static [&'static str],
162 pub applies: Applies,
164}
165
166impl std::fmt::Debug for StepSpec {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 f.debug_struct("StepSpec")
169 .field("name", &self.name)
170 .field("chapter", &self.chapter)
171 .field("mutates", &self.mutates)
172 .field("destructive", &self.destructive)
173 .field("optional", &self.optional)
174 .field("prereqs", &self.prereqs)
175 .finish_non_exhaustive()
176 }
177}
178
179const SECRET_STEPS: [(&str, &str); 2] = [
182 ("RELEASE_BOT_APP_ID", "bot-secrets"),
183 ("RELEASE_BOT_APP_PRIVATE_KEY", "bot-secrets"),
184];
185
186#[must_use]
188pub fn secret_step(name: &str) -> Option<&'static str> {
189 SECRET_STEPS
190 .iter()
191 .find(|(secret, _)| *secret == name)
192 .map(|(_, step)| *step)
193}
194
195pub const STEPS: [StepSpec; 15] = [
203 StepSpec {
204 name: "package-check",
205 chapter: "§0",
206 mutates: Mutates::Nothing,
207 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",
208 destructive: false,
209 optional: false,
210 forge_cli: &[],
211 prereqs: &[],
212 applies: needs_packaging,
213 },
214 StepSpec {
215 name: "default-branch",
216 chapter: "§1",
217 mutates: Mutates::Forge,
218 proves: "the trunk is the default branch",
219 destructive: false,
220 optional: false,
221 forge_cli: &[Forge::Github, Forge::Gitlab],
222 prereqs: &[],
223 applies: needs_adapter,
224 },
225 StepSpec {
226 name: "single-trunk",
227 chapter: "§1",
228 mutates: Mutates::Forge,
229 proves: "no long-lived branch besides the trunk remains",
230 destructive: true,
231 optional: false,
232 forge_cli: &[Forge::Github, Forge::Gitlab],
233 prereqs: &["default-branch"],
234 applies: needs_adapter,
235 },
236 StepSpec {
237 name: "merge-cleanup",
238 chapter: "§1",
239 mutates: Mutates::Forge,
240 proves: "the forge deletes a branch when its merge lands",
241 destructive: false,
242 optional: false,
243 forge_cli: &[Forge::Github, Forge::Gitlab],
244 prereqs: &["default-branch"],
245 applies: needs_adapter_and_guards,
246 },
247 StepSpec {
248 name: "branch-reminder",
249 chapter: "§1",
250 mutates: Mutates::Local,
251 proves: "a pull reminds the operator when a merged branch lingers locally",
252 destructive: false,
253 optional: false,
254 forge_cli: &[],
255 prereqs: &[],
256 applies: needs_workflow_guards,
257 },
258 StepSpec {
259 name: "ci-permissions",
260 chapter: "§2",
261 mutates: Mutates::Forge,
262 proves: "CI may write and open requests",
263 destructive: false,
264 optional: false,
265 forge_cli: &[Forge::Github, Forge::Gitlab],
266 prereqs: &[],
267 applies: needs_release,
268 },
269 StepSpec {
270 name: "install-bot",
271 chapter: "§2",
272 mutates: Mutates::Forge,
273 proves: "the bot identity can act on this project",
274 destructive: false,
275 optional: false,
276 forge_cli: &[Forge::Github, Forge::Gitlab],
277 prereqs: &[],
278 applies: needs_release,
279 },
280 StepSpec {
281 name: "bot-secrets",
282 chapter: "§2",
283 mutates: Mutates::Forge,
284 proves: "the bot credentials are stored on the project",
285 destructive: false,
286 optional: false,
287 forge_cli: &[Forge::Github, Forge::Gitlab],
288 prereqs: &[],
289 applies: needs_release,
290 },
291 StepSpec {
292 name: "forge-version",
293 chapter: "§3",
294 mutates: Mutates::Nothing,
295 proves: "the forge meets the convention's minimum version",
296 destructive: false,
297 optional: false,
298 forge_cli: &[Forge::Gitlab],
299 prereqs: &[],
300 applies: needs_adapter,
301 },
302 StepSpec {
303 name: "protect-trunk",
304 chapter: "§3",
305 mutates: Mutates::Forge,
306 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",
307 destructive: false,
308 optional: false,
309 forge_cli: &[Forge::Github, Forge::Gitlab],
310 prereqs: &["default-branch", "forge-version"],
311 applies: needs_adapter,
312 },
313 StepSpec {
314 name: "protect-tags",
315 chapter: "§3",
316 mutates: Mutates::Forge,
317 proves: "v* is protected as far as the forge allows",
318 destructive: false,
319 optional: false,
320 forge_cli: &[Forge::Github, Forge::Gitlab],
321 prereqs: &[],
322 applies: needs_tags,
323 },
324 StepSpec {
325 name: "protect-release-lines",
326 chapter: "§3",
327 mutates: Mutates::Forge,
328 proves: "release/* cannot be force-pushed or deleted",
329 destructive: false,
330 optional: true,
331 forge_cli: &[Forge::Github, Forge::Gitlab],
332 prereqs: &[],
333 applies: needs_release,
334 },
335 StepSpec {
336 name: "auto-merge",
337 chapter: "§3",
338 mutates: Mutates::Forge,
339 proves: "a request may merge itself once its required checks pass",
340 destructive: false,
341 optional: false,
342 forge_cli: &[Forge::Github, Forge::Gitlab],
343 prereqs: &["default-branch"],
344 applies: needs_release,
345 },
346 StepSpec {
347 name: "protections-check",
348 chapter: "§3",
349 mutates: Mutates::Nothing,
350 proves: "exactly the owned protections, with those rules",
351 destructive: false,
352 optional: false,
353 forge_cli: &[Forge::Github, Forge::Gitlab],
354 prereqs: &[],
355 applies: needs_adapter,
356 },
357 StepSpec {
358 name: "private-vulnerability-reporting",
359 chapter: "§3",
360 mutates: Mutates::Forge,
361 proves: "a vulnerability report has a private intake path, with the forge's limits named",
362 destructive: false,
363 optional: false,
364 forge_cli: &[Forge::Github, Forge::Gitlab],
365 prereqs: &[],
366 applies: needs_reporting_policy,
367 },
368];
369
370impl StepSpec {
371 #[must_use]
379 pub fn proves_for(&self, ctx: &Ctx) -> &'static str {
380 if self.name != "protect-trunk" {
381 return self.proves;
382 }
383 match (ctx.merge_form(), ctx.forge_gate()) {
384 (crate::landing::MergeForm::Squash, true) => self.proves,
385 (crate::landing::MergeForm::Squash, false) => {
386 "the trunk takes no direct push and merges only by squash with the request's title and body as the message"
387 }
388 (crate::landing::MergeForm::Rebase, true) => {
389 "the trunk takes no direct push, merges only by rebase with every commit keeping its own message, and requires the named check"
390 }
391 (crate::landing::MergeForm::Rebase, false) => {
392 "the trunk takes no direct push and merges only by rebase with every commit keeping its own message"
393 }
394 }
395 }
396}
397
398#[must_use]
400pub fn spec(name: &str) -> Option<&'static StepSpec> {
401 STEPS.iter().find(|step| step.name == name)
402}
403
404#[cfg(test)]
405mod tests {
406 use super::{Ctx, STEPS, spec};
407 use crate::profile::ReleaseMode;
408
409 fn ctx(forge: Option<&str>, mode: ReleaseMode) -> Ctx {
413 let mut ctx = Ctx::for_tests(
414 camino::Utf8PathBuf::from("/tmp/target"),
415 "acme/widget".to_owned(),
416 crate::detect::Forge::Github,
417 std::path::PathBuf::from("gh"),
418 Some("rust"),
419 );
420 ctx.declared_forge = forge.map(str::to_owned);
421 ctx.forge = forge.and_then(crate::detect::Forge::parse);
422 ctx.profile.forge = ctx.declared_forge.clone();
423 ctx.profile.release.mode = mode;
424 ctx.tag_protection = mode != ReleaseMode::None;
425 if mode != ReleaseMode::Automatic {
426 ctx.profile.release.driver = None;
427 ctx.profile.release.style = None;
428 ctx.profile.release.line_prefix = None;
429 }
430 ctx
431 }
432
433 fn applicable(forge: Option<&str>, mode: ReleaseMode) -> Vec<&'static str> {
435 let ctx = ctx(forge, mode);
436 STEPS
437 .iter()
438 .filter(|step| (step.applies)(&ctx).is_none())
439 .map(|step| step.name)
440 .collect()
441 }
442
443 #[test]
447 fn the_workflow_steps_follow_the_workflow_guards() {
448 let mut ctx = ctx(Some("github"), ReleaseMode::Automatic);
449 ctx.capabilities.workflow_guards = Some(false);
450 for name in ["merge-cleanup", "branch-reminder"] {
451 let step = spec(name).expect("the step exists");
452 let reason = (step.applies)(&ctx).expect("the step does not apply");
453 assert!(reason.contains("capabilities.workflow_guards"), "{reason}");
454 }
455 for name in [
456 "default-branch",
457 "single-trunk",
458 "protect-trunk",
459 "auto-merge",
460 ] {
461 let step = spec(name).expect("the step exists");
462 assert_eq!((step.applies)(&ctx), None, "{name}");
463 }
464 }
465
466 #[test]
470 fn the_applicability_matrix_follows_the_profile() {
471 for mode in [
473 ReleaseMode::Automatic,
474 ReleaseMode::External,
475 ReleaseMode::None,
476 ] {
477 let steps = applicable(None, mode);
478 assert_eq!(
479 steps,
480 if mode == ReleaseMode::Automatic {
481 vec!["package-check", "branch-reminder"]
482 } else {
483 vec!["branch-reminder"]
484 },
485 "no forge, {mode:?}"
486 );
487 }
488 assert_eq!(
490 applicable(Some("codeberg"), ReleaseMode::None),
491 ["branch-reminder"]
492 );
493
494 for forge in ["github", "gitlab"] {
495 let automatic = applicable(Some(forge), ReleaseMode::Automatic);
496 for step in STEPS.iter().map(|step| step.name) {
497 assert!(automatic.contains(&step), "{forge} automatic: {step}");
498 }
499 for mode in [ReleaseMode::External, ReleaseMode::None] {
504 let steps = applicable(Some(forge), mode);
505 assert_eq!(
506 steps.contains(&"protect-tags"),
507 mode == ReleaseMode::External,
508 "{forge} {mode:?}: protect-tags"
509 );
510 for step in [
511 "default-branch",
512 "single-trunk",
513 "merge-cleanup",
514 "branch-reminder",
515 "forge-version",
516 "protect-trunk",
517 "protections-check",
518 "private-vulnerability-reporting",
519 ] {
520 assert!(steps.contains(&step), "{forge} {mode:?}: {step}");
521 }
522 for step in [
523 "package-check",
524 "ci-permissions",
525 "install-bot",
526 "bot-secrets",
527 "auto-merge",
528 "protect-release-lines",
529 ] {
530 assert!(!steps.contains(&step), "{forge} {mode:?}: {step} applies");
531 }
532 }
533 }
534 }
535
536 #[test]
541 fn release_less_protect_tags_does_not_apply() {
542 for forge in ["github", "gitlab"] {
543 let none = ctx(Some(forge), ReleaseMode::None);
544 let reason = (spec("protect-tags").expect("the step exists").applies)(&none)
545 .expect("a release-less target protects no tag");
546 assert!(reason.contains("profile.release.mode is none"), "{reason}");
547 for mode in [ReleaseMode::Automatic, ReleaseMode::External] {
548 let releasing = ctx(Some(forge), mode);
549 assert_eq!(
550 (spec("protect-tags").expect("the step exists").applies)(&releasing),
551 None,
552 "{forge} {mode:?}"
553 );
554 }
555 }
556 }
557
558 #[test]
561 fn the_reporting_channel_follows_the_requested_policy() {
562 let mut ctx = ctx(Some("github"), ReleaseMode::None);
563 assert!(
564 (spec("private-vulnerability-reporting")
565 .expect("the step exists")
566 .applies)(&ctx)
567 .is_none()
568 );
569 ctx.capabilities.reporting_policy = false;
570 let reason = (spec("private-vulnerability-reporting")
571 .expect("the step exists")
572 .applies)(&ctx)
573 .expect("an unrequested policy names itself");
574 assert!(reason.contains("capabilities.reporting_policy"), "{reason}");
575 }
576
577 #[test]
580 fn the_packaging_gate_needs_a_named_driver() {
581 let mut ctx = ctx(Some("github"), ReleaseMode::Automatic);
582 ctx.profile.release.driver = None;
583 let reason = (spec("package-check").expect("the step exists").applies)(&ctx)
584 .expect("a driverless release names itself");
585 assert!(reason.contains("profile.release.driver"), "{reason}");
586 }
587
588 #[test]
591 fn every_prereq_is_an_earlier_step() {
592 for (idx, step) in STEPS.iter().enumerate() {
593 for prereq in step.prereqs {
594 let position = STEPS
595 .iter()
596 .position(|other| other.name == *prereq)
597 .unwrap_or(usize::MAX);
598 assert!(
599 position < idx,
600 "{}: prereq {prereq} is not an earlier step",
601 step.name
602 );
603 }
604 }
605 let reporting = &STEPS[STEPS.len() - 1];
606 assert_eq!(reporting.name, "private-vulnerability-reporting");
607 assert_eq!(reporting.chapter, "§3");
608 assert_eq!(reporting.mutates, super::Mutates::Forge);
609 assert!(!reporting.optional && !reporting.destructive);
610 assert!(reporting.prereqs.is_empty());
611 assert_eq!(STEPS[STEPS.len() - 2].name, "protections-check");
612 assert!(spec("package-check").is_some());
613 assert!(spec("no-such-step").is_none());
614 }
615}