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
26pub const TITLE_CHECK: &str = "pr-title";
29
30#[derive(Debug)]
32pub enum StepState {
33 Satisfied {
36 detail: String,
38 limitation: Option<String>,
40 },
41 Unsatisfied {
43 detail: String,
45 },
46 Inapplicable {
50 detail: String,
52 },
53 Unknown {
55 detail: String,
57 },
58}
59
60impl StepState {
61 #[must_use]
63 pub const fn satisfied(&self) -> bool {
64 matches!(self, Self::Satisfied { .. })
65 }
66
67 fn ok(detail: impl Into<String>) -> Self {
68 Self::Satisfied {
69 detail: detail.into(),
70 limitation: None,
71 }
72 }
73
74 fn ok_with_limitation(detail: impl Into<String>, limitation: impl Into<String>) -> Self {
75 Self::Satisfied {
76 detail: detail.into(),
77 limitation: Some(limitation.into()),
78 }
79 }
80
81 fn not(detail: impl Into<String>) -> Self {
82 Self::Unsatisfied {
83 detail: detail.into(),
84 }
85 }
86
87 fn inapplicable(detail: impl Into<String>) -> Self {
88 Self::Inapplicable {
89 detail: detail.into(),
90 }
91 }
92
93 fn unknown(detail: impl Into<String>) -> Self {
94 Self::Unknown {
95 detail: detail.into(),
96 }
97 }
98}
99
100enum Api {
102 Ok(Value),
104 Missing,
106 Failed(String),
108}
109
110pub fn observe(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
117 if step == "package-check" {
118 return package_check(ctx, run);
119 }
120 match ctx.forge {
121 Forge::Github => github(ctx, step, run),
122 Forge::Gitlab => gitlab(ctx, step, run),
123 }
124}
125
126fn package_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
129 let (program, args): (&str, &[&str]) = match ctx.tech {
130 Some("rust") => ("cargo", &["publish", "--dry-run", "--allow-dirty"]),
131 Some("python") => ("python3", &["-m", "build"]),
132 Some("bash") => {
133 return Ok(StepState::ok(
134 "no registry for this technology; there is nothing to package",
135 ));
136 }
137 Some(other) => {
138 return Ok(StepState::unknown(format!(
139 "no packaging check is defined for {other}"
140 )));
141 }
142 None => {
143 return Ok(StepState::unknown(
144 "no version file names a technology; see rk binding --list",
145 ));
146 }
147 };
148 let exec = Exec {
149 program: program.into(),
150 args: args.iter().map(Into::into).collect(),
151 env: ctx.child_env("package-check"),
152 cwd: ctx.target.as_std_path().to_path_buf(),
153 stdin: None,
154 };
155 let outcome = run(&exec)?;
156 Ok(if outcome.success() {
157 StepState::ok("the package builds and passes the registry's dry run")
158 } else {
159 StepState::not(format!(
160 "the packaging check failed: {}",
161 last_line(&outcome.stderr)
162 ))
163 })
164}
165
166pub fn single_trunk_guard(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
176 for candidate in TRUNK_CANDIDATES {
177 if candidate == TRUNK_BRANCH {
178 continue;
179 }
180 let state = match ctx.forge {
181 Forge::Github => github_candidate_guard(ctx, run, candidate)?,
182 Forge::Gitlab => gitlab_candidate_guard(ctx, run, candidate)?,
183 };
184 if !state.satisfied() {
185 return Ok(state);
186 }
187 }
188 Ok(StepState::ok(
189 "every candidate branch is absent, or an ancestor of the trunk",
190 ))
191}
192
193fn github_candidate_guard(
195 ctx: &Ctx,
196 run: &mut Runner,
197 candidate: &str,
198) -> Result<StepState, RkError> {
199 match api_get(
200 ctx,
201 run,
202 &format!("repos/{}/git/ref/heads/{candidate}", ctx.repo),
203 )? {
204 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
205 Api::Failed(err) => return Ok(StepState::unknown(err)),
206 Api::Ok(_) => {}
207 }
208 match api_get(
209 ctx,
210 run,
211 &format!("repos/{}/compare/{candidate}...{TRUNK_BRANCH}", ctx.repo),
212 )? {
213 Api::Ok(body) => {
214 let status = body["status"].as_str().unwrap_or("");
215 Ok(if matches!(status, "ahead" | "identical") {
216 StepState::ok(format!("{candidate} is an ancestor of {TRUNK_BRANCH}"))
217 } else {
218 StepState::not(format!(
219 "{candidate} is not an ancestor of {TRUNK_BRANCH} ({status}); deleting it would lose work"
220 ))
221 })
222 }
223 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
224 Api::Failed(err) => Ok(StepState::unknown(err)),
225 }
226}
227
228fn gitlab_candidate_guard(
230 ctx: &Ctx,
231 run: &mut Runner,
232 candidate: &str,
233) -> Result<StepState, RkError> {
234 let project = ctx.repo.replace('/', "%2F");
235 match api_get(
236 ctx,
237 run,
238 &format!("projects/{project}/repository/branches/{candidate}"),
239 )? {
240 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
241 Api::Failed(err) => return Ok(StepState::unknown(err)),
242 Api::Ok(_) => {}
243 }
244 match api_get(
245 ctx,
246 run,
247 &format!("projects/{project}/repository/compare?from={TRUNK_BRANCH}&to={candidate}"),
248 )? {
249 Api::Ok(body) => {
250 let ahead = body["commits"]
251 .as_array()
252 .is_some_and(|list| !list.is_empty());
253 Ok(if ahead {
254 StepState::not(format!(
255 "{candidate} carries commits {TRUNK_BRANCH} does not; deleting it would lose work"
256 ))
257 } else {
258 StepState::ok(format!("{candidate} is an ancestor of {TRUNK_BRANCH}"))
259 })
260 }
261 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
262 Api::Failed(err) => Ok(StepState::unknown(err)),
263 }
264}
265
266fn api_get(ctx: &Ctx, run: &mut Runner, path: &str) -> Result<Api, RkError> {
268 let exec = Exec {
269 program: ctx.cli.clone().into_os_string(),
270 args: vec!["api".into(), path.into()],
271 env: ctx.child_env("observe"),
272 cwd: ctx.target.as_std_path().to_path_buf(),
273 stdin: None,
274 };
275 let outcome = run(&exec)?;
276 if outcome.success() {
277 return Ok(
278 serde_json::from_slice::<Value>(&outcome.stdout).map_or_else(
279 |_| Api::Failed("the forge answer did not parse as JSON".into()),
280 Api::Ok,
281 ),
282 );
283 }
284 let stderr = String::from_utf8_lossy(&outcome.stderr).into_owned();
285 if stderr.contains("404") {
286 Ok(Api::Missing)
287 } else {
288 Ok(Api::Failed(last_line(&outcome.stderr)))
289 }
290}
291
292fn last_line(bytes: &[u8]) -> String {
294 String::from_utf8_lossy(bytes)
295 .lines()
296 .rev()
297 .find(|line| !line.trim().is_empty())
298 .unwrap_or("no output")
299 .to_owned()
300}
301
302#[allow(clippy::too_many_lines)]
303fn github(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
304 let repo = &ctx.repo;
305 match step {
306 "default-branch" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
307 Api::Ok(body) => {
308 let found = body["default_branch"].as_str().unwrap_or("");
309 if found == TRUNK_BRANCH {
310 StepState::ok(format!("{TRUNK_BRANCH} is the default branch"))
311 } else {
312 StepState::not(format!("the default branch is {found}"))
313 }
314 }
315 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
316 Api::Failed(err) => StepState::unknown(err),
317 }),
318 "single-trunk" => {
319 for candidate in TRUNK_CANDIDATES {
320 if candidate == TRUNK_BRANCH {
321 continue;
322 }
323 match api_get(ctx, run, &format!("repos/{repo}/git/ref/heads/{candidate}"))? {
324 Api::Missing => {}
325 Api::Ok(_) => {
326 return Ok(StepState::not(format!("a {candidate} branch still exists")));
327 }
328 Api::Failed(err) => return Ok(StepState::unknown(err)),
329 }
330 }
331 Ok(StepState::ok(
332 "no long-lived branch besides the trunk remains",
333 ))
334 }
335 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
336 Api::Ok(body) => {
337 if body["delete_branch_on_merge"].as_bool().unwrap_or(false) {
338 StepState::ok("a merged branch is deleted by the forge")
339 } else {
340 StepState::not("a merged branch outlives its merge")
341 }
342 }
343 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
344 Api::Failed(err) => StepState::unknown(err),
345 }),
346 "ci-permissions" => Ok(
347 match api_get(
348 ctx,
349 run,
350 &format!("repos/{repo}/actions/permissions/workflow"),
351 )? {
352 Api::Ok(body) => {
353 let write = body["default_workflow_permissions"] == "write";
354 let approve = body["can_approve_pull_request_reviews"] == true;
355 if write && approve {
356 StepState::ok("CI may write and open requests")
357 } else {
358 StepState::not(format!(
359 "workflow permissions are {} with request approval {}",
360 body["default_workflow_permissions"],
361 body["can_approve_pull_request_reviews"]
362 ))
363 }
364 }
365 Api::Missing => StepState::not("no workflow permissions are readable"),
366 Api::Failed(err) => StepState::unknown(err),
367 },
368 ),
369 "bot-secrets" => Ok(
370 match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
371 Api::Ok(body) => {
372 let names: Vec<&str> = body["secrets"]
373 .as_array()
374 .map(|list| {
375 list.iter()
376 .filter_map(|secret| secret["name"].as_str())
377 .collect()
378 })
379 .unwrap_or_default();
380 let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
381 if wanted.iter().all(|name| names.contains(name)) {
382 StepState::ok("both bot secrets are stored")
383 } else if names.is_empty() {
384 StepState::not("no bot secrets are stored")
385 } else {
386 StepState::not(format!("stored secrets: {}", names.join(", ")))
387 }
388 }
389 Api::Missing => StepState::not("no secrets are readable"),
390 Api::Failed(err) => StepState::unknown(err),
391 },
392 ),
393 "protect-trunk" => github_trunk_ruleset(ctx, run),
394 "protect-tags" => github_ruleset(
395 ctx,
396 run,
397 "release-tags",
398 "tag",
399 "refs/tags/v*",
400 &["deletion", "update"],
401 ),
402 "protect-release-lines" => {
403 match github_ruleset_body(ctx, run, "release-lines")? {
404 RulesetLookup::Absent => {
405 return Ok(StepState::inapplicable(
406 "release/* is unprotected; optional — applied only where older lines exist",
407 ));
408 }
409 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
410 RulesetLookup::Found(_) => {}
411 }
412 github_ruleset(
413 ctx,
414 run,
415 "release-lines",
416 "branch",
417 "refs/heads/release/*",
418 &["deletion", "non_fast_forward"],
419 )
420 }
421 "protections-check" => {
422 let mut failures = Vec::new();
426 let mut unknowns = Vec::new();
427 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
428 match github(ctx, owned, run)? {
429 StepState::Satisfied { .. } | StepState::Inapplicable { .. } => {}
430 StepState::Unsatisfied { detail } => {
431 failures.push(format!("{owned}: {detail}"));
432 }
433 StepState::Unknown { detail } => {
434 unknowns.push(format!("{owned}: {detail}"));
435 }
436 }
437 }
438 match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
439 Api::Ok(body) => {
440 let owned = [
441 format!("{TRUNK_BRANCH}-protection"),
442 "release-tags".to_owned(),
443 "release-lines".to_owned(),
444 ];
445 for ruleset in body.as_array().into_iter().flatten() {
446 let name = ruleset["name"].as_str().unwrap_or("");
447 if !owned.iter().any(|expected| expected == name) {
448 failures.push(format!("a ruleset no step owns: {name}"));
449 }
450 }
451 }
452 Api::Missing | Api::Failed(_) => {
453 unknowns.push("the ruleset inventory is not readable".to_owned());
454 }
455 }
456 Ok(if !failures.is_empty() {
457 StepState::not(failures.join("; "))
458 } else if !unknowns.is_empty() {
459 StepState::unknown(unknowns.join("; "))
460 } else {
461 StepState::ok("exactly the owned protections, with those rules")
462 })
463 }
464 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
465 }
466}
467
468#[must_use]
476pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
477 match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
478 AppApi::Ok(body) => {
479 let id = body["id"].as_i64().unwrap_or_default();
480 StepState::ok(format!("installation {id} covers {}", ctx.repo))
481 }
482 AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
483 AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
484 }
485}
486
487fn github_ruleset(
492 ctx: &Ctx,
493 run: &mut Runner,
494 name: &str,
495 target: &str,
496 include: &str,
497 rules: &[&str],
498) -> Result<StepState, RkError> {
499 let detail = match github_ruleset_body(ctx, run, name)? {
500 RulesetLookup::Found(detail) => detail,
501 RulesetLookup::Absent => {
502 return Ok(StepState::not(format!("no ruleset named {name}")));
503 }
504 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
505 };
506 if detail["enforcement"] != "active" {
507 return Ok(StepState::not(format!("{name} is not active")));
508 }
509 if detail["target"] != target {
512 return Ok(StepState::not(format!(
513 "{name} does not target {target} refs"
514 )));
515 }
516 if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
517 return Ok(StepState::not(format!(
518 "{name} does not cover {include} alone"
519 )));
520 }
521 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
522 return Ok(StepState::not(format!(
523 "{name} excludes refs from its own coverage"
524 )));
525 }
526 let mut held: Vec<&str> = detail["rules"]
527 .as_array()
528 .map(|list| {
529 list.iter()
530 .filter_map(|rule| rule["type"].as_str())
531 .collect()
532 })
533 .unwrap_or_default();
534 held.sort_unstable();
535 let mut expected: Vec<&str> = rules.to_vec();
536 expected.sort_unstable();
537 if held == expected {
538 Ok(StepState::ok(format!(
539 "{name} is active with exactly its rules"
540 )))
541 } else {
542 Ok(StepState::not(format!(
543 "{name} carries the rules [{}] where the setup owns [{}]",
544 held.join(", "),
545 expected.join(", ")
546 )))
547 }
548}
549
550fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
552 let name = format!("{TRUNK_BRANCH}-protection");
553 let detail = match github_ruleset_body(ctx, run, &name)? {
554 RulesetLookup::Found(detail) => detail,
555 RulesetLookup::Absent => {
556 return Ok(StepState::not(format!("no ruleset named {name}")));
557 }
558 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
559 };
560 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
561 let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
562 let mut faults = Vec::new();
563 if detail["enforcement"] != "active" {
564 faults.push(format!("{name} is not active"));
565 }
566 if detail["target"] != "branch" {
570 faults.push(format!("{name} does not target branches"));
571 }
572 let expected_ref = serde_json::json!([format!("refs/heads/{TRUNK_BRANCH}")]);
573 if detail["conditions"]["ref_name"]["include"] != expected_ref {
574 faults.push(format!(
575 "{name} does not cover refs/heads/{TRUNK_BRANCH} alone"
576 ));
577 }
578 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
581 faults.push(format!("{name} excludes refs from its own coverage"));
582 }
583 if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
584 faults.push("a bypass actor is named".to_owned());
585 }
586 let owned = [
587 "deletion",
588 "non_fast_forward",
589 "pull_request",
590 "required_status_checks",
591 ];
592 for required in owned {
593 if !has(required) {
594 faults.push(format!("the {required} rule is missing"));
595 }
596 }
597 for rule in &rules {
598 if let Some(kind) = rule["type"].as_str() {
599 if !owned.contains(&kind) {
600 faults.push(format!("an unowned rule is present: {kind}"));
604 }
605 }
606 }
607 if let Some(request) = rules.iter().find(|rule| rule["type"] == "pull_request") {
608 if request["parameters"]["allowed_merge_methods"] != serde_json::json!(["squash"]) {
609 faults.push("the merge method is not exactly a squash merge".to_owned());
610 }
611 }
612 if let Some(checks) = rules
613 .iter()
614 .find(|rule| rule["type"] == "required_status_checks")
615 {
616 let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
617 .as_array()
618 .map(|list| {
619 list.iter()
620 .filter_map(|check| check["context"].as_str())
621 .collect()
622 })
623 .unwrap_or_default();
624 if contexts.is_empty() {
629 faults.push("no status check is required".to_owned());
630 } else if let Some(expected) = &ctx.required_check {
631 let mut held = contexts.clone();
632 held.sort_unstable();
633 let mut owned_contexts = [expected.as_str(), TITLE_CHECK];
634 owned_contexts.sort_unstable();
635 if held != owned_contexts {
636 faults.push(format!(
637 "the required checks are [{}] where the setup owns [{}]",
638 contexts.join(", "),
639 owned_contexts.join(", ")
640 ));
641 }
642 } else if !contexts.contains(&TITLE_CHECK) {
643 faults.push(format!("the {TITLE_CHECK} check is not required"));
644 }
645 }
646 match squash_title_source(ctx, run)? {
647 TitleSource::Owned => {}
648 TitleSource::Fault(fault) => faults.push(fault),
649 TitleSource::Unreadable(err) => {
653 if faults.is_empty() {
654 return Ok(StepState::unknown(err));
655 }
656 }
657 }
658 Ok(if faults.is_empty() {
659 StepState::ok(format!("{name} holds the release-merge shape"))
660 } else {
661 StepState::not(faults.join("; "))
662 })
663}
664
665enum TitleSource {
667 Owned,
669 Fault(String),
671 Unreadable(String),
673}
674
675fn squash_title_source(ctx: &Ctx, run: &mut Runner) -> Result<TitleSource, RkError> {
679 Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
680 Api::Ok(body) => {
681 if body["squash_merge_commit_title"] == "PR_TITLE" {
682 TitleSource::Owned
683 } else {
684 TitleSource::Fault(format!(
685 "the squash title source is {} where the setup owns PR_TITLE",
686 body["squash_merge_commit_title"]
687 ))
688 }
689 }
690 Api::Missing => TitleSource::Fault(format!("the forge does not know {}", ctx.repo)),
691 Api::Failed(err) => TitleSource::Unreadable(err),
692 })
693}
694
695enum RulesetLookup {
698 Found(Value),
700 Absent,
703 Unreadable(String),
705}
706
707fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
709 let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
713 Api::Ok(body) => body,
714 Api::Missing => {
715 return Ok(RulesetLookup::Unreadable(
716 "the ruleset inventory is not readable".into(),
717 ));
718 }
719 Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
720 };
721 let id = list
722 .as_array()
723 .into_iter()
724 .flatten()
725 .find(|ruleset| ruleset["name"] == name)
726 .and_then(|ruleset| ruleset["id"].as_i64());
727 let Some(id) = id else {
728 return Ok(RulesetLookup::Absent);
729 };
730 match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
731 Api::Ok(body) => Ok(RulesetLookup::Found(body)),
732 Api::Missing => Ok(RulesetLookup::Unreadable(format!(
736 "the {name} detail is not readable"
737 ))),
738 Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
739 }
740}
741
742const GITLAB_TAG_LIMITATION: &str =
744 "an Owner or Maintainer can still delete a protected tag through the UI or API";
745
746const GITLAB_TITLE_LIMITATION: &str = "the title gate stops accident, not authority: a merge request runs its own CI configuration, and a title edit starts no new pipeline";
749
750#[allow(clippy::too_many_lines)]
751fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
752 let project = ctx.repo.replace('/', "%2F");
753 match step {
754 "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
755 Api::Ok(body) => {
756 let found = body["default_branch"].as_str().unwrap_or("");
757 if found == TRUNK_BRANCH {
758 StepState::ok(format!("{TRUNK_BRANCH} is the default branch"))
759 } else {
760 StepState::not(format!("the default branch is {found}"))
761 }
762 }
763 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
764 Api::Failed(err) => StepState::unknown(err),
765 }),
766 "single-trunk" => {
767 for candidate in TRUNK_CANDIDATES {
768 if candidate == TRUNK_BRANCH {
769 continue;
770 }
771 match api_get(
772 ctx,
773 run,
774 &format!("projects/{project}/repository/branches/{candidate}"),
775 )? {
776 Api::Missing => {}
777 Api::Ok(_) => {
778 return Ok(StepState::not(format!("a {candidate} branch still exists")));
779 }
780 Api::Failed(err) => return Ok(StepState::unknown(err)),
781 }
782 }
783 Ok(StepState::ok(
784 "no long-lived branch besides the trunk remains",
785 ))
786 }
787 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
788 Api::Ok(body) => {
789 if body["remove_source_branch_after_merge"]
790 .as_bool()
791 .unwrap_or(false)
792 {
793 StepState::ok("a merged branch is deleted by the forge")
794 } else {
795 StepState::not("a merged branch outlives its merge")
796 }
797 }
798 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
799 Api::Failed(err) => StepState::unknown(err),
800 }),
801 "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
802 Api::Ok(body) => {
803 if body["jobs_enabled"] == true {
804 StepState::ok("pipelines are enabled")
805 } else {
806 StepState::not("pipelines are disabled")
807 }
808 }
809 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
810 Api::Failed(err) => StepState::unknown(err),
811 }),
812 "install-bot" => {
813 let mut active = false;
819 let mut exhausted = false;
820 for page in 1..=10u32 {
821 let path = format!(
822 "projects/{project}/access_tokens?state=active&per_page=100&page={page}"
823 );
824 let list = match api_get(ctx, run, &path)? {
825 Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
826 Api::Missing => Vec::new(),
827 Api::Failed(err) => return Ok(StepState::unknown(err)),
828 };
829 active = active
830 || list.iter().any(|token| {
831 token["name"] == "release-bot"
832 && token["revoked"] == false
833 && token["active"] != false
834 });
835 if list.len() < 100 {
836 exhausted = true;
837 }
838 if active || exhausted {
839 break;
840 }
841 }
842 if !active {
843 return Ok(if exhausted {
844 StepState::not("no active release-bot token exists")
845 } else {
846 StepState::unknown(
847 "the token listing did not exhaust within ten pages; nothing was decided",
848 )
849 });
850 }
851 Ok(
855 match api_get(
856 ctx,
857 run,
858 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
859 )? {
860 Api::Ok(_) => StepState::ok(
861 "an active release-bot token exists and its variable is stored",
862 ),
863 Api::Missing => StepState::not(
864 "an active release-bot token exists with no stored variable; a rerun revokes and replaces it",
865 ),
866 Api::Failed(err) => StepState::unknown(err),
867 },
868 )
869 }
870 "bot-secrets" => Ok(
871 match api_get(
872 ctx,
873 run,
874 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
875 )? {
876 Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
877 Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
878 Api::Failed(err) => StepState::unknown(err),
879 },
880 ),
881 "protect-trunk" => {
882 let protection = match api_get(
883 ctx,
884 run,
885 &format!("projects/{project}/protected_branches/{TRUNK_BRANCH}"),
886 )? {
887 Api::Ok(body) => body,
888 Api::Missing => {
889 return Ok(StepState::not(format!("{TRUNK_BRANCH} is not protected")));
890 }
891 Api::Failed(err) => return Ok(StepState::unknown(err)),
892 };
893 let grants = protection["push_access_levels"]
897 .as_array()
898 .cloned()
899 .unwrap_or_default();
900 let no_push = grants.len() == 1 && grants[0]["access_level"] == 0;
901 let merges = protection["merge_access_levels"]
905 .as_array()
906 .cloned()
907 .unwrap_or_default();
908 let can_merge = merges.len() == 1 && merges[0]["access_level"] == 40;
909 let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
910 Api::Ok(body) => body,
911 Api::Missing | Api::Failed(_) => Value::Null,
912 };
913 let mut faults = Vec::new();
914 if !no_push {
915 faults.push(format!(
916 "{TRUNK_BRANCH} still takes a direct push: the forge honors the most permissive of {} push grants",
917 grants.len()
918 ));
919 }
920 if !can_merge {
921 faults.push(format!(
922 "{TRUNK_BRANCH} merge grants are not exactly the one owned maintainer level"
923 ));
924 }
925 if protection["allow_force_push"] != false {
926 faults.push(format!("{TRUNK_BRANCH} allows force pushes"));
927 }
928 if settings["only_allow_merge_if_pipeline_succeeds"] != true {
929 faults.push("the pipeline requirement is off".to_owned());
930 }
931 if settings["merge_method"] != "ff" {
932 faults.push("the merge method is not fast-forward".to_owned());
933 }
934 if settings["squash_option"] != "always" {
935 faults.push("merge requests do not always squash".to_owned());
936 }
937 if settings["squash_commit_template"] != "%{title}" {
938 faults.push("the squash template is not the merge request's title".to_owned());
939 }
940 Ok(if faults.is_empty() {
941 StepState::ok_with_limitation(
942 format!("{TRUNK_BRANCH} holds the release-merge shape"),
943 GITLAB_TITLE_LIMITATION,
944 )
945 } else {
946 StepState::not(faults.join("; "))
947 })
948 }
949 "protect-tags" => Ok(
950 match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
951 Api::Ok(_) => {
952 StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
953 }
954 Api::Missing => StepState::not("v* is not protected"),
955 Api::Failed(err) => StepState::unknown(err),
956 },
957 ),
958 "protect-release-lines" => Ok(
959 match api_get(
960 ctx,
961 run,
962 &format!("projects/{project}/protected_branches/release%2F%2A"),
963 )? {
964 Api::Ok(body) => {
965 let level_ok = |levels: &Value| {
966 levels
967 .as_array()
968 .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
969 };
970 if body["allow_force_push"] != false {
971 StepState::not("release/* allows force pushes")
972 } else if !level_ok(&body["push_access_levels"])
973 || !level_ok(&body["merge_access_levels"])
974 {
975 StepState::not(
979 "release/* grants are not exactly the owned maintainer levels",
980 )
981 } else {
982 StepState::ok("release/* refuses force pushes and deletion by git clients")
983 }
984 }
985 Api::Missing => StepState::inapplicable(
986 "release/* is unprotected; optional — applied only where older lines exist",
987 ),
988 Api::Failed(err) => StepState::unknown(err),
989 },
990 ),
991 "protections-check" => {
992 let mut failures = Vec::new();
995 let mut unknowns = Vec::new();
996 let mut limitations: Vec<String> = Vec::new();
999 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
1000 match gitlab(ctx, owned, run)? {
1001 StepState::Satisfied {
1002 limitation: found, ..
1003 } => limitations.extend(found),
1004 StepState::Inapplicable { .. } => {}
1005 StepState::Unsatisfied { detail } => {
1006 failures.push(format!("{owned}: {detail}"));
1007 }
1008 StepState::Unknown { detail } => {
1009 unknowns.push(format!("{owned}: {detail}"));
1010 }
1011 }
1012 }
1013 Ok(if !failures.is_empty() {
1014 StepState::not(failures.join("; "))
1015 } else if !unknowns.is_empty() {
1016 StepState::unknown(unknowns.join("; "))
1017 } else {
1018 StepState::Satisfied {
1019 detail: "the protections hold, as far as this forge enforces them".into(),
1020 limitation: if limitations.is_empty() {
1021 None
1022 } else {
1023 Some(limitations.join("; "))
1024 },
1025 }
1026 })
1027 }
1028 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1029 }
1030}