1use super::{Config, LOCAL_GITHUB_BYPASS, Protection, invalid, legacy_local_protection};
4use crate::error::RkError;
5use crate::landing::Integration;
6
7pub struct Floor {
9 pub key: &'static str,
11 pub minimum: &'static str,
13 pub heading: &'static str,
15 accepts: fn(&Protection) -> bool,
16}
17
18const SQUASH: &str = "The trunk takes one validated squash commit at a time";
20
21const REQUEST: &str = "A forge-integrated trunk merges through a checked request";
23
24const 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 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
82const 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
153const 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 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#[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
245pub 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}