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;
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
31const GITLAB_PRIVATE_REPORTING_LIMITATION: &str = "GitLab has no project-level private reporting switch; the reporter must enable confidentiality; this proves project feature access, not successful submission by every external reporter";
32
33#[derive(Debug)]
35pub enum StepState {
36 Satisfied {
39 detail: String,
41 limitation: Option<String>,
43 },
44 Unsatisfied {
46 detail: String,
48 },
49 Inapplicable {
52 detail: String,
54 },
55 Unknown {
57 detail: String,
59 },
60}
61
62impl StepState {
63 #[must_use]
65 pub const fn satisfied(&self) -> bool {
66 matches!(self, Self::Satisfied { .. })
67 }
68
69 fn ok(detail: impl Into<String>) -> Self {
70 Self::Satisfied {
71 detail: detail.into(),
72 limitation: None,
73 }
74 }
75
76 fn ok_with_limitation(detail: impl Into<String>, limitation: impl Into<String>) -> Self {
77 Self::Satisfied {
78 detail: detail.into(),
79 limitation: Some(limitation.into()),
80 }
81 }
82
83 fn not(detail: impl Into<String>) -> Self {
84 Self::Unsatisfied {
85 detail: detail.into(),
86 }
87 }
88
89 fn inapplicable(detail: impl Into<String>) -> Self {
90 Self::Inapplicable {
91 detail: detail.into(),
92 }
93 }
94
95 fn unknown(detail: impl Into<String>) -> Self {
96 Self::Unknown {
97 detail: detail.into(),
98 }
99 }
100}
101
102enum Api {
104 Ok(Value),
106 Missing,
108 Failed(String),
110}
111
112pub fn observe(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
119 if step == "package-check" {
120 return package_check(ctx, run);
121 }
122 if step == "branch-reminder" {
123 return Ok(branch_reminder_state(ctx));
124 }
125 if step == "forge-version" {
126 return forge_version(ctx, run);
127 }
128 match ctx.forge {
129 Forge::Github => github(ctx, step, run),
130 Forge::Gitlab => gitlab(ctx, step, run),
131 }
132}
133
134fn package_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
137 let (program, args): (&str, &[&str]) = match ctx.tech {
138 Some("rust") => ("cargo", &["publish", "--dry-run", "--allow-dirty"]),
139 Some("python") => ("python3", &["-m", "build"]),
140 Some("bash") => {
141 return Ok(StepState::ok(
142 "no registry for this technology; there is nothing to package",
143 ));
144 }
145 Some(other) => {
146 return Ok(StepState::unknown(format!(
147 "no packaging check is defined for {other}"
148 )));
149 }
150 None => {
151 return Ok(StepState::unknown(
152 "no version file names a technology; see rk binding --list",
153 ));
154 }
155 };
156 let exec = Exec {
157 program: program.into(),
158 args: args.iter().map(Into::into).collect(),
159 env: ctx.child_env("package-check"),
160 cwd: ctx.target.as_std_path().to_path_buf(),
161 stdin: None,
162 };
163 let outcome = run(&exec)?;
164 Ok(if outcome.success() {
165 StepState::ok("the package builds and passes the registry's dry run")
166 } else {
167 StepState::not(format!(
168 "the packaging check failed: {}",
169 last_line(&outcome.stderr)
170 ))
171 })
172}
173
174fn branch_reminder_state(ctx: &Ctx) -> StepState {
177 use crate::setup::branch_reminder::{HookState, observe_hook};
178 match observe_hook(&ctx.target) {
179 HookState::Installed => {
180 StepState::ok("the post-merge hook carries the release-kit reminder")
181 }
182 HookState::Absent => StepState::not("no post-merge hook is installed"),
183 HookState::Foreign => {
184 StepState::not("a post-merge hook exists without the release-kit marker")
185 }
186 HookState::Drifted => StepState::not("the reminder hook drifted from this binary's body"),
187 HookState::Unreadable(detail) => StepState::unknown(detail),
188 }
189}
190
191pub const GITLAB_VERSION_FLOOR: (u64, u64) = (18, 2);
198
199const GITLAB_EDITIONS: [&str; 2] = ["ee", "ce"];
202
203fn version_refusal(found: &str, prerelease: Option<&str>) -> String {
206 let (major, minor) = GITLAB_VERSION_FLOOR;
207 let mut said = vec![format!(
208 "this GitLab instance reports {found}; the convention needs {major}.{minor} or newer"
209 )];
210 if let Some(suffix) = prerelease {
211 said.push(format!(
212 "the -{suffix} suffix is a pre-release, and nothing proves the feature shipped in it, so this step fails closed"
213 ));
214 }
215 said.push(format!(
216 "the merge-request pipeline triggers a child pipeline with `strategy: mirror`, which GitLab added in {major}.{minor}"
217 ));
218 said.push(
219 "below it the child's status never reaches the parent pipeline, so a failing project job merges".to_owned(),
220 );
221 said.push(format!(
222 "upgrade the instance to {major}.{minor} or newer, or host the project on gitlab.com"
223 ));
224 said.join("; ")
225}
226
227fn forge_version(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
233 if ctx.forge == Forge::Github {
234 return Ok(StepState::ok(
235 "github.com is a rolling service and declares no version floor",
236 ));
237 }
238 let body = match api_get(ctx, run, "version")? {
239 Api::Ok(body) => body,
240 Api::Missing => {
241 return Ok(StepState::unknown(
242 "this instance answers no GET /version; the floor cannot be read. Check that glab is authenticated against it: glab auth login",
243 ));
244 }
245 Api::Failed(err) => {
246 return Ok(StepState::unknown(format!(
247 "the version could not be read: {err}. Check that glab is authenticated against this instance: glab auth login"
248 )));
249 }
250 };
251 let Some(found) = body["version"].as_str() else {
252 return Ok(StepState::unknown(
253 "the forge answer carries no version field; the floor cannot be read. Check that glab is authenticated against this instance: glab auth login",
254 ));
255 };
256 let (number, suffix) = found
257 .split_once('-')
258 .map_or((found, None), |(n, s)| (n, Some(s)));
259 let mut parts = number.split('.');
260 let parsed = parts
261 .next()
262 .and_then(|major| major.parse::<u64>().ok())
263 .zip(parts.next().and_then(|minor| minor.parse::<u64>().ok()));
264 let Some(pair) = parsed else {
265 return Ok(StepState::unknown(format!(
266 "the forge reports the version as '{found}', which names no major and minor pair; the floor cannot be read"
267 )));
268 };
269 if let Some(suffix) = suffix.filter(|s| !GITLAB_EDITIONS.contains(s)) {
270 return Ok(StepState::not(version_refusal(found, Some(suffix))));
271 }
272 if pair < GITLAB_VERSION_FLOOR {
273 return Ok(StepState::not(version_refusal(found, None)));
274 }
275 let (major, minor) = GITLAB_VERSION_FLOOR;
276 Ok(StepState::ok(format!(
277 "this instance reports {found}, at or above the {major}.{minor} floor"
278 )))
279}
280
281pub fn single_trunk_guard(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
291 let trunk = ctx.trunk();
292 for candidate in TRUNK_CANDIDATES {
293 if candidate == trunk {
294 continue;
295 }
296 let state = match ctx.forge {
297 Forge::Github => github_candidate_guard(ctx, run, candidate)?,
298 Forge::Gitlab => gitlab_candidate_guard(ctx, run, candidate)?,
299 };
300 if !state.satisfied() {
301 return Ok(state);
302 }
303 }
304 Ok(StepState::ok(
305 "every candidate branch is absent, or an ancestor of the trunk",
306 ))
307}
308
309fn github_candidate_guard(
311 ctx: &Ctx,
312 run: &mut Runner,
313 candidate: &str,
314) -> Result<StepState, RkError> {
315 let trunk = ctx.trunk();
316 match api_get(
317 ctx,
318 run,
319 &format!("repos/{}/git/ref/heads/{candidate}", ctx.repo),
320 )? {
321 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
322 Api::Failed(err) => return Ok(StepState::unknown(err)),
323 Api::Ok(_) => {}
324 }
325 match api_get(
326 ctx,
327 run,
328 &format!("repos/{}/compare/{candidate}...{trunk}", ctx.repo),
329 )? {
330 Api::Ok(body) => {
331 let status = body["status"].as_str().unwrap_or("");
332 Ok(if matches!(status, "ahead" | "identical") {
333 StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
334 } else {
335 StepState::not(format!(
336 "{candidate} is not an ancestor of {trunk} ({status}); deleting it would lose work"
337 ))
338 })
339 }
340 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
341 Api::Failed(err) => Ok(StepState::unknown(err)),
342 }
343}
344
345fn gitlab_candidate_guard(
347 ctx: &Ctx,
348 run: &mut Runner,
349 candidate: &str,
350) -> Result<StepState, RkError> {
351 let trunk = ctx.trunk();
352 let project = ctx.repo.replace('/', "%2F");
353 match api_get(
354 ctx,
355 run,
356 &format!("projects/{project}/repository/branches/{candidate}"),
357 )? {
358 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
359 Api::Failed(err) => return Ok(StepState::unknown(err)),
360 Api::Ok(_) => {}
361 }
362 match api_get(
363 ctx,
364 run,
365 &format!("projects/{project}/repository/compare?from={trunk}&to={candidate}"),
366 )? {
367 Api::Ok(body) => {
368 let ahead = body["commits"]
369 .as_array()
370 .is_some_and(|list| !list.is_empty());
371 Ok(if ahead {
372 StepState::not(format!(
373 "{candidate} carries commits {trunk} does not; deleting it would lose work"
374 ))
375 } else {
376 StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
377 })
378 }
379 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
380 Api::Failed(err) => Ok(StepState::unknown(err)),
381 }
382}
383
384fn api_get(ctx: &Ctx, run: &mut Runner, path: &str) -> Result<Api, RkError> {
386 let exec = Exec {
387 program: ctx.cli.clone().into_os_string(),
388 args: vec!["api".into(), path.into()],
389 env: ctx.child_env("observe"),
390 cwd: ctx.target.as_std_path().to_path_buf(),
391 stdin: None,
392 };
393 let outcome = run(&exec)?;
394 if outcome.success() {
395 return Ok(
396 serde_json::from_slice::<Value>(&outcome.stdout).map_or_else(
397 |_| Api::Failed("the forge answer did not parse as JSON".into()),
398 Api::Ok,
399 ),
400 );
401 }
402 let stderr = String::from_utf8_lossy(&outcome.stderr).into_owned();
403 if stderr.contains("404") {
404 Ok(Api::Missing)
405 } else {
406 Ok(Api::Failed(last_line(&outcome.stderr)))
407 }
408}
409
410fn last_line(bytes: &[u8]) -> String {
412 String::from_utf8_lossy(bytes)
413 .lines()
414 .rev()
415 .find(|line| !line.trim().is_empty())
416 .unwrap_or("no output")
417 .to_owned()
418}
419
420#[allow(clippy::too_many_lines)]
421fn github(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
422 let trunk = ctx.trunk();
423 let repo = &ctx.repo;
424 match step {
425 "private-vulnerability-reporting" => {
426 let visibility_path = format!("repos/{repo}");
427 match api_get(ctx, run, &visibility_path)? {
428 Api::Ok(body) => match body["private"].as_bool() {
429 Some(true) => {
430 return Ok(StepState::inapplicable(
431 "private vulnerability reporting is available for public repositories",
432 ));
433 }
434 Some(false) => {}
435 None => {
436 return Ok(StepState::unknown(format!(
437 "{visibility_path}: repository visibility is unreadable"
438 )));
439 }
440 },
441 Api::Missing => {
442 return Ok(StepState::unknown(format!(
443 "{visibility_path}: repository visibility is unreadable (404)"
444 )));
445 }
446 Api::Failed(err) => {
447 return Ok(StepState::unknown(format!("{visibility_path}: {err}")));
448 }
449 }
450 let path = format!("repos/{repo}/private-vulnerability-reporting");
451 Ok(match api_get(ctx, run, &path)? {
452 Api::Ok(body) => match body["enabled"].as_bool() {
453 Some(true) => StepState::ok("private vulnerability reporting is enabled"),
454 Some(false) => StepState::not("private vulnerability reporting is disabled"),
455 None => StepState::unknown(format!("{path}: enabled is unreadable")),
456 },
457 Api::Missing => {
458 StepState::unknown(format!("{path}: reporting state is unreadable (404)"))
459 }
460 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
461 })
462 }
463
464 "default-branch" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
465 Api::Ok(body) => {
466 let found = body["default_branch"].as_str().unwrap_or("");
467 if found == trunk {
468 StepState::ok(format!("{trunk} is the default branch"))
469 } else {
470 StepState::not(format!("the default branch is {found}"))
471 }
472 }
473 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
474 Api::Failed(err) => StepState::unknown(err),
475 }),
476 "single-trunk" => {
477 for candidate in TRUNK_CANDIDATES {
478 if candidate == trunk {
479 continue;
480 }
481 match api_get(ctx, run, &format!("repos/{repo}/git/ref/heads/{candidate}"))? {
482 Api::Missing => {}
483 Api::Ok(_) => {
484 return Ok(StepState::not(format!("a {candidate} branch still exists")));
485 }
486 Api::Failed(err) => return Ok(StepState::unknown(err)),
487 }
488 }
489 Ok(StepState::ok(
490 "no long-lived branch besides the trunk remains",
491 ))
492 }
493 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
494 Api::Ok(body) => {
495 if body["delete_branch_on_merge"].as_bool().unwrap_or(false) {
496 StepState::ok("a merged branch is deleted by the forge")
497 } else {
498 StepState::not("a merged branch outlives its merge")
499 }
500 }
501 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
502 Api::Failed(err) => StepState::unknown(err),
503 }),
504 "auto-merge" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
505 Api::Ok(body) => {
506 if body["allow_auto_merge"].as_bool().unwrap_or(false) {
507 StepState::ok("a request may merge itself once its checks pass")
508 } else {
509 StepState::not("a request cannot merge itself; the auto-merge switch is off")
510 }
511 }
512 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
513 Api::Failed(err) => StepState::unknown(err),
514 }),
515 "ci-permissions" => Ok(
516 match api_get(
517 ctx,
518 run,
519 &format!("repos/{repo}/actions/permissions/workflow"),
520 )? {
521 Api::Ok(body) => {
522 let write = body["default_workflow_permissions"] == "write";
523 let approve = body["can_approve_pull_request_reviews"] == true;
524 if write && approve {
525 StepState::ok("CI may write and open requests")
526 } else {
527 StepState::not(format!(
528 "workflow permissions are {} with request approval {}",
529 body["default_workflow_permissions"],
530 body["can_approve_pull_request_reviews"]
531 ))
532 }
533 }
534 Api::Missing => StepState::not("no workflow permissions are readable"),
535 Api::Failed(err) => StepState::unknown(err),
536 },
537 ),
538 "bot-secrets" => Ok(
539 match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
540 Api::Ok(body) => {
541 let names: Vec<&str> = body["secrets"]
542 .as_array()
543 .map(|list| {
544 list.iter()
545 .filter_map(|secret| secret["name"].as_str())
546 .collect()
547 })
548 .unwrap_or_default();
549 let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
550 if wanted.iter().all(|name| names.contains(name)) {
551 StepState::ok("both bot secrets are stored")
552 } else if names.is_empty() {
553 StepState::not("no bot secrets are stored")
554 } else {
555 StepState::not(format!("stored secrets: {}", names.join(", ")))
556 }
557 }
558 Api::Missing => StepState::not("no secrets are readable"),
559 Api::Failed(err) => StepState::unknown(err),
560 },
561 ),
562 "protect-trunk" => github_trunk_ruleset(ctx, run),
563 "protect-tags" => github_ruleset(
564 ctx,
565 run,
566 "release-tags",
567 "tag",
568 "refs/tags/v*",
569 &["deletion", "update"],
570 ),
571 "protect-release-lines" => {
572 match github_ruleset_body(ctx, run, "release-lines")? {
573 RulesetLookup::Absent => {
574 return Ok(StepState::inapplicable(
575 "release/* is unprotected; optional — applied only where older lines exist",
576 ));
577 }
578 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
579 RulesetLookup::Found(_) => {}
580 }
581 github_ruleset(
582 ctx,
583 run,
584 "release-lines",
585 "branch",
586 "refs/heads/release/*",
587 &["deletion", "non_fast_forward"],
588 )
589 }
590 "protections-check" => {
591 let mut failures = Vec::new();
595 let mut unknowns = Vec::new();
596 let mut limitations: Vec<String> = Vec::new();
598 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
599 match github(ctx, owned, run)? {
600 StepState::Satisfied {
601 limitation: found, ..
602 } => limitations.extend(found),
603 StepState::Inapplicable { .. } => {}
604 StepState::Unsatisfied { detail } => {
605 failures.push(format!("{owned}: {detail}"));
606 }
607 StepState::Unknown { detail } => {
608 unknowns.push(format!("{owned}: {detail}"));
609 }
610 }
611 }
612 match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
613 Api::Ok(body) => {
614 let owned = [
615 format!("{trunk}-protection"),
616 "release-tags".to_owned(),
617 "release-lines".to_owned(),
618 ];
619 for ruleset in body.as_array().into_iter().flatten() {
620 let name = ruleset["name"].as_str().unwrap_or("");
621 if !owned.iter().any(|expected| expected == name) {
622 failures.push(format!("a ruleset no step owns: {name}"));
623 }
624 }
625 }
626 Api::Missing | Api::Failed(_) => {
627 unknowns.push("the ruleset inventory is not readable".to_owned());
628 }
629 }
630 Ok(if !failures.is_empty() {
631 StepState::not(failures.join("; "))
632 } else if !unknowns.is_empty() {
633 StepState::unknown(unknowns.join("; "))
634 } else {
635 StepState::Satisfied {
636 detail: "exactly the owned protections, with those rules".into(),
637 limitation: if limitations.is_empty() {
638 None
639 } else {
640 Some(limitations.join("; "))
641 },
642 }
643 })
644 }
645 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
646 }
647}
648
649#[must_use]
657pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
658 match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
659 AppApi::Ok(body) => {
660 let id = body["id"].as_i64().unwrap_or_default();
661 StepState::ok(format!("installation {id} covers {}", ctx.repo))
662 }
663 AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
664 AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
665 }
666}
667
668fn github_ruleset(
673 ctx: &Ctx,
674 run: &mut Runner,
675 name: &str,
676 target: &str,
677 include: &str,
678 rules: &[&str],
679) -> Result<StepState, RkError> {
680 let detail = match github_ruleset_body(ctx, run, name)? {
681 RulesetLookup::Found(detail) => detail,
682 RulesetLookup::Absent => {
683 return Ok(StepState::not(format!("no ruleset named {name}")));
684 }
685 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
686 };
687 if detail["enforcement"] != "active" {
688 return Ok(StepState::not(format!("{name} is not active")));
689 }
690 if detail["target"] != target {
693 return Ok(StepState::not(format!(
694 "{name} does not target {target} refs"
695 )));
696 }
697 if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
698 return Ok(StepState::not(format!(
699 "{name} does not cover {include} alone"
700 )));
701 }
702 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
703 return Ok(StepState::not(format!(
704 "{name} excludes refs from its own coverage"
705 )));
706 }
707 let mut held: Vec<&str> = detail["rules"]
708 .as_array()
709 .map(|list| {
710 list.iter()
711 .filter_map(|rule| rule["type"].as_str())
712 .collect()
713 })
714 .unwrap_or_default();
715 held.sort_unstable();
716 let mut expected: Vec<&str> = rules.to_vec();
717 expected.sort_unstable();
718 if held == expected {
719 Ok(StepState::ok(format!(
720 "{name} is active with exactly its rules"
721 )))
722 } else {
723 Ok(StepState::not(format!(
724 "{name} carries the rules [{}] where the setup owns [{}]",
725 held.join(", "),
726 expected.join(", ")
727 )))
728 }
729}
730
731const OWNED_TRUNK_RULES: [&str; 4] = [
736 "deletion",
737 "non_fast_forward",
738 "pull_request",
739 "required_status_checks",
740];
741
742fn unowned_rule_faults(rules: &[Value]) -> Vec<String> {
750 rules
751 .iter()
752 .filter_map(|rule| rule["type"].as_str())
753 .filter(|kind| !OWNED_TRUNK_RULES.contains(kind))
754 .map(|kind| {
755 if kind == "merge_queue" {
756 MERGE_QUEUE_FAULT.to_owned()
757 } else {
758 format!("an unowned rule is present: {kind}")
759 }
760 })
761 .collect()
762}
763
764fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
765 let trunk = ctx.trunk();
766 let name = format!("{trunk}-protection");
767 let detail = match github_ruleset_body(ctx, run, &name)? {
768 RulesetLookup::Found(detail) => detail,
769 RulesetLookup::Absent => {
770 return Ok(StepState::not(format!("no ruleset named {name}")));
771 }
772 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
773 };
774 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
775 let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
776 let mut faults = Vec::new();
777 if detail["enforcement"] != "active" {
778 faults.push(format!("{name} is not active"));
779 }
780 if detail["target"] != "branch" {
784 faults.push(format!("{name} does not target branches"));
785 }
786 let expected_ref = serde_json::json!([format!("refs/heads/{trunk}")]);
787 if detail["conditions"]["ref_name"]["include"] != expected_ref {
788 faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
789 }
790 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
793 faults.push(format!("{name} excludes refs from its own coverage"));
794 }
795 if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
796 faults.push("a bypass actor is named".to_owned());
797 }
798 for required in OWNED_TRUNK_RULES {
799 if !has(required) {
800 faults.push(format!("the {required} rule is missing"));
801 }
802 }
803 faults.extend(unowned_rule_faults(&rules));
804 if let Some(request) = rules.iter().find(|rule| rule["type"] == "pull_request") {
805 if request["parameters"]["allowed_merge_methods"] != serde_json::json!(["squash"]) {
806 faults.push("the merge method is not exactly a squash merge".to_owned());
807 }
808 }
809 if let Some(checks) = rules
810 .iter()
811 .find(|rule| rule["type"] == "required_status_checks")
812 {
813 if checks["parameters"]["strict_required_status_checks_policy"] != true {
814 faults.push(STALE_MERGE_FAULT.to_owned());
815 }
816 let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
817 .as_array()
818 .map(|list| {
819 list.iter()
820 .filter_map(|check| check["context"].as_str())
821 .collect()
822 })
823 .unwrap_or_default();
824 if contexts.is_empty() {
829 faults.push("no status check is required".to_owned());
830 } else if let Some(expected) = &ctx.required_check {
831 let mut held = contexts.clone();
832 held.sort_unstable();
833 let mut owned_contexts = [expected.as_str(), TITLE_CHECK];
834 owned_contexts.sort_unstable();
835 if held != owned_contexts {
836 faults.push(format!(
837 "the required checks are [{}] where the setup owns [{}]",
838 contexts.join(", "),
839 owned_contexts.join(", ")
840 ));
841 }
842 } else if !contexts.contains(&TITLE_CHECK) {
843 faults.push(format!("the {TITLE_CHECK} check is not required"));
844 }
845 }
846 match squash_merge_sources(ctx, run)? {
847 MergeSources::Owned => {}
848 MergeSources::Faults(proven) => faults.extend(proven),
849 MergeSources::Unreadable(err) => {
853 if faults.is_empty() {
854 return Ok(StepState::unknown(err));
855 }
856 }
857 }
858 if let Some(shape) = gate_faults(ctx) {
859 faults.push(shape);
860 }
861 if !faults.is_empty() {
862 return Ok(StepState::not(faults.join("; ")));
863 }
864 Ok(StepState::ok(format!(
865 "{name} holds the release-merge shape"
866 )))
867}
868
869fn gate_faults(ctx: &Ctx) -> Option<String> {
879 let check = ctx.required_check.as_deref()?;
880 workflow_jobs::faults(
881 &workflow_jobs::read_gate(&ctx.target, check, ctx.trunk()),
882 check,
883 ctx.trunk(),
884 )
885}
886
887enum MergeSources {
889 Owned,
891 Faults(Vec<String>),
893 Unreadable(String),
895}
896
897fn squash_merge_sources(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
904 Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
905 Api::Ok(body) => {
906 let mut faults = Vec::new();
907 if body["squash_merge_commit_title"] != "PR_TITLE" {
908 faults.push(format!(
909 "the squash title source is {} where the setup owns PR_TITLE",
910 body["squash_merge_commit_title"]
911 ));
912 }
913 if body["squash_merge_commit_message"] != "PR_BODY" {
914 faults.push(format!(
915 "the squash message source is {} where the setup owns PR_BODY",
916 body["squash_merge_commit_message"]
917 ));
918 }
919 if faults.is_empty() {
920 MergeSources::Owned
921 } else {
922 MergeSources::Faults(faults)
923 }
924 }
925 Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
926 Api::Failed(err) => MergeSources::Unreadable(err),
927 })
928}
929
930enum RulesetLookup {
933 Found(Value),
935 Absent,
938 Unreadable(String),
940}
941
942fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
944 let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
948 Api::Ok(body) => body,
949 Api::Missing => {
950 return Ok(RulesetLookup::Unreadable(
951 "the ruleset inventory is not readable".into(),
952 ));
953 }
954 Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
955 };
956 let id = list
957 .as_array()
958 .into_iter()
959 .flatten()
960 .find(|ruleset| ruleset["name"] == name)
961 .and_then(|ruleset| ruleset["id"].as_i64());
962 let Some(id) = id else {
963 return Ok(RulesetLookup::Absent);
964 };
965 match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
966 Api::Ok(body) => Ok(RulesetLookup::Found(body)),
967 Api::Missing => Ok(RulesetLookup::Unreadable(format!(
971 "the {name} detail is not readable"
972 ))),
973 Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
974 }
975}
976
977const 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";
981
982const GITLAB_TAG_LIMITATION: &str =
984 "an Owner or Maintainer can still delete a protected tag through the UI or API";
985
986const MERGE_QUEUE_FAULT: &str = "a merge queue is enabled on the trunk; this convention lands no workflow that triggers on merge_group, so the queue waits on a required check that never reports and drops the request when its CI timeout expires. rk setup step protect-trunk --apply rewrites the ruleset without it";
991
992const STALE_MERGE_FAULT: &str = "the trunk permits a merge from a branch that does not carry the trunk's tip; an armed release request can therefore ship a version computed against a trunk that moved. rk setup step protect-trunk --apply rewrites the ruleset with the freshness requirement";
994
995const 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";
998
999#[allow(clippy::too_many_lines)]
1000fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
1001 let trunk = ctx.trunk();
1002 let project = ctx.repo.replace('/', "%2F");
1003 match step {
1004 "private-vulnerability-reporting" => {
1005 let path = format!("projects/{project}");
1006 Ok(match api_get(ctx, run, &path)? {
1007 Api::Ok(body) => {
1008 let access = body["issues_access_level"].as_str();
1009 if !matches!(access, Some("enabled" | "private" | "disabled")) {
1010 StepState::unknown("issue intake access is unreadable")
1011 } else if body
1012 .get("issues_enabled")
1013 .is_some_and(|flag| !flag.is_boolean())
1014 {
1015 StepState::unknown("legacy issue intake flag is unreadable")
1016 } else if body["issues_enabled"] == false || access == Some("disabled") {
1017 StepState::not("issue intake is disabled; see setup guide step 3g")
1018 } else if access == Some("private") {
1019 StepState::not("issue intake is restricted; see setup guide step 3g")
1020 } else {
1021 StepState::ok_with_limitation(
1022 "issue intake is enabled",
1023 GITLAB_PRIVATE_REPORTING_LIMITATION,
1024 )
1025 }
1026 }
1027 Api::Missing => {
1028 StepState::unknown(format!("{path}: issue intake is unreadable (404)"))
1029 }
1030 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
1031 })
1032 }
1033
1034 "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1035 Api::Ok(body) => {
1036 let found = body["default_branch"].as_str().unwrap_or("");
1037 if found == trunk {
1038 StepState::ok(format!("{trunk} is the default branch"))
1039 } else {
1040 StepState::not(format!("the default branch is {found}"))
1041 }
1042 }
1043 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1044 Api::Failed(err) => StepState::unknown(err),
1045 }),
1046 "single-trunk" => {
1047 for candidate in TRUNK_CANDIDATES {
1048 if candidate == trunk {
1049 continue;
1050 }
1051 match api_get(
1052 ctx,
1053 run,
1054 &format!("projects/{project}/repository/branches/{candidate}"),
1055 )? {
1056 Api::Missing => {}
1057 Api::Ok(_) => {
1058 return Ok(StepState::not(format!("a {candidate} branch still exists")));
1059 }
1060 Api::Failed(err) => return Ok(StepState::unknown(err)),
1061 }
1062 }
1063 Ok(StepState::ok(
1064 "no long-lived branch besides the trunk remains",
1065 ))
1066 }
1067 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1068 Api::Ok(body) => {
1069 if body["remove_source_branch_after_merge"]
1070 .as_bool()
1071 .unwrap_or(false)
1072 {
1073 StepState::ok("a merged branch is deleted by the forge")
1074 } else {
1075 StepState::not("a merged branch outlives its merge")
1076 }
1077 }
1078 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1079 Api::Failed(err) => StepState::unknown(err),
1080 }),
1081 "auto-merge" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1082 Api::Ok(body) => {
1083 if body["only_allow_merge_if_pipeline_succeeds"]
1084 .as_bool()
1085 .unwrap_or(false)
1086 {
1087 StepState::ok_with_limitation(
1088 "a request may merge itself once its pipeline passes",
1089 GITLAB_AUTO_MERGE_LIMITATION,
1090 )
1091 } else {
1092 StepState::not(
1093 "the pipeline requirement auto-merge rides on is off; protect-trunk asserts it",
1094 )
1095 }
1096 }
1097 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1098 Api::Failed(err) => StepState::unknown(err),
1099 }),
1100 "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1101 Api::Ok(body) => {
1102 if body["jobs_enabled"] == true {
1103 StepState::ok("pipelines are enabled")
1104 } else {
1105 StepState::not("pipelines are disabled")
1106 }
1107 }
1108 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1109 Api::Failed(err) => StepState::unknown(err),
1110 }),
1111 "install-bot" => {
1112 let mut active = false;
1118 let mut exhausted = false;
1119 for page in 1..=10u32 {
1120 let path = format!(
1121 "projects/{project}/access_tokens?state=active&per_page=100&page={page}"
1122 );
1123 let list = match api_get(ctx, run, &path)? {
1124 Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1125 Api::Missing => Vec::new(),
1126 Api::Failed(err) => return Ok(StepState::unknown(err)),
1127 };
1128 active = active
1129 || list.iter().any(|token| {
1130 token["name"] == "release-bot"
1131 && token["revoked"] == false
1132 && token["active"] != false
1133 });
1134 if list.len() < 100 {
1135 exhausted = true;
1136 }
1137 if active || exhausted {
1138 break;
1139 }
1140 }
1141 if !active {
1142 return Ok(if exhausted {
1143 StepState::not("no active release-bot token exists")
1144 } else {
1145 StepState::unknown(
1146 "the token listing did not exhaust within ten pages; nothing was decided",
1147 )
1148 });
1149 }
1150 Ok(
1154 match api_get(
1155 ctx,
1156 run,
1157 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1158 )? {
1159 Api::Ok(_) => StepState::ok(
1160 "an active release-bot token exists and its variable is stored",
1161 ),
1162 Api::Missing => StepState::not(
1163 "an active release-bot token exists with no stored variable; a rerun revokes and replaces it",
1164 ),
1165 Api::Failed(err) => StepState::unknown(err),
1166 },
1167 )
1168 }
1169 "bot-secrets" => Ok(
1170 match api_get(
1171 ctx,
1172 run,
1173 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1174 )? {
1175 Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
1176 Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
1177 Api::Failed(err) => StepState::unknown(err),
1178 },
1179 ),
1180 "protect-trunk" => {
1181 let protection = match api_get(
1182 ctx,
1183 run,
1184 &format!("projects/{project}/protected_branches/{trunk}"),
1185 )? {
1186 Api::Ok(body) => body,
1187 Api::Missing => {
1188 return Ok(StepState::not(format!("{trunk} is not protected")));
1189 }
1190 Api::Failed(err) => return Ok(StepState::unknown(err)),
1191 };
1192 let grants = protection["push_access_levels"]
1196 .as_array()
1197 .cloned()
1198 .unwrap_or_default();
1199 let no_push = grants.len() == 1 && grants[0]["access_level"] == 0;
1200 let merges = protection["merge_access_levels"]
1204 .as_array()
1205 .cloned()
1206 .unwrap_or_default();
1207 let can_merge = merges.len() == 1 && merges[0]["access_level"] == 40;
1208 let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
1209 Api::Ok(body) => body,
1210 Api::Missing | Api::Failed(_) => Value::Null,
1211 };
1212 let mut faults = Vec::new();
1213 if !no_push {
1214 faults.push(format!(
1215 "{trunk} still takes a direct push: the forge honors the most permissive of {} push grants",
1216 grants.len()
1217 ));
1218 }
1219 if !can_merge {
1220 faults.push(format!(
1221 "{trunk} merge grants are not exactly the one owned maintainer level"
1222 ));
1223 }
1224 if protection["allow_force_push"] != false {
1225 faults.push(format!("{trunk} allows force pushes"));
1226 }
1227 if settings["only_allow_merge_if_pipeline_succeeds"] != true {
1228 faults.push("the pipeline requirement is off".to_owned());
1229 }
1230 if settings["merge_method"] != "ff" {
1231 faults.push("the merge method is not fast-forward".to_owned());
1232 }
1233 if settings["squash_option"] != "always" {
1234 faults.push("merge requests do not always squash".to_owned());
1235 }
1236 if settings["squash_commit_template"] != "%{title}" {
1237 faults.push("the squash template is not the merge request's title".to_owned());
1238 }
1239 Ok(if faults.is_empty() {
1240 StepState::ok_with_limitation(
1241 format!("{trunk} holds the release-merge shape"),
1242 GITLAB_TITLE_LIMITATION,
1243 )
1244 } else {
1245 StepState::not(faults.join("; "))
1246 })
1247 }
1248 "protect-tags" => Ok(
1249 match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
1250 Api::Ok(_) => {
1251 StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
1252 }
1253 Api::Missing => StepState::not("v* is not protected"),
1254 Api::Failed(err) => StepState::unknown(err),
1255 },
1256 ),
1257 "protect-release-lines" => Ok(
1258 match api_get(
1259 ctx,
1260 run,
1261 &format!("projects/{project}/protected_branches/release%2F%2A"),
1262 )? {
1263 Api::Ok(body) => {
1264 let level_ok = |levels: &Value| {
1265 levels
1266 .as_array()
1267 .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
1268 };
1269 if body["allow_force_push"] != false {
1270 StepState::not("release/* allows force pushes")
1271 } else if !level_ok(&body["push_access_levels"])
1272 || !level_ok(&body["merge_access_levels"])
1273 {
1274 StepState::not(
1278 "release/* grants are not exactly the owned maintainer levels",
1279 )
1280 } else {
1281 StepState::ok("release/* refuses force pushes and deletion by git clients")
1282 }
1283 }
1284 Api::Missing => StepState::inapplicable(
1285 "release/* is unprotected; optional — applied only where older lines exist",
1286 ),
1287 Api::Failed(err) => StepState::unknown(err),
1288 },
1289 ),
1290 "protections-check" => {
1291 let mut failures = Vec::new();
1294 let mut unknowns = Vec::new();
1295 let mut limitations: Vec<String> = Vec::new();
1298 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
1299 match gitlab(ctx, owned, run)? {
1300 StepState::Satisfied {
1301 limitation: found, ..
1302 } => limitations.extend(found),
1303 StepState::Inapplicable { .. } => {}
1304 StepState::Unsatisfied { detail } => {
1305 failures.push(format!("{owned}: {detail}"));
1306 }
1307 StepState::Unknown { detail } => {
1308 unknowns.push(format!("{owned}: {detail}"));
1309 }
1310 }
1311 }
1312 Ok(if !failures.is_empty() {
1313 StepState::not(failures.join("; "))
1314 } else if !unknowns.is_empty() {
1315 StepState::unknown(unknowns.join("; "))
1316 } else {
1317 StepState::Satisfied {
1318 detail: "the protections hold, as far as this forge enforces them".into(),
1319 limitation: if limitations.is_empty() {
1320 None
1321 } else {
1322 Some(limitations.join("; "))
1323 },
1324 }
1325 })
1326 }
1327 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1328 }
1329}