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/// Every step applies wherever the run stands: the local ones.
39const fn always(_: &Ctx) -> Option<String> {
40    None
41}
42
43/// A step that calls the forge applies only where the profile names one
44/// this release drives.
45fn 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
55/// A step that serves the release automation applies only to an automatic
56/// release at a forge this release drives.
57fn 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
68/// The packaging gate reads the release driver's own command, so it
69/// applies where an automatic release names one.
70fn 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
82/// The private reporting channel pairs with the landed policy, so it
83/// applies where the target requested one at a forge this release drives.
84fn 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
90/// One step of the setup, in canonical order.
91pub struct StepSpec {
92    /// The name, which is also the `rk setup step` argument and the script
93    /// file name in every forge tree.
94    pub name: &'static str,
95    /// The `method/02-setup.md` section the step executes.
96    pub chapter: &'static str,
97    /// What the step touches under apply.
98    pub mutates: Mutates,
99    /// What the step proves, from the chapter.
100    pub proves: &'static str,
101    /// Whether the step deletes anything; a destructive step carries its own
102    /// refusal beyond `--apply`.
103    pub destructive: bool,
104    /// Whether a full run skips the step: an optional step applies only
105    /// where its condition holds, by name, through `rk setup step`.
106    pub optional: bool,
107    /// The forges at which the step reaches the forge through its CLI.
108    ///
109    /// Empty where the step is local work alone. A forge absent from the
110    /// list is one this step answers without a call, which `forge-version`
111    /// does on GitHub: a rolling service declares no version floor, so
112    /// there is nothing to read. A run that reaches the CLI for a step
113    /// that never calls it refuses an operator who has no reason to need
114    /// one.
115    pub forge_cli: &'static [Forge],
116    /// Steps that must be observed satisfied before this one applies.
117    pub prereqs: &'static [&'static str],
118    /// Why this step does not apply to a target, or `None` where it does.
119    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
135/// The fifteen steps, with reporting intake last.
136///
137/// `package-check`, `branch-reminder`, and `forge-version` belong to no
138/// forge tree — the first reads its command from the technology binding,
139/// the second writes an embedded hook body into the target's own git
140/// directory, and the third is one read-only API call the observer already
141/// makes — which makes them the three steps outside the parity rule.
142pub 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/// Look one step up by name.
311#[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    /// One resolved configuration to judge the table against: the forge
322    /// the profile declares, whether this release has an adapter for it,
323    /// and the release mode.
324    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    /// The steps one configuration applies, in table order.
345    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    /// SATISFIES forge-setup:applicability-follows-the-target-configuration
355    /// The matrix across an absent forge, an unknown one, and each driven
356    /// one, at each release mode.
357    #[test]
358    fn the_applicability_matrix_follows_the_profile() {
359        // No forge: the local step alone.
360        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        // A forge this release has no adapter for reads like no forge.
377        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            // A release-less target keeps the trunk half and the tag
388            // protection, and takes none of the release half.
389            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    /// The reporting channel pairs with the landed policy: a target that
419    /// requests none is asked for none.
420    #[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    /// The packaging gate reads the release driver's own command, so an
438    /// ambiguous observation that named none leaves it out.
439    #[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    /// Every prerequisite names a step that exists and comes earlier, so the
449    /// full run can never be refused by its own table.
450    #[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}