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