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, LOCAL_GITHUB_BYPASS, Protection, invalid, legacy_local_protection};
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.allowed_merge_methods",
36        minimum: "exactly [squash]",
37        heading: SQUASH,
38        accepts: |p| p.allowed_merge_methods == ["squash"],
39    },
40    Floor {
41        key: "protection.github.squash_title_source",
42        minimum: "PR_TITLE",
43        heading: SQUASH,
44        accepts: |p| p.github.squash_title_source == "PR_TITLE",
45    },
46    Floor {
47        key: "protection.github.squash_body_source",
48        minimum: "PR_BODY",
49        heading: SQUASH,
50        accepts: |p| p.github.squash_body_source == "PR_BODY",
51    },
52    Floor {
53        key: "protection.gitlab.merge_method",
54        minimum: "ff",
55        heading: SQUASH,
56        accepts: |p| p.gitlab.merge_method == "ff",
57    },
58    Floor {
59        key: "protection.gitlab.squash_option",
60        minimum: "always",
61        heading: SQUASH,
62        accepts: |p| p.gitlab.squash_option == "always",
63    },
64    Floor {
65        key: "protection.gitlab.squash_commit_template",
66        minimum: "references %{title}",
67        heading: SQUASH,
68        // The title alone, deliberately. `forge-setup:the-setup-asserts-
69        // the-squash-body-source` states that GitLab's template puts no
70        // request description on the trunk, so requiring `%{description}`
71        // here would contradict the setup this table is meant to floor.
72        accepts: |p| p.gitlab.squash_commit_template.contains("%{title}"),
73    },
74    Floor {
75        key: "protection.gitlab.merge_access_level",
76        minimum: "at least 30",
77        heading: SQUASH,
78        accepts: |p| p.gitlab.merge_access_level >= 30,
79    },
80];
81
82/// What a forge-integrated trunk adds: the request rule, the check the
83/// request must carry, and the review policy above them.
84const FORGE_ONLY: &[Floor] = &[
85    Floor {
86        key: "protection.bypass_actors",
87        minimum: "empty",
88        heading: SQUASH,
89        accepts: |p| p.bypass_actors.is_empty(),
90    },
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 changes.
154///
155/// GitHub keeps the request and strict-check rules so they can hold a
156/// release request on the trunk it was tested against. The repository
157/// administrator alone bypasses the ruleset carrying them, for the direct
158/// push this mode exists to make. Deletion and force-push protection sit in
159/// the safety ruleset, which names nobody, so that bypass never reaches
160/// them. GitLab keeps its native access-level answer.
161const LOCAL_ONLY: &[Floor] = &[
162    Floor {
163        key: "protection.bypass_actors",
164        minimum: "exactly [repository-admin]",
165        heading: SQUASH,
166        accepts: |p| p.bypass_actors == [LOCAL_GITHUB_BYPASS] || legacy_local_protection(p),
167    },
168    Floor {
169        key: "protection.strict_required_status_checks",
170        minimum: "true",
171        heading: REQUEST,
172        accepts: |p| p.strict_required_status_checks,
173    },
174    Floor {
175        key: "protection.owned_trunk_rules",
176        minimum: "contains deletion, non_fast_forward, pull_request, required_status_checks",
177        heading: REQUEST,
178        accepts: |p| {
179            [
180                "deletion",
181                "non_fast_forward",
182                "pull_request",
183                "required_status_checks",
184            ]
185            .iter()
186            .all(|rule| p.owned_trunk_rules.iter().any(|owned| owned == rule))
187                || legacy_local_protection(p)
188        },
189    },
190    Floor {
191        key: "protection.gitlab.push_access_level",
192        // 0 closes the trunk to every push, which is stricter than a
193        // restriction and what a target that never flipped the axis
194        // still carries. 30 is developer, which is everyone, and is the
195        // one value this mode refuses.
196        minimum: "at least 40 (maintainers alone); 0 is stricter and closes the trunk entirely",
197        heading: SQUASH,
198        accepts: |p| p.gitlab.push_access_level == 0 || p.gitlab.push_access_level >= 40,
199    },
200    Floor {
201        key: "protection.required_approving_review_count",
202        minimum: "at least 0",
203        heading: REQUEST,
204        accepts: |p| p.required_approving_review_count >= 0,
205    },
206    Floor {
207        key: "protection.dismiss_stale_reviews_on_push",
208        minimum: "false; true is stricter",
209        heading: REQUEST,
210        accepts: |p| {
211            let _ = p.dismiss_stale_reviews_on_push;
212            true
213        },
214    },
215    Floor {
216        key: "protection.require_code_owner_review",
217        minimum: "false; true is stricter",
218        heading: REQUEST,
219        accepts: |p| {
220            let _ = p.require_code_owner_review;
221            true
222        },
223    },
224    Floor {
225        key: "protection.require_last_push_approval",
226        minimum: "false; true is stricter",
227        heading: REQUEST,
228        accepts: |p| {
229            let _ = p.require_last_push_approval;
230            true
231        },
232    },
233];
234
235/// Every floored configuration key under the given integration mode.
236#[must_use]
237pub fn floors(integration: Integration) -> Vec<&'static Floor> {
238    let extra = match integration {
239        Integration::Forge => FORGE_ONLY,
240        Integration::Local => LOCAL_ONLY,
241    };
242    SHARED.iter().chain(extra).collect()
243}
244
245/// Judge a configuration before any consumer uses it.
246///
247/// A configuration silent about `git.integration` is judged as a
248/// forge-integrated one: that is the shape every target landed before the
249/// axis existed, and reading silence as `local` would lift two floors a
250/// target still relies on.
251///
252/// # Errors
253/// Refuses the first weakened policy, naming its key, floor and method source.
254pub fn check(config: &Config) -> Result<(), RkError> {
255    let integration = config.git.integration.unwrap_or(Integration::Forge);
256    for floor in floors(integration) {
257        if !(floor.accepts)(&config.protection) {
258            return Err(invalid(format!(
259                "{}: floor is {}; see rk method invariants ({})",
260                floor.key, floor.minimum, floor.heading
261            )));
262        }
263    }
264    Ok(())
265}
266
267#[cfg(test)]
268mod tests {
269    use super::{check, floors};
270    use crate::config::Config;
271    use crate::landing::Integration;
272
273    #[test]
274    fn every_floor_names_a_real_invariant_heading() {
275        let chapter = crate::embedded::METHOD
276            .get_file("01-invariants.md")
277            .expect("the invariants ship")
278            .contents_utf8()
279            .expect("prose is utf8");
280        for integration in [Integration::Forge, Integration::Local] {
281            for floor in floors(integration) {
282                assert!(
283                    chapter
284                        .lines()
285                        .any(|line| line.strip_prefix("## ") == Some(floor.heading)),
286                    "{}: {}",
287                    floor.key,
288                    floor.heading
289                );
290            }
291        }
292        check(&Config::default()).expect("defaults meet every floor");
293        let mut local = Config::default();
294        local.git.integration = Some(Integration::Local);
295        local.protection = crate::config::local_protection();
296        check(&local).expect("defaults meet every local floor too");
297    }
298
299    #[test]
300    fn a_value_below_a_floor_refuses_naming_the_invariants() {
301        let mut config = Config::default();
302        config.protection.allowed_merge_methods.push("merge".into());
303        let error = check(&config)
304            .expect_err("merge violates squash only")
305            .to_string();
306        for expected in [
307            "protection.allowed_merge_methods",
308            "floor",
309            "squash",
310            "rk method invariants",
311        ] {
312            assert!(error.contains(expected), "{error}");
313        }
314    }
315
316    #[test]
317    fn a_stricter_value_passes_the_floor() {
318        let mut config = Config::default();
319        config.protection.required_approving_review_count = 3;
320        config.protection.dismiss_stale_reviews_on_push = true;
321        config.protection.require_code_owner_review = true;
322        config.protection.require_last_push_approval = true;
323        config.protection.gitlab.merge_access_level = 40;
324        config.protection.tag_pattern = "refs/tags/*".into();
325        config
326            .protection
327            .owned_trunk_rules
328            .push("required_signatures".into());
329        check(&config).expect("a target can be stricter");
330    }
331
332    #[test]
333    fn one_policy_is_judged_by_the_recorded_integration_mode() {
334        let mut config = Config::default();
335        config.protection.owned_trunk_rules = vec!["deletion".into(), "non_fast_forward".into()];
336        config.git.integration = Some(Integration::Forge);
337        let error = check(&config)
338            .expect_err("a forge-integrated trunk needs the request rule")
339            .to_string();
340        assert!(error.contains("protection.owned_trunk_rules"), "{error}");
341        assert!(error.contains("pull_request"), "{error}");
342        config.git.integration = Some(Integration::Local);
343        config.protection.gitlab.push_access_level = 40;
344        check(&config).expect("the legacy local policy remains readable for migration");
345    }
346
347    #[test]
348    fn a_silent_config_is_judged_as_forge_integrated() {
349        let mut config = Config::default();
350        config.protection.owned_trunk_rules = vec!["deletion".into(), "non_fast_forward".into()];
351        config.git.integration = None;
352        check(&config).expect_err("silence is judged by the stricter table");
353    }
354
355    #[test]
356    fn a_local_target_still_refuses_a_developer_wide_trunk_push() {
357        let mut config = Config::default();
358        config.git.integration = Some(Integration::Local);
359        config.protection = crate::config::local_protection();
360        config.protection.gitlab.push_access_level = 30;
361        let error = check(&config)
362            .expect_err("developer level is everyone")
363            .to_string();
364        assert!(
365            error.contains("protection.gitlab.push_access_level"),
366            "{error}"
367        );
368        config.protection.gitlab.push_access_level = 40;
369        check(&config).expect("maintainers alone is a restriction");
370    }
371
372    #[test]
373    fn the_tag_floors_hold_under_both_integration_modes() {
374        for integration in [Integration::Forge, Integration::Local] {
375            let mut config = Config::default();
376            config.git.integration = Some(integration);
377            config.protection.tag_pattern = "refs/tags/release-*".into();
378            let error = check(&config)
379                .expect_err("a pattern missing published versions refuses")
380                .to_string();
381            assert!(error.contains("protection.tag_pattern"), "{error}");
382        }
383    }
384}