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
738const OWNED_TRUNK_RULES: [&str; 4] = [
743 "deletion",
744 "non_fast_forward",
745 "pull_request",
746 "required_status_checks",
747];
748
749fn unowned_rule_faults(rules: &[Value]) -> Vec<String> {
757 rules
758 .iter()
759 .filter_map(|rule| rule["type"].as_str())
760 .filter(|kind| !OWNED_TRUNK_RULES.contains(kind))
761 .map(|kind| {
762 if kind == "merge_queue" {
763 MERGE_QUEUE_FAULT.to_owned()
764 } else {
765 format!("an unowned rule is present: {kind}")
766 }
767 })
768 .collect()
769}
770
771fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
772 let trunk = ctx.trunk();
773 let name = ctx.trunk_ruleset().to_owned();
774 let detail = match github_ruleset_body(ctx, run, &name)? {
775 RulesetLookup::Found(detail) => detail,
776 RulesetLookup::Absent => {
777 return Ok(StepState::not(format!("no ruleset named {name}")));
778 }
779 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
780 };
781 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
782 let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
783 let mut faults = Vec::new();
784 if detail["enforcement"] != "active" {
785 faults.push(format!("{name} is not active"));
786 }
787 if detail["target"] != "branch" {
791 faults.push(format!("{name} does not target branches"));
792 }
793 let expected_ref = serde_json::json!([format!("refs/heads/{trunk}")]);
794 if detail["conditions"]["ref_name"]["include"] != expected_ref {
795 faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
796 }
797 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
800 faults.push(format!("{name} excludes refs from its own coverage"));
801 }
802 if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
803 faults.push("a bypass actor is named".to_owned());
804 }
805 for required in OWNED_TRUNK_RULES {
806 if !has(required) {
807 faults.push(format!("the {required} rule is missing"));
808 }
809 }
810 faults.extend(unowned_rule_faults(&rules));
811 if let Some(request) = rules.iter().find(|rule| rule["type"] == "pull_request") {
812 if request["parameters"]["allowed_merge_methods"] != serde_json::json!(["squash"]) {
813 faults.push("the merge method is not exactly a squash merge".to_owned());
814 }
815 }
816 if let Some(checks) = rules
817 .iter()
818 .find(|rule| rule["type"] == "required_status_checks")
819 {
820 if checks["parameters"]["strict_required_status_checks_policy"] != true {
821 faults.push(STALE_MERGE_FAULT.to_owned());
822 }
823 let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
824 .as_array()
825 .map(|list| {
826 list.iter()
827 .filter_map(|check| check["context"].as_str())
828 .collect()
829 })
830 .unwrap_or_default();
831 if contexts.is_empty() {
836 faults.push("no status check is required".to_owned());
837 } else if let Some(expected) = &ctx.required_check {
838 let mut held = contexts.clone();
839 held.sort_unstable();
840 let title_check = ctx.title_check();
841 let mut owned_contexts = [expected.as_str(), title_check];
842 owned_contexts.sort_unstable();
843 if held != owned_contexts {
844 faults.push(format!(
845 "the required checks are [{}] where the setup owns [{}]",
846 contexts.join(", "),
847 owned_contexts.join(", ")
848 ));
849 }
850 } else if !contexts.contains(&ctx.title_check()) {
851 faults.push(format!("the {} check is not required", ctx.title_check()));
852 }
853 }
854 match squash_merge_sources(ctx, run)? {
855 MergeSources::Owned => {}
856 MergeSources::Faults(proven) => faults.extend(proven),
857 MergeSources::Unreadable(err) => {
861 if faults.is_empty() {
862 return Ok(StepState::unknown(err));
863 }
864 }
865 }
866 if let Some(shape) = gate_faults(ctx) {
867 faults.push(shape);
868 }
869 if !faults.is_empty() {
870 return Ok(StepState::not(faults.join("; ")));
871 }
872 Ok(StepState::ok(format!(
873 "{name} holds the release-merge shape"
874 )))
875}
876
877fn gate_faults(ctx: &Ctx) -> Option<String> {
887 let check = ctx.required_check.as_deref()?;
888 workflow_jobs::faults(
889 &workflow_jobs::read_gate(&ctx.target, check, ctx.trunk()),
890 check,
891 ctx.trunk(),
892 )
893}
894
895enum MergeSources {
897 Owned,
899 Faults(Vec<String>),
901 Unreadable(String),
903}
904
905fn squash_merge_sources(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
912 Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
913 Api::Ok(body) => {
914 let mut faults = Vec::new();
915 if body["squash_merge_commit_title"] != "PR_TITLE" {
916 faults.push(format!(
917 "the squash title source is {} where the setup owns PR_TITLE",
918 body["squash_merge_commit_title"]
919 ));
920 }
921 if body["squash_merge_commit_message"] != "PR_BODY" {
922 faults.push(format!(
923 "the squash message source is {} where the setup owns PR_BODY",
924 body["squash_merge_commit_message"]
925 ));
926 }
927 if faults.is_empty() {
928 MergeSources::Owned
929 } else {
930 MergeSources::Faults(faults)
931 }
932 }
933 Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
934 Api::Failed(err) => MergeSources::Unreadable(err),
935 })
936}
937
938enum RulesetLookup {
941 Found(Value),
943 Absent,
946 Unreadable(String),
948}
949
950fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
952 let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
956 Api::Ok(body) => body,
957 Api::Missing => {
958 return Ok(RulesetLookup::Unreadable(
959 "the ruleset inventory is not readable".into(),
960 ));
961 }
962 Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
963 };
964 let id = list
965 .as_array()
966 .into_iter()
967 .flatten()
968 .find(|ruleset| ruleset["name"] == name)
969 .and_then(|ruleset| ruleset["id"].as_i64());
970 let Some(id) = id else {
971 return Ok(RulesetLookup::Absent);
972 };
973 match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
974 Api::Ok(body) => Ok(RulesetLookup::Found(body)),
975 Api::Missing => Ok(RulesetLookup::Unreadable(format!(
979 "the {name} detail is not readable"
980 ))),
981 Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
982 }
983}
984
985const 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";
989
990const GITLAB_TAG_LIMITATION: &str =
992 "an Owner or Maintainer can still delete a protected tag through the UI or API";
993
994const 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";
999
1000const 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";
1002
1003const 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";
1006
1007#[allow(clippy::too_many_lines)]
1008fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
1009 let trunk = ctx.trunk();
1010 let project = ctx.repo.replace('/', "%2F");
1011 match step {
1012 "private-vulnerability-reporting" => {
1013 let path = format!("projects/{project}");
1014 Ok(match api_get(ctx, run, &path)? {
1015 Api::Ok(body) => {
1016 let access = body["issues_access_level"].as_str();
1017 if !matches!(access, Some("enabled" | "private" | "disabled")) {
1018 StepState::unknown("issue intake access is unreadable")
1019 } else if body
1020 .get("issues_enabled")
1021 .is_some_and(|flag| !flag.is_boolean())
1022 {
1023 StepState::unknown("legacy issue intake flag is unreadable")
1024 } else if body["issues_enabled"] == false || access == Some("disabled") {
1025 StepState::not("issue intake is disabled; see setup guide step 3g")
1026 } else if access == Some("private") {
1027 StepState::not("issue intake is restricted; see setup guide step 3g")
1028 } else {
1029 StepState::ok_with_limitation(
1030 "issue intake is enabled",
1031 GITLAB_PRIVATE_REPORTING_LIMITATION,
1032 )
1033 }
1034 }
1035 Api::Missing => {
1036 StepState::unknown(format!("{path}: issue intake is unreadable (404)"))
1037 }
1038 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
1039 })
1040 }
1041
1042 "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1043 Api::Ok(body) => {
1044 let found = body["default_branch"].as_str().unwrap_or("");
1045 if found == trunk {
1046 StepState::ok(format!("{trunk} is the default branch"))
1047 } else {
1048 StepState::not(format!("the default branch is {found}"))
1049 }
1050 }
1051 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1052 Api::Failed(err) => StepState::unknown(err),
1053 }),
1054 "single-trunk" => {
1055 for candidate in ctx.retired_branches() {
1056 let candidate = candidate.as_str();
1057 if candidate == trunk {
1058 continue;
1059 }
1060 match api_get(
1061 ctx,
1062 run,
1063 &format!("projects/{project}/repository/branches/{candidate}"),
1064 )? {
1065 Api::Missing => {}
1066 Api::Ok(_) => {
1067 return Ok(StepState::not(format!("a {candidate} branch still exists")));
1068 }
1069 Api::Failed(err) => return Ok(StepState::unknown(err)),
1070 }
1071 }
1072 Ok(StepState::ok(
1073 "no long-lived branch besides the trunk remains",
1074 ))
1075 }
1076 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1077 Api::Ok(body) => {
1078 if body["remove_source_branch_after_merge"]
1079 .as_bool()
1080 .unwrap_or(false)
1081 {
1082 StepState::ok("a merged branch is deleted by the forge")
1083 } else {
1084 StepState::not("a merged branch outlives its merge")
1085 }
1086 }
1087 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1088 Api::Failed(err) => StepState::unknown(err),
1089 }),
1090 "auto-merge" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1091 Api::Ok(body) => {
1092 if body["only_allow_merge_if_pipeline_succeeds"]
1093 .as_bool()
1094 .unwrap_or(false)
1095 {
1096 StepState::ok_with_limitation(
1097 "a request may merge itself once its pipeline passes",
1098 GITLAB_AUTO_MERGE_LIMITATION,
1099 )
1100 } else {
1101 StepState::not(
1102 "the pipeline requirement auto-merge rides on is off; protect-trunk asserts it",
1103 )
1104 }
1105 }
1106 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1107 Api::Failed(err) => StepState::unknown(err),
1108 }),
1109 "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1110 Api::Ok(body) => {
1111 if body["jobs_enabled"] == true {
1112 StepState::ok("pipelines are enabled")
1113 } else {
1114 StepState::not("pipelines are disabled")
1115 }
1116 }
1117 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1118 Api::Failed(err) => StepState::unknown(err),
1119 }),
1120 "install-bot" => {
1121 let mut active = false;
1127 let mut exhausted = false;
1128 for page in 1..=10u32 {
1129 let path = format!(
1130 "projects/{project}/access_tokens?state=active&per_page=100&page={page}"
1131 );
1132 let list = match api_get(ctx, run, &path)? {
1133 Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1134 Api::Missing => Vec::new(),
1135 Api::Failed(err) => return Ok(StepState::unknown(err)),
1136 };
1137 active = active
1138 || list.iter().any(|token| {
1139 token["name"] == "release-bot"
1140 && token["revoked"] == false
1141 && token["active"] != false
1142 });
1143 if list.len() < 100 {
1144 exhausted = true;
1145 }
1146 if active || exhausted {
1147 break;
1148 }
1149 }
1150 if !active {
1151 return Ok(if exhausted {
1152 StepState::not("no active release-bot token exists")
1153 } else {
1154 StepState::unknown(
1155 "the token listing did not exhaust within ten pages; nothing was decided",
1156 )
1157 });
1158 }
1159 Ok(
1163 match api_get(
1164 ctx,
1165 run,
1166 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1167 )? {
1168 Api::Ok(_) => StepState::ok(
1169 "an active release-bot token exists and its variable is stored",
1170 ),
1171 Api::Missing => StepState::not(
1172 "an active release-bot token exists with no stored variable; a rerun revokes and replaces it",
1173 ),
1174 Api::Failed(err) => StepState::unknown(err),
1175 },
1176 )
1177 }
1178 "bot-secrets" => Ok(
1179 match api_get(
1180 ctx,
1181 run,
1182 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1183 )? {
1184 Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
1185 Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
1186 Api::Failed(err) => StepState::unknown(err),
1187 },
1188 ),
1189 "protect-trunk" => {
1190 let protection = match api_get(
1191 ctx,
1192 run,
1193 &format!("projects/{project}/protected_branches/{trunk}"),
1194 )? {
1195 Api::Ok(body) => body,
1196 Api::Missing => {
1197 return Ok(StepState::not(format!("{trunk} is not protected")));
1198 }
1199 Api::Failed(err) => return Ok(StepState::unknown(err)),
1200 };
1201 let grants = protection["push_access_levels"]
1205 .as_array()
1206 .cloned()
1207 .unwrap_or_default();
1208 let no_push = grants.len() == 1 && grants[0]["access_level"] == 0;
1209 let merges = protection["merge_access_levels"]
1213 .as_array()
1214 .cloned()
1215 .unwrap_or_default();
1216 let can_merge = merges.len() == 1 && merges[0]["access_level"] == 40;
1217 let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
1218 Api::Ok(body) => body,
1219 Api::Missing | Api::Failed(_) => Value::Null,
1220 };
1221 let mut faults = Vec::new();
1222 if !no_push {
1223 faults.push(format!(
1224 "{trunk} still takes a direct push: the forge honors the most permissive of {} push grants",
1225 grants.len()
1226 ));
1227 }
1228 if !can_merge {
1229 faults.push(format!(
1230 "{trunk} merge grants are not exactly the one owned maintainer level"
1231 ));
1232 }
1233 if protection["allow_force_push"] != false {
1234 faults.push(format!("{trunk} allows force pushes"));
1235 }
1236 if settings["only_allow_merge_if_pipeline_succeeds"] != true {
1237 faults.push("the pipeline requirement is off".to_owned());
1238 }
1239 if settings["merge_method"] != "ff" {
1240 faults.push("the merge method is not fast-forward".to_owned());
1241 }
1242 if settings["squash_option"] != "always" {
1243 faults.push("merge requests do not always squash".to_owned());
1244 }
1245 if settings["squash_commit_template"] != "%{title}" {
1246 faults.push("the squash template is not the merge request's title".to_owned());
1247 }
1248 Ok(if faults.is_empty() {
1249 StepState::ok_with_limitation(
1250 format!("{trunk} holds the release-merge shape"),
1251 GITLAB_TITLE_LIMITATION,
1252 )
1253 } else {
1254 StepState::not(faults.join("; "))
1255 })
1256 }
1257 "protect-tags" => Ok(
1258 match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
1259 Api::Ok(_) => {
1260 StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
1261 }
1262 Api::Missing => StepState::not("v* is not protected"),
1263 Api::Failed(err) => StepState::unknown(err),
1264 },
1265 ),
1266 "protect-release-lines" => Ok(
1267 match api_get(
1268 ctx,
1269 run,
1270 &format!("projects/{project}/protected_branches/release%2F%2A"),
1271 )? {
1272 Api::Ok(body) => {
1273 let level_ok = |levels: &Value| {
1274 levels
1275 .as_array()
1276 .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
1277 };
1278 if body["allow_force_push"] != false {
1279 StepState::not("release/* allows force pushes")
1280 } else if !level_ok(&body["push_access_levels"])
1281 || !level_ok(&body["merge_access_levels"])
1282 {
1283 StepState::not(
1287 "release/* grants are not exactly the owned maintainer levels",
1288 )
1289 } else {
1290 StepState::ok("release/* refuses force pushes and deletion by git clients")
1291 }
1292 }
1293 Api::Missing => StepState::inapplicable(
1294 "release/* is unprotected; optional — applied only where older lines exist",
1295 ),
1296 Api::Failed(err) => StepState::unknown(err),
1297 },
1298 ),
1299 "protections-check" => {
1300 let mut failures = Vec::new();
1303 let mut unknowns = Vec::new();
1304 let mut limitations: Vec<String> = Vec::new();
1307 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
1308 match gitlab(ctx, owned, run)? {
1309 StepState::Satisfied {
1310 limitation: found, ..
1311 } => limitations.extend(found),
1312 StepState::Inapplicable { .. } => {}
1313 StepState::Unsatisfied { detail } => {
1314 failures.push(format!("{owned}: {detail}"));
1315 }
1316 StepState::Unknown { detail } => {
1317 unknowns.push(format!("{owned}: {detail}"));
1318 }
1319 }
1320 }
1321 Ok(if !failures.is_empty() {
1322 StepState::not(failures.join("; "))
1323 } else if !unknowns.is_empty() {
1324 StepState::unknown(unknowns.join("; "))
1325 } else {
1326 StepState::Satisfied {
1327 detail: "the protections hold, as far as this forge enforces them".into(),
1328 limitation: if limitations.is_empty() {
1329 None
1330 } else {
1331 Some(limitations.join("; "))
1332 },
1333 }
1334 })
1335 }
1336 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1337 }
1338}