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