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 if step == "branch-reminder" {
121 return Ok(branch_reminder_state(ctx));
122 }
123 match ctx.forge {
124 Forge::Github => github(ctx, step, run),
125 Forge::Gitlab => gitlab(ctx, step, run),
126 }
127}
128
129fn package_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
132 let (program, args): (&str, &[&str]) = match ctx.tech {
133 Some("rust") => ("cargo", &["publish", "--dry-run", "--allow-dirty"]),
134 Some("python") => ("python3", &["-m", "build"]),
135 Some("bash") => {
136 return Ok(StepState::ok(
137 "no registry for this technology; there is nothing to package",
138 ));
139 }
140 Some(other) => {
141 return Ok(StepState::unknown(format!(
142 "no packaging check is defined for {other}"
143 )));
144 }
145 None => {
146 return Ok(StepState::unknown(
147 "no version file names a technology; see rk binding --list",
148 ));
149 }
150 };
151 let exec = Exec {
152 program: program.into(),
153 args: args.iter().map(Into::into).collect(),
154 env: ctx.child_env("package-check"),
155 cwd: ctx.target.as_std_path().to_path_buf(),
156 stdin: None,
157 };
158 let outcome = run(&exec)?;
159 Ok(if outcome.success() {
160 StepState::ok("the package builds and passes the registry's dry run")
161 } else {
162 StepState::not(format!(
163 "the packaging check failed: {}",
164 last_line(&outcome.stderr)
165 ))
166 })
167}
168
169fn branch_reminder_state(ctx: &Ctx) -> StepState {
172 use crate::setup::branch_reminder::{HookState, observe_hook};
173 match observe_hook(&ctx.target) {
174 HookState::Installed => {
175 StepState::ok("the post-merge hook carries the release-kit reminder")
176 }
177 HookState::Absent => StepState::not("no post-merge hook is installed"),
178 HookState::Foreign => {
179 StepState::not("a post-merge hook exists without the release-kit marker")
180 }
181 HookState::Drifted => StepState::not("the reminder hook drifted from this binary's body"),
182 HookState::Unreadable(detail) => StepState::unknown(detail),
183 }
184}
185
186pub fn single_trunk_guard(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
196 for candidate in TRUNK_CANDIDATES {
197 if candidate == TRUNK_BRANCH {
198 continue;
199 }
200 let state = match ctx.forge {
201 Forge::Github => github_candidate_guard(ctx, run, candidate)?,
202 Forge::Gitlab => gitlab_candidate_guard(ctx, run, candidate)?,
203 };
204 if !state.satisfied() {
205 return Ok(state);
206 }
207 }
208 Ok(StepState::ok(
209 "every candidate branch is absent, or an ancestor of the trunk",
210 ))
211}
212
213fn github_candidate_guard(
215 ctx: &Ctx,
216 run: &mut Runner,
217 candidate: &str,
218) -> Result<StepState, RkError> {
219 match api_get(
220 ctx,
221 run,
222 &format!("repos/{}/git/ref/heads/{candidate}", ctx.repo),
223 )? {
224 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
225 Api::Failed(err) => return Ok(StepState::unknown(err)),
226 Api::Ok(_) => {}
227 }
228 match api_get(
229 ctx,
230 run,
231 &format!("repos/{}/compare/{candidate}...{TRUNK_BRANCH}", ctx.repo),
232 )? {
233 Api::Ok(body) => {
234 let status = body["status"].as_str().unwrap_or("");
235 Ok(if matches!(status, "ahead" | "identical") {
236 StepState::ok(format!("{candidate} is an ancestor of {TRUNK_BRANCH}"))
237 } else {
238 StepState::not(format!(
239 "{candidate} is not an ancestor of {TRUNK_BRANCH} ({status}); deleting it would lose work"
240 ))
241 })
242 }
243 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
244 Api::Failed(err) => Ok(StepState::unknown(err)),
245 }
246}
247
248fn gitlab_candidate_guard(
250 ctx: &Ctx,
251 run: &mut Runner,
252 candidate: &str,
253) -> Result<StepState, RkError> {
254 let project = ctx.repo.replace('/', "%2F");
255 match api_get(
256 ctx,
257 run,
258 &format!("projects/{project}/repository/branches/{candidate}"),
259 )? {
260 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
261 Api::Failed(err) => return Ok(StepState::unknown(err)),
262 Api::Ok(_) => {}
263 }
264 match api_get(
265 ctx,
266 run,
267 &format!("projects/{project}/repository/compare?from={TRUNK_BRANCH}&to={candidate}"),
268 )? {
269 Api::Ok(body) => {
270 let ahead = body["commits"]
271 .as_array()
272 .is_some_and(|list| !list.is_empty());
273 Ok(if ahead {
274 StepState::not(format!(
275 "{candidate} carries commits {TRUNK_BRANCH} does not; deleting it would lose work"
276 ))
277 } else {
278 StepState::ok(format!("{candidate} is an ancestor of {TRUNK_BRANCH}"))
279 })
280 }
281 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
282 Api::Failed(err) => Ok(StepState::unknown(err)),
283 }
284}
285
286fn api_get(ctx: &Ctx, run: &mut Runner, path: &str) -> Result<Api, RkError> {
288 let exec = Exec {
289 program: ctx.cli.clone().into_os_string(),
290 args: vec!["api".into(), path.into()],
291 env: ctx.child_env("observe"),
292 cwd: ctx.target.as_std_path().to_path_buf(),
293 stdin: None,
294 };
295 let outcome = run(&exec)?;
296 if outcome.success() {
297 return Ok(
298 serde_json::from_slice::<Value>(&outcome.stdout).map_or_else(
299 |_| Api::Failed("the forge answer did not parse as JSON".into()),
300 Api::Ok,
301 ),
302 );
303 }
304 let stderr = String::from_utf8_lossy(&outcome.stderr).into_owned();
305 if stderr.contains("404") {
306 Ok(Api::Missing)
307 } else {
308 Ok(Api::Failed(last_line(&outcome.stderr)))
309 }
310}
311
312fn last_line(bytes: &[u8]) -> String {
314 String::from_utf8_lossy(bytes)
315 .lines()
316 .rev()
317 .find(|line| !line.trim().is_empty())
318 .unwrap_or("no output")
319 .to_owned()
320}
321
322#[allow(clippy::too_many_lines)]
323fn github(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
324 let repo = &ctx.repo;
325 match step {
326 "default-branch" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
327 Api::Ok(body) => {
328 let found = body["default_branch"].as_str().unwrap_or("");
329 if found == TRUNK_BRANCH {
330 StepState::ok(format!("{TRUNK_BRANCH} is the default branch"))
331 } else {
332 StepState::not(format!("the default branch is {found}"))
333 }
334 }
335 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
336 Api::Failed(err) => StepState::unknown(err),
337 }),
338 "single-trunk" => {
339 for candidate in TRUNK_CANDIDATES {
340 if candidate == TRUNK_BRANCH {
341 continue;
342 }
343 match api_get(ctx, run, &format!("repos/{repo}/git/ref/heads/{candidate}"))? {
344 Api::Missing => {}
345 Api::Ok(_) => {
346 return Ok(StepState::not(format!("a {candidate} branch still exists")));
347 }
348 Api::Failed(err) => return Ok(StepState::unknown(err)),
349 }
350 }
351 Ok(StepState::ok(
352 "no long-lived branch besides the trunk remains",
353 ))
354 }
355 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
356 Api::Ok(body) => {
357 if body["delete_branch_on_merge"].as_bool().unwrap_or(false) {
358 StepState::ok("a merged branch is deleted by the forge")
359 } else {
360 StepState::not("a merged branch outlives its merge")
361 }
362 }
363 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
364 Api::Failed(err) => StepState::unknown(err),
365 }),
366 "auto-merge" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
367 Api::Ok(body) => {
368 if body["allow_auto_merge"].as_bool().unwrap_or(false) {
369 StepState::ok("a request may merge itself once its checks pass")
370 } else {
371 StepState::not("a request cannot merge itself; the auto-merge switch is off")
372 }
373 }
374 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
375 Api::Failed(err) => StepState::unknown(err),
376 }),
377 "ci-permissions" => Ok(
378 match api_get(
379 ctx,
380 run,
381 &format!("repos/{repo}/actions/permissions/workflow"),
382 )? {
383 Api::Ok(body) => {
384 let write = body["default_workflow_permissions"] == "write";
385 let approve = body["can_approve_pull_request_reviews"] == true;
386 if write && approve {
387 StepState::ok("CI may write and open requests")
388 } else {
389 StepState::not(format!(
390 "workflow permissions are {} with request approval {}",
391 body["default_workflow_permissions"],
392 body["can_approve_pull_request_reviews"]
393 ))
394 }
395 }
396 Api::Missing => StepState::not("no workflow permissions are readable"),
397 Api::Failed(err) => StepState::unknown(err),
398 },
399 ),
400 "bot-secrets" => Ok(
401 match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
402 Api::Ok(body) => {
403 let names: Vec<&str> = body["secrets"]
404 .as_array()
405 .map(|list| {
406 list.iter()
407 .filter_map(|secret| secret["name"].as_str())
408 .collect()
409 })
410 .unwrap_or_default();
411 let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
412 if wanted.iter().all(|name| names.contains(name)) {
413 StepState::ok("both bot secrets are stored")
414 } else if names.is_empty() {
415 StepState::not("no bot secrets are stored")
416 } else {
417 StepState::not(format!("stored secrets: {}", names.join(", ")))
418 }
419 }
420 Api::Missing => StepState::not("no secrets are readable"),
421 Api::Failed(err) => StepState::unknown(err),
422 },
423 ),
424 "protect-trunk" => github_trunk_ruleset(ctx, run),
425 "protect-tags" => github_ruleset(
426 ctx,
427 run,
428 "release-tags",
429 "tag",
430 "refs/tags/v*",
431 &["deletion", "update"],
432 ),
433 "protect-release-lines" => {
434 match github_ruleset_body(ctx, run, "release-lines")? {
435 RulesetLookup::Absent => {
436 return Ok(StepState::inapplicable(
437 "release/* is unprotected; optional — applied only where older lines exist",
438 ));
439 }
440 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
441 RulesetLookup::Found(_) => {}
442 }
443 github_ruleset(
444 ctx,
445 run,
446 "release-lines",
447 "branch",
448 "refs/heads/release/*",
449 &["deletion", "non_fast_forward"],
450 )
451 }
452 "protections-check" => {
453 let mut failures = Vec::new();
457 let mut unknowns = Vec::new();
458 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
459 match github(ctx, owned, run)? {
460 StepState::Satisfied { .. } | StepState::Inapplicable { .. } => {}
461 StepState::Unsatisfied { detail } => {
462 failures.push(format!("{owned}: {detail}"));
463 }
464 StepState::Unknown { detail } => {
465 unknowns.push(format!("{owned}: {detail}"));
466 }
467 }
468 }
469 match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
470 Api::Ok(body) => {
471 let owned = [
472 format!("{TRUNK_BRANCH}-protection"),
473 "release-tags".to_owned(),
474 "release-lines".to_owned(),
475 ];
476 for ruleset in body.as_array().into_iter().flatten() {
477 let name = ruleset["name"].as_str().unwrap_or("");
478 if !owned.iter().any(|expected| expected == name) {
479 failures.push(format!("a ruleset no step owns: {name}"));
480 }
481 }
482 }
483 Api::Missing | Api::Failed(_) => {
484 unknowns.push("the ruleset inventory is not readable".to_owned());
485 }
486 }
487 Ok(if !failures.is_empty() {
488 StepState::not(failures.join("; "))
489 } else if !unknowns.is_empty() {
490 StepState::unknown(unknowns.join("; "))
491 } else {
492 StepState::ok("exactly the owned protections, with those rules")
493 })
494 }
495 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
496 }
497}
498
499#[must_use]
507pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
508 match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
509 AppApi::Ok(body) => {
510 let id = body["id"].as_i64().unwrap_or_default();
511 StepState::ok(format!("installation {id} covers {}", ctx.repo))
512 }
513 AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
514 AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
515 }
516}
517
518fn github_ruleset(
523 ctx: &Ctx,
524 run: &mut Runner,
525 name: &str,
526 target: &str,
527 include: &str,
528 rules: &[&str],
529) -> Result<StepState, RkError> {
530 let detail = match github_ruleset_body(ctx, run, name)? {
531 RulesetLookup::Found(detail) => detail,
532 RulesetLookup::Absent => {
533 return Ok(StepState::not(format!("no ruleset named {name}")));
534 }
535 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
536 };
537 if detail["enforcement"] != "active" {
538 return Ok(StepState::not(format!("{name} is not active")));
539 }
540 if detail["target"] != target {
543 return Ok(StepState::not(format!(
544 "{name} does not target {target} refs"
545 )));
546 }
547 if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
548 return Ok(StepState::not(format!(
549 "{name} does not cover {include} alone"
550 )));
551 }
552 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
553 return Ok(StepState::not(format!(
554 "{name} excludes refs from its own coverage"
555 )));
556 }
557 let mut held: Vec<&str> = detail["rules"]
558 .as_array()
559 .map(|list| {
560 list.iter()
561 .filter_map(|rule| rule["type"].as_str())
562 .collect()
563 })
564 .unwrap_or_default();
565 held.sort_unstable();
566 let mut expected: Vec<&str> = rules.to_vec();
567 expected.sort_unstable();
568 if held == expected {
569 Ok(StepState::ok(format!(
570 "{name} is active with exactly its rules"
571 )))
572 } else {
573 Ok(StepState::not(format!(
574 "{name} carries the rules [{}] where the setup owns [{}]",
575 held.join(", "),
576 expected.join(", ")
577 )))
578 }
579}
580
581fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
583 let name = format!("{TRUNK_BRANCH}-protection");
584 let detail = match github_ruleset_body(ctx, run, &name)? {
585 RulesetLookup::Found(detail) => detail,
586 RulesetLookup::Absent => {
587 return Ok(StepState::not(format!("no ruleset named {name}")));
588 }
589 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
590 };
591 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
592 let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
593 let mut faults = Vec::new();
594 if detail["enforcement"] != "active" {
595 faults.push(format!("{name} is not active"));
596 }
597 if detail["target"] != "branch" {
601 faults.push(format!("{name} does not target branches"));
602 }
603 let expected_ref = serde_json::json!([format!("refs/heads/{TRUNK_BRANCH}")]);
604 if detail["conditions"]["ref_name"]["include"] != expected_ref {
605 faults.push(format!(
606 "{name} does not cover refs/heads/{TRUNK_BRANCH} alone"
607 ));
608 }
609 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
612 faults.push(format!("{name} excludes refs from its own coverage"));
613 }
614 if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
615 faults.push("a bypass actor is named".to_owned());
616 }
617 let owned = [
618 "deletion",
619 "non_fast_forward",
620 "pull_request",
621 "required_status_checks",
622 ];
623 for required in owned {
624 if !has(required) {
625 faults.push(format!("the {required} rule is missing"));
626 }
627 }
628 for rule in &rules {
629 if let Some(kind) = rule["type"].as_str() {
630 if !owned.contains(&kind) {
631 faults.push(format!("an unowned rule is present: {kind}"));
635 }
636 }
637 }
638 if let Some(request) = rules.iter().find(|rule| rule["type"] == "pull_request") {
639 if request["parameters"]["allowed_merge_methods"] != serde_json::json!(["squash"]) {
640 faults.push("the merge method is not exactly a squash merge".to_owned());
641 }
642 }
643 if let Some(checks) = rules
644 .iter()
645 .find(|rule| rule["type"] == "required_status_checks")
646 {
647 let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
648 .as_array()
649 .map(|list| {
650 list.iter()
651 .filter_map(|check| check["context"].as_str())
652 .collect()
653 })
654 .unwrap_or_default();
655 if contexts.is_empty() {
660 faults.push("no status check is required".to_owned());
661 } else if let Some(expected) = &ctx.required_check {
662 let mut held = contexts.clone();
663 held.sort_unstable();
664 let mut owned_contexts = [expected.as_str(), TITLE_CHECK];
665 owned_contexts.sort_unstable();
666 if held != owned_contexts {
667 faults.push(format!(
668 "the required checks are [{}] where the setup owns [{}]",
669 contexts.join(", "),
670 owned_contexts.join(", ")
671 ));
672 }
673 } else if !contexts.contains(&TITLE_CHECK) {
674 faults.push(format!("the {TITLE_CHECK} check is not required"));
675 }
676 }
677 match squash_merge_sources(ctx, run)? {
678 MergeSources::Owned => {}
679 MergeSources::Faults(proven) => faults.extend(proven),
680 MergeSources::Unreadable(err) => {
684 if faults.is_empty() {
685 return Ok(StepState::unknown(err));
686 }
687 }
688 }
689 Ok(if faults.is_empty() {
690 StepState::ok(format!("{name} holds the release-merge shape"))
691 } else {
692 StepState::not(faults.join("; "))
693 })
694}
695
696enum MergeSources {
698 Owned,
700 Faults(Vec<String>),
702 Unreadable(String),
704}
705
706fn squash_merge_sources(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
713 Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
714 Api::Ok(body) => {
715 let mut faults = Vec::new();
716 if body["squash_merge_commit_title"] != "PR_TITLE" {
717 faults.push(format!(
718 "the squash title source is {} where the setup owns PR_TITLE",
719 body["squash_merge_commit_title"]
720 ));
721 }
722 if body["squash_merge_commit_message"] != "PR_BODY" {
723 faults.push(format!(
724 "the squash message source is {} where the setup owns PR_BODY",
725 body["squash_merge_commit_message"]
726 ));
727 }
728 if faults.is_empty() {
729 MergeSources::Owned
730 } else {
731 MergeSources::Faults(faults)
732 }
733 }
734 Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
735 Api::Failed(err) => MergeSources::Unreadable(err),
736 })
737}
738
739enum RulesetLookup {
742 Found(Value),
744 Absent,
747 Unreadable(String),
749}
750
751fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
753 let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
757 Api::Ok(body) => body,
758 Api::Missing => {
759 return Ok(RulesetLookup::Unreadable(
760 "the ruleset inventory is not readable".into(),
761 ));
762 }
763 Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
764 };
765 let id = list
766 .as_array()
767 .into_iter()
768 .flatten()
769 .find(|ruleset| ruleset["name"] == name)
770 .and_then(|ruleset| ruleset["id"].as_i64());
771 let Some(id) = id else {
772 return Ok(RulesetLookup::Absent);
773 };
774 match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
775 Api::Ok(body) => Ok(RulesetLookup::Found(body)),
776 Api::Missing => Ok(RulesetLookup::Unreadable(format!(
780 "the {name} detail is not readable"
781 ))),
782 Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
783 }
784}
785
786const GITLAB_AUTO_MERGE_LIMITATION: &str = "the forge offers no project-level auto-merge switch: availability follows the pipeline requirement protect-trunk asserts, and turning that requirement off removes auto-merge with nothing here reporting it";
790
791const GITLAB_TAG_LIMITATION: &str =
793 "an Owner or Maintainer can still delete a protected tag through the UI or API";
794
795const 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";
798
799#[allow(clippy::too_many_lines)]
800fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
801 let project = ctx.repo.replace('/', "%2F");
802 match step {
803 "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
804 Api::Ok(body) => {
805 let found = body["default_branch"].as_str().unwrap_or("");
806 if found == TRUNK_BRANCH {
807 StepState::ok(format!("{TRUNK_BRANCH} is the default branch"))
808 } else {
809 StepState::not(format!("the default branch is {found}"))
810 }
811 }
812 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
813 Api::Failed(err) => StepState::unknown(err),
814 }),
815 "single-trunk" => {
816 for candidate in TRUNK_CANDIDATES {
817 if candidate == TRUNK_BRANCH {
818 continue;
819 }
820 match api_get(
821 ctx,
822 run,
823 &format!("projects/{project}/repository/branches/{candidate}"),
824 )? {
825 Api::Missing => {}
826 Api::Ok(_) => {
827 return Ok(StepState::not(format!("a {candidate} branch still exists")));
828 }
829 Api::Failed(err) => return Ok(StepState::unknown(err)),
830 }
831 }
832 Ok(StepState::ok(
833 "no long-lived branch besides the trunk remains",
834 ))
835 }
836 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
837 Api::Ok(body) => {
838 if body["remove_source_branch_after_merge"]
839 .as_bool()
840 .unwrap_or(false)
841 {
842 StepState::ok("a merged branch is deleted by the forge")
843 } else {
844 StepState::not("a merged branch outlives its merge")
845 }
846 }
847 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
848 Api::Failed(err) => StepState::unknown(err),
849 }),
850 "auto-merge" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
851 Api::Ok(body) => {
852 if body["only_allow_merge_if_pipeline_succeeds"]
853 .as_bool()
854 .unwrap_or(false)
855 {
856 StepState::ok_with_limitation(
857 "a request may merge itself once its pipeline passes",
858 GITLAB_AUTO_MERGE_LIMITATION,
859 )
860 } else {
861 StepState::not(
862 "the pipeline requirement auto-merge rides on is off; protect-trunk asserts it",
863 )
864 }
865 }
866 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
867 Api::Failed(err) => StepState::unknown(err),
868 }),
869 "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
870 Api::Ok(body) => {
871 if body["jobs_enabled"] == true {
872 StepState::ok("pipelines are enabled")
873 } else {
874 StepState::not("pipelines are disabled")
875 }
876 }
877 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
878 Api::Failed(err) => StepState::unknown(err),
879 }),
880 "install-bot" => {
881 let mut active = false;
887 let mut exhausted = false;
888 for page in 1..=10u32 {
889 let path = format!(
890 "projects/{project}/access_tokens?state=active&per_page=100&page={page}"
891 );
892 let list = match api_get(ctx, run, &path)? {
893 Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
894 Api::Missing => Vec::new(),
895 Api::Failed(err) => return Ok(StepState::unknown(err)),
896 };
897 active = active
898 || list.iter().any(|token| {
899 token["name"] == "release-bot"
900 && token["revoked"] == false
901 && token["active"] != false
902 });
903 if list.len() < 100 {
904 exhausted = true;
905 }
906 if active || exhausted {
907 break;
908 }
909 }
910 if !active {
911 return Ok(if exhausted {
912 StepState::not("no active release-bot token exists")
913 } else {
914 StepState::unknown(
915 "the token listing did not exhaust within ten pages; nothing was decided",
916 )
917 });
918 }
919 Ok(
923 match api_get(
924 ctx,
925 run,
926 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
927 )? {
928 Api::Ok(_) => StepState::ok(
929 "an active release-bot token exists and its variable is stored",
930 ),
931 Api::Missing => StepState::not(
932 "an active release-bot token exists with no stored variable; a rerun revokes and replaces it",
933 ),
934 Api::Failed(err) => StepState::unknown(err),
935 },
936 )
937 }
938 "bot-secrets" => Ok(
939 match api_get(
940 ctx,
941 run,
942 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
943 )? {
944 Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
945 Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
946 Api::Failed(err) => StepState::unknown(err),
947 },
948 ),
949 "protect-trunk" => {
950 let protection = match api_get(
951 ctx,
952 run,
953 &format!("projects/{project}/protected_branches/{TRUNK_BRANCH}"),
954 )? {
955 Api::Ok(body) => body,
956 Api::Missing => {
957 return Ok(StepState::not(format!("{TRUNK_BRANCH} is not protected")));
958 }
959 Api::Failed(err) => return Ok(StepState::unknown(err)),
960 };
961 let grants = protection["push_access_levels"]
965 .as_array()
966 .cloned()
967 .unwrap_or_default();
968 let no_push = grants.len() == 1 && grants[0]["access_level"] == 0;
969 let merges = protection["merge_access_levels"]
973 .as_array()
974 .cloned()
975 .unwrap_or_default();
976 let can_merge = merges.len() == 1 && merges[0]["access_level"] == 40;
977 let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
978 Api::Ok(body) => body,
979 Api::Missing | Api::Failed(_) => Value::Null,
980 };
981 let mut faults = Vec::new();
982 if !no_push {
983 faults.push(format!(
984 "{TRUNK_BRANCH} still takes a direct push: the forge honors the most permissive of {} push grants",
985 grants.len()
986 ));
987 }
988 if !can_merge {
989 faults.push(format!(
990 "{TRUNK_BRANCH} merge grants are not exactly the one owned maintainer level"
991 ));
992 }
993 if protection["allow_force_push"] != false {
994 faults.push(format!("{TRUNK_BRANCH} allows force pushes"));
995 }
996 if settings["only_allow_merge_if_pipeline_succeeds"] != true {
997 faults.push("the pipeline requirement is off".to_owned());
998 }
999 if settings["merge_method"] != "ff" {
1000 faults.push("the merge method is not fast-forward".to_owned());
1001 }
1002 if settings["squash_option"] != "always" {
1003 faults.push("merge requests do not always squash".to_owned());
1004 }
1005 if settings["squash_commit_template"] != "%{title}" {
1006 faults.push("the squash template is not the merge request's title".to_owned());
1007 }
1008 Ok(if faults.is_empty() {
1009 StepState::ok_with_limitation(
1010 format!("{TRUNK_BRANCH} holds the release-merge shape"),
1011 GITLAB_TITLE_LIMITATION,
1012 )
1013 } else {
1014 StepState::not(faults.join("; "))
1015 })
1016 }
1017 "protect-tags" => Ok(
1018 match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
1019 Api::Ok(_) => {
1020 StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
1021 }
1022 Api::Missing => StepState::not("v* is not protected"),
1023 Api::Failed(err) => StepState::unknown(err),
1024 },
1025 ),
1026 "protect-release-lines" => Ok(
1027 match api_get(
1028 ctx,
1029 run,
1030 &format!("projects/{project}/protected_branches/release%2F%2A"),
1031 )? {
1032 Api::Ok(body) => {
1033 let level_ok = |levels: &Value| {
1034 levels
1035 .as_array()
1036 .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
1037 };
1038 if body["allow_force_push"] != false {
1039 StepState::not("release/* allows force pushes")
1040 } else if !level_ok(&body["push_access_levels"])
1041 || !level_ok(&body["merge_access_levels"])
1042 {
1043 StepState::not(
1047 "release/* grants are not exactly the owned maintainer levels",
1048 )
1049 } else {
1050 StepState::ok("release/* refuses force pushes and deletion by git clients")
1051 }
1052 }
1053 Api::Missing => StepState::inapplicable(
1054 "release/* is unprotected; optional — applied only where older lines exist",
1055 ),
1056 Api::Failed(err) => StepState::unknown(err),
1057 },
1058 ),
1059 "protections-check" => {
1060 let mut failures = Vec::new();
1063 let mut unknowns = Vec::new();
1064 let mut limitations: Vec<String> = Vec::new();
1067 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
1068 match gitlab(ctx, owned, run)? {
1069 StepState::Satisfied {
1070 limitation: found, ..
1071 } => limitations.extend(found),
1072 StepState::Inapplicable { .. } => {}
1073 StepState::Unsatisfied { detail } => {
1074 failures.push(format!("{owned}: {detail}"));
1075 }
1076 StepState::Unknown { detail } => {
1077 unknowns.push(format!("{owned}: {detail}"));
1078 }
1079 }
1080 }
1081 Ok(if !failures.is_empty() {
1082 StepState::not(failures.join("; "))
1083 } else if !unknowns.is_empty() {
1084 StepState::unknown(unknowns.join("; "))
1085 } else {
1086 StepState::Satisfied {
1087 detail: "the protections hold, as far as this forge enforces them".into(),
1088 limitation: if limitations.is_empty() {
1089 None
1090 } else {
1091 Some(limitations.join("; "))
1092 },
1093 }
1094 })
1095 }
1096 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1097 }
1098}