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, MergeForm};
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:
19/// the linear trunk, in the recorded merge form.
20const LINEAR_TRUNK: &str = "The trunk takes one validated integration at a time";
21
22/// The heading the floors that only a forge can enforce cite.
23const REQUEST: &str = "A forge-integrated trunk merges through a checked request";
24
25/// The tag floor and the linear-trunk floors, identical under both
26/// integration modes and both merge forms: the release request integrates
27/// at the forge either way, so what protects it and what protects a
28/// published version never varies.
29const SHARED: &[Floor] = &[
30    Floor {
31        key: "protection.tag_pattern",
32        minimum: "refs/tags/v* or refs/tags/*, covering every published version",
33        heading: "A published version is immutable",
34        accepts: |p| matches!(p.tag_pattern.as_str(), "refs/tags/v*" | "refs/tags/*"),
35    },
36    Floor {
37        key: "protection.gitlab.merge_method",
38        minimum: "ff",
39        heading: LINEAR_TRUNK,
40        accepts: |p| p.gitlab.merge_method == "ff",
41    },
42    Floor {
43        key: "protection.gitlab.squash_commit_template",
44        minimum: "references %{title}",
45        heading: LINEAR_TRUNK,
46        // The title alone, deliberately. `forge-setup:the-setup-asserts-
47        // the-squash-body-source` states that GitLab's template puts no
48        // request description on the trunk, so requiring `%{description}`
49        // here would contradict the setup this table is meant to floor.
50        accepts: |p| p.gitlab.squash_commit_template.contains("%{title}"),
51    },
52    Floor {
53        key: "protection.gitlab.merge_access_level",
54        minimum: "at least 30",
55        heading: LINEAR_TRUNK,
56        accepts: |p| p.gitlab.merge_access_level >= 30,
57    },
58];
59
60/// What a forge-integrated trunk adds: the request rule, the check the
61/// request must carry, and the review policy above them.
62const FORGE_ONLY: &[Floor] = &[
63    Floor {
64        key: "protection.bypass_actors",
65        minimum: "empty",
66        heading: LINEAR_TRUNK,
67        accepts: |p| p.bypass_actors.is_empty(),
68    },
69    Floor {
70        key: "protection.strict_required_status_checks",
71        minimum: "true",
72        heading: REQUEST,
73        accepts: |p| p.strict_required_status_checks,
74    },
75    Floor {
76        key: "protection.owned_trunk_rules",
77        minimum: "contains deletion, non_fast_forward, pull_request, required_status_checks",
78        heading: REQUEST,
79        accepts: |p| {
80            [
81                "deletion",
82                "non_fast_forward",
83                "pull_request",
84                "required_status_checks",
85            ]
86            .iter()
87            .all(|rule| p.owned_trunk_rules.iter().any(|owned| owned == rule))
88        },
89    },
90    Floor {
91        key: "protection.gitlab.push_access_level",
92        minimum: "0 (no direct pushes)",
93        heading: REQUEST,
94        accepts: |p| p.gitlab.push_access_level == 0,
95    },
96    Floor {
97        key: "protection.required_approving_review_count",
98        minimum: "at least 0",
99        heading: REQUEST,
100        accepts: |p| p.required_approving_review_count >= 0,
101    },
102    Floor {
103        key: "protection.dismiss_stale_reviews_on_push",
104        minimum: "false; true is stricter",
105        heading: REQUEST,
106        accepts: |p| {
107            let _ = p.dismiss_stale_reviews_on_push;
108            true
109        },
110    },
111    Floor {
112        key: "protection.require_code_owner_review",
113        minimum: "false; true is stricter",
114        heading: REQUEST,
115        accepts: |p| {
116            let _ = p.require_code_owner_review;
117            true
118        },
119    },
120    Floor {
121        key: "protection.require_last_push_approval",
122        minimum: "false; true is stricter",
123        heading: REQUEST,
124        accepts: |p| {
125            let _ = p.require_last_push_approval;
126            true
127        },
128    },
129];
130
131/// What a locally integrated trunk changes.
132///
133/// GitHub keeps the request and strict-check rules so they can hold a
134/// release request on the trunk it was tested against. The repository
135/// administrator alone bypasses the ruleset carrying them, for the direct
136/// push this mode exists to make. Deletion and force-push protection sit in
137/// the safety ruleset, which names nobody, so that bypass never reaches
138/// them. GitLab keeps its native access-level answer.
139const LOCAL_ONLY: &[Floor] = &[
140    Floor {
141        key: "protection.bypass_actors",
142        minimum: "exactly [repository-admin]",
143        heading: LINEAR_TRUNK,
144        accepts: |p| p.bypass_actors == [LOCAL_GITHUB_BYPASS] || legacy_local_protection(p),
145    },
146    Floor {
147        key: "protection.strict_required_status_checks",
148        minimum: "true",
149        heading: REQUEST,
150        accepts: |p| p.strict_required_status_checks,
151    },
152    Floor {
153        key: "protection.owned_trunk_rules",
154        minimum: "contains deletion, non_fast_forward, pull_request, required_status_checks",
155        heading: REQUEST,
156        accepts: |p| {
157            [
158                "deletion",
159                "non_fast_forward",
160                "pull_request",
161                "required_status_checks",
162            ]
163            .iter()
164            .all(|rule| p.owned_trunk_rules.iter().any(|owned| owned == rule))
165                || legacy_local_protection(p)
166        },
167    },
168    Floor {
169        key: "protection.gitlab.push_access_level",
170        // 0 closes the trunk to every push, which is stricter than a
171        // restriction and what a target that never flipped the axis
172        // still carries. 30 is developer, which is everyone, and is the
173        // one value this mode refuses.
174        minimum: "at least 40 (maintainers alone); 0 is stricter and closes the trunk entirely",
175        heading: LINEAR_TRUNK,
176        accepts: |p| p.gitlab.push_access_level == 0 || p.gitlab.push_access_level >= 40,
177    },
178    Floor {
179        key: "protection.required_approving_review_count",
180        minimum: "at least 0",
181        heading: REQUEST,
182        accepts: |p| p.required_approving_review_count >= 0,
183    },
184    Floor {
185        key: "protection.dismiss_stale_reviews_on_push",
186        minimum: "false; true is stricter",
187        heading: REQUEST,
188        accepts: |p| {
189            let _ = p.dismiss_stale_reviews_on_push;
190            true
191        },
192    },
193    Floor {
194        key: "protection.require_code_owner_review",
195        minimum: "false; true is stricter",
196        heading: REQUEST,
197        accepts: |p| {
198            let _ = p.require_code_owner_review;
199            true
200        },
201    },
202    Floor {
203        key: "protection.require_last_push_approval",
204        minimum: "false; true is stricter",
205        heading: REQUEST,
206        accepts: |p| {
207            let _ = p.require_last_push_approval;
208            true
209        },
210    },
211];
212
213/// The merge floors of the `squash` form: the squash merge alone, with
214/// the request's title and body as the trunk commit's message, and GitLab
215/// squashing every merge request.
216const SQUASH_FORM: &[Floor] = &[
217    Floor {
218        key: "protection.allowed_merge_methods",
219        minimum: "exactly [squash]",
220        heading: LINEAR_TRUNK,
221        accepts: |p| p.allowed_merge_methods == ["squash"],
222    },
223    Floor {
224        key: "protection.github.squash_title_source",
225        minimum: "PR_TITLE",
226        heading: LINEAR_TRUNK,
227        accepts: |p| p.github.squash_title_source == "PR_TITLE",
228    },
229    Floor {
230        key: "protection.github.squash_body_source",
231        minimum: "PR_BODY",
232        heading: LINEAR_TRUNK,
233        accepts: |p| p.github.squash_body_source == "PR_BODY",
234    },
235    Floor {
236        key: "protection.gitlab.squash_option",
237        minimum: "always",
238        heading: LINEAR_TRUNK,
239        accepts: |p| p.gitlab.squash_option == "always",
240    },
241];
242
243/// The merge floors of the `rebase` form: the rebase merge alone, and
244/// GitLab squashing nothing, so every commit of a branch keeps its own
245/// message. No squash source applies, so neither squash source floor is
246/// here.
247const REBASE_FORM: &[Floor] = &[
248    Floor {
249        key: "protection.allowed_merge_methods",
250        minimum: "exactly [rebase]",
251        heading: LINEAR_TRUNK,
252        accepts: |p| p.allowed_merge_methods == ["rebase"],
253    },
254    Floor {
255        key: "protection.gitlab.squash_option",
256        minimum: "never",
257        heading: LINEAR_TRUNK,
258        accepts: |p| p.gitlab.squash_option == "never",
259    },
260];
261
262/// Every floored configuration key under the given integration mode and
263/// merge form.
264#[must_use]
265pub fn floors(integration: Integration, form: MergeForm) -> Vec<&'static Floor> {
266    let extra = match integration {
267        Integration::Forge => FORGE_ONLY,
268        Integration::Local => LOCAL_ONLY,
269    };
270    let merge = match form {
271        MergeForm::Squash => SQUASH_FORM,
272        MergeForm::Rebase => REBASE_FORM,
273    };
274    SHARED.iter().chain(merge).chain(extra).collect()
275}
276
277/// Judge a configuration before any consumer uses it.
278///
279/// A configuration silent about `git.integration` is judged as a
280/// forge-integrated one: that is the shape every target landed before the
281/// axis existed, and reading silence as `local` would lift two floors a
282/// target still relies on.
283///
284/// A configuration that states `git.merge_form` is judged by that form's
285/// merge floors alone, so a rebase value refuses under `squash` and a
286/// squash value under `rebase`. One silent about it resolves the form from
287/// the landing record, which this reader does not see, so it passes where
288/// its merge values are exactly one form's, and is otherwise judged as
289/// `squash`, the form every target took before the parameter existed. The
290/// setup derives the merge values from the recorded form either way, and
291/// the next landing writes the resolved form's values back.
292///
293/// # Errors
294/// Refuses the first weakened policy, naming its key, floor and method source.
295pub fn check(config: &Config) -> Result<(), RkError> {
296    let integration = config.git.integration.unwrap_or(Integration::Forge);
297    let form = config.git.merge_form.unwrap_or_else(|| {
298        let holds = |table: &[Floor]| {
299            table
300                .iter()
301                .all(|floor| (floor.accepts)(&config.protection))
302        };
303        if !holds(SQUASH_FORM) && holds(REBASE_FORM) {
304            MergeForm::Rebase
305        } else {
306            MergeForm::Squash
307        }
308    });
309    for floor in floors(integration, form) {
310        if !(floor.accepts)(&config.protection) {
311            return Err(invalid(format!(
312                "{}: floor is {}; see rk method invariants ({})",
313                floor.key, floor.minimum, floor.heading
314            )));
315        }
316    }
317    Ok(())
318}
319
320#[cfg(test)]
321mod tests {
322    use super::{check, floors};
323    use crate::config::Config;
324    use crate::landing::{Integration, MergeForm};
325
326    #[test]
327    fn every_floor_names_a_real_invariant_heading() {
328        let chapter = crate::embedded::METHOD
329            .get_file("01-invariants.md")
330            .expect("the invariants ship")
331            .contents_utf8()
332            .expect("prose is utf8");
333        for integration in [Integration::Forge, Integration::Local] {
334            for form in [MergeForm::Squash, MergeForm::Rebase] {
335                for floor in floors(integration, form) {
336                    assert!(
337                        chapter
338                            .lines()
339                            .any(|line| line.strip_prefix("## ") == Some(floor.heading)),
340                        "{}: {}",
341                        floor.key,
342                        floor.heading
343                    );
344                }
345            }
346        }
347        check(&Config::default()).expect("defaults meet every floor");
348        let mut local = Config::default();
349        local.git.integration = Some(Integration::Local);
350        local.protection = crate::config::local_protection();
351        check(&local).expect("defaults meet every local floor too");
352    }
353
354    #[test]
355    fn a_value_below_a_floor_refuses_naming_the_invariants() {
356        let mut config = Config::default();
357        config.protection.allowed_merge_methods.push("merge".into());
358        let error = check(&config)
359            .expect_err("merge violates squash only")
360            .to_string();
361        for expected in [
362            "protection.allowed_merge_methods",
363            "floor",
364            "squash",
365            "rk method invariants",
366        ] {
367            assert!(error.contains(expected), "{error}");
368        }
369    }
370
371    #[test]
372    fn a_stricter_value_passes_the_floor() {
373        let mut config = Config::default();
374        config.protection.required_approving_review_count = 3;
375        config.protection.dismiss_stale_reviews_on_push = true;
376        config.protection.require_code_owner_review = true;
377        config.protection.require_last_push_approval = true;
378        config.protection.gitlab.merge_access_level = 40;
379        config.protection.tag_pattern = "refs/tags/*".into();
380        config
381            .protection
382            .owned_trunk_rules
383            .push("required_signatures".into());
384        check(&config).expect("a target can be stricter");
385    }
386
387    #[test]
388    fn one_policy_is_judged_by_the_recorded_integration_mode() {
389        let mut config = Config::default();
390        config.protection.owned_trunk_rules = vec!["deletion".into(), "non_fast_forward".into()];
391        config.git.integration = Some(Integration::Forge);
392        let error = check(&config)
393            .expect_err("a forge-integrated trunk needs the request rule")
394            .to_string();
395        assert!(error.contains("protection.owned_trunk_rules"), "{error}");
396        assert!(error.contains("pull_request"), "{error}");
397        config.git.integration = Some(Integration::Local);
398        config.protection.gitlab.push_access_level = 40;
399        check(&config).expect("the legacy local policy remains readable for migration");
400    }
401
402    #[test]
403    fn a_silent_config_is_judged_as_forge_integrated() {
404        let mut config = Config::default();
405        config.protection.owned_trunk_rules = vec!["deletion".into(), "non_fast_forward".into()];
406        config.git.integration = None;
407        check(&config).expect_err("silence is judged by the stricter table");
408    }
409
410    #[test]
411    fn a_local_target_still_refuses_a_developer_wide_trunk_push() {
412        let mut config = Config::default();
413        config.git.integration = Some(Integration::Local);
414        config.protection = crate::config::local_protection();
415        config.protection.gitlab.push_access_level = 30;
416        let error = check(&config)
417            .expect_err("developer level is everyone")
418            .to_string();
419        assert!(
420            error.contains("protection.gitlab.push_access_level"),
421            "{error}"
422        );
423        config.protection.gitlab.push_access_level = 40;
424        check(&config).expect("maintainers alone is a restriction");
425    }
426
427    #[test]
428    fn the_tag_floors_hold_under_both_integration_modes() {
429        for integration in [Integration::Forge, Integration::Local] {
430            let mut config = Config::default();
431            config.git.integration = Some(integration);
432            config.protection.tag_pattern = "refs/tags/release-*".into();
433            let error = check(&config)
434                .expect_err("a pattern missing published versions refuses")
435                .to_string();
436            assert!(error.contains("protection.tag_pattern"), "{error}");
437        }
438    }
439
440    /// The rebase policy: the rebase merge alone, and GitLab squashing
441    /// nothing. The squash sources keep whatever the file holds.
442    fn rebase_policy(config: &mut Config) {
443        config.protection = crate::config::with_merge_form(&config.protection, MergeForm::Rebase);
444    }
445
446    /// SATISFIES target-config:an-invariant-bearing-key-carries-a-floor
447    #[test]
448    fn merge_form_rebase_values_pass_only_under_the_rebase_form() {
449        for integration in [Integration::Forge, Integration::Local] {
450            let mut config = Config::default();
451            config.git.integration = Some(integration);
452            if integration == Integration::Local {
453                config.protection = crate::config::local_protection();
454            }
455            rebase_policy(&mut config);
456            config.git.merge_form = Some(MergeForm::Rebase);
457            check(&config).expect("the rebase form accepts its own values");
458            config.git.merge_form = Some(MergeForm::Squash);
459            let error = check(&config)
460                .expect_err("the squash form refuses the rebase method")
461                .to_string();
462            assert!(
463                error.contains("protection.allowed_merge_methods"),
464                "{error}"
465            );
466            assert!(error.contains("exactly [squash]"), "{error}");
467            assert!(error.contains("rk method invariants"), "{error}");
468        }
469    }
470
471    /// A file silent about the form resolves it from the record, which
472    /// the reader does not see: one form's whole values pass, and a mix of
473    /// the two is judged as squash.
474    #[test]
475    fn merge_form_silence_takes_one_forms_whole_values() {
476        let mut config = Config::default();
477        config.git.merge_form = None;
478        check(&config).expect("the squash values pass a silent file");
479        rebase_policy(&mut config);
480        check(&config).expect("the rebase values pass a silent file");
481        config.protection.gitlab.squash_option = "always".into();
482        let error = check(&config)
483            .expect_err("a mix of the two forms is no form")
484            .to_string();
485        assert!(error.contains("exactly [squash]"), "{error}");
486    }
487
488    #[test]
489    fn merge_form_squash_values_refuse_under_the_rebase_form() {
490        let mut config = Config::default();
491        config.git.merge_form = Some(MergeForm::Rebase);
492        let error = check(&config)
493            .expect_err("the rebase form refuses the squash method")
494            .to_string();
495        assert!(
496            error.contains("protection.allowed_merge_methods"),
497            "{error}"
498        );
499        assert!(error.contains("exactly [rebase]"), "{error}");
500        // The method alone is not enough: GitLab must squash nothing.
501        config.protection.allowed_merge_methods = vec!["rebase".into()];
502        let error = check(&config)
503            .expect_err("a squashing GitLab refuses under rebase")
504            .to_string();
505        assert!(error.contains("protection.gitlab.squash_option"), "{error}");
506        assert!(error.contains("never"), "{error}");
507    }
508
509    #[test]
510    fn merge_form_rebase_lifts_the_squash_source_floors() {
511        let mut config = Config::default();
512        config.git.merge_form = Some(MergeForm::Rebase);
513        rebase_policy(&mut config);
514        config.protection.github.squash_title_source = "COMMIT_OR_PR_TITLE".into();
515        config.protection.github.squash_body_source = "BLANK".into();
516        check(&config).expect("no squash source applies under rebase");
517        config.git.merge_form = Some(MergeForm::Squash);
518        config.protection = crate::config::with_merge_form(&config.protection, MergeForm::Squash);
519        let error = check(&config)
520            .expect_err("the squash form floors its title source")
521            .to_string();
522        assert!(
523            error.contains("protection.github.squash_title_source"),
524            "{error}"
525        );
526    }
527
528    #[test]
529    fn merge_form_never_admits_a_second_method() {
530        let mut config = Config::default();
531        config.git.merge_form = Some(MergeForm::Rebase);
532        rebase_policy(&mut config);
533        config.protection.allowed_merge_methods = vec!["rebase".into(), "squash".into()];
534        let error = check(&config)
535            .expect_err("two methods are not one linear form")
536            .to_string();
537        assert!(
538            error.contains("protection.allowed_merge_methods"),
539            "{error}"
540        );
541    }
542}