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/// A crate that publishes to no registry packages nothing, so the
106/// packaging gate has nothing to prove there.
107///
108/// SATISFIES forge-setup:a-package-check-states-policy-reach
109pub(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
116/// The packaging gate reads the driver's own command, and applies only
117/// where the release publishes to a registry.
118fn needs_packaging(ctx: &Ctx) -> Option<String> {
119    needs_driver(ctx).or_else(|| no_registry(ctx))
120}
121
122/// The private reporting channel pairs with the landed policy, so it
123/// applies where the target requested one at a forge this release drives.
124fn 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
130/// One step of the setup, in canonical order.
131pub struct StepSpec {
132    /// The name, which is also the `rk setup step` argument and the script
133    /// file name in every forge tree.
134    pub name: &'static str,
135    /// The `method/02-setup.md` section the step executes.
136    pub chapter: &'static str,
137    /// What the step touches under apply.
138    pub mutates: Mutates,
139    /// What the step proves, from the chapter.
140    pub proves: &'static str,
141    /// Whether the step deletes anything; a destructive step carries its own
142    /// refusal beyond `--apply`.
143    pub destructive: bool,
144    /// Whether a full run skips the step: an optional step applies only
145    /// where its condition holds, by name, through `rk setup step`.
146    pub optional: bool,
147    /// The forges at which the step reaches the forge through its CLI.
148    ///
149    /// Empty where the step is local work alone. A forge absent from the
150    /// list is one this step answers without a call, which `forge-version`
151    /// does on GitHub: a rolling service declares no version floor, so
152    /// there is nothing to read. A run that reaches the CLI for a step
153    /// that never calls it refuses an operator who has no reason to need
154    /// one.
155    pub forge_cli: &'static [Forge],
156    /// Steps that must be observed satisfied before this one applies.
157    pub prereqs: &'static [&'static str],
158    /// Why this step does not apply to a target, or `None` where it does.
159    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
175/// The setup step that stores each stored secret a landed workflow reads,
176/// so a landing that moves a secret can name who stores the new one.
177const SECRET_STEPS: [(&str, &str); 2] = [
178    ("RELEASE_BOT_APP_ID", "bot-secrets"),
179    ("RELEASE_BOT_APP_PRIVATE_KEY", "bot-secrets"),
180];
181
182/// The setup step that stores the stored secret `name`, where one does.
183#[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
191/// The fifteen steps, with reporting intake last.
192///
193/// `package-check`, `branch-reminder`, and `forge-version` belong to no
194/// forge tree — the first reads its command from the technology binding,
195/// the second writes an embedded hook body into the target's own git
196/// directory, and the third is one read-only API call the observer already
197/// makes — which makes them the three steps outside the parity rule.
198pub 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    /// What this step proves for one target.
368    ///
369    /// The table's text is the release-bearing `squash` claim, which every
370    /// target made before the merge form existed, so a proof written then
371    /// still reads as compatible. `protect-trunk` names the target's own
372    /// merge form, and names the required check only where a forge gate
373    /// lands to report one.
374    #[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/// Look one step up by name.
395#[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    /// One resolved configuration to judge the table against: the forge
406    /// the profile declares, whether this release has an adapter for it,
407    /// and the release mode.
408    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    /// The steps one configuration applies, in table order.
430    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    /// The two workflow steps follow the workflow guards: without them,
440    /// each reports why it does not apply, and every other step answers
441    /// as before.
442    #[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    /// SATISFIES forge-setup:applicability-follows-the-target-configuration
463    /// The matrix across an absent forge, an unknown one, and each driven
464    /// one, at each release mode.
465    #[test]
466    fn the_applicability_matrix_follows_the_profile() {
467        // No forge: the local step alone.
468        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        // A forge this release has no adapter for reads like no forge.
485        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            // A target with no automatic release keeps the trunk half and
496            // takes none of the release half. The tag protection follows
497            // the release mode alone: an external release keeps it, and a
498            // release-less target mints no tag to protect.
499            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    /// A release-less target mints no tag, so `protect-tags` reports not
533    /// applicable with the release mode that decided it, at either forge.
534    ///
535    /// SATISFIES forge-setup:applicability-follows-the-target-configuration
536    #[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    /// The reporting channel pairs with the landed policy: a target that
555    /// requests none is asked for none.
556    #[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    /// The packaging gate reads the release driver's own command, so an
574    /// ambiguous observation that named none leaves it out.
575    #[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    /// Every prerequisite names a step that exists and comes earlier, so the
585    /// full run can never be refused by its own table.
586    #[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}