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 if step == "forge-version" {
125 return forge_version(ctx, run);
126 }
127 match ctx.forge {
128 Forge::Github => github(ctx, step, run),
129 Forge::Gitlab => gitlab(ctx, step, run),
130 }
131}
132
133fn package_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
136 let (program, args): (&str, &[&str]) = match ctx.tech {
137 Some("rust") => ("cargo", &["publish", "--dry-run", "--allow-dirty"]),
138 Some("python") => ("python3", &["-m", "build"]),
139 Some("bash") => {
140 return Ok(StepState::ok(
141 "no registry for this technology; there is nothing to package",
142 ));
143 }
144 Some(other) => {
145 return Ok(StepState::unknown(format!(
146 "no packaging check is defined for {other}"
147 )));
148 }
149 None => {
150 return Ok(StepState::unknown(
151 "no version file names a technology; see rk binding --list",
152 ));
153 }
154 };
155 let exec = Exec {
156 program: program.into(),
157 args: args.iter().map(Into::into).collect(),
158 env: ctx.child_env("package-check"),
159 cwd: ctx.target.as_std_path().to_path_buf(),
160 stdin: None,
161 };
162 let outcome = run(&exec)?;
163 Ok(if outcome.success() {
164 StepState::ok("the package builds and passes the registry's dry run")
165 } else {
166 StepState::not(format!(
167 "the packaging check failed: {}",
168 last_line(&outcome.stderr)
169 ))
170 })
171}
172
173fn branch_reminder_state(ctx: &Ctx) -> StepState {
176 use crate::setup::branch_reminder::{HookState, observe_hook};
177 match observe_hook(&ctx.target) {
178 HookState::Installed => {
179 StepState::ok("the post-merge hook carries the release-kit reminder")
180 }
181 HookState::Absent => StepState::not("no post-merge hook is installed"),
182 HookState::Foreign => {
183 StepState::not("a post-merge hook exists without the release-kit marker")
184 }
185 HookState::Drifted => StepState::not("the reminder hook drifted from this binary's body"),
186 HookState::Unreadable(detail) => StepState::unknown(detail),
187 }
188}
189
190pub const GITLAB_VERSION_FLOOR: (u64, u64) = (18, 2);
197
198const GITLAB_EDITIONS: [&str; 2] = ["ee", "ce"];
201
202fn version_refusal(found: &str, prerelease: Option<&str>) -> String {
205 let (major, minor) = GITLAB_VERSION_FLOOR;
206 let mut said = vec![format!(
207 "this GitLab instance reports {found}; the convention needs {major}.{minor} or newer"
208 )];
209 if let Some(suffix) = prerelease {
210 said.push(format!(
211 "the -{suffix} suffix is a pre-release, and nothing proves the feature shipped in it, so this step fails closed"
212 ));
213 }
214 said.push(format!(
215 "the merge-request pipeline triggers a child pipeline with `strategy: mirror`, which GitLab added in {major}.{minor}"
216 ));
217 said.push(
218 "below it the child's status never reaches the parent pipeline, so a failing project job merges".to_owned(),
219 );
220 said.push(format!(
221 "upgrade the instance to {major}.{minor} or newer, or host the project on gitlab.com"
222 ));
223 said.join("; ")
224}
225
226fn forge_version(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
232 if ctx.forge == Forge::Github {
233 return Ok(StepState::ok(
234 "github.com is a rolling service and declares no version floor",
235 ));
236 }
237 let body = match api_get(ctx, run, "version")? {
238 Api::Ok(body) => body,
239 Api::Missing => {
240 return Ok(StepState::unknown(
241 "this instance answers no GET /version; the floor cannot be read. Check that glab is authenticated against it: glab auth login",
242 ));
243 }
244 Api::Failed(err) => {
245 return Ok(StepState::unknown(format!(
246 "the version could not be read: {err}. Check that glab is authenticated against this instance: glab auth login"
247 )));
248 }
249 };
250 let Some(found) = body["version"].as_str() else {
251 return Ok(StepState::unknown(
252 "the forge answer carries no version field; the floor cannot be read. Check that glab is authenticated against this instance: glab auth login",
253 ));
254 };
255 let (number, suffix) = found
256 .split_once('-')
257 .map_or((found, None), |(n, s)| (n, Some(s)));
258 let mut parts = number.split('.');
259 let parsed = parts
260 .next()
261 .and_then(|major| major.parse::<u64>().ok())
262 .zip(parts.next().and_then(|minor| minor.parse::<u64>().ok()));
263 let Some(pair) = parsed else {
264 return Ok(StepState::unknown(format!(
265 "the forge reports the version as '{found}', which names no major and minor pair; the floor cannot be read"
266 )));
267 };
268 if let Some(suffix) = suffix.filter(|s| !GITLAB_EDITIONS.contains(s)) {
269 return Ok(StepState::not(version_refusal(found, Some(suffix))));
270 }
271 if pair < GITLAB_VERSION_FLOOR {
272 return Ok(StepState::not(version_refusal(found, None)));
273 }
274 let (major, minor) = GITLAB_VERSION_FLOOR;
275 Ok(StepState::ok(format!(
276 "this instance reports {found}, at or above the {major}.{minor} floor"
277 )))
278}
279
280pub fn single_trunk_guard(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
290 for candidate in TRUNK_CANDIDATES {
291 if candidate == TRUNK_BRANCH {
292 continue;
293 }
294 let state = match ctx.forge {
295 Forge::Github => github_candidate_guard(ctx, run, candidate)?,
296 Forge::Gitlab => gitlab_candidate_guard(ctx, run, candidate)?,
297 };
298 if !state.satisfied() {
299 return Ok(state);
300 }
301 }
302 Ok(StepState::ok(
303 "every candidate branch is absent, or an ancestor of the trunk",
304 ))
305}
306
307fn github_candidate_guard(
309 ctx: &Ctx,
310 run: &mut Runner,
311 candidate: &str,
312) -> Result<StepState, RkError> {
313 match api_get(
314 ctx,
315 run,
316 &format!("repos/{}/git/ref/heads/{candidate}", ctx.repo),
317 )? {
318 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
319 Api::Failed(err) => return Ok(StepState::unknown(err)),
320 Api::Ok(_) => {}
321 }
322 match api_get(
323 ctx,
324 run,
325 &format!("repos/{}/compare/{candidate}...{TRUNK_BRANCH}", ctx.repo),
326 )? {
327 Api::Ok(body) => {
328 let status = body["status"].as_str().unwrap_or("");
329 Ok(if matches!(status, "ahead" | "identical") {
330 StepState::ok(format!("{candidate} is an ancestor of {TRUNK_BRANCH}"))
331 } else {
332 StepState::not(format!(
333 "{candidate} is not an ancestor of {TRUNK_BRANCH} ({status}); deleting it would lose work"
334 ))
335 })
336 }
337 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
338 Api::Failed(err) => Ok(StepState::unknown(err)),
339 }
340}
341
342fn gitlab_candidate_guard(
344 ctx: &Ctx,
345 run: &mut Runner,
346 candidate: &str,
347) -> Result<StepState, RkError> {
348 let project = ctx.repo.replace('/', "%2F");
349 match api_get(
350 ctx,
351 run,
352 &format!("projects/{project}/repository/branches/{candidate}"),
353 )? {
354 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
355 Api::Failed(err) => return Ok(StepState::unknown(err)),
356 Api::Ok(_) => {}
357 }
358 match api_get(
359 ctx,
360 run,
361 &format!("projects/{project}/repository/compare?from={TRUNK_BRANCH}&to={candidate}"),
362 )? {
363 Api::Ok(body) => {
364 let ahead = body["commits"]
365 .as_array()
366 .is_some_and(|list| !list.is_empty());
367 Ok(if ahead {
368 StepState::not(format!(
369 "{candidate} carries commits {TRUNK_BRANCH} does not; deleting it would lose work"
370 ))
371 } else {
372 StepState::ok(format!("{candidate} is an ancestor of {TRUNK_BRANCH}"))
373 })
374 }
375 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
376 Api::Failed(err) => Ok(StepState::unknown(err)),
377 }
378}
379
380fn api_get(ctx: &Ctx, run: &mut Runner, path: &str) -> Result<Api, RkError> {
382 let exec = Exec {
383 program: ctx.cli.clone().into_os_string(),
384 args: vec!["api".into(), path.into()],
385 env: ctx.child_env("observe"),
386 cwd: ctx.target.as_std_path().to_path_buf(),
387 stdin: None,
388 };
389 let outcome = run(&exec)?;
390 if outcome.success() {
391 return Ok(
392 serde_json::from_slice::<Value>(&outcome.stdout).map_or_else(
393 |_| Api::Failed("the forge answer did not parse as JSON".into()),
394 Api::Ok,
395 ),
396 );
397 }
398 let stderr = String::from_utf8_lossy(&outcome.stderr).into_owned();
399 if stderr.contains("404") {
400 Ok(Api::Missing)
401 } else {
402 Ok(Api::Failed(last_line(&outcome.stderr)))
403 }
404}
405
406fn last_line(bytes: &[u8]) -> String {
408 String::from_utf8_lossy(bytes)
409 .lines()
410 .rev()
411 .find(|line| !line.trim().is_empty())
412 .unwrap_or("no output")
413 .to_owned()
414}
415
416#[allow(clippy::too_many_lines)]
417fn github(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
418 let repo = &ctx.repo;
419 match step {
420 "default-branch" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
421 Api::Ok(body) => {
422 let found = body["default_branch"].as_str().unwrap_or("");
423 if found == TRUNK_BRANCH {
424 StepState::ok(format!("{TRUNK_BRANCH} is the default branch"))
425 } else {
426 StepState::not(format!("the default branch is {found}"))
427 }
428 }
429 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
430 Api::Failed(err) => StepState::unknown(err),
431 }),
432 "single-trunk" => {
433 for candidate in TRUNK_CANDIDATES {
434 if candidate == TRUNK_BRANCH {
435 continue;
436 }
437 match api_get(ctx, run, &format!("repos/{repo}/git/ref/heads/{candidate}"))? {
438 Api::Missing => {}
439 Api::Ok(_) => {
440 return Ok(StepState::not(format!("a {candidate} branch still exists")));
441 }
442 Api::Failed(err) => return Ok(StepState::unknown(err)),
443 }
444 }
445 Ok(StepState::ok(
446 "no long-lived branch besides the trunk remains",
447 ))
448 }
449 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
450 Api::Ok(body) => {
451 if body["delete_branch_on_merge"].as_bool().unwrap_or(false) {
452 StepState::ok("a merged branch is deleted by the forge")
453 } else {
454 StepState::not("a merged branch outlives its merge")
455 }
456 }
457 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
458 Api::Failed(err) => StepState::unknown(err),
459 }),
460 "auto-merge" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
461 Api::Ok(body) => {
462 if body["allow_auto_merge"].as_bool().unwrap_or(false) {
463 StepState::ok("a request may merge itself once its checks pass")
464 } else {
465 StepState::not("a request cannot merge itself; the auto-merge switch is off")
466 }
467 }
468 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
469 Api::Failed(err) => StepState::unknown(err),
470 }),
471 "ci-permissions" => Ok(
472 match api_get(
473 ctx,
474 run,
475 &format!("repos/{repo}/actions/permissions/workflow"),
476 )? {
477 Api::Ok(body) => {
478 let write = body["default_workflow_permissions"] == "write";
479 let approve = body["can_approve_pull_request_reviews"] == true;
480 if write && approve {
481 StepState::ok("CI may write and open requests")
482 } else {
483 StepState::not(format!(
484 "workflow permissions are {} with request approval {}",
485 body["default_workflow_permissions"],
486 body["can_approve_pull_request_reviews"]
487 ))
488 }
489 }
490 Api::Missing => StepState::not("no workflow permissions are readable"),
491 Api::Failed(err) => StepState::unknown(err),
492 },
493 ),
494 "bot-secrets" => Ok(
495 match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
496 Api::Ok(body) => {
497 let names: Vec<&str> = body["secrets"]
498 .as_array()
499 .map(|list| {
500 list.iter()
501 .filter_map(|secret| secret["name"].as_str())
502 .collect()
503 })
504 .unwrap_or_default();
505 let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
506 if wanted.iter().all(|name| names.contains(name)) {
507 StepState::ok("both bot secrets are stored")
508 } else if names.is_empty() {
509 StepState::not("no bot secrets are stored")
510 } else {
511 StepState::not(format!("stored secrets: {}", names.join(", ")))
512 }
513 }
514 Api::Missing => StepState::not("no secrets are readable"),
515 Api::Failed(err) => StepState::unknown(err),
516 },
517 ),
518 "protect-trunk" => github_trunk_ruleset(ctx, run),
519 "protect-tags" => github_ruleset(
520 ctx,
521 run,
522 "release-tags",
523 "tag",
524 "refs/tags/v*",
525 &["deletion", "update"],
526 ),
527 "protect-release-lines" => {
528 match github_ruleset_body(ctx, run, "release-lines")? {
529 RulesetLookup::Absent => {
530 return Ok(StepState::inapplicable(
531 "release/* is unprotected; optional — applied only where older lines exist",
532 ));
533 }
534 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
535 RulesetLookup::Found(_) => {}
536 }
537 github_ruleset(
538 ctx,
539 run,
540 "release-lines",
541 "branch",
542 "refs/heads/release/*",
543 &["deletion", "non_fast_forward"],
544 )
545 }
546 "protections-check" => {
547 let mut failures = Vec::new();
551 let mut unknowns = Vec::new();
552 let mut limitations: Vec<String> = Vec::new();
554 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
555 match github(ctx, owned, run)? {
556 StepState::Satisfied {
557 limitation: found, ..
558 } => limitations.extend(found),
559 StepState::Inapplicable { .. } => {}
560 StepState::Unsatisfied { detail } => {
561 failures.push(format!("{owned}: {detail}"));
562 }
563 StepState::Unknown { detail } => {
564 unknowns.push(format!("{owned}: {detail}"));
565 }
566 }
567 }
568 match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
569 Api::Ok(body) => {
570 let owned = [
571 format!("{TRUNK_BRANCH}-protection"),
572 "release-tags".to_owned(),
573 "release-lines".to_owned(),
574 ];
575 for ruleset in body.as_array().into_iter().flatten() {
576 let name = ruleset["name"].as_str().unwrap_or("");
577 if !owned.iter().any(|expected| expected == name) {
578 failures.push(format!("a ruleset no step owns: {name}"));
579 }
580 }
581 }
582 Api::Missing | Api::Failed(_) => {
583 unknowns.push("the ruleset inventory is not readable".to_owned());
584 }
585 }
586 Ok(if !failures.is_empty() {
587 StepState::not(failures.join("; "))
588 } else if !unknowns.is_empty() {
589 StepState::unknown(unknowns.join("; "))
590 } else {
591 StepState::Satisfied {
592 detail: "exactly the owned protections, with those rules".into(),
593 limitation: if limitations.is_empty() {
594 None
595 } else {
596 Some(limitations.join("; "))
597 },
598 }
599 })
600 }
601 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
602 }
603}
604
605#[must_use]
613pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
614 match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
615 AppApi::Ok(body) => {
616 let id = body["id"].as_i64().unwrap_or_default();
617 StepState::ok(format!("installation {id} covers {}", ctx.repo))
618 }
619 AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
620 AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
621 }
622}
623
624fn github_ruleset(
629 ctx: &Ctx,
630 run: &mut Runner,
631 name: &str,
632 target: &str,
633 include: &str,
634 rules: &[&str],
635) -> Result<StepState, RkError> {
636 let detail = match github_ruleset_body(ctx, run, name)? {
637 RulesetLookup::Found(detail) => detail,
638 RulesetLookup::Absent => {
639 return Ok(StepState::not(format!("no ruleset named {name}")));
640 }
641 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
642 };
643 if detail["enforcement"] != "active" {
644 return Ok(StepState::not(format!("{name} is not active")));
645 }
646 if detail["target"] != target {
649 return Ok(StepState::not(format!(
650 "{name} does not target {target} refs"
651 )));
652 }
653 if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
654 return Ok(StepState::not(format!(
655 "{name} does not cover {include} alone"
656 )));
657 }
658 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
659 return Ok(StepState::not(format!(
660 "{name} excludes refs from its own coverage"
661 )));
662 }
663 let mut held: Vec<&str> = detail["rules"]
664 .as_array()
665 .map(|list| {
666 list.iter()
667 .filter_map(|rule| rule["type"].as_str())
668 .collect()
669 })
670 .unwrap_or_default();
671 held.sort_unstable();
672 let mut expected: Vec<&str> = rules.to_vec();
673 expected.sort_unstable();
674 if held == expected {
675 Ok(StepState::ok(format!(
676 "{name} is active with exactly its rules"
677 )))
678 } else {
679 Ok(StepState::not(format!(
680 "{name} carries the rules [{}] where the setup owns [{}]",
681 held.join(", "),
682 expected.join(", ")
683 )))
684 }
685}
686
687const OWNED_TRUNK_RULES: [&str; 4] = [
692 "deletion",
693 "non_fast_forward",
694 "pull_request",
695 "required_status_checks",
696];
697
698fn unowned_rule_faults(rules: &[Value]) -> Vec<String> {
706 rules
707 .iter()
708 .filter_map(|rule| rule["type"].as_str())
709 .filter(|kind| !OWNED_TRUNK_RULES.contains(kind))
710 .map(|kind| {
711 if kind == "merge_queue" {
712 MERGE_QUEUE_FAULT.to_owned()
713 } else {
714 format!("an unowned rule is present: {kind}")
715 }
716 })
717 .collect()
718}
719
720fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
721 let name = format!("{TRUNK_BRANCH}-protection");
722 let detail = match github_ruleset_body(ctx, run, &name)? {
723 RulesetLookup::Found(detail) => detail,
724 RulesetLookup::Absent => {
725 return Ok(StepState::not(format!("no ruleset named {name}")));
726 }
727 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
728 };
729 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
730 let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
731 let mut faults = Vec::new();
732 if detail["enforcement"] != "active" {
733 faults.push(format!("{name} is not active"));
734 }
735 if detail["target"] != "branch" {
739 faults.push(format!("{name} does not target branches"));
740 }
741 let expected_ref = serde_json::json!([format!("refs/heads/{TRUNK_BRANCH}")]);
742 if detail["conditions"]["ref_name"]["include"] != expected_ref {
743 faults.push(format!(
744 "{name} does not cover refs/heads/{TRUNK_BRANCH} alone"
745 ));
746 }
747 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
750 faults.push(format!("{name} excludes refs from its own coverage"));
751 }
752 if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
753 faults.push("a bypass actor is named".to_owned());
754 }
755 for required in OWNED_TRUNK_RULES {
756 if !has(required) {
757 faults.push(format!("the {required} rule is missing"));
758 }
759 }
760 faults.extend(unowned_rule_faults(&rules));
761 if let Some(request) = rules.iter().find(|rule| rule["type"] == "pull_request") {
762 if request["parameters"]["allowed_merge_methods"] != serde_json::json!(["squash"]) {
763 faults.push("the merge method is not exactly a squash merge".to_owned());
764 }
765 }
766 if let Some(checks) = rules
767 .iter()
768 .find(|rule| rule["type"] == "required_status_checks")
769 {
770 if checks["parameters"]["strict_required_status_checks_policy"] != true {
771 faults.push(STALE_MERGE_FAULT.to_owned());
772 }
773 let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
774 .as_array()
775 .map(|list| {
776 list.iter()
777 .filter_map(|check| check["context"].as_str())
778 .collect()
779 })
780 .unwrap_or_default();
781 if contexts.is_empty() {
786 faults.push("no status check is required".to_owned());
787 } else if let Some(expected) = &ctx.required_check {
788 let mut held = contexts.clone();
789 held.sort_unstable();
790 let mut owned_contexts = [expected.as_str(), TITLE_CHECK];
791 owned_contexts.sort_unstable();
792 if held != owned_contexts {
793 faults.push(format!(
794 "the required checks are [{}] where the setup owns [{}]",
795 contexts.join(", "),
796 owned_contexts.join(", ")
797 ));
798 }
799 } else if !contexts.contains(&TITLE_CHECK) {
800 faults.push(format!("the {TITLE_CHECK} check is not required"));
801 }
802 }
803 match squash_merge_sources(ctx, run)? {
804 MergeSources::Owned => {}
805 MergeSources::Faults(proven) => faults.extend(proven),
806 MergeSources::Unreadable(err) => {
810 if faults.is_empty() {
811 return Ok(StepState::unknown(err));
812 }
813 }
814 }
815 if let Some(shape) = gate_faults(ctx) {
816 faults.push(shape);
817 }
818 if !faults.is_empty() {
819 return Ok(StepState::not(faults.join("; ")));
820 }
821 Ok(StepState::ok(format!(
822 "{name} holds the release-merge shape"
823 )))
824}
825
826fn gate_faults(ctx: &Ctx) -> Option<String> {
836 let check = ctx.required_check.as_deref()?;
837 workflow_jobs::faults(&workflow_jobs::read_gate(&ctx.target, check), check)
838}
839
840enum MergeSources {
842 Owned,
844 Faults(Vec<String>),
846 Unreadable(String),
848}
849
850fn squash_merge_sources(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
857 Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
858 Api::Ok(body) => {
859 let mut faults = Vec::new();
860 if body["squash_merge_commit_title"] != "PR_TITLE" {
861 faults.push(format!(
862 "the squash title source is {} where the setup owns PR_TITLE",
863 body["squash_merge_commit_title"]
864 ));
865 }
866 if body["squash_merge_commit_message"] != "PR_BODY" {
867 faults.push(format!(
868 "the squash message source is {} where the setup owns PR_BODY",
869 body["squash_merge_commit_message"]
870 ));
871 }
872 if faults.is_empty() {
873 MergeSources::Owned
874 } else {
875 MergeSources::Faults(faults)
876 }
877 }
878 Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
879 Api::Failed(err) => MergeSources::Unreadable(err),
880 })
881}
882
883enum RulesetLookup {
886 Found(Value),
888 Absent,
891 Unreadable(String),
893}
894
895fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
897 let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
901 Api::Ok(body) => body,
902 Api::Missing => {
903 return Ok(RulesetLookup::Unreadable(
904 "the ruleset inventory is not readable".into(),
905 ));
906 }
907 Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
908 };
909 let id = list
910 .as_array()
911 .into_iter()
912 .flatten()
913 .find(|ruleset| ruleset["name"] == name)
914 .and_then(|ruleset| ruleset["id"].as_i64());
915 let Some(id) = id else {
916 return Ok(RulesetLookup::Absent);
917 };
918 match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
919 Api::Ok(body) => Ok(RulesetLookup::Found(body)),
920 Api::Missing => Ok(RulesetLookup::Unreadable(format!(
924 "the {name} detail is not readable"
925 ))),
926 Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
927 }
928}
929
930const 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";
934
935const GITLAB_TAG_LIMITATION: &str =
937 "an Owner or Maintainer can still delete a protected tag through the UI or API";
938
939const 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";
944
945const 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";
947
948const 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";
951
952#[allow(clippy::too_many_lines)]
953fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
954 let project = ctx.repo.replace('/', "%2F");
955 match step {
956 "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
957 Api::Ok(body) => {
958 let found = body["default_branch"].as_str().unwrap_or("");
959 if found == TRUNK_BRANCH {
960 StepState::ok(format!("{TRUNK_BRANCH} is the default branch"))
961 } else {
962 StepState::not(format!("the default branch is {found}"))
963 }
964 }
965 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
966 Api::Failed(err) => StepState::unknown(err),
967 }),
968 "single-trunk" => {
969 for candidate in TRUNK_CANDIDATES {
970 if candidate == TRUNK_BRANCH {
971 continue;
972 }
973 match api_get(
974 ctx,
975 run,
976 &format!("projects/{project}/repository/branches/{candidate}"),
977 )? {
978 Api::Missing => {}
979 Api::Ok(_) => {
980 return Ok(StepState::not(format!("a {candidate} branch still exists")));
981 }
982 Api::Failed(err) => return Ok(StepState::unknown(err)),
983 }
984 }
985 Ok(StepState::ok(
986 "no long-lived branch besides the trunk remains",
987 ))
988 }
989 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
990 Api::Ok(body) => {
991 if body["remove_source_branch_after_merge"]
992 .as_bool()
993 .unwrap_or(false)
994 {
995 StepState::ok("a merged branch is deleted by the forge")
996 } else {
997 StepState::not("a merged branch outlives its merge")
998 }
999 }
1000 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1001 Api::Failed(err) => StepState::unknown(err),
1002 }),
1003 "auto-merge" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1004 Api::Ok(body) => {
1005 if body["only_allow_merge_if_pipeline_succeeds"]
1006 .as_bool()
1007 .unwrap_or(false)
1008 {
1009 StepState::ok_with_limitation(
1010 "a request may merge itself once its pipeline passes",
1011 GITLAB_AUTO_MERGE_LIMITATION,
1012 )
1013 } else {
1014 StepState::not(
1015 "the pipeline requirement auto-merge rides on is off; protect-trunk asserts it",
1016 )
1017 }
1018 }
1019 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1020 Api::Failed(err) => StepState::unknown(err),
1021 }),
1022 "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1023 Api::Ok(body) => {
1024 if body["jobs_enabled"] == true {
1025 StepState::ok("pipelines are enabled")
1026 } else {
1027 StepState::not("pipelines are disabled")
1028 }
1029 }
1030 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1031 Api::Failed(err) => StepState::unknown(err),
1032 }),
1033 "install-bot" => {
1034 let mut active = false;
1040 let mut exhausted = false;
1041 for page in 1..=10u32 {
1042 let path = format!(
1043 "projects/{project}/access_tokens?state=active&per_page=100&page={page}"
1044 );
1045 let list = match api_get(ctx, run, &path)? {
1046 Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1047 Api::Missing => Vec::new(),
1048 Api::Failed(err) => return Ok(StepState::unknown(err)),
1049 };
1050 active = active
1051 || list.iter().any(|token| {
1052 token["name"] == "release-bot"
1053 && token["revoked"] == false
1054 && token["active"] != false
1055 });
1056 if list.len() < 100 {
1057 exhausted = true;
1058 }
1059 if active || exhausted {
1060 break;
1061 }
1062 }
1063 if !active {
1064 return Ok(if exhausted {
1065 StepState::not("no active release-bot token exists")
1066 } else {
1067 StepState::unknown(
1068 "the token listing did not exhaust within ten pages; nothing was decided",
1069 )
1070 });
1071 }
1072 Ok(
1076 match api_get(
1077 ctx,
1078 run,
1079 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1080 )? {
1081 Api::Ok(_) => StepState::ok(
1082 "an active release-bot token exists and its variable is stored",
1083 ),
1084 Api::Missing => StepState::not(
1085 "an active release-bot token exists with no stored variable; a rerun revokes and replaces it",
1086 ),
1087 Api::Failed(err) => StepState::unknown(err),
1088 },
1089 )
1090 }
1091 "bot-secrets" => Ok(
1092 match api_get(
1093 ctx,
1094 run,
1095 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1096 )? {
1097 Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
1098 Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
1099 Api::Failed(err) => StepState::unknown(err),
1100 },
1101 ),
1102 "protect-trunk" => {
1103 let protection = match api_get(
1104 ctx,
1105 run,
1106 &format!("projects/{project}/protected_branches/{TRUNK_BRANCH}"),
1107 )? {
1108 Api::Ok(body) => body,
1109 Api::Missing => {
1110 return Ok(StepState::not(format!("{TRUNK_BRANCH} is not protected")));
1111 }
1112 Api::Failed(err) => return Ok(StepState::unknown(err)),
1113 };
1114 let grants = protection["push_access_levels"]
1118 .as_array()
1119 .cloned()
1120 .unwrap_or_default();
1121 let no_push = grants.len() == 1 && grants[0]["access_level"] == 0;
1122 let merges = protection["merge_access_levels"]
1126 .as_array()
1127 .cloned()
1128 .unwrap_or_default();
1129 let can_merge = merges.len() == 1 && merges[0]["access_level"] == 40;
1130 let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
1131 Api::Ok(body) => body,
1132 Api::Missing | Api::Failed(_) => Value::Null,
1133 };
1134 let mut faults = Vec::new();
1135 if !no_push {
1136 faults.push(format!(
1137 "{TRUNK_BRANCH} still takes a direct push: the forge honors the most permissive of {} push grants",
1138 grants.len()
1139 ));
1140 }
1141 if !can_merge {
1142 faults.push(format!(
1143 "{TRUNK_BRANCH} merge grants are not exactly the one owned maintainer level"
1144 ));
1145 }
1146 if protection["allow_force_push"] != false {
1147 faults.push(format!("{TRUNK_BRANCH} allows force pushes"));
1148 }
1149 if settings["only_allow_merge_if_pipeline_succeeds"] != true {
1150 faults.push("the pipeline requirement is off".to_owned());
1151 }
1152 if settings["merge_method"] != "ff" {
1153 faults.push("the merge method is not fast-forward".to_owned());
1154 }
1155 if settings["squash_option"] != "always" {
1156 faults.push("merge requests do not always squash".to_owned());
1157 }
1158 if settings["squash_commit_template"] != "%{title}" {
1159 faults.push("the squash template is not the merge request's title".to_owned());
1160 }
1161 Ok(if faults.is_empty() {
1162 StepState::ok_with_limitation(
1163 format!("{TRUNK_BRANCH} holds the release-merge shape"),
1164 GITLAB_TITLE_LIMITATION,
1165 )
1166 } else {
1167 StepState::not(faults.join("; "))
1168 })
1169 }
1170 "protect-tags" => Ok(
1171 match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
1172 Api::Ok(_) => {
1173 StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
1174 }
1175 Api::Missing => StepState::not("v* is not protected"),
1176 Api::Failed(err) => StepState::unknown(err),
1177 },
1178 ),
1179 "protect-release-lines" => Ok(
1180 match api_get(
1181 ctx,
1182 run,
1183 &format!("projects/{project}/protected_branches/release%2F%2A"),
1184 )? {
1185 Api::Ok(body) => {
1186 let level_ok = |levels: &Value| {
1187 levels
1188 .as_array()
1189 .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
1190 };
1191 if body["allow_force_push"] != false {
1192 StepState::not("release/* allows force pushes")
1193 } else if !level_ok(&body["push_access_levels"])
1194 || !level_ok(&body["merge_access_levels"])
1195 {
1196 StepState::not(
1200 "release/* grants are not exactly the owned maintainer levels",
1201 )
1202 } else {
1203 StepState::ok("release/* refuses force pushes and deletion by git clients")
1204 }
1205 }
1206 Api::Missing => StepState::inapplicable(
1207 "release/* is unprotected; optional — applied only where older lines exist",
1208 ),
1209 Api::Failed(err) => StepState::unknown(err),
1210 },
1211 ),
1212 "protections-check" => {
1213 let mut failures = Vec::new();
1216 let mut unknowns = Vec::new();
1217 let mut limitations: Vec<String> = Vec::new();
1220 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
1221 match gitlab(ctx, owned, run)? {
1222 StepState::Satisfied {
1223 limitation: found, ..
1224 } => limitations.extend(found),
1225 StepState::Inapplicable { .. } => {}
1226 StepState::Unsatisfied { detail } => {
1227 failures.push(format!("{owned}: {detail}"));
1228 }
1229 StepState::Unknown { detail } => {
1230 unknowns.push(format!("{owned}: {detail}"));
1231 }
1232 }
1233 }
1234 Ok(if !failures.is_empty() {
1235 StepState::not(failures.join("; "))
1236 } else if !unknowns.is_empty() {
1237 StepState::unknown(unknowns.join("; "))
1238 } else {
1239 StepState::Satisfied {
1240 detail: "the protections hold, as far as this forge enforces them".into(),
1241 limitation: if limitations.is_empty() {
1242 None
1243 } else {
1244 Some(limitations.join("; "))
1245 },
1246 }
1247 })
1248 }
1249 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1250 }
1251}