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