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
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 for candidate in TRUNK_CANDIDATES {
292 if candidate == TRUNK_BRANCH {
293 continue;
294 }
295 let state = match ctx.forge {
296 Forge::Github => github_candidate_guard(ctx, run, candidate)?,
297 Forge::Gitlab => gitlab_candidate_guard(ctx, run, candidate)?,
298 };
299 if !state.satisfied() {
300 return Ok(state);
301 }
302 }
303 Ok(StepState::ok(
304 "every candidate branch is absent, or an ancestor of the trunk",
305 ))
306}
307
308fn github_candidate_guard(
310 ctx: &Ctx,
311 run: &mut Runner,
312 candidate: &str,
313) -> Result<StepState, RkError> {
314 match api_get(
315 ctx,
316 run,
317 &format!("repos/{}/git/ref/heads/{candidate}", ctx.repo),
318 )? {
319 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
320 Api::Failed(err) => return Ok(StepState::unknown(err)),
321 Api::Ok(_) => {}
322 }
323 match api_get(
324 ctx,
325 run,
326 &format!("repos/{}/compare/{candidate}...{TRUNK_BRANCH}", ctx.repo),
327 )? {
328 Api::Ok(body) => {
329 let status = body["status"].as_str().unwrap_or("");
330 Ok(if matches!(status, "ahead" | "identical") {
331 StepState::ok(format!("{candidate} is an ancestor of {TRUNK_BRANCH}"))
332 } else {
333 StepState::not(format!(
334 "{candidate} is not an ancestor of {TRUNK_BRANCH} ({status}); deleting it would lose work"
335 ))
336 })
337 }
338 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
339 Api::Failed(err) => Ok(StepState::unknown(err)),
340 }
341}
342
343fn gitlab_candidate_guard(
345 ctx: &Ctx,
346 run: &mut Runner,
347 candidate: &str,
348) -> Result<StepState, RkError> {
349 let project = ctx.repo.replace('/', "%2F");
350 match api_get(
351 ctx,
352 run,
353 &format!("projects/{project}/repository/branches/{candidate}"),
354 )? {
355 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
356 Api::Failed(err) => return Ok(StepState::unknown(err)),
357 Api::Ok(_) => {}
358 }
359 match api_get(
360 ctx,
361 run,
362 &format!("projects/{project}/repository/compare?from={TRUNK_BRANCH}&to={candidate}"),
363 )? {
364 Api::Ok(body) => {
365 let ahead = body["commits"]
366 .as_array()
367 .is_some_and(|list| !list.is_empty());
368 Ok(if ahead {
369 StepState::not(format!(
370 "{candidate} carries commits {TRUNK_BRANCH} does not; deleting it would lose work"
371 ))
372 } else {
373 StepState::ok(format!("{candidate} is an ancestor of {TRUNK_BRANCH}"))
374 })
375 }
376 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
377 Api::Failed(err) => Ok(StepState::unknown(err)),
378 }
379}
380
381fn api_get(ctx: &Ctx, run: &mut Runner, path: &str) -> Result<Api, RkError> {
383 let exec = Exec {
384 program: ctx.cli.clone().into_os_string(),
385 args: vec!["api".into(), path.into()],
386 env: ctx.child_env("observe"),
387 cwd: ctx.target.as_std_path().to_path_buf(),
388 stdin: None,
389 };
390 let outcome = run(&exec)?;
391 if outcome.success() {
392 return Ok(
393 serde_json::from_slice::<Value>(&outcome.stdout).map_or_else(
394 |_| Api::Failed("the forge answer did not parse as JSON".into()),
395 Api::Ok,
396 ),
397 );
398 }
399 let stderr = String::from_utf8_lossy(&outcome.stderr).into_owned();
400 if stderr.contains("404") {
401 Ok(Api::Missing)
402 } else {
403 Ok(Api::Failed(last_line(&outcome.stderr)))
404 }
405}
406
407fn last_line(bytes: &[u8]) -> String {
409 String::from_utf8_lossy(bytes)
410 .lines()
411 .rev()
412 .find(|line| !line.trim().is_empty())
413 .unwrap_or("no output")
414 .to_owned()
415}
416
417#[allow(clippy::too_many_lines)]
418fn github(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
419 let repo = &ctx.repo;
420 match step {
421 "private-vulnerability-reporting" => {
422 let visibility_path = format!("repos/{repo}");
423 match api_get(ctx, run, &visibility_path)? {
424 Api::Ok(body) => match body["private"].as_bool() {
425 Some(true) => {
426 return Ok(StepState::inapplicable(
427 "private vulnerability reporting is available for public repositories",
428 ));
429 }
430 Some(false) => {}
431 None => {
432 return Ok(StepState::unknown(format!(
433 "{visibility_path}: repository visibility is unreadable"
434 )));
435 }
436 },
437 Api::Missing => {
438 return Ok(StepState::unknown(format!(
439 "{visibility_path}: repository visibility is unreadable (404)"
440 )));
441 }
442 Api::Failed(err) => {
443 return Ok(StepState::unknown(format!("{visibility_path}: {err}")));
444 }
445 }
446 let path = format!("repos/{repo}/private-vulnerability-reporting");
447 Ok(match api_get(ctx, run, &path)? {
448 Api::Ok(body) => match body["enabled"].as_bool() {
449 Some(true) => StepState::ok("private vulnerability reporting is enabled"),
450 Some(false) => StepState::not("private vulnerability reporting is disabled"),
451 None => StepState::unknown(format!("{path}: enabled is unreadable")),
452 },
453 Api::Missing => {
454 StepState::unknown(format!("{path}: reporting state is unreadable (404)"))
455 }
456 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
457 })
458 }
459
460 "default-branch" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
461 Api::Ok(body) => {
462 let found = body["default_branch"].as_str().unwrap_or("");
463 if found == TRUNK_BRANCH {
464 StepState::ok(format!("{TRUNK_BRANCH} is the default branch"))
465 } else {
466 StepState::not(format!("the default branch is {found}"))
467 }
468 }
469 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
470 Api::Failed(err) => StepState::unknown(err),
471 }),
472 "single-trunk" => {
473 for candidate in TRUNK_CANDIDATES {
474 if candidate == TRUNK_BRANCH {
475 continue;
476 }
477 match api_get(ctx, run, &format!("repos/{repo}/git/ref/heads/{candidate}"))? {
478 Api::Missing => {}
479 Api::Ok(_) => {
480 return Ok(StepState::not(format!("a {candidate} branch still exists")));
481 }
482 Api::Failed(err) => return Ok(StepState::unknown(err)),
483 }
484 }
485 Ok(StepState::ok(
486 "no long-lived branch besides the trunk remains",
487 ))
488 }
489 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
490 Api::Ok(body) => {
491 if body["delete_branch_on_merge"].as_bool().unwrap_or(false) {
492 StepState::ok("a merged branch is deleted by the forge")
493 } else {
494 StepState::not("a merged branch outlives its merge")
495 }
496 }
497 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
498 Api::Failed(err) => StepState::unknown(err),
499 }),
500 "auto-merge" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
501 Api::Ok(body) => {
502 if body["allow_auto_merge"].as_bool().unwrap_or(false) {
503 StepState::ok("a request may merge itself once its checks pass")
504 } else {
505 StepState::not("a request cannot merge itself; the auto-merge switch is off")
506 }
507 }
508 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
509 Api::Failed(err) => StepState::unknown(err),
510 }),
511 "ci-permissions" => Ok(
512 match api_get(
513 ctx,
514 run,
515 &format!("repos/{repo}/actions/permissions/workflow"),
516 )? {
517 Api::Ok(body) => {
518 let write = body["default_workflow_permissions"] == "write";
519 let approve = body["can_approve_pull_request_reviews"] == true;
520 if write && approve {
521 StepState::ok("CI may write and open requests")
522 } else {
523 StepState::not(format!(
524 "workflow permissions are {} with request approval {}",
525 body["default_workflow_permissions"],
526 body["can_approve_pull_request_reviews"]
527 ))
528 }
529 }
530 Api::Missing => StepState::not("no workflow permissions are readable"),
531 Api::Failed(err) => StepState::unknown(err),
532 },
533 ),
534 "bot-secrets" => Ok(
535 match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
536 Api::Ok(body) => {
537 let names: Vec<&str> = body["secrets"]
538 .as_array()
539 .map(|list| {
540 list.iter()
541 .filter_map(|secret| secret["name"].as_str())
542 .collect()
543 })
544 .unwrap_or_default();
545 let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
546 if wanted.iter().all(|name| names.contains(name)) {
547 StepState::ok("both bot secrets are stored")
548 } else if names.is_empty() {
549 StepState::not("no bot secrets are stored")
550 } else {
551 StepState::not(format!("stored secrets: {}", names.join(", ")))
552 }
553 }
554 Api::Missing => StepState::not("no secrets are readable"),
555 Api::Failed(err) => StepState::unknown(err),
556 },
557 ),
558 "protect-trunk" => github_trunk_ruleset(ctx, run),
559 "protect-tags" => github_ruleset(
560 ctx,
561 run,
562 "release-tags",
563 "tag",
564 "refs/tags/v*",
565 &["deletion", "update"],
566 ),
567 "protect-release-lines" => {
568 match github_ruleset_body(ctx, run, "release-lines")? {
569 RulesetLookup::Absent => {
570 return Ok(StepState::inapplicable(
571 "release/* is unprotected; optional — applied only where older lines exist",
572 ));
573 }
574 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
575 RulesetLookup::Found(_) => {}
576 }
577 github_ruleset(
578 ctx,
579 run,
580 "release-lines",
581 "branch",
582 "refs/heads/release/*",
583 &["deletion", "non_fast_forward"],
584 )
585 }
586 "protections-check" => {
587 let mut failures = Vec::new();
591 let mut unknowns = Vec::new();
592 let mut limitations: Vec<String> = Vec::new();
594 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
595 match github(ctx, owned, run)? {
596 StepState::Satisfied {
597 limitation: found, ..
598 } => limitations.extend(found),
599 StepState::Inapplicable { .. } => {}
600 StepState::Unsatisfied { detail } => {
601 failures.push(format!("{owned}: {detail}"));
602 }
603 StepState::Unknown { detail } => {
604 unknowns.push(format!("{owned}: {detail}"));
605 }
606 }
607 }
608 match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
609 Api::Ok(body) => {
610 let owned = [
611 format!("{TRUNK_BRANCH}-protection"),
612 "release-tags".to_owned(),
613 "release-lines".to_owned(),
614 ];
615 for ruleset in body.as_array().into_iter().flatten() {
616 let name = ruleset["name"].as_str().unwrap_or("");
617 if !owned.iter().any(|expected| expected == name) {
618 failures.push(format!("a ruleset no step owns: {name}"));
619 }
620 }
621 }
622 Api::Missing | Api::Failed(_) => {
623 unknowns.push("the ruleset inventory is not readable".to_owned());
624 }
625 }
626 Ok(if !failures.is_empty() {
627 StepState::not(failures.join("; "))
628 } else if !unknowns.is_empty() {
629 StepState::unknown(unknowns.join("; "))
630 } else {
631 StepState::Satisfied {
632 detail: "exactly the owned protections, with those rules".into(),
633 limitation: if limitations.is_empty() {
634 None
635 } else {
636 Some(limitations.join("; "))
637 },
638 }
639 })
640 }
641 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
642 }
643}
644
645#[must_use]
653pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
654 match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
655 AppApi::Ok(body) => {
656 let id = body["id"].as_i64().unwrap_or_default();
657 StepState::ok(format!("installation {id} covers {}", ctx.repo))
658 }
659 AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
660 AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
661 }
662}
663
664fn github_ruleset(
669 ctx: &Ctx,
670 run: &mut Runner,
671 name: &str,
672 target: &str,
673 include: &str,
674 rules: &[&str],
675) -> Result<StepState, RkError> {
676 let detail = match github_ruleset_body(ctx, run, name)? {
677 RulesetLookup::Found(detail) => detail,
678 RulesetLookup::Absent => {
679 return Ok(StepState::not(format!("no ruleset named {name}")));
680 }
681 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
682 };
683 if detail["enforcement"] != "active" {
684 return Ok(StepState::not(format!("{name} is not active")));
685 }
686 if detail["target"] != target {
689 return Ok(StepState::not(format!(
690 "{name} does not target {target} refs"
691 )));
692 }
693 if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
694 return Ok(StepState::not(format!(
695 "{name} does not cover {include} alone"
696 )));
697 }
698 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
699 return Ok(StepState::not(format!(
700 "{name} excludes refs from its own coverage"
701 )));
702 }
703 let mut held: Vec<&str> = detail["rules"]
704 .as_array()
705 .map(|list| {
706 list.iter()
707 .filter_map(|rule| rule["type"].as_str())
708 .collect()
709 })
710 .unwrap_or_default();
711 held.sort_unstable();
712 let mut expected: Vec<&str> = rules.to_vec();
713 expected.sort_unstable();
714 if held == expected {
715 Ok(StepState::ok(format!(
716 "{name} is active with exactly its rules"
717 )))
718 } else {
719 Ok(StepState::not(format!(
720 "{name} carries the rules [{}] where the setup owns [{}]",
721 held.join(", "),
722 expected.join(", ")
723 )))
724 }
725}
726
727const OWNED_TRUNK_RULES: [&str; 4] = [
732 "deletion",
733 "non_fast_forward",
734 "pull_request",
735 "required_status_checks",
736];
737
738fn unowned_rule_faults(rules: &[Value]) -> Vec<String> {
746 rules
747 .iter()
748 .filter_map(|rule| rule["type"].as_str())
749 .filter(|kind| !OWNED_TRUNK_RULES.contains(kind))
750 .map(|kind| {
751 if kind == "merge_queue" {
752 MERGE_QUEUE_FAULT.to_owned()
753 } else {
754 format!("an unowned rule is present: {kind}")
755 }
756 })
757 .collect()
758}
759
760fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
761 let name = format!("{TRUNK_BRANCH}-protection");
762 let detail = match github_ruleset_body(ctx, run, &name)? {
763 RulesetLookup::Found(detail) => detail,
764 RulesetLookup::Absent => {
765 return Ok(StepState::not(format!("no ruleset named {name}")));
766 }
767 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
768 };
769 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
770 let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
771 let mut faults = Vec::new();
772 if detail["enforcement"] != "active" {
773 faults.push(format!("{name} is not active"));
774 }
775 if detail["target"] != "branch" {
779 faults.push(format!("{name} does not target branches"));
780 }
781 let expected_ref = serde_json::json!([format!("refs/heads/{TRUNK_BRANCH}")]);
782 if detail["conditions"]["ref_name"]["include"] != expected_ref {
783 faults.push(format!(
784 "{name} does not cover refs/heads/{TRUNK_BRANCH} alone"
785 ));
786 }
787 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
790 faults.push(format!("{name} excludes refs from its own coverage"));
791 }
792 if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
793 faults.push("a bypass actor is named".to_owned());
794 }
795 for required in OWNED_TRUNK_RULES {
796 if !has(required) {
797 faults.push(format!("the {required} rule is missing"));
798 }
799 }
800 faults.extend(unowned_rule_faults(&rules));
801 if let Some(request) = rules.iter().find(|rule| rule["type"] == "pull_request") {
802 if request["parameters"]["allowed_merge_methods"] != serde_json::json!(["squash"]) {
803 faults.push("the merge method is not exactly a squash merge".to_owned());
804 }
805 }
806 if let Some(checks) = rules
807 .iter()
808 .find(|rule| rule["type"] == "required_status_checks")
809 {
810 if checks["parameters"]["strict_required_status_checks_policy"] != true {
811 faults.push(STALE_MERGE_FAULT.to_owned());
812 }
813 let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
814 .as_array()
815 .map(|list| {
816 list.iter()
817 .filter_map(|check| check["context"].as_str())
818 .collect()
819 })
820 .unwrap_or_default();
821 if contexts.is_empty() {
826 faults.push("no status check is required".to_owned());
827 } else if let Some(expected) = &ctx.required_check {
828 let mut held = contexts.clone();
829 held.sort_unstable();
830 let mut owned_contexts = [expected.as_str(), TITLE_CHECK];
831 owned_contexts.sort_unstable();
832 if held != owned_contexts {
833 faults.push(format!(
834 "the required checks are [{}] where the setup owns [{}]",
835 contexts.join(", "),
836 owned_contexts.join(", ")
837 ));
838 }
839 } else if !contexts.contains(&TITLE_CHECK) {
840 faults.push(format!("the {TITLE_CHECK} check is not required"));
841 }
842 }
843 match squash_merge_sources(ctx, run)? {
844 MergeSources::Owned => {}
845 MergeSources::Faults(proven) => faults.extend(proven),
846 MergeSources::Unreadable(err) => {
850 if faults.is_empty() {
851 return Ok(StepState::unknown(err));
852 }
853 }
854 }
855 if let Some(shape) = gate_faults(ctx) {
856 faults.push(shape);
857 }
858 if !faults.is_empty() {
859 return Ok(StepState::not(faults.join("; ")));
860 }
861 Ok(StepState::ok(format!(
862 "{name} holds the release-merge shape"
863 )))
864}
865
866fn gate_faults(ctx: &Ctx) -> Option<String> {
876 let check = ctx.required_check.as_deref()?;
877 workflow_jobs::faults(&workflow_jobs::read_gate(&ctx.target, check), check)
878}
879
880enum MergeSources {
882 Owned,
884 Faults(Vec<String>),
886 Unreadable(String),
888}
889
890fn squash_merge_sources(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
897 Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
898 Api::Ok(body) => {
899 let mut faults = Vec::new();
900 if body["squash_merge_commit_title"] != "PR_TITLE" {
901 faults.push(format!(
902 "the squash title source is {} where the setup owns PR_TITLE",
903 body["squash_merge_commit_title"]
904 ));
905 }
906 if body["squash_merge_commit_message"] != "PR_BODY" {
907 faults.push(format!(
908 "the squash message source is {} where the setup owns PR_BODY",
909 body["squash_merge_commit_message"]
910 ));
911 }
912 if faults.is_empty() {
913 MergeSources::Owned
914 } else {
915 MergeSources::Faults(faults)
916 }
917 }
918 Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
919 Api::Failed(err) => MergeSources::Unreadable(err),
920 })
921}
922
923enum RulesetLookup {
926 Found(Value),
928 Absent,
931 Unreadable(String),
933}
934
935fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
937 let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
941 Api::Ok(body) => body,
942 Api::Missing => {
943 return Ok(RulesetLookup::Unreadable(
944 "the ruleset inventory is not readable".into(),
945 ));
946 }
947 Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
948 };
949 let id = list
950 .as_array()
951 .into_iter()
952 .flatten()
953 .find(|ruleset| ruleset["name"] == name)
954 .and_then(|ruleset| ruleset["id"].as_i64());
955 let Some(id) = id else {
956 return Ok(RulesetLookup::Absent);
957 };
958 match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
959 Api::Ok(body) => Ok(RulesetLookup::Found(body)),
960 Api::Missing => Ok(RulesetLookup::Unreadable(format!(
964 "the {name} detail is not readable"
965 ))),
966 Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
967 }
968}
969
970const 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";
974
975const GITLAB_TAG_LIMITATION: &str =
977 "an Owner or Maintainer can still delete a protected tag through the UI or API";
978
979const 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";
984
985const 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";
987
988const 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";
991
992#[allow(clippy::too_many_lines)]
993fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
994 let project = ctx.repo.replace('/', "%2F");
995 match step {
996 "private-vulnerability-reporting" => {
997 let path = format!("projects/{project}");
998 Ok(match api_get(ctx, run, &path)? {
999 Api::Ok(body) => {
1000 let access = body["issues_access_level"].as_str();
1001 if !matches!(access, Some("enabled" | "private" | "disabled")) {
1002 StepState::unknown("issue intake access is unreadable")
1003 } else if body
1004 .get("issues_enabled")
1005 .is_some_and(|flag| !flag.is_boolean())
1006 {
1007 StepState::unknown("legacy issue intake flag is unreadable")
1008 } else if body["issues_enabled"] == false || access == Some("disabled") {
1009 StepState::not("issue intake is disabled; see setup guide step 3g")
1010 } else if access == Some("private") {
1011 StepState::not("issue intake is restricted; see setup guide step 3g")
1012 } else {
1013 StepState::ok_with_limitation(
1014 "issue intake is enabled",
1015 GITLAB_PRIVATE_REPORTING_LIMITATION,
1016 )
1017 }
1018 }
1019 Api::Missing => {
1020 StepState::unknown(format!("{path}: issue intake is unreadable (404)"))
1021 }
1022 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
1023 })
1024 }
1025
1026 "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1027 Api::Ok(body) => {
1028 let found = body["default_branch"].as_str().unwrap_or("");
1029 if found == TRUNK_BRANCH {
1030 StepState::ok(format!("{TRUNK_BRANCH} is the default branch"))
1031 } else {
1032 StepState::not(format!("the default branch is {found}"))
1033 }
1034 }
1035 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1036 Api::Failed(err) => StepState::unknown(err),
1037 }),
1038 "single-trunk" => {
1039 for candidate in TRUNK_CANDIDATES {
1040 if candidate == TRUNK_BRANCH {
1041 continue;
1042 }
1043 match api_get(
1044 ctx,
1045 run,
1046 &format!("projects/{project}/repository/branches/{candidate}"),
1047 )? {
1048 Api::Missing => {}
1049 Api::Ok(_) => {
1050 return Ok(StepState::not(format!("a {candidate} branch still exists")));
1051 }
1052 Api::Failed(err) => return Ok(StepState::unknown(err)),
1053 }
1054 }
1055 Ok(StepState::ok(
1056 "no long-lived branch besides the trunk remains",
1057 ))
1058 }
1059 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1060 Api::Ok(body) => {
1061 if body["remove_source_branch_after_merge"]
1062 .as_bool()
1063 .unwrap_or(false)
1064 {
1065 StepState::ok("a merged branch is deleted by the forge")
1066 } else {
1067 StepState::not("a merged branch outlives its merge")
1068 }
1069 }
1070 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1071 Api::Failed(err) => StepState::unknown(err),
1072 }),
1073 "auto-merge" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1074 Api::Ok(body) => {
1075 if body["only_allow_merge_if_pipeline_succeeds"]
1076 .as_bool()
1077 .unwrap_or(false)
1078 {
1079 StepState::ok_with_limitation(
1080 "a request may merge itself once its pipeline passes",
1081 GITLAB_AUTO_MERGE_LIMITATION,
1082 )
1083 } else {
1084 StepState::not(
1085 "the pipeline requirement auto-merge rides on is off; protect-trunk asserts it",
1086 )
1087 }
1088 }
1089 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1090 Api::Failed(err) => StepState::unknown(err),
1091 }),
1092 "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1093 Api::Ok(body) => {
1094 if body["jobs_enabled"] == true {
1095 StepState::ok("pipelines are enabled")
1096 } else {
1097 StepState::not("pipelines are disabled")
1098 }
1099 }
1100 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1101 Api::Failed(err) => StepState::unknown(err),
1102 }),
1103 "install-bot" => {
1104 let mut active = false;
1110 let mut exhausted = false;
1111 for page in 1..=10u32 {
1112 let path = format!(
1113 "projects/{project}/access_tokens?state=active&per_page=100&page={page}"
1114 );
1115 let list = match api_get(ctx, run, &path)? {
1116 Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1117 Api::Missing => Vec::new(),
1118 Api::Failed(err) => return Ok(StepState::unknown(err)),
1119 };
1120 active = active
1121 || list.iter().any(|token| {
1122 token["name"] == "release-bot"
1123 && token["revoked"] == false
1124 && token["active"] != false
1125 });
1126 if list.len() < 100 {
1127 exhausted = true;
1128 }
1129 if active || exhausted {
1130 break;
1131 }
1132 }
1133 if !active {
1134 return Ok(if exhausted {
1135 StepState::not("no active release-bot token exists")
1136 } else {
1137 StepState::unknown(
1138 "the token listing did not exhaust within ten pages; nothing was decided",
1139 )
1140 });
1141 }
1142 Ok(
1146 match api_get(
1147 ctx,
1148 run,
1149 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1150 )? {
1151 Api::Ok(_) => StepState::ok(
1152 "an active release-bot token exists and its variable is stored",
1153 ),
1154 Api::Missing => StepState::not(
1155 "an active release-bot token exists with no stored variable; a rerun revokes and replaces it",
1156 ),
1157 Api::Failed(err) => StepState::unknown(err),
1158 },
1159 )
1160 }
1161 "bot-secrets" => Ok(
1162 match api_get(
1163 ctx,
1164 run,
1165 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1166 )? {
1167 Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
1168 Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
1169 Api::Failed(err) => StepState::unknown(err),
1170 },
1171 ),
1172 "protect-trunk" => {
1173 let protection = match api_get(
1174 ctx,
1175 run,
1176 &format!("projects/{project}/protected_branches/{TRUNK_BRANCH}"),
1177 )? {
1178 Api::Ok(body) => body,
1179 Api::Missing => {
1180 return Ok(StepState::not(format!("{TRUNK_BRANCH} is not protected")));
1181 }
1182 Api::Failed(err) => return Ok(StepState::unknown(err)),
1183 };
1184 let grants = protection["push_access_levels"]
1188 .as_array()
1189 .cloned()
1190 .unwrap_or_default();
1191 let no_push = grants.len() == 1 && grants[0]["access_level"] == 0;
1192 let merges = protection["merge_access_levels"]
1196 .as_array()
1197 .cloned()
1198 .unwrap_or_default();
1199 let can_merge = merges.len() == 1 && merges[0]["access_level"] == 40;
1200 let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
1201 Api::Ok(body) => body,
1202 Api::Missing | Api::Failed(_) => Value::Null,
1203 };
1204 let mut faults = Vec::new();
1205 if !no_push {
1206 faults.push(format!(
1207 "{TRUNK_BRANCH} still takes a direct push: the forge honors the most permissive of {} push grants",
1208 grants.len()
1209 ));
1210 }
1211 if !can_merge {
1212 faults.push(format!(
1213 "{TRUNK_BRANCH} merge grants are not exactly the one owned maintainer level"
1214 ));
1215 }
1216 if protection["allow_force_push"] != false {
1217 faults.push(format!("{TRUNK_BRANCH} allows force pushes"));
1218 }
1219 if settings["only_allow_merge_if_pipeline_succeeds"] != true {
1220 faults.push("the pipeline requirement is off".to_owned());
1221 }
1222 if settings["merge_method"] != "ff" {
1223 faults.push("the merge method is not fast-forward".to_owned());
1224 }
1225 if settings["squash_option"] != "always" {
1226 faults.push("merge requests do not always squash".to_owned());
1227 }
1228 if settings["squash_commit_template"] != "%{title}" {
1229 faults.push("the squash template is not the merge request's title".to_owned());
1230 }
1231 Ok(if faults.is_empty() {
1232 StepState::ok_with_limitation(
1233 format!("{TRUNK_BRANCH} holds the release-merge shape"),
1234 GITLAB_TITLE_LIMITATION,
1235 )
1236 } else {
1237 StepState::not(faults.join("; "))
1238 })
1239 }
1240 "protect-tags" => Ok(
1241 match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
1242 Api::Ok(_) => {
1243 StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
1244 }
1245 Api::Missing => StepState::not("v* is not protected"),
1246 Api::Failed(err) => StepState::unknown(err),
1247 },
1248 ),
1249 "protect-release-lines" => Ok(
1250 match api_get(
1251 ctx,
1252 run,
1253 &format!("projects/{project}/protected_branches/release%2F%2A"),
1254 )? {
1255 Api::Ok(body) => {
1256 let level_ok = |levels: &Value| {
1257 levels
1258 .as_array()
1259 .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
1260 };
1261 if body["allow_force_push"] != false {
1262 StepState::not("release/* allows force pushes")
1263 } else if !level_ok(&body["push_access_levels"])
1264 || !level_ok(&body["merge_access_levels"])
1265 {
1266 StepState::not(
1270 "release/* grants are not exactly the owned maintainer levels",
1271 )
1272 } else {
1273 StepState::ok("release/* refuses force pushes and deletion by git clients")
1274 }
1275 }
1276 Api::Missing => StepState::inapplicable(
1277 "release/* is unprotected; optional — applied only where older lines exist",
1278 ),
1279 Api::Failed(err) => StepState::unknown(err),
1280 },
1281 ),
1282 "protections-check" => {
1283 let mut failures = Vec::new();
1286 let mut unknowns = Vec::new();
1287 let mut limitations: Vec<String> = Vec::new();
1290 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
1291 match gitlab(ctx, owned, run)? {
1292 StepState::Satisfied {
1293 limitation: found, ..
1294 } => limitations.extend(found),
1295 StepState::Inapplicable { .. } => {}
1296 StepState::Unsatisfied { detail } => {
1297 failures.push(format!("{owned}: {detail}"));
1298 }
1299 StepState::Unknown { detail } => {
1300 unknowns.push(format!("{owned}: {detail}"));
1301 }
1302 }
1303 }
1304 Ok(if !failures.is_empty() {
1305 StepState::not(failures.join("; "))
1306 } else if !unknowns.is_empty() {
1307 StepState::unknown(unknowns.join("; "))
1308 } else {
1309 StepState::Satisfied {
1310 detail: "the protections hold, as far as this forge enforces them".into(),
1311 limitation: if limitations.is_empty() {
1312 None
1313 } else {
1314 Some(limitations.join("; "))
1315 },
1316 }
1317 })
1318 }
1319 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1320 }
1321}