Skip to main content

release_kit/config/
floors.rs

1//! The single owner of configuration policy judgments and their method sources.
2
3use super::{Config, Protection, invalid};
4use crate::error::RkError;
5
6/// A minimum policy and the method heading it serves.
7pub struct Floor {
8    /// Fully qualified configuration key.
9    pub key: &'static str,
10    /// Human-readable minimum or invariant.
11    pub minimum: &'static str,
12    /// Exact heading in the invariants chapter.
13    pub heading: &'static str,
14    accepts: fn(&Protection) -> bool,
15}
16
17/// Every floored configuration key, including permissive boolean floors.
18pub const FLOORS: &[Floor] = &[
19    Floor {
20        key: "protection.tag_pattern",
21        minimum: "refs/tags/v* or refs/tags/*, covering every published version",
22        heading: "A published version is immutable",
23        accepts: |p| matches!(p.tag_pattern.as_str(), "refs/tags/v*" | "refs/tags/*"),
24    },
25    Floor {
26        key: "protection.bypass_actors",
27        minimum: "empty",
28        heading: "Trunk is written through pull requests only",
29        accepts: |p| p.bypass_actors.is_empty(),
30    },
31    Floor {
32        key: "protection.allowed_merge_methods",
33        minimum: "exactly [squash]",
34        heading: "Trunk is written through pull requests only",
35        accepts: |p| p.allowed_merge_methods == ["squash"],
36    },
37    Floor {
38        key: "protection.strict_required_status_checks",
39        minimum: "true",
40        heading: "Trunk is written through pull requests only",
41        accepts: |p| p.strict_required_status_checks,
42    },
43    Floor {
44        key: "protection.owned_trunk_rules",
45        minimum: "contains deletion, non_fast_forward, pull_request, required_status_checks",
46        heading: "Trunk is written through pull requests only",
47        accepts: |p| {
48            [
49                "deletion",
50                "non_fast_forward",
51                "pull_request",
52                "required_status_checks",
53            ]
54            .iter()
55            .all(|rule| p.owned_trunk_rules.iter().any(|owned| owned == rule))
56        },
57    },
58    Floor {
59        key: "protection.required_approving_review_count",
60        minimum: "at least 0",
61        heading: "Trunk is written through pull requests only",
62        accepts: |p| p.required_approving_review_count >= 0,
63    },
64    Floor {
65        key: "protection.dismiss_stale_reviews_on_push",
66        minimum: "false; true is stricter",
67        heading: "Trunk is written through pull requests only",
68        accepts: |p| {
69            let _ = p.dismiss_stale_reviews_on_push;
70            true
71        },
72    },
73    Floor {
74        key: "protection.require_code_owner_review",
75        minimum: "false; true is stricter",
76        heading: "Trunk is written through pull requests only",
77        accepts: |p| {
78            let _ = p.require_code_owner_review;
79            true
80        },
81    },
82    Floor {
83        key: "protection.require_last_push_approval",
84        minimum: "false; true is stricter",
85        heading: "Trunk is written through pull requests only",
86        accepts: |p| {
87            let _ = p.require_last_push_approval;
88            true
89        },
90    },
91    Floor {
92        key: "protection.github.squash_title_source",
93        minimum: "PR_TITLE",
94        heading: "Trunk is written through pull requests only",
95        accepts: |p| p.github.squash_title_source == "PR_TITLE",
96    },
97    Floor {
98        key: "protection.github.squash_body_source",
99        minimum: "PR_BODY",
100        heading: "Trunk is written through pull requests only",
101        accepts: |p| p.github.squash_body_source == "PR_BODY",
102    },
103    Floor {
104        key: "protection.gitlab.merge_method",
105        minimum: "ff",
106        heading: "Trunk is written through pull requests only",
107        accepts: |p| p.gitlab.merge_method == "ff",
108    },
109    Floor {
110        key: "protection.gitlab.squash_option",
111        minimum: "always",
112        heading: "Trunk is written through pull requests only",
113        accepts: |p| p.gitlab.squash_option == "always",
114    },
115    Floor {
116        key: "protection.gitlab.squash_commit_template",
117        minimum: "references %{title}",
118        heading: "Trunk is written through pull requests only",
119        // The title alone, deliberately. `forge-setup:the-setup-asserts-
120        // the-squash-body-source` states that GitLab's template puts no
121        // request description on the trunk, so requiring `%{description}`
122        // here would contradict the setup this table is meant to floor.
123        accepts: |p| p.gitlab.squash_commit_template.contains("%{title}"),
124    },
125    Floor {
126        key: "protection.gitlab.push_access_level",
127        minimum: "0 (no direct pushes)",
128        heading: "Trunk is written through pull requests only",
129        accepts: |p| p.gitlab.push_access_level == 0,
130    },
131    Floor {
132        key: "protection.gitlab.merge_access_level",
133        minimum: "at least 30",
134        heading: "Trunk is written through pull requests only",
135        accepts: |p| p.gitlab.merge_access_level >= 30,
136    },
137];
138
139/// Judge a configuration before any consumer uses it.
140///
141/// # Errors
142/// Refuses the first weakened policy, naming its key, floor and method source.
143pub fn check(config: &Config) -> Result<(), RkError> {
144    for floor in FLOORS {
145        if !(floor.accepts)(&config.protection) {
146            return Err(invalid(format!(
147                "{}: floor is {}; see rk method invariants ({})",
148                floor.key, floor.minimum, floor.heading
149            )));
150        }
151    }
152    Ok(())
153}
154
155#[cfg(test)]
156mod tests {
157    #![allow(clippy::expect_used)]
158
159    use super::{FLOORS, check};
160    use crate::config::Config;
161
162    #[test]
163    fn every_floor_names_a_real_invariant_heading() {
164        let chapter = crate::embedded::METHOD
165            .get_file("01-invariants.md")
166            .expect("the invariants ship")
167            .contents_utf8()
168            .expect("prose is utf8");
169        for floor in FLOORS {
170            assert!(
171                chapter
172                    .lines()
173                    .any(|line| line.strip_prefix("## ") == Some(floor.heading)),
174                "{}: {}",
175                floor.key,
176                floor.heading
177            );
178        }
179        check(&Config::default()).expect("defaults meet every floor");
180    }
181
182    #[test]
183    fn a_value_below_a_floor_refuses_naming_the_invariants() {
184        let mut config = Config::default();
185        config.protection.allowed_merge_methods.push("merge".into());
186        let error = check(&config)
187            .expect_err("merge violates squash only")
188            .to_string();
189        for expected in [
190            "protection.allowed_merge_methods",
191            "floor",
192            "squash",
193            "rk method invariants",
194        ] {
195            assert!(error.contains(expected), "{error}");
196        }
197    }
198
199    #[test]
200    fn a_stricter_value_passes_the_floor() {
201        let mut config = Config::default();
202        config.protection.required_approving_review_count = 3;
203        config.protection.dismiss_stale_reviews_on_push = true;
204        config.protection.require_code_owner_review = true;
205        config.protection.require_last_push_approval = true;
206        config.protection.gitlab.merge_access_level = 40;
207        config.protection.tag_pattern = "refs/tags/*".into();
208        config
209            .protection
210            .owned_trunk_rules
211            .push("required_signatures".into());
212        check(&config).expect("a target can be stricter");
213    }
214}