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 "ci-permissions" => Ok(
367 match api_get(
368 ctx,
369 run,
370 &format!("repos/{repo}/actions/permissions/workflow"),
371 )? {
372 Api::Ok(body) => {
373 let write = body["default_workflow_permissions"] == "write";
374 let approve = body["can_approve_pull_request_reviews"] == true;
375 if write && approve {
376 StepState::ok("CI may write and open requests")
377 } else {
378 StepState::not(format!(
379 "workflow permissions are {} with request approval {}",
380 body["default_workflow_permissions"],
381 body["can_approve_pull_request_reviews"]
382 ))
383 }
384 }
385 Api::Missing => StepState::not("no workflow permissions are readable"),
386 Api::Failed(err) => StepState::unknown(err),
387 },
388 ),
389 "bot-secrets" => Ok(
390 match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
391 Api::Ok(body) => {
392 let names: Vec<&str> = body["secrets"]
393 .as_array()
394 .map(|list| {
395 list.iter()
396 .filter_map(|secret| secret["name"].as_str())
397 .collect()
398 })
399 .unwrap_or_default();
400 let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
401 if wanted.iter().all(|name| names.contains(name)) {
402 StepState::ok("both bot secrets are stored")
403 } else if names.is_empty() {
404 StepState::not("no bot secrets are stored")
405 } else {
406 StepState::not(format!("stored secrets: {}", names.join(", ")))
407 }
408 }
409 Api::Missing => StepState::not("no secrets are readable"),
410 Api::Failed(err) => StepState::unknown(err),
411 },
412 ),
413 "protect-trunk" => github_trunk_ruleset(ctx, run),
414 "protect-tags" => github_ruleset(
415 ctx,
416 run,
417 "release-tags",
418 "tag",
419 "refs/tags/v*",
420 &["deletion", "update"],
421 ),
422 "protect-release-lines" => {
423 match github_ruleset_body(ctx, run, "release-lines")? {
424 RulesetLookup::Absent => {
425 return Ok(StepState::inapplicable(
426 "release/* is unprotected; optional — applied only where older lines exist",
427 ));
428 }
429 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
430 RulesetLookup::Found(_) => {}
431 }
432 github_ruleset(
433 ctx,
434 run,
435 "release-lines",
436 "branch",
437 "refs/heads/release/*",
438 &["deletion", "non_fast_forward"],
439 )
440 }
441 "protections-check" => {
442 let mut failures = Vec::new();
446 let mut unknowns = Vec::new();
447 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
448 match github(ctx, owned, run)? {
449 StepState::Satisfied { .. } | StepState::Inapplicable { .. } => {}
450 StepState::Unsatisfied { detail } => {
451 failures.push(format!("{owned}: {detail}"));
452 }
453 StepState::Unknown { detail } => {
454 unknowns.push(format!("{owned}: {detail}"));
455 }
456 }
457 }
458 match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
459 Api::Ok(body) => {
460 let owned = [
461 format!("{TRUNK_BRANCH}-protection"),
462 "release-tags".to_owned(),
463 "release-lines".to_owned(),
464 ];
465 for ruleset in body.as_array().into_iter().flatten() {
466 let name = ruleset["name"].as_str().unwrap_or("");
467 if !owned.iter().any(|expected| expected == name) {
468 failures.push(format!("a ruleset no step owns: {name}"));
469 }
470 }
471 }
472 Api::Missing | Api::Failed(_) => {
473 unknowns.push("the ruleset inventory is not readable".to_owned());
474 }
475 }
476 Ok(if !failures.is_empty() {
477 StepState::not(failures.join("; "))
478 } else if !unknowns.is_empty() {
479 StepState::unknown(unknowns.join("; "))
480 } else {
481 StepState::ok("exactly the owned protections, with those rules")
482 })
483 }
484 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
485 }
486}
487
488#[must_use]
496pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
497 match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
498 AppApi::Ok(body) => {
499 let id = body["id"].as_i64().unwrap_or_default();
500 StepState::ok(format!("installation {id} covers {}", ctx.repo))
501 }
502 AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
503 AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
504 }
505}
506
507fn github_ruleset(
512 ctx: &Ctx,
513 run: &mut Runner,
514 name: &str,
515 target: &str,
516 include: &str,
517 rules: &[&str],
518) -> Result<StepState, RkError> {
519 let detail = match github_ruleset_body(ctx, run, name)? {
520 RulesetLookup::Found(detail) => detail,
521 RulesetLookup::Absent => {
522 return Ok(StepState::not(format!("no ruleset named {name}")));
523 }
524 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
525 };
526 if detail["enforcement"] != "active" {
527 return Ok(StepState::not(format!("{name} is not active")));
528 }
529 if detail["target"] != target {
532 return Ok(StepState::not(format!(
533 "{name} does not target {target} refs"
534 )));
535 }
536 if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
537 return Ok(StepState::not(format!(
538 "{name} does not cover {include} alone"
539 )));
540 }
541 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
542 return Ok(StepState::not(format!(
543 "{name} excludes refs from its own coverage"
544 )));
545 }
546 let mut held: Vec<&str> = detail["rules"]
547 .as_array()
548 .map(|list| {
549 list.iter()
550 .filter_map(|rule| rule["type"].as_str())
551 .collect()
552 })
553 .unwrap_or_default();
554 held.sort_unstable();
555 let mut expected: Vec<&str> = rules.to_vec();
556 expected.sort_unstable();
557 if held == expected {
558 Ok(StepState::ok(format!(
559 "{name} is active with exactly its rules"
560 )))
561 } else {
562 Ok(StepState::not(format!(
563 "{name} carries the rules [{}] where the setup owns [{}]",
564 held.join(", "),
565 expected.join(", ")
566 )))
567 }
568}
569
570fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
572 let name = format!("{TRUNK_BRANCH}-protection");
573 let detail = match github_ruleset_body(ctx, run, &name)? {
574 RulesetLookup::Found(detail) => detail,
575 RulesetLookup::Absent => {
576 return Ok(StepState::not(format!("no ruleset named {name}")));
577 }
578 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
579 };
580 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
581 let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
582 let mut faults = Vec::new();
583 if detail["enforcement"] != "active" {
584 faults.push(format!("{name} is not active"));
585 }
586 if detail["target"] != "branch" {
590 faults.push(format!("{name} does not target branches"));
591 }
592 let expected_ref = serde_json::json!([format!("refs/heads/{TRUNK_BRANCH}")]);
593 if detail["conditions"]["ref_name"]["include"] != expected_ref {
594 faults.push(format!(
595 "{name} does not cover refs/heads/{TRUNK_BRANCH} alone"
596 ));
597 }
598 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
601 faults.push(format!("{name} excludes refs from its own coverage"));
602 }
603 if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
604 faults.push("a bypass actor is named".to_owned());
605 }
606 let owned = [
607 "deletion",
608 "non_fast_forward",
609 "pull_request",
610 "required_status_checks",
611 ];
612 for required in owned {
613 if !has(required) {
614 faults.push(format!("the {required} rule is missing"));
615 }
616 }
617 for rule in &rules {
618 if let Some(kind) = rule["type"].as_str() {
619 if !owned.contains(&kind) {
620 faults.push(format!("an unowned rule is present: {kind}"));
624 }
625 }
626 }
627 if let Some(request) = rules.iter().find(|rule| rule["type"] == "pull_request") {
628 if request["parameters"]["allowed_merge_methods"] != serde_json::json!(["squash"]) {
629 faults.push("the merge method is not exactly a squash merge".to_owned());
630 }
631 }
632 if let Some(checks) = rules
633 .iter()
634 .find(|rule| rule["type"] == "required_status_checks")
635 {
636 let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
637 .as_array()
638 .map(|list| {
639 list.iter()
640 .filter_map(|check| check["context"].as_str())
641 .collect()
642 })
643 .unwrap_or_default();
644 if contexts.is_empty() {
649 faults.push("no status check is required".to_owned());
650 } else if let Some(expected) = &ctx.required_check {
651 let mut held = contexts.clone();
652 held.sort_unstable();
653 let mut owned_contexts = [expected.as_str(), TITLE_CHECK];
654 owned_contexts.sort_unstable();
655 if held != owned_contexts {
656 faults.push(format!(
657 "the required checks are [{}] where the setup owns [{}]",
658 contexts.join(", "),
659 owned_contexts.join(", ")
660 ));
661 }
662 } else if !contexts.contains(&TITLE_CHECK) {
663 faults.push(format!("the {TITLE_CHECK} check is not required"));
664 }
665 }
666 match squash_merge_sources(ctx, run)? {
667 MergeSources::Owned => {}
668 MergeSources::Faults(proven) => faults.extend(proven),
669 MergeSources::Unreadable(err) => {
673 if faults.is_empty() {
674 return Ok(StepState::unknown(err));
675 }
676 }
677 }
678 Ok(if faults.is_empty() {
679 StepState::ok(format!("{name} holds the release-merge shape"))
680 } else {
681 StepState::not(faults.join("; "))
682 })
683}
684
685enum MergeSources {
687 Owned,
689 Faults(Vec<String>),
691 Unreadable(String),
693}
694
695fn squash_merge_sources(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
702 Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
703 Api::Ok(body) => {
704 let mut faults = Vec::new();
705 if body["squash_merge_commit_title"] != "PR_TITLE" {
706 faults.push(format!(
707 "the squash title source is {} where the setup owns PR_TITLE",
708 body["squash_merge_commit_title"]
709 ));
710 }
711 if body["squash_merge_commit_message"] != "PR_BODY" {
712 faults.push(format!(
713 "the squash message source is {} where the setup owns PR_BODY",
714 body["squash_merge_commit_message"]
715 ));
716 }
717 if faults.is_empty() {
718 MergeSources::Owned
719 } else {
720 MergeSources::Faults(faults)
721 }
722 }
723 Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
724 Api::Failed(err) => MergeSources::Unreadable(err),
725 })
726}
727
728enum RulesetLookup {
731 Found(Value),
733 Absent,
736 Unreadable(String),
738}
739
740fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
742 let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
746 Api::Ok(body) => body,
747 Api::Missing => {
748 return Ok(RulesetLookup::Unreadable(
749 "the ruleset inventory is not readable".into(),
750 ));
751 }
752 Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
753 };
754 let id = list
755 .as_array()
756 .into_iter()
757 .flatten()
758 .find(|ruleset| ruleset["name"] == name)
759 .and_then(|ruleset| ruleset["id"].as_i64());
760 let Some(id) = id else {
761 return Ok(RulesetLookup::Absent);
762 };
763 match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
764 Api::Ok(body) => Ok(RulesetLookup::Found(body)),
765 Api::Missing => Ok(RulesetLookup::Unreadable(format!(
769 "the {name} detail is not readable"
770 ))),
771 Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
772 }
773}
774
775const GITLAB_TAG_LIMITATION: &str =
777 "an Owner or Maintainer can still delete a protected tag through the UI or API";
778
779const 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";
782
783#[allow(clippy::too_many_lines)]
784fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
785 let project = ctx.repo.replace('/', "%2F");
786 match step {
787 "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
788 Api::Ok(body) => {
789 let found = body["default_branch"].as_str().unwrap_or("");
790 if found == TRUNK_BRANCH {
791 StepState::ok(format!("{TRUNK_BRANCH} is the default branch"))
792 } else {
793 StepState::not(format!("the default branch is {found}"))
794 }
795 }
796 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
797 Api::Failed(err) => StepState::unknown(err),
798 }),
799 "single-trunk" => {
800 for candidate in TRUNK_CANDIDATES {
801 if candidate == TRUNK_BRANCH {
802 continue;
803 }
804 match api_get(
805 ctx,
806 run,
807 &format!("projects/{project}/repository/branches/{candidate}"),
808 )? {
809 Api::Missing => {}
810 Api::Ok(_) => {
811 return Ok(StepState::not(format!("a {candidate} branch still exists")));
812 }
813 Api::Failed(err) => return Ok(StepState::unknown(err)),
814 }
815 }
816 Ok(StepState::ok(
817 "no long-lived branch besides the trunk remains",
818 ))
819 }
820 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
821 Api::Ok(body) => {
822 if body["remove_source_branch_after_merge"]
823 .as_bool()
824 .unwrap_or(false)
825 {
826 StepState::ok("a merged branch is deleted by the forge")
827 } else {
828 StepState::not("a merged branch outlives its merge")
829 }
830 }
831 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
832 Api::Failed(err) => StepState::unknown(err),
833 }),
834 "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
835 Api::Ok(body) => {
836 if body["jobs_enabled"] == true {
837 StepState::ok("pipelines are enabled")
838 } else {
839 StepState::not("pipelines are disabled")
840 }
841 }
842 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
843 Api::Failed(err) => StepState::unknown(err),
844 }),
845 "install-bot" => {
846 let mut active = false;
852 let mut exhausted = false;
853 for page in 1..=10u32 {
854 let path = format!(
855 "projects/{project}/access_tokens?state=active&per_page=100&page={page}"
856 );
857 let list = match api_get(ctx, run, &path)? {
858 Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
859 Api::Missing => Vec::new(),
860 Api::Failed(err) => return Ok(StepState::unknown(err)),
861 };
862 active = active
863 || list.iter().any(|token| {
864 token["name"] == "release-bot"
865 && token["revoked"] == false
866 && token["active"] != false
867 });
868 if list.len() < 100 {
869 exhausted = true;
870 }
871 if active || exhausted {
872 break;
873 }
874 }
875 if !active {
876 return Ok(if exhausted {
877 StepState::not("no active release-bot token exists")
878 } else {
879 StepState::unknown(
880 "the token listing did not exhaust within ten pages; nothing was decided",
881 )
882 });
883 }
884 Ok(
888 match api_get(
889 ctx,
890 run,
891 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
892 )? {
893 Api::Ok(_) => StepState::ok(
894 "an active release-bot token exists and its variable is stored",
895 ),
896 Api::Missing => StepState::not(
897 "an active release-bot token exists with no stored variable; a rerun revokes and replaces it",
898 ),
899 Api::Failed(err) => StepState::unknown(err),
900 },
901 )
902 }
903 "bot-secrets" => Ok(
904 match api_get(
905 ctx,
906 run,
907 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
908 )? {
909 Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
910 Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
911 Api::Failed(err) => StepState::unknown(err),
912 },
913 ),
914 "protect-trunk" => {
915 let protection = match api_get(
916 ctx,
917 run,
918 &format!("projects/{project}/protected_branches/{TRUNK_BRANCH}"),
919 )? {
920 Api::Ok(body) => body,
921 Api::Missing => {
922 return Ok(StepState::not(format!("{TRUNK_BRANCH} is not protected")));
923 }
924 Api::Failed(err) => return Ok(StepState::unknown(err)),
925 };
926 let grants = protection["push_access_levels"]
930 .as_array()
931 .cloned()
932 .unwrap_or_default();
933 let no_push = grants.len() == 1 && grants[0]["access_level"] == 0;
934 let merges = protection["merge_access_levels"]
938 .as_array()
939 .cloned()
940 .unwrap_or_default();
941 let can_merge = merges.len() == 1 && merges[0]["access_level"] == 40;
942 let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
943 Api::Ok(body) => body,
944 Api::Missing | Api::Failed(_) => Value::Null,
945 };
946 let mut faults = Vec::new();
947 if !no_push {
948 faults.push(format!(
949 "{TRUNK_BRANCH} still takes a direct push: the forge honors the most permissive of {} push grants",
950 grants.len()
951 ));
952 }
953 if !can_merge {
954 faults.push(format!(
955 "{TRUNK_BRANCH} merge grants are not exactly the one owned maintainer level"
956 ));
957 }
958 if protection["allow_force_push"] != false {
959 faults.push(format!("{TRUNK_BRANCH} allows force pushes"));
960 }
961 if settings["only_allow_merge_if_pipeline_succeeds"] != true {
962 faults.push("the pipeline requirement is off".to_owned());
963 }
964 if settings["merge_method"] != "ff" {
965 faults.push("the merge method is not fast-forward".to_owned());
966 }
967 if settings["squash_option"] != "always" {
968 faults.push("merge requests do not always squash".to_owned());
969 }
970 if settings["squash_commit_template"] != "%{title}" {
971 faults.push("the squash template is not the merge request's title".to_owned());
972 }
973 Ok(if faults.is_empty() {
974 StepState::ok_with_limitation(
975 format!("{TRUNK_BRANCH} holds the release-merge shape"),
976 GITLAB_TITLE_LIMITATION,
977 )
978 } else {
979 StepState::not(faults.join("; "))
980 })
981 }
982 "protect-tags" => Ok(
983 match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
984 Api::Ok(_) => {
985 StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
986 }
987 Api::Missing => StepState::not("v* is not protected"),
988 Api::Failed(err) => StepState::unknown(err),
989 },
990 ),
991 "protect-release-lines" => Ok(
992 match api_get(
993 ctx,
994 run,
995 &format!("projects/{project}/protected_branches/release%2F%2A"),
996 )? {
997 Api::Ok(body) => {
998 let level_ok = |levels: &Value| {
999 levels
1000 .as_array()
1001 .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
1002 };
1003 if body["allow_force_push"] != false {
1004 StepState::not("release/* allows force pushes")
1005 } else if !level_ok(&body["push_access_levels"])
1006 || !level_ok(&body["merge_access_levels"])
1007 {
1008 StepState::not(
1012 "release/* grants are not exactly the owned maintainer levels",
1013 )
1014 } else {
1015 StepState::ok("release/* refuses force pushes and deletion by git clients")
1016 }
1017 }
1018 Api::Missing => StepState::inapplicable(
1019 "release/* is unprotected; optional — applied only where older lines exist",
1020 ),
1021 Api::Failed(err) => StepState::unknown(err),
1022 },
1023 ),
1024 "protections-check" => {
1025 let mut failures = Vec::new();
1028 let mut unknowns = Vec::new();
1029 let mut limitations: Vec<String> = Vec::new();
1032 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
1033 match gitlab(ctx, owned, run)? {
1034 StepState::Satisfied {
1035 limitation: found, ..
1036 } => limitations.extend(found),
1037 StepState::Inapplicable { .. } => {}
1038 StepState::Unsatisfied { detail } => {
1039 failures.push(format!("{owned}: {detail}"));
1040 }
1041 StepState::Unknown { detail } => {
1042 unknowns.push(format!("{owned}: {detail}"));
1043 }
1044 }
1045 }
1046 Ok(if !failures.is_empty() {
1047 StepState::not(failures.join("; "))
1048 } else if !unknowns.is_empty() {
1049 StepState::unknown(unknowns.join("; "))
1050 } else {
1051 StepState::Satisfied {
1052 detail: "the protections hold, as far as this forge enforces them".into(),
1053 limitation: if limitations.is_empty() {
1054 None
1055 } else {
1056 Some(limitations.join("; "))
1057 },
1058 }
1059 })
1060 }
1061 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1062 }
1063}