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;
5use crate::landing::Integration;
6
7/// A minimum policy and the method heading it serves.
8pub struct Floor {
9    /// Fully qualified configuration key.
10    pub key: &'static str,
11    /// Human-readable minimum or invariant.
12    pub minimum: &'static str,
13    /// Exact heading in the invariants chapter.
14    pub heading: &'static str,
15    accepts: fn(&Protection) -> bool,
16}
17
18/// The heading every floor that holds under both integration modes cites.
19const SQUASH: &str = "The trunk takes one validated squash commit at a time";
20
21/// The heading the floors that only a forge can enforce cite.
22const REQUEST: &str = "A forge-integrated trunk merges through a checked request";
23
24/// The tag floor and the squash floors, identical under both integration
25/// modes: the release request integrates at the forge either way, so what
26/// protects it and what protects a published version never varies.
27const SHARED: &[Floor] = &[
28    Floor {
29        key: "protection.tag_pattern",
30        minimum: "refs/tags/v* or refs/tags/*, covering every published version",
31        heading: "A published version is immutable",
32        accepts: |p| matches!(p.tag_pattern.as_str(), "refs/tags/v*" | "refs/tags/*"),
33    },
34    Floor {
35        key: "protection.bypass_actors",
36        minimum: "empty",
37        heading: SQUASH,
38        accepts: |p| p.bypass_actors.is_empty(),
39    },
40    Floor {
41        key: "protection.allowed_merge_methods",
42        minimum: "exactly [squash]",
43        heading: SQUASH,
44        accepts: |p| p.allowed_merge_methods == ["squash"],
45    },
46    Floor {
47        key: "protection.github.squash_title_source",
48        minimum: "PR_TITLE",
49        heading: SQUASH,
50        accepts: |p| p.github.squash_title_source == "PR_TITLE",
51    },
52    Floor {
53        key: "protection.github.squash_body_source",
54        minimum: "PR_BODY",
55        heading: SQUASH,
56        accepts: |p| p.github.squash_body_source == "PR_BODY",
57    },
58    Floor {
59        key: "protection.gitlab.merge_method",
60        minimum: "ff",
61        heading: SQUASH,
62        accepts: |p| p.gitlab.merge_method == "ff",
63    },
64    Floor {
65        key: "protection.gitlab.squash_option",
66        minimum: "always",
67        heading: SQUASH,
68        accepts: |p| p.gitlab.squash_option == "always",
69    },
70    Floor {
71        key: "protection.gitlab.squash_commit_template",
72        minimum: "references %{title}",
73        heading: SQUASH,
74        // The title alone, deliberately. `forge-setup:the-setup-asserts-
75        // the-squash-body-source` states that GitLab's template puts no
76        // request description on the trunk, so requiring `%{description}`
77        // here would contradict the setup this table is meant to floor.
78        accepts: |p| p.gitlab.squash_commit_template.contains("%{title}"),
79    },
80    Floor {
81        key: "protection.gitlab.merge_access_level",
82        minimum: "at least 30",
83        heading: SQUASH,
84        accepts: |p| p.gitlab.merge_access_level >= 30,
85    },
86];
87
88/// What a forge-integrated trunk adds: the request rule, the check the
89/// request must carry, and the review policy above them.
90const FORGE_ONLY: &[Floor] = &[
91    Floor {
92        key: "protection.strict_required_status_checks",
93        minimum: "true",
94        heading: REQUEST,
95        accepts: |p| p.strict_required_status_checks,
96    },
97    Floor {
98        key: "protection.owned_trunk_rules",
99        minimum: "contains deletion, non_fast_forward, pull_request, required_status_checks",
100        heading: REQUEST,
101        accepts: |p| {
102            [
103                "deletion",
104                "non_fast_forward",
105                "pull_request",
106                "required_status_checks",
107            ]
108            .iter()
109            .all(|rule| p.owned_trunk_rules.iter().any(|owned| owned == rule))
110        },
111    },
112    Floor {
113        key: "protection.gitlab.push_access_level",
114        minimum: "0 (no direct pushes)",
115        heading: REQUEST,
116        accepts: |p| p.gitlab.push_access_level == 0,
117    },
118    Floor {
119        key: "protection.required_approving_review_count",
120        minimum: "at least 0",
121        heading: REQUEST,
122        accepts: |p| p.required_approving_review_count >= 0,
123    },
124    Floor {
125        key: "protection.dismiss_stale_reviews_on_push",
126        minimum: "false; true is stricter",
127        heading: REQUEST,
128        accepts: |p| {
129            let _ = p.dismiss_stale_reviews_on_push;
130            true
131        },
132    },
133    Floor {
134        key: "protection.require_code_owner_review",
135        minimum: "false; true is stricter",
136        heading: REQUEST,
137        accepts: |p| {
138            let _ = p.require_code_owner_review;
139            true
140        },
141    },
142    Floor {
143        key: "protection.require_last_push_approval",
144        minimum: "false; true is stricter",
145        heading: REQUEST,
146        accepts: |p| {
147            let _ = p.require_last_push_approval;
148            true
149        },
150    },
151];
152
153/// What a locally integrated trunk keeps in their place.
154///
155/// The request rule and the required-check rule are gone, because no
156/// forge can require a check before the push that starts it, and the
157/// push is the mechanism this mode uses. What still holds against a
158/// direct push stays: no deletion and no force-push. Who may make the
159/// push is the forge's own answer rather than a rule this table can
160/// floor on both forges — GitLab names a level, and a GitHub ruleset
161/// carries no allowed-pushers list — so the one thing floored here is
162/// that a stated GitLab level is not broad enough to name every writer.
163const LOCAL_ONLY: &[Floor] = &[
164    Floor {
165        key: "protection.owned_trunk_rules",
166        minimum: "contains deletion, non_fast_forward",
167        heading: SQUASH,
168        accepts: |p| {
169            ["deletion", "non_fast_forward"]
170                .iter()
171                .all(|rule| p.owned_trunk_rules.iter().any(|owned| owned == rule))
172        },
173    },
174    Floor {
175        key: "protection.gitlab.push_access_level",
176        // 0 closes the trunk to every push, which is stricter than a
177        // restriction and what a target that never flipped the axis
178        // still carries. 30 is developer, which is everyone, and is the
179        // one value this mode refuses.
180        minimum: "at least 40 (maintainers alone); 0 is stricter and closes the trunk entirely",
181        heading: SQUASH,
182        accepts: |p| p.gitlab.push_access_level == 0 || p.gitlab.push_access_level >= 40,
183    },
184];
185
186/// Every floored configuration key under the given integration mode.
187#[must_use]
188pub fn floors(integration: Integration) -> Vec<&'static Floor> {
189    let extra = match integration {
190        Integration::Forge => FORGE_ONLY,
191        Integration::Local => LOCAL_ONLY,
192    };
193    SHARED.iter().chain(extra).collect()
194}
195
196/// Judge a configuration before any consumer uses it.
197///
198/// A configuration silent about `git.integration` is judged as a
199/// forge-integrated one: that is the shape every target landed before the
200/// axis existed, and reading silence as `local` would lift two floors a
201/// target still relies on.
202///
203/// # Errors
204/// Refuses the first weakened policy, naming its key, floor and method source.
205pub fn check(config: &Config) -> Result<(), RkError> {
206    let integration = config.git.integration.unwrap_or(Integration::Forge);
207    for floor in floors(integration) {
208        if !(floor.accepts)(&config.protection) {
209            return Err(invalid(format!(
210                "{}: floor is {}; see rk method invariants ({})",
211                floor.key, floor.minimum, floor.heading
212            )));
213        }
214    }
215    Ok(())
216}
217
218#[cfg(test)]
219mod tests {
220    use super::{check, floors};
221    use crate::config::Config;
222    use crate::landing::Integration;
223
224    #[test]
225    fn every_floor_names_a_real_invariant_heading() {
226        let chapter = crate::embedded::METHOD
227            .get_file("01-invariants.md")
228            .expect("the invariants ship")
229            .contents_utf8()
230            .expect("prose is utf8");
231        for integration in [Integration::Forge, Integration::Local] {
232            for floor in floors(integration) {
233                assert!(
234                    chapter
235                        .lines()
236                        .any(|line| line.strip_prefix("## ") == Some(floor.heading)),
237                    "{}: {}",
238                    floor.key,
239                    floor.heading
240                );
241            }
242        }
243        check(&Config::default()).expect("defaults meet every floor");
244        let mut local = Config::default();
245        local.git.integration = Some(Integration::Local);
246        check(&local).expect("defaults meet every local floor too");
247    }
248
249    #[test]
250    fn a_value_below_a_floor_refuses_naming_the_invariants() {
251        let mut config = Config::default();
252        config.protection.allowed_merge_methods.push("merge".into());
253        let error = check(&config)
254            .expect_err("merge violates squash only")
255            .to_string();
256        for expected in [
257            "protection.allowed_merge_methods",
258            "floor",
259            "squash",
260            "rk method invariants",
261        ] {
262            assert!(error.contains(expected), "{error}");
263        }
264    }
265
266    #[test]
267    fn a_stricter_value_passes_the_floor() {
268        let mut config = Config::default();
269        config.protection.required_approving_review_count = 3;
270        config.protection.dismiss_stale_reviews_on_push = true;
271        config.protection.require_code_owner_review = true;
272        config.protection.require_last_push_approval = true;
273        config.protection.gitlab.merge_access_level = 40;
274        config.protection.tag_pattern = "refs/tags/*".into();
275        config
276            .protection
277            .owned_trunk_rules
278            .push("required_signatures".into());
279        check(&config).expect("a target can be stricter");
280    }
281
282    #[test]
283    fn one_policy_is_judged_by_the_recorded_integration_mode() {
284        let mut config = Config::default();
285        config.protection.owned_trunk_rules = vec!["deletion".into(), "non_fast_forward".into()];
286        config.git.integration = Some(Integration::Forge);
287        let error = check(&config)
288            .expect_err("a forge-integrated trunk needs the request rule")
289            .to_string();
290        assert!(error.contains("protection.owned_trunk_rules"), "{error}");
291        assert!(error.contains("pull_request"), "{error}");
292        config.git.integration = Some(Integration::Local);
293        check(&config).expect("the same policy holds under local integration");
294    }
295
296    #[test]
297    fn a_silent_config_is_judged_as_forge_integrated() {
298        let mut config = Config::default();
299        config.protection.owned_trunk_rules = vec!["deletion".into(), "non_fast_forward".into()];
300        config.git.integration = None;
301        check(&config).expect_err("silence is judged by the stricter table");
302    }
303
304    #[test]
305    fn a_local_target_still_refuses_a_developer_wide_trunk_push() {
306        let mut config = Config::default();
307        config.git.integration = Some(Integration::Local);
308        config.protection.gitlab.push_access_level = 30;
309        let error = check(&config)
310            .expect_err("developer level is everyone")
311            .to_string();
312        assert!(
313            error.contains("protection.gitlab.push_access_level"),
314            "{error}"
315        );
316        config.protection.gitlab.push_access_level = 40;
317        check(&config).expect("maintainers alone is a restriction");
318    }
319
320    #[test]
321    fn the_tag_floors_hold_under_both_integration_modes() {
322        for integration in [Integration::Forge, Integration::Local] {
323            let mut config = Config::default();
324            config.git.integration = Some(integration);
325            config.protection.tag_pattern = "refs/tags/release-*".into();
326            let error = check(&config)
327                .expect_err("a pattern missing published versions refuses")
328                .to_string();
329            assert!(error.contains("protection.tag_pattern"), "{error}");
330        }
331    }
332}