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