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 packaging gate reads the release driver's own command, so it
78/// applies where an automatic release names one.
79fn needs_driver(ctx: &Ctx) -> Option<String> {
80    if !ctx.automatic_release() {
81        return Some(format!(
82            "profile.release.mode is {}, so no package is published from here",
83            ctx.profile.release.mode.as_str()
84        ));
85    }
86    ctx.driver().is_none().then(|| {
87        "profile.release.driver names no technology, so no packaging command is known".to_owned()
88    })
89}
90
91/// The private reporting channel pairs with the landed policy, so it
92/// applies where the target requested one at a forge this release drives.
93fn needs_reporting_policy(ctx: &Ctx) -> Option<String> {
94    needs_adapter(ctx).or_else(|| {
95        (!ctx.reporting_policy()).then(|| "capabilities.reporting_policy is false".to_owned())
96    })
97}
98
99/// One step of the setup, in canonical order.
100pub struct StepSpec {
101    /// The name, which is also the `rk setup step` argument and the script
102    /// file name in every forge tree.
103    pub name: &'static str,
104    /// The `method/02-setup.md` section the step executes.
105    pub chapter: &'static str,
106    /// What the step touches under apply.
107    pub mutates: Mutates,
108    /// What the step proves, from the chapter.
109    pub proves: &'static str,
110    /// Whether the step deletes anything; a destructive step carries its own
111    /// refusal beyond `--apply`.
112    pub destructive: bool,
113    /// Whether a full run skips the step: an optional step applies only
114    /// where its condition holds, by name, through `rk setup step`.
115    pub optional: bool,
116    /// The forges at which the step reaches the forge through its CLI.
117    ///
118    /// Empty where the step is local work alone. A forge absent from the
119    /// list is one this step answers without a call, which `forge-version`
120    /// does on GitHub: a rolling service declares no version floor, so
121    /// there is nothing to read. A run that reaches the CLI for a step
122    /// that never calls it refuses an operator who has no reason to need
123    /// one.
124    pub forge_cli: &'static [Forge],
125    /// Steps that must be observed satisfied before this one applies.
126    pub prereqs: &'static [&'static str],
127    /// Why this step does not apply to a target, or `None` where it does.
128    pub applies: Applies,
129}
130
131impl std::fmt::Debug for StepSpec {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.debug_struct("StepSpec")
134            .field("name", &self.name)
135            .field("chapter", &self.chapter)
136            .field("mutates", &self.mutates)
137            .field("destructive", &self.destructive)
138            .field("optional", &self.optional)
139            .field("prereqs", &self.prereqs)
140            .finish_non_exhaustive()
141    }
142}
143
144/// The fifteen steps, with reporting intake last.
145///
146/// `package-check`, `branch-reminder`, and `forge-version` belong to no
147/// forge tree — the first reads its command from the technology binding,
148/// the second writes an embedded hook body into the target's own git
149/// directory, and the third is one read-only API call the observer already
150/// makes — which makes them the three steps outside the parity rule.
151pub const STEPS: [StepSpec; 15] = [
152    StepSpec {
153        name: "package-check",
154        chapter: "§0",
155        mutates: Mutates::Nothing,
156        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",
157        destructive: false,
158        optional: false,
159        forge_cli: &[],
160        prereqs: &[],
161        applies: needs_driver,
162    },
163    StepSpec {
164        name: "default-branch",
165        chapter: "§1",
166        mutates: Mutates::Forge,
167        proves: "the trunk is the default branch",
168        destructive: false,
169        optional: false,
170        forge_cli: &[Forge::Github, Forge::Gitlab],
171        prereqs: &[],
172        applies: needs_adapter,
173    },
174    StepSpec {
175        name: "single-trunk",
176        chapter: "§1",
177        mutates: Mutates::Forge,
178        proves: "no long-lived branch besides the trunk remains",
179        destructive: true,
180        optional: false,
181        forge_cli: &[Forge::Github, Forge::Gitlab],
182        prereqs: &["default-branch"],
183        applies: needs_adapter,
184    },
185    StepSpec {
186        name: "merge-cleanup",
187        chapter: "§1",
188        mutates: Mutates::Forge,
189        proves: "the forge deletes a branch when its merge lands",
190        destructive: false,
191        optional: false,
192        forge_cli: &[Forge::Github, Forge::Gitlab],
193        prereqs: &["default-branch"],
194        applies: needs_adapter_and_guards,
195    },
196    StepSpec {
197        name: "branch-reminder",
198        chapter: "§1",
199        mutates: Mutates::Local,
200        proves: "a pull reminds the operator when a merged branch lingers locally",
201        destructive: false,
202        optional: false,
203        forge_cli: &[],
204        prereqs: &[],
205        applies: needs_workflow_guards,
206    },
207    StepSpec {
208        name: "ci-permissions",
209        chapter: "§2",
210        mutates: Mutates::Forge,
211        proves: "CI may write and open requests",
212        destructive: false,
213        optional: false,
214        forge_cli: &[Forge::Github, Forge::Gitlab],
215        prereqs: &[],
216        applies: needs_release,
217    },
218    StepSpec {
219        name: "install-bot",
220        chapter: "§2",
221        mutates: Mutates::Forge,
222        proves: "the bot identity can act on this project",
223        destructive: false,
224        optional: false,
225        forge_cli: &[Forge::Github, Forge::Gitlab],
226        prereqs: &[],
227        applies: needs_release,
228    },
229    StepSpec {
230        name: "bot-secrets",
231        chapter: "§2",
232        mutates: Mutates::Forge,
233        proves: "the bot credentials are stored on the project",
234        destructive: false,
235        optional: false,
236        forge_cli: &[Forge::Github, Forge::Gitlab],
237        prereqs: &[],
238        applies: needs_release,
239    },
240    StepSpec {
241        name: "forge-version",
242        chapter: "§3",
243        mutates: Mutates::Nothing,
244        proves: "the forge meets the convention's minimum version",
245        destructive: false,
246        optional: false,
247        forge_cli: &[Forge::Gitlab],
248        prereqs: &[],
249        applies: needs_adapter,
250    },
251    StepSpec {
252        name: "protect-trunk",
253        chapter: "§3",
254        mutates: Mutates::Forge,
255        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",
256        destructive: false,
257        optional: false,
258        forge_cli: &[Forge::Github, Forge::Gitlab],
259        prereqs: &["default-branch", "forge-version"],
260        applies: needs_adapter,
261    },
262    StepSpec {
263        name: "protect-tags",
264        chapter: "§3",
265        mutates: Mutates::Forge,
266        proves: "v* is protected as far as the forge allows",
267        destructive: false,
268        optional: false,
269        forge_cli: &[Forge::Github, Forge::Gitlab],
270        prereqs: &[],
271        applies: needs_adapter,
272    },
273    StepSpec {
274        name: "protect-release-lines",
275        chapter: "§3",
276        mutates: Mutates::Forge,
277        proves: "release/* cannot be force-pushed or deleted",
278        destructive: false,
279        optional: true,
280        forge_cli: &[Forge::Github, Forge::Gitlab],
281        prereqs: &[],
282        applies: needs_release,
283    },
284    StepSpec {
285        name: "auto-merge",
286        chapter: "§3",
287        mutates: Mutates::Forge,
288        proves: "a request may merge itself once its required checks pass",
289        destructive: false,
290        optional: false,
291        forge_cli: &[Forge::Github, Forge::Gitlab],
292        prereqs: &["default-branch"],
293        applies: needs_release,
294    },
295    StepSpec {
296        name: "protections-check",
297        chapter: "§3",
298        mutates: Mutates::Nothing,
299        proves: "exactly the owned protections, with those rules",
300        destructive: false,
301        optional: false,
302        forge_cli: &[Forge::Github, Forge::Gitlab],
303        prereqs: &[],
304        applies: needs_adapter,
305    },
306    StepSpec {
307        name: "private-vulnerability-reporting",
308        chapter: "§3",
309        mutates: Mutates::Forge,
310        proves: "a vulnerability report has a private intake path, with the forge's limits named",
311        destructive: false,
312        optional: false,
313        forge_cli: &[Forge::Github, Forge::Gitlab],
314        prereqs: &[],
315        applies: needs_reporting_policy,
316    },
317];
318
319/// Look one step up by name.
320#[must_use]
321pub fn spec(name: &str) -> Option<&'static StepSpec> {
322    STEPS.iter().find(|step| step.name == name)
323}
324
325#[cfg(test)]
326mod tests {
327    use super::{Ctx, STEPS, spec};
328    use crate::profile::ReleaseMode;
329
330    /// One resolved configuration to judge the table against: the forge
331    /// the profile declares, whether this release has an adapter for it,
332    /// and the release mode.
333    fn ctx(forge: Option<&str>, mode: ReleaseMode) -> Ctx {
334        let mut ctx = Ctx::for_tests(
335            camino::Utf8PathBuf::from("/tmp/target"),
336            "acme/widget".to_owned(),
337            crate::detect::Forge::Github,
338            std::path::PathBuf::from("gh"),
339            Some("rust"),
340        );
341        ctx.declared_forge = forge.map(str::to_owned);
342        ctx.forge = forge.and_then(crate::detect::Forge::parse);
343        ctx.profile.forge = ctx.declared_forge.clone();
344        ctx.profile.release.mode = mode;
345        if mode != ReleaseMode::Automatic {
346            ctx.profile.release.driver = None;
347            ctx.profile.release.style = None;
348            ctx.profile.release.line_prefix = None;
349        }
350        ctx
351    }
352
353    /// The steps one configuration applies, in table order.
354    fn applicable(forge: Option<&str>, mode: ReleaseMode) -> Vec<&'static str> {
355        let ctx = ctx(forge, mode);
356        STEPS
357            .iter()
358            .filter(|step| (step.applies)(&ctx).is_none())
359            .map(|step| step.name)
360            .collect()
361    }
362
363    /// The two workflow steps follow the workflow guards: without them,
364    /// each reports why it does not apply, and every other step answers
365    /// as before.
366    #[test]
367    fn the_workflow_steps_follow_the_workflow_guards() {
368        let mut ctx = ctx(Some("github"), ReleaseMode::Automatic);
369        ctx.capabilities.workflow_guards = Some(false);
370        for name in ["merge-cleanup", "branch-reminder"] {
371            let step = spec(name).expect("the step exists");
372            let reason = (step.applies)(&ctx).expect("the step does not apply");
373            assert!(reason.contains("capabilities.workflow_guards"), "{reason}");
374        }
375        for name in [
376            "default-branch",
377            "single-trunk",
378            "protect-trunk",
379            "auto-merge",
380        ] {
381            let step = spec(name).expect("the step exists");
382            assert_eq!((step.applies)(&ctx), None, "{name}");
383        }
384    }
385
386    /// SATISFIES forge-setup:applicability-follows-the-target-configuration
387    /// The matrix across an absent forge, an unknown one, and each driven
388    /// one, at each release mode.
389    #[test]
390    fn the_applicability_matrix_follows_the_profile() {
391        // No forge: the local step alone.
392        for mode in [
393            ReleaseMode::Automatic,
394            ReleaseMode::External,
395            ReleaseMode::None,
396        ] {
397            let steps = applicable(None, mode);
398            assert_eq!(
399                steps,
400                if mode == ReleaseMode::Automatic {
401                    vec!["package-check", "branch-reminder"]
402                } else {
403                    vec!["branch-reminder"]
404                },
405                "no forge, {mode:?}"
406            );
407        }
408        // A forge this release has no adapter for reads like no forge.
409        assert_eq!(
410            applicable(Some("codeberg"), ReleaseMode::None),
411            ["branch-reminder"]
412        );
413
414        for forge in ["github", "gitlab"] {
415            let automatic = applicable(Some(forge), ReleaseMode::Automatic);
416            for step in STEPS.iter().map(|step| step.name) {
417                assert!(automatic.contains(&step), "{forge} automatic: {step}");
418            }
419            // A release-less target keeps the trunk half and the tag
420            // protection, and takes none of the release half.
421            for mode in [ReleaseMode::External, ReleaseMode::None] {
422                let steps = applicable(Some(forge), mode);
423                for step in [
424                    "default-branch",
425                    "single-trunk",
426                    "merge-cleanup",
427                    "branch-reminder",
428                    "forge-version",
429                    "protect-trunk",
430                    "protect-tags",
431                    "protections-check",
432                    "private-vulnerability-reporting",
433                ] {
434                    assert!(steps.contains(&step), "{forge} {mode:?}: {step}");
435                }
436                for step in [
437                    "package-check",
438                    "ci-permissions",
439                    "install-bot",
440                    "bot-secrets",
441                    "auto-merge",
442                    "protect-release-lines",
443                ] {
444                    assert!(!steps.contains(&step), "{forge} {mode:?}: {step} applies");
445                }
446            }
447        }
448    }
449
450    /// The reporting channel pairs with the landed policy: a target that
451    /// requests none is asked for none.
452    #[test]
453    fn the_reporting_channel_follows_the_requested_policy() {
454        let mut ctx = ctx(Some("github"), ReleaseMode::None);
455        assert!(
456            (spec("private-vulnerability-reporting")
457                .expect("the step exists")
458                .applies)(&ctx)
459            .is_none()
460        );
461        ctx.capabilities.reporting_policy = false;
462        let reason = (spec("private-vulnerability-reporting")
463            .expect("the step exists")
464            .applies)(&ctx)
465        .expect("an unrequested policy names itself");
466        assert!(reason.contains("capabilities.reporting_policy"), "{reason}");
467    }
468
469    /// The packaging gate reads the release driver's own command, so an
470    /// ambiguous observation that named none leaves it out.
471    #[test]
472    fn the_packaging_gate_needs_a_named_driver() {
473        let mut ctx = ctx(Some("github"), ReleaseMode::Automatic);
474        ctx.profile.release.driver = None;
475        let reason = (spec("package-check").expect("the step exists").applies)(&ctx)
476            .expect("a driverless release names itself");
477        assert!(reason.contains("profile.release.driver"), "{reason}");
478    }
479
480    /// Every prerequisite names a step that exists and comes earlier, so the
481    /// full run can never be refused by its own table.
482    #[test]
483    fn every_prereq_is_an_earlier_step() {
484        for (idx, step) in STEPS.iter().enumerate() {
485            for prereq in step.prereqs {
486                let position = STEPS
487                    .iter()
488                    .position(|other| other.name == *prereq)
489                    .unwrap_or(usize::MAX);
490                assert!(
491                    position < idx,
492                    "{}: prereq {prereq} is not an earlier step",
493                    step.name
494                );
495            }
496        }
497        let reporting = &STEPS[STEPS.len() - 1];
498        assert_eq!(reporting.name, "private-vulnerability-reporting");
499        assert_eq!(reporting.chapter, "§3");
500        assert_eq!(reporting.mutates, super::Mutates::Forge);
501        assert!(!reporting.optional && !reporting.destructive);
502        assert!(reporting.prereqs.is_empty());
503        assert_eq!(STEPS[STEPS.len() - 2].name, "protections-check");
504        assert!(spec("package-check").is_some());
505        assert!(spec("no-such-step").is_none());
506    }
507}