Skip to main content

release_kit/setup/
steps.rs

1//! The setup step table, defined by what each step proves and by the
2//! target configuration it applies to.
3//!
4//! Steps follow `method/02-setup.md`, with reporting intake after the
5//! protection inventory. Each step carries a pure predicate over the
6//! resolved configuration: a forge step applies only where an adapter
7//! exists, the release half applies only to an automatic release whose
8//! automation this release carries, and a local step applies wherever its
9//! own prerequisites hold. A step that does not apply reports as such
10//! with the profile value that decided it, and carries no operator
11//! reason; an exclusion is the operator's own statement about a step that
12//! does apply.
13//!
14//! SATISFIES forge-setup:applicability-follows-the-target-configuration
15
16use crate::detect::Forge;
17
18use super::context::Ctx;
19
20/// What a step touches when it applies.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Mutates {
23    /// The step only reads and reports.
24    Nothing,
25    /// The step writes forge configuration.
26    Forge,
27    /// The step writes local repository state in the target.
28    Local,
29}
30
31/// Why a step does not apply to a target, or `None` where it does.
32///
33/// The predicate is pure over the resolved configuration: it reads no
34/// forge, no disk, and no environment, so `rk setup --list`, a preview, a
35/// check, and an apply all answer alike.
36pub type Applies = fn(&Ctx) -> Option<String>;
37
38/// A step that only shapes how the project works applies only where the
39/// target opted into the workflow guards.
40fn 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
46/// The forge's merge cleanup serves the workflow guards at a forge this
47/// release drives.
48fn needs_adapter_and_guards(ctx: &Ctx) -> Option<String> {
49    needs_adapter(ctx).or_else(|| needs_workflow_guards(ctx))
50}
51
52/// A step that calls the forge applies only where the profile names one
53/// this release drives.
54fn 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
64/// A step that serves the release automation applies only to an automatic
65/// release at a forge this release drives.
66fn 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
77/// The tag protection serves the tags a release mints, so it applies
78/// wherever the release mode is not `none`, at a forge this release
79/// drives.
80fn 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
91/// The packaging gate reads the release driver's own command, so it
92/// applies where an automatic release names one.
93fn 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
105/// The private reporting channel pairs with the landed policy, so it
106/// applies where the target requested one at a forge this release drives.
107fn 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
113/// One step of the setup, in canonical order.
114pub struct StepSpec {
115    /// The name, which is also the `rk setup step` argument and the script
116    /// file name in every forge tree.
117    pub name: &'static str,
118    /// The `method/02-setup.md` section the step executes.
119    pub chapter: &'static str,
120    /// What the step touches under apply.
121    pub mutates: Mutates,
122    /// What the step proves, from the chapter.
123    pub proves: &'static str,
124    /// Whether the step deletes anything; a destructive step carries its own
125    /// refusal beyond `--apply`.
126    pub destructive: bool,
127    /// Whether a full run skips the step: an optional step applies only
128    /// where its condition holds, by name, through `rk setup step`.
129    pub optional: bool,
130    /// The forges at which the step reaches the forge through its CLI.
131    ///
132    /// Empty where the step is local work alone. A forge absent from the
133    /// list is one this step answers without a call, which `forge-version`
134    /// does on GitHub: a rolling service declares no version floor, so
135    /// there is nothing to read. A run that reaches the CLI for a step
136    /// that never calls it refuses an operator who has no reason to need
137    /// one.
138    pub forge_cli: &'static [Forge],
139    /// Steps that must be observed satisfied before this one applies.
140    pub prereqs: &'static [&'static str],
141    /// Why this step does not apply to a target, or `None` where it does.
142    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
158/// The fifteen steps, with reporting intake last.
159///
160/// `package-check`, `branch-reminder`, and `forge-version` belong to no
161/// forge tree — the first reads its command from the technology binding,
162/// the second writes an embedded hook body into the target's own git
163/// directory, and the third is one read-only API call the observer already
164/// makes — which makes them the three steps outside the parity rule.
165pub 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    /// What this step proves for one target.
335    ///
336    /// The table's text is the release-bearing `squash` claim, which every
337    /// target made before the merge form existed, so a proof written then
338    /// still reads as compatible. `protect-trunk` names the target's own
339    /// merge form, and names the required check only where a forge gate
340    /// lands to report one.
341    #[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/// Look one step up by name.
362#[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    /// One resolved configuration to judge the table against: the forge
373    /// the profile declares, whether this release has an adapter for it,
374    /// and the release mode.
375    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    /// The steps one configuration applies, in table order.
397    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    /// The two workflow steps follow the workflow guards: without them,
407    /// each reports why it does not apply, and every other step answers
408    /// as before.
409    #[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    /// SATISFIES forge-setup:applicability-follows-the-target-configuration
430    /// The matrix across an absent forge, an unknown one, and each driven
431    /// one, at each release mode.
432    #[test]
433    fn the_applicability_matrix_follows_the_profile() {
434        // No forge: the local step alone.
435        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        // A forge this release has no adapter for reads like no forge.
452        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            // A target with no automatic release keeps the trunk half and
463            // takes none of the release half. The tag protection follows
464            // the release mode alone: an external release keeps it, and a
465            // release-less target mints no tag to protect.
466            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    /// A release-less target mints no tag, so `protect-tags` reports not
500    /// applicable with the release mode that decided it, at either forge.
501    ///
502    /// SATISFIES forge-setup:applicability-follows-the-target-configuration
503    #[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    /// The reporting channel pairs with the landed policy: a target that
522    /// requests none is asked for none.
523    #[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    /// The packaging gate reads the release driver's own command, so an
541    /// ambiguous observation that named none leaves it out.
542    #[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    /// Every prerequisite names a step that exists and comes earlier, so the
552    /// full run can never be refused by its own table.
553    #[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}