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