1use serde_json::Value;
11
12use crate::detect::Forge;
13use crate::error::RkError;
14use crate::setup::app_jwt::{self, AppApi};
15use crate::setup::context::{Ctx, TRUNK_BRANCH};
16use crate::setup::process::{Exec, Outcome};
17
18pub type Runner<'a> = dyn FnMut(&Exec) -> Result<Outcome, RkError> + 'a;
21
22pub const TRUNK_CANDIDATES: [&str; 2] = ["main", "develop"];
25
26#[derive(Debug)]
28pub enum StepState {
29 Satisfied {
32 detail: String,
34 limitation: Option<String>,
36 },
37 Unsatisfied {
39 detail: String,
41 },
42 Inapplicable {
46 detail: String,
48 },
49 Unknown {
51 detail: String,
53 },
54}
55
56impl StepState {
57 #[must_use]
59 pub const fn satisfied(&self) -> bool {
60 matches!(self, Self::Satisfied { .. })
61 }
62
63 fn ok(detail: impl Into<String>) -> Self {
64 Self::Satisfied {
65 detail: detail.into(),
66 limitation: None,
67 }
68 }
69
70 fn ok_with_limitation(detail: impl Into<String>, limitation: impl Into<String>) -> Self {
71 Self::Satisfied {
72 detail: detail.into(),
73 limitation: Some(limitation.into()),
74 }
75 }
76
77 fn not(detail: impl Into<String>) -> Self {
78 Self::Unsatisfied {
79 detail: detail.into(),
80 }
81 }
82
83 fn inapplicable(detail: impl Into<String>) -> Self {
84 Self::Inapplicable {
85 detail: detail.into(),
86 }
87 }
88
89 fn unknown(detail: impl Into<String>) -> Self {
90 Self::Unknown {
91 detail: detail.into(),
92 }
93 }
94}
95
96enum Api {
98 Ok(Value),
100 Missing,
102 Failed(String),
104}
105
106pub fn observe(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
113 if step == "package-check" {
114 return package_check(ctx, run);
115 }
116 match ctx.forge {
117 Forge::Github => github(ctx, step, run),
118 Forge::Gitlab => gitlab(ctx, step, run),
119 }
120}
121
122fn package_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
125 let (program, args): (&str, &[&str]) = match ctx.tech {
126 Some("rust") => ("cargo", &["publish", "--dry-run", "--allow-dirty"]),
127 Some("python") => ("python3", &["-m", "build"]),
128 Some("bash") => {
129 return Ok(StepState::ok(
130 "no registry for this technology; there is nothing to package",
131 ));
132 }
133 Some(other) => {
134 return Ok(StepState::unknown(format!(
135 "no packaging check is defined for {other}"
136 )));
137 }
138 None => {
139 return Ok(StepState::unknown(
140 "no version file names a technology; see rk binding --list",
141 ));
142 }
143 };
144 let exec = Exec {
145 program: program.into(),
146 args: args.iter().map(Into::into).collect(),
147 env: ctx.child_env("package-check"),
148 cwd: ctx.target.as_std_path().to_path_buf(),
149 stdin: None,
150 };
151 let outcome = run(&exec)?;
152 Ok(if outcome.success() {
153 StepState::ok("the package builds and passes the registry's dry run")
154 } else {
155 StepState::not(format!(
156 "the packaging check failed: {}",
157 last_line(&outcome.stderr)
158 ))
159 })
160}
161
162pub fn single_trunk_guard(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
172 for candidate in TRUNK_CANDIDATES {
173 if candidate == TRUNK_BRANCH {
174 continue;
175 }
176 let state = match ctx.forge {
177 Forge::Github => github_candidate_guard(ctx, run, candidate)?,
178 Forge::Gitlab => gitlab_candidate_guard(ctx, run, candidate)?,
179 };
180 if !state.satisfied() {
181 return Ok(state);
182 }
183 }
184 Ok(StepState::ok(
185 "every candidate branch is absent, or an ancestor of the trunk",
186 ))
187}
188
189fn github_candidate_guard(
191 ctx: &Ctx,
192 run: &mut Runner,
193 candidate: &str,
194) -> Result<StepState, RkError> {
195 match api_get(
196 ctx,
197 run,
198 &format!("repos/{}/git/ref/heads/{candidate}", ctx.repo),
199 )? {
200 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
201 Api::Failed(err) => return Ok(StepState::unknown(err)),
202 Api::Ok(_) => {}
203 }
204 match api_get(
205 ctx,
206 run,
207 &format!("repos/{}/compare/{candidate}...{TRUNK_BRANCH}", ctx.repo),
208 )? {
209 Api::Ok(body) => {
210 let status = body["status"].as_str().unwrap_or("");
211 Ok(if matches!(status, "ahead" | "identical") {
212 StepState::ok(format!("{candidate} is an ancestor of {TRUNK_BRANCH}"))
213 } else {
214 StepState::not(format!(
215 "{candidate} is not an ancestor of {TRUNK_BRANCH} ({status}); deleting it would lose work"
216 ))
217 })
218 }
219 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
220 Api::Failed(err) => Ok(StepState::unknown(err)),
221 }
222}
223
224fn gitlab_candidate_guard(
226 ctx: &Ctx,
227 run: &mut Runner,
228 candidate: &str,
229) -> Result<StepState, RkError> {
230 let project = ctx.repo.replace('/', "%2F");
231 match api_get(
232 ctx,
233 run,
234 &format!("projects/{project}/repository/branches/{candidate}"),
235 )? {
236 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
237 Api::Failed(err) => return Ok(StepState::unknown(err)),
238 Api::Ok(_) => {}
239 }
240 match api_get(
241 ctx,
242 run,
243 &format!("projects/{project}/repository/compare?from={TRUNK_BRANCH}&to={candidate}"),
244 )? {
245 Api::Ok(body) => {
246 let ahead = body["commits"]
247 .as_array()
248 .is_some_and(|list| !list.is_empty());
249 Ok(if ahead {
250 StepState::not(format!(
251 "{candidate} carries commits {TRUNK_BRANCH} does not; deleting it would lose work"
252 ))
253 } else {
254 StepState::ok(format!("{candidate} is an ancestor of {TRUNK_BRANCH}"))
255 })
256 }
257 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
258 Api::Failed(err) => Ok(StepState::unknown(err)),
259 }
260}
261
262fn api_get(ctx: &Ctx, run: &mut Runner, path: &str) -> Result<Api, RkError> {
264 let exec = Exec {
265 program: ctx.cli.clone().into_os_string(),
266 args: vec!["api".into(), path.into()],
267 env: ctx.child_env("observe"),
268 cwd: ctx.target.as_std_path().to_path_buf(),
269 stdin: None,
270 };
271 let outcome = run(&exec)?;
272 if outcome.success() {
273 return Ok(
274 serde_json::from_slice::<Value>(&outcome.stdout).map_or_else(
275 |_| Api::Failed("the forge answer did not parse as JSON".into()),
276 Api::Ok,
277 ),
278 );
279 }
280 let stderr = String::from_utf8_lossy(&outcome.stderr).into_owned();
281 if stderr.contains("404") {
282 Ok(Api::Missing)
283 } else {
284 Ok(Api::Failed(last_line(&outcome.stderr)))
285 }
286}
287
288fn last_line(bytes: &[u8]) -> String {
290 String::from_utf8_lossy(bytes)
291 .lines()
292 .rev()
293 .find(|line| !line.trim().is_empty())
294 .unwrap_or("no output")
295 .to_owned()
296}
297
298#[allow(clippy::too_many_lines)]
299fn github(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
300 let repo = &ctx.repo;
301 match step {
302 "default-branch" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
303 Api::Ok(body) => {
304 let found = body["default_branch"].as_str().unwrap_or("");
305 if found == TRUNK_BRANCH {
306 StepState::ok(format!("{TRUNK_BRANCH} is the default branch"))
307 } else {
308 StepState::not(format!("the default branch is {found}"))
309 }
310 }
311 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
312 Api::Failed(err) => StepState::unknown(err),
313 }),
314 "single-trunk" => {
315 for candidate in TRUNK_CANDIDATES {
316 if candidate == TRUNK_BRANCH {
317 continue;
318 }
319 match api_get(ctx, run, &format!("repos/{repo}/git/ref/heads/{candidate}"))? {
320 Api::Missing => {}
321 Api::Ok(_) => {
322 return Ok(StepState::not(format!("a {candidate} branch still exists")));
323 }
324 Api::Failed(err) => return Ok(StepState::unknown(err)),
325 }
326 }
327 Ok(StepState::ok(
328 "no long-lived branch besides the trunk remains",
329 ))
330 }
331 "ci-permissions" => Ok(
332 match api_get(
333 ctx,
334 run,
335 &format!("repos/{repo}/actions/permissions/workflow"),
336 )? {
337 Api::Ok(body) => {
338 let write = body["default_workflow_permissions"] == "write";
339 let approve = body["can_approve_pull_request_reviews"] == true;
340 if write && approve {
341 StepState::ok("CI may write and open requests")
342 } else {
343 StepState::not(format!(
344 "workflow permissions are {} with request approval {}",
345 body["default_workflow_permissions"],
346 body["can_approve_pull_request_reviews"]
347 ))
348 }
349 }
350 Api::Missing => StepState::not("no workflow permissions are readable"),
351 Api::Failed(err) => StepState::unknown(err),
352 },
353 ),
354 "bot-secrets" => Ok(
355 match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
356 Api::Ok(body) => {
357 let names: Vec<&str> = body["secrets"]
358 .as_array()
359 .map(|list| {
360 list.iter()
361 .filter_map(|secret| secret["name"].as_str())
362 .collect()
363 })
364 .unwrap_or_default();
365 let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
366 if wanted.iter().all(|name| names.contains(name)) {
367 StepState::ok("both bot secrets are stored")
368 } else if names.is_empty() {
369 StepState::not("no bot secrets are stored")
370 } else {
371 StepState::not(format!("stored secrets: {}", names.join(", ")))
372 }
373 }
374 Api::Missing => StepState::not("no secrets are readable"),
375 Api::Failed(err) => StepState::unknown(err),
376 },
377 ),
378 "protect-trunk" => github_trunk_ruleset(ctx, run),
379 "protect-tags" => github_ruleset(
380 ctx,
381 run,
382 "release-tags",
383 "tag",
384 "refs/tags/v*",
385 &["deletion", "update"],
386 ),
387 "protect-release-lines" => {
388 match github_ruleset_body(ctx, run, "release-lines")? {
389 RulesetLookup::Absent => {
390 return Ok(StepState::inapplicable(
391 "release/* is unprotected; optional — applied only where older lines exist",
392 ));
393 }
394 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
395 RulesetLookup::Found(_) => {}
396 }
397 github_ruleset(
398 ctx,
399 run,
400 "release-lines",
401 "branch",
402 "refs/heads/release/*",
403 &["deletion", "non_fast_forward"],
404 )
405 }
406 "protections-check" => {
407 let mut failures = Vec::new();
411 let mut unknowns = Vec::new();
412 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
413 match github(ctx, owned, run)? {
414 StepState::Satisfied { .. } | StepState::Inapplicable { .. } => {}
415 StepState::Unsatisfied { detail } => {
416 failures.push(format!("{owned}: {detail}"));
417 }
418 StepState::Unknown { detail } => {
419 unknowns.push(format!("{owned}: {detail}"));
420 }
421 }
422 }
423 match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
424 Api::Ok(body) => {
425 let owned = [
426 format!("{TRUNK_BRANCH}-protection"),
427 "release-tags".to_owned(),
428 "release-lines".to_owned(),
429 ];
430 for ruleset in body.as_array().into_iter().flatten() {
431 let name = ruleset["name"].as_str().unwrap_or("");
432 if !owned.iter().any(|expected| expected == name) {
433 failures.push(format!("a ruleset no step owns: {name}"));
434 }
435 }
436 }
437 Api::Missing | Api::Failed(_) => {
438 unknowns.push("the ruleset inventory is not readable".to_owned());
439 }
440 }
441 Ok(if !failures.is_empty() {
442 StepState::not(failures.join("; "))
443 } else if !unknowns.is_empty() {
444 StepState::unknown(unknowns.join("; "))
445 } else {
446 StepState::ok("exactly the owned protections, with those rules")
447 })
448 }
449 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
450 }
451}
452
453#[must_use]
461pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
462 match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
463 AppApi::Ok(body) => {
464 let id = body["id"].as_i64().unwrap_or_default();
465 StepState::ok(format!("installation {id} covers {}", ctx.repo))
466 }
467 AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
468 AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
469 }
470}
471
472fn github_ruleset(
477 ctx: &Ctx,
478 run: &mut Runner,
479 name: &str,
480 target: &str,
481 include: &str,
482 rules: &[&str],
483) -> Result<StepState, RkError> {
484 let detail = match github_ruleset_body(ctx, run, name)? {
485 RulesetLookup::Found(detail) => detail,
486 RulesetLookup::Absent => {
487 return Ok(StepState::not(format!("no ruleset named {name}")));
488 }
489 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
490 };
491 if detail["enforcement"] != "active" {
492 return Ok(StepState::not(format!("{name} is not active")));
493 }
494 if detail["target"] != target {
497 return Ok(StepState::not(format!(
498 "{name} does not target {target} refs"
499 )));
500 }
501 if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
502 return Ok(StepState::not(format!(
503 "{name} does not cover {include} alone"
504 )));
505 }
506 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
507 return Ok(StepState::not(format!(
508 "{name} excludes refs from its own coverage"
509 )));
510 }
511 let mut held: Vec<&str> = detail["rules"]
512 .as_array()
513 .map(|list| {
514 list.iter()
515 .filter_map(|rule| rule["type"].as_str())
516 .collect()
517 })
518 .unwrap_or_default();
519 held.sort_unstable();
520 let mut expected: Vec<&str> = rules.to_vec();
521 expected.sort_unstable();
522 if held == expected {
523 Ok(StepState::ok(format!(
524 "{name} is active with exactly its rules"
525 )))
526 } else {
527 Ok(StepState::not(format!(
528 "{name} carries the rules [{}] where the setup owns [{}]",
529 held.join(", "),
530 expected.join(", ")
531 )))
532 }
533}
534
535fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
537 let name = format!("{TRUNK_BRANCH}-protection");
538 let detail = match github_ruleset_body(ctx, run, &name)? {
539 RulesetLookup::Found(detail) => detail,
540 RulesetLookup::Absent => {
541 return Ok(StepState::not(format!("no ruleset named {name}")));
542 }
543 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
544 };
545 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
546 let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
547 let mut faults = Vec::new();
548 if detail["enforcement"] != "active" {
549 faults.push(format!("{name} is not active"));
550 }
551 if detail["target"] != "branch" {
555 faults.push(format!("{name} does not target branches"));
556 }
557 let expected_ref = serde_json::json!([format!("refs/heads/{TRUNK_BRANCH}")]);
558 if detail["conditions"]["ref_name"]["include"] != expected_ref {
559 faults.push(format!(
560 "{name} does not cover refs/heads/{TRUNK_BRANCH} alone"
561 ));
562 }
563 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
566 faults.push(format!("{name} excludes refs from its own coverage"));
567 }
568 if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
569 faults.push("a bypass actor is named".to_owned());
570 }
571 let owned = [
572 "deletion",
573 "non_fast_forward",
574 "pull_request",
575 "required_status_checks",
576 ];
577 for required in owned {
578 if !has(required) {
579 faults.push(format!("the {required} rule is missing"));
580 }
581 }
582 for rule in &rules {
583 if let Some(kind) = rule["type"].as_str() {
584 if !owned.contains(&kind) {
585 faults.push(format!("an unowned rule is present: {kind}"));
589 }
590 }
591 }
592 if let Some(request) = rules.iter().find(|rule| rule["type"] == "pull_request") {
593 if request["parameters"]["allowed_merge_methods"] != serde_json::json!(["squash"]) {
594 faults.push("the merge method is not exactly a squash merge".to_owned());
595 }
596 }
597 if let Some(checks) = rules
598 .iter()
599 .find(|rule| rule["type"] == "required_status_checks")
600 {
601 let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
602 .as_array()
603 .map(|list| {
604 list.iter()
605 .filter_map(|check| check["context"].as_str())
606 .collect()
607 })
608 .unwrap_or_default();
609 if contexts.is_empty() {
612 faults.push("no status check is required".to_owned());
613 } else if let Some(expected) = &ctx.required_check {
614 if contexts != [expected.as_str()] {
615 faults.push(format!(
616 "the required checks are [{}] where the setup owns [{expected}]",
617 contexts.join(", ")
618 ));
619 }
620 }
621 }
622 Ok(if faults.is_empty() {
623 StepState::ok(format!("{name} holds the release-merge shape"))
624 } else {
625 StepState::not(faults.join("; "))
626 })
627}
628
629enum RulesetLookup {
632 Found(Value),
634 Absent,
637 Unreadable(String),
639}
640
641fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
643 let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
647 Api::Ok(body) => body,
648 Api::Missing => {
649 return Ok(RulesetLookup::Unreadable(
650 "the ruleset inventory is not readable".into(),
651 ));
652 }
653 Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
654 };
655 let id = list
656 .as_array()
657 .into_iter()
658 .flatten()
659 .find(|ruleset| ruleset["name"] == name)
660 .and_then(|ruleset| ruleset["id"].as_i64());
661 let Some(id) = id else {
662 return Ok(RulesetLookup::Absent);
663 };
664 match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
665 Api::Ok(body) => Ok(RulesetLookup::Found(body)),
666 Api::Missing => Ok(RulesetLookup::Unreadable(format!(
670 "the {name} detail is not readable"
671 ))),
672 Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
673 }
674}
675
676const GITLAB_TAG_LIMITATION: &str =
678 "an Owner or Maintainer can still delete a protected tag through the UI or API";
679
680#[allow(clippy::too_many_lines)]
681fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
682 let project = ctx.repo.replace('/', "%2F");
683 match step {
684 "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
685 Api::Ok(body) => {
686 let found = body["default_branch"].as_str().unwrap_or("");
687 if found == TRUNK_BRANCH {
688 StepState::ok(format!("{TRUNK_BRANCH} is the default branch"))
689 } else {
690 StepState::not(format!("the default branch is {found}"))
691 }
692 }
693 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
694 Api::Failed(err) => StepState::unknown(err),
695 }),
696 "single-trunk" => {
697 for candidate in TRUNK_CANDIDATES {
698 if candidate == TRUNK_BRANCH {
699 continue;
700 }
701 match api_get(
702 ctx,
703 run,
704 &format!("projects/{project}/repository/branches/{candidate}"),
705 )? {
706 Api::Missing => {}
707 Api::Ok(_) => {
708 return Ok(StepState::not(format!("a {candidate} branch still exists")));
709 }
710 Api::Failed(err) => return Ok(StepState::unknown(err)),
711 }
712 }
713 Ok(StepState::ok(
714 "no long-lived branch besides the trunk remains",
715 ))
716 }
717 "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
718 Api::Ok(body) => {
719 if body["jobs_enabled"] == true {
720 StepState::ok("pipelines are enabled")
721 } else {
722 StepState::not("pipelines are disabled")
723 }
724 }
725 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
726 Api::Failed(err) => StepState::unknown(err),
727 }),
728 "install-bot" => {
729 let mut active = false;
735 let mut exhausted = false;
736 for page in 1..=10u32 {
737 let path = format!(
738 "projects/{project}/access_tokens?state=active&per_page=100&page={page}"
739 );
740 let list = match api_get(ctx, run, &path)? {
741 Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
742 Api::Missing => Vec::new(),
743 Api::Failed(err) => return Ok(StepState::unknown(err)),
744 };
745 active = active
746 || list.iter().any(|token| {
747 token["name"] == "release-bot"
748 && token["revoked"] == false
749 && token["active"] != false
750 });
751 if list.len() < 100 {
752 exhausted = true;
753 }
754 if active || exhausted {
755 break;
756 }
757 }
758 if !active {
759 return Ok(if exhausted {
760 StepState::not("no active release-bot token exists")
761 } else {
762 StepState::unknown(
763 "the token listing did not exhaust within ten pages; nothing was decided",
764 )
765 });
766 }
767 Ok(
771 match api_get(
772 ctx,
773 run,
774 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
775 )? {
776 Api::Ok(_) => StepState::ok(
777 "an active release-bot token exists and its variable is stored",
778 ),
779 Api::Missing => StepState::not(
780 "an active release-bot token exists with no stored variable; a rerun revokes and replaces it",
781 ),
782 Api::Failed(err) => StepState::unknown(err),
783 },
784 )
785 }
786 "bot-secrets" => Ok(
787 match api_get(
788 ctx,
789 run,
790 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
791 )? {
792 Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
793 Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
794 Api::Failed(err) => StepState::unknown(err),
795 },
796 ),
797 "protect-trunk" => {
798 let protection = match api_get(
799 ctx,
800 run,
801 &format!("projects/{project}/protected_branches/{TRUNK_BRANCH}"),
802 )? {
803 Api::Ok(body) => body,
804 Api::Missing => {
805 return Ok(StepState::not(format!("{TRUNK_BRANCH} is not protected")));
806 }
807 Api::Failed(err) => return Ok(StepState::unknown(err)),
808 };
809 let grants = protection["push_access_levels"]
813 .as_array()
814 .cloned()
815 .unwrap_or_default();
816 let no_push = grants.len() == 1 && grants[0]["access_level"] == 0;
817 let merges = protection["merge_access_levels"]
821 .as_array()
822 .cloned()
823 .unwrap_or_default();
824 let can_merge = merges.len() == 1 && merges[0]["access_level"] == 40;
825 let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
826 Api::Ok(body) => body,
827 Api::Missing | Api::Failed(_) => Value::Null,
828 };
829 let mut faults = Vec::new();
830 if !no_push {
831 faults.push(format!(
832 "{TRUNK_BRANCH} still takes a direct push: the forge honors the most permissive of {} push grants",
833 grants.len()
834 ));
835 }
836 if !can_merge {
837 faults.push(format!(
838 "{TRUNK_BRANCH} merge grants are not exactly the one owned maintainer level"
839 ));
840 }
841 if protection["allow_force_push"] != false {
842 faults.push(format!("{TRUNK_BRANCH} allows force pushes"));
843 }
844 if settings["only_allow_merge_if_pipeline_succeeds"] != true {
845 faults.push("the pipeline requirement is off".to_owned());
846 }
847 if settings["merge_method"] != "ff" {
848 faults.push("the merge method is not fast-forward".to_owned());
849 }
850 if settings["squash_option"] != "always" {
851 faults.push("merge requests do not always squash".to_owned());
852 }
853 Ok(if faults.is_empty() {
854 StepState::ok(format!("{TRUNK_BRANCH} holds the release-merge shape"))
855 } else {
856 StepState::not(faults.join("; "))
857 })
858 }
859 "protect-tags" => Ok(
860 match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
861 Api::Ok(_) => {
862 StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
863 }
864 Api::Missing => StepState::not("v* is not protected"),
865 Api::Failed(err) => StepState::unknown(err),
866 },
867 ),
868 "protect-release-lines" => Ok(
869 match api_get(
870 ctx,
871 run,
872 &format!("projects/{project}/protected_branches/release%2F%2A"),
873 )? {
874 Api::Ok(body) => {
875 let level_ok = |levels: &Value| {
876 levels
877 .as_array()
878 .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
879 };
880 if body["allow_force_push"] != false {
881 StepState::not("release/* allows force pushes")
882 } else if !level_ok(&body["push_access_levels"])
883 || !level_ok(&body["merge_access_levels"])
884 {
885 StepState::not(
889 "release/* grants are not exactly the owned maintainer levels",
890 )
891 } else {
892 StepState::ok("release/* refuses force pushes and deletion by git clients")
893 }
894 }
895 Api::Missing => StepState::inapplicable(
896 "release/* is unprotected; optional — applied only where older lines exist",
897 ),
898 Api::Failed(err) => StepState::unknown(err),
899 },
900 ),
901 "protections-check" => {
902 let mut failures = Vec::new();
905 let mut unknowns = Vec::new();
906 let mut limitation = None;
907 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
908 match gitlab(ctx, owned, run)? {
909 StepState::Satisfied {
910 limitation: found, ..
911 } => limitation = limitation.or(found),
912 StepState::Inapplicable { .. } => {}
913 StepState::Unsatisfied { detail } => {
914 failures.push(format!("{owned}: {detail}"));
915 }
916 StepState::Unknown { detail } => {
917 unknowns.push(format!("{owned}: {detail}"));
918 }
919 }
920 }
921 Ok(if !failures.is_empty() {
922 StepState::not(failures.join("; "))
923 } else if !unknowns.is_empty() {
924 StepState::unknown(unknowns.join("; "))
925 } else {
926 StepState::Satisfied {
927 detail: "the protections hold, as far as this forge enforces them".into(),
928 limitation,
929 }
930 })
931 }
932 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
933 }
934}