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(
427 clippy::too_many_lines,
428 reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
429)]
430fn github(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
431 let trunk = ctx.trunk();
432 let repo = &ctx.repo;
433 match step {
434 "private-vulnerability-reporting" => {
435 let visibility_path = format!("repos/{repo}");
436 match api_get(ctx, run, &visibility_path)? {
437 Api::Ok(body) => match body["private"].as_bool() {
438 Some(true) => {
439 return Ok(StepState::inapplicable(
440 "private vulnerability reporting is available for public repositories",
441 ));
442 }
443 Some(false) => {}
444 None => {
445 return Ok(StepState::unknown(format!(
446 "{visibility_path}: repository visibility is unreadable"
447 )));
448 }
449 },
450 Api::Missing => {
451 return Ok(StepState::unknown(format!(
452 "{visibility_path}: repository visibility is unreadable (404)"
453 )));
454 }
455 Api::Failed(err) => {
456 return Ok(StepState::unknown(format!("{visibility_path}: {err}")));
457 }
458 }
459 let path = format!("repos/{repo}/private-vulnerability-reporting");
460 Ok(match api_get(ctx, run, &path)? {
461 Api::Ok(body) => match body["enabled"].as_bool() {
462 Some(true) => StepState::ok("private vulnerability reporting is enabled"),
463 Some(false) => StepState::not("private vulnerability reporting is disabled"),
464 None => StepState::unknown(format!("{path}: enabled is unreadable")),
465 },
466 Api::Missing => {
467 StepState::unknown(format!("{path}: reporting state is unreadable (404)"))
468 }
469 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
470 })
471 }
472
473 "default-branch" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
474 Api::Ok(body) => {
475 let found = body["default_branch"].as_str().unwrap_or("");
476 if found == trunk {
477 StepState::ok(format!("{trunk} is the default branch"))
478 } else {
479 StepState::not(format!("the default branch is {found}"))
480 }
481 }
482 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
483 Api::Failed(err) => StepState::unknown(err),
484 }),
485 "single-trunk" => {
486 for candidate in ctx.retired_branches() {
487 let candidate = candidate.as_str();
488 if candidate == trunk {
489 continue;
490 }
491 match api_get(ctx, run, &format!("repos/{repo}/git/ref/heads/{candidate}"))? {
492 Api::Missing => {}
493 Api::Ok(_) => {
494 return Ok(StepState::not(format!("a {candidate} branch still exists")));
495 }
496 Api::Failed(err) => return Ok(StepState::unknown(err)),
497 }
498 }
499 Ok(StepState::ok(
500 "no long-lived branch besides the trunk remains",
501 ))
502 }
503 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
504 Api::Ok(body) => {
505 if body["delete_branch_on_merge"].as_bool().unwrap_or(false) {
506 StepState::ok("a merged branch is deleted by the forge")
507 } else {
508 StepState::not("a merged branch outlives its merge")
509 }
510 }
511 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
512 Api::Failed(err) => StepState::unknown(err),
513 }),
514 "auto-merge" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
515 Api::Ok(body) => {
516 if body["allow_auto_merge"].as_bool().unwrap_or(false) {
517 StepState::ok("a request may merge itself once its checks pass")
518 } else {
519 StepState::not("a request cannot merge itself; the auto-merge switch is off")
520 }
521 }
522 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
523 Api::Failed(err) => StepState::unknown(err),
524 }),
525 "ci-permissions" => Ok(
526 match api_get(
527 ctx,
528 run,
529 &format!("repos/{repo}/actions/permissions/workflow"),
530 )? {
531 Api::Ok(body) => {
532 let write = body["default_workflow_permissions"] == "write";
533 let approve = body["can_approve_pull_request_reviews"] == true;
534 if write && approve {
535 StepState::ok("CI may write and open requests")
536 } else {
537 StepState::not(format!(
538 "workflow permissions are {} with request approval {}",
539 body["default_workflow_permissions"],
540 body["can_approve_pull_request_reviews"]
541 ))
542 }
543 }
544 Api::Missing => StepState::not("no workflow permissions are readable"),
545 Api::Failed(err) => StepState::unknown(err),
546 },
547 ),
548 "bot-secrets" => Ok(
549 match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
550 Api::Ok(body) => {
551 let names: Vec<&str> = body["secrets"]
552 .as_array()
553 .map(|list| {
554 list.iter()
555 .filter_map(|secret| secret["name"].as_str())
556 .collect()
557 })
558 .unwrap_or_default();
559 let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
560 if wanted.iter().all(|name| names.contains(name)) {
561 StepState::ok("both bot secrets are stored")
562 } else if names.is_empty() {
563 StepState::not("no bot secrets are stored")
564 } else {
565 StepState::not(format!("stored secrets: {}", names.join(", ")))
566 }
567 }
568 Api::Missing => StepState::not("no secrets are readable"),
569 Api::Failed(err) => StepState::unknown(err),
570 },
571 ),
572 "protect-trunk" => github_trunk_ruleset(ctx, run),
573 "protect-tags" => github_ruleset(
574 ctx,
575 run,
576 ctx.tag_ruleset(),
577 "tag",
578 "refs/tags/v*",
579 &["deletion", "update"],
580 ),
581 "protect-release-lines" => {
582 match github_ruleset_body(ctx, run, ctx.lines_ruleset())? {
583 RulesetLookup::Absent => {
584 return Ok(StepState::inapplicable(
585 "release/* is unprotected; optional — applied only where older lines exist",
586 ));
587 }
588 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
589 RulesetLookup::Found(_) => {}
590 }
591 github_ruleset(
592 ctx,
593 run,
594 ctx.lines_ruleset(),
595 "branch",
596 "refs/heads/release/*",
597 &["deletion", "non_fast_forward"],
598 )
599 }
600 "protections-check" => {
601 let mut failures = Vec::new();
605 let mut unknowns = Vec::new();
606 let mut limitations: Vec<String> = Vec::new();
608 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
609 match github(ctx, owned, run)? {
610 StepState::Satisfied {
611 limitation: found, ..
612 } => limitations.extend(found),
613 StepState::Inapplicable { .. } => {}
614 StepState::Unsatisfied { detail } => {
615 failures.push(format!("{owned}: {detail}"));
616 }
617 StepState::Unknown { detail } => {
618 unknowns.push(format!("{owned}: {detail}"));
619 }
620 }
621 }
622 match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
623 Api::Ok(body) => {
624 let owned = [
625 ctx.trunk_ruleset().to_owned(),
626 ctx.tag_ruleset().to_owned(),
627 ctx.lines_ruleset().to_owned(),
628 ];
629 for ruleset in body.as_array().into_iter().flatten() {
630 let name = ruleset["name"].as_str().unwrap_or("");
631 if !owned.iter().any(|expected| expected == name) {
632 failures.push(format!("a ruleset no step owns: {name}"));
633 }
634 }
635 }
636 Api::Missing | Api::Failed(_) => {
637 unknowns.push("the ruleset inventory is not readable".to_owned());
638 }
639 }
640 Ok(if !failures.is_empty() {
641 StepState::not(failures.join("; "))
642 } else if !unknowns.is_empty() {
643 StepState::unknown(unknowns.join("; "))
644 } else {
645 StepState::Satisfied {
646 detail: "exactly the owned protections, with those rules".into(),
647 limitation: if limitations.is_empty() {
648 None
649 } else {
650 Some(limitations.join("; "))
651 },
652 }
653 })
654 }
655 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
656 }
657}
658
659#[must_use]
667pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
668 match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
669 AppApi::Ok(body) => {
670 let id = body["id"].as_i64().unwrap_or_default();
671 StepState::ok(format!("installation {id} covers {}", ctx.repo))
672 }
673 AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
674 AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
675 }
676}
677
678fn github_ruleset(
683 ctx: &Ctx,
684 run: &mut Runner,
685 name: &str,
686 target: &str,
687 include: &str,
688 rules: &[&str],
689) -> Result<StepState, RkError> {
690 let detail = match github_ruleset_body(ctx, run, name)? {
691 RulesetLookup::Found(detail) => detail,
692 RulesetLookup::Absent => {
693 return Ok(StepState::not(format!("no ruleset named {name}")));
694 }
695 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
696 };
697 if detail["enforcement"] != "active" {
698 return Ok(StepState::not(format!("{name} is not active")));
699 }
700 if detail["target"] != target {
703 return Ok(StepState::not(format!(
704 "{name} does not target {target} refs"
705 )));
706 }
707 if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
708 return Ok(StepState::not(format!(
709 "{name} does not cover {include} alone"
710 )));
711 }
712 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
713 return Ok(StepState::not(format!(
714 "{name} excludes refs from its own coverage"
715 )));
716 }
717 let mut held: Vec<&str> = detail["rules"]
718 .as_array()
719 .map(|list| {
720 list.iter()
721 .filter_map(|rule| rule["type"].as_str())
722 .collect()
723 })
724 .unwrap_or_default();
725 held.sort_unstable();
726 let mut expected: Vec<&str> = rules.to_vec();
727 expected.sort_unstable();
728 if held == expected {
729 Ok(StepState::ok(format!(
730 "{name} is active with exactly its rules"
731 )))
732 } else {
733 Ok(StepState::not(format!(
734 "{name} carries the rules [{}] where the setup owns [{}]",
735 held.join(", "),
736 expected.join(", ")
737 )))
738 }
739}
740
741fn unowned_rule_faults(rules: &[Value], owned: &[String]) -> Vec<String> {
756 rules
757 .iter()
758 .filter_map(|rule| rule["type"].as_str())
759 .filter(|kind| !owned.iter().any(|name| name == kind))
760 .map(|kind| {
761 if kind == "merge_queue" {
762 MERGE_QUEUE_FAULT.to_owned()
763 } else {
764 format!("an unowned rule is present: {kind}")
765 }
766 })
767 .collect()
768}
769
770fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
771 let trunk = ctx.trunk();
772 let name = ctx.trunk_ruleset().to_owned();
773 let detail = match github_ruleset_body(ctx, run, &name)? {
774 RulesetLookup::Found(detail) => detail,
775 RulesetLookup::Absent => {
776 return Ok(StepState::not(format!("no ruleset named {name}")));
777 }
778 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
779 };
780 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
781 let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
782 let mut faults = Vec::new();
783 if detail["enforcement"] != "active" {
784 faults.push(format!("{name} is not active"));
785 }
786 if detail["target"] != "branch" {
790 faults.push(format!("{name} does not target branches"));
791 }
792 let expected_ref = serde_json::json!([format!("refs/heads/{trunk}")]);
793 if detail["conditions"]["ref_name"]["include"] != expected_ref {
794 faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
795 }
796 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
799 faults.push(format!("{name} excludes refs from its own coverage"));
800 }
801 if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
802 faults.push("a bypass actor is named".to_owned());
803 }
804 for required in &ctx.protection().owned_trunk_rules {
805 if !has(required) {
806 faults.push(format!("the {required} rule is missing"));
807 }
808 }
809 faults.extend(unowned_rule_faults(
810 &rules,
811 &ctx.protection().owned_trunk_rules,
812 ));
813 if let Some(request) = rules.iter().find(|rule| rule["type"] == "pull_request") {
814 if request["parameters"]["allowed_merge_methods"]
815 != serde_json::json!(ctx.protection().allowed_merge_methods)
816 {
817 faults.push("the merge method is not exactly a squash merge".to_owned());
818 }
819 }
820 if let Some(checks) = rules
821 .iter()
822 .find(|rule| rule["type"] == "required_status_checks")
823 {
824 if checks["parameters"]["strict_required_status_checks_policy"]
825 != ctx.protection().strict_required_status_checks
826 {
827 faults.push(STALE_MERGE_FAULT.to_owned());
828 }
829 let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
830 .as_array()
831 .map(|list| {
832 list.iter()
833 .filter_map(|check| check["context"].as_str())
834 .collect()
835 })
836 .unwrap_or_default();
837 if contexts.is_empty() {
842 faults.push("no status check is required".to_owned());
843 } else if let Some(expected) = &ctx.required_check {
844 let mut held = contexts.clone();
845 held.sort_unstable();
846 let title_check = ctx.title_check();
847 let mut owned_contexts = [expected.as_str(), title_check];
848 owned_contexts.sort_unstable();
849 if held != owned_contexts {
850 faults.push(format!(
851 "the required checks are [{}] where the setup owns [{}]",
852 contexts.join(", "),
853 owned_contexts.join(", ")
854 ));
855 }
856 } else if !contexts.contains(&ctx.title_check()) {
857 faults.push(format!("the {} check is not required", ctx.title_check()));
858 }
859 }
860 match squash_merge_sources(ctx, run)? {
861 MergeSources::Owned => {}
862 MergeSources::Faults(proven) => faults.extend(proven),
863 MergeSources::Unreadable(err) => {
867 if faults.is_empty() {
868 return Ok(StepState::unknown(err));
869 }
870 }
871 }
872 if let Some(shape) = gate_faults(ctx) {
873 faults.push(shape);
874 }
875 if !faults.is_empty() {
876 return Ok(StepState::not(faults.join("; ")));
877 }
878 Ok(StepState::ok(format!(
879 "{name} holds the release-merge shape"
880 )))
881}
882
883fn gate_faults(ctx: &Ctx) -> Option<String> {
893 let check = ctx.required_check.as_deref()?;
894 workflow_jobs::faults(
895 &workflow_jobs::read_gate(&ctx.target, check, ctx.trunk()),
896 check,
897 ctx.trunk(),
898 )
899}
900
901enum MergeSources {
903 Owned,
905 Faults(Vec<String>),
907 Unreadable(String),
909}
910
911fn squash_merge_sources(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
918 Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
919 Api::Ok(body) => {
920 let mut faults = Vec::new();
921 let owned_title = ctx.protection().github.squash_title_source.as_str();
922 let owned_body = ctx.protection().github.squash_body_source.as_str();
923 if body["squash_merge_commit_title"] != owned_title {
924 faults.push(format!(
925 "the squash title source is {} where the setup owns {owned_title}",
926 body["squash_merge_commit_title"]
927 ));
928 }
929 if body["squash_merge_commit_message"] != owned_body {
930 faults.push(format!(
931 "the squash message source is {} where the setup owns {owned_body}",
932 body["squash_merge_commit_message"]
933 ));
934 }
935 if faults.is_empty() {
936 MergeSources::Owned
937 } else {
938 MergeSources::Faults(faults)
939 }
940 }
941 Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
942 Api::Failed(err) => MergeSources::Unreadable(err),
943 })
944}
945
946enum RulesetLookup {
949 Found(Value),
951 Absent,
954 Unreadable(String),
956}
957
958fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
960 let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
964 Api::Ok(body) => body,
965 Api::Missing => {
966 return Ok(RulesetLookup::Unreadable(
967 "the ruleset inventory is not readable".into(),
968 ));
969 }
970 Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
971 };
972 let id = list
973 .as_array()
974 .into_iter()
975 .flatten()
976 .find(|ruleset| ruleset["name"] == name)
977 .and_then(|ruleset| ruleset["id"].as_i64());
978 let Some(id) = id else {
979 return Ok(RulesetLookup::Absent);
980 };
981 match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
982 Api::Ok(body) => Ok(RulesetLookup::Found(body)),
983 Api::Missing => Ok(RulesetLookup::Unreadable(format!(
987 "the {name} detail is not readable"
988 ))),
989 Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
990 }
991}
992
993const 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";
997
998const GITLAB_TAG_LIMITATION: &str =
1000 "an Owner or Maintainer can still delete a protected tag through the UI or API";
1001
1002const 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";
1007
1008const 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";
1010
1011const 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";
1014
1015#[allow(
1016 clippy::too_many_lines,
1017 reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
1018)]
1019fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
1020 let trunk = ctx.trunk();
1021 let project = ctx.repo.replace('/', "%2F");
1022 match step {
1023 "private-vulnerability-reporting" => {
1024 let path = format!("projects/{project}");
1025 Ok(match api_get(ctx, run, &path)? {
1026 Api::Ok(body) => {
1027 let access = body["issues_access_level"].as_str();
1028 if !matches!(access, Some("enabled" | "private" | "disabled")) {
1029 StepState::unknown("issue intake access is unreadable")
1030 } else if body
1031 .get("issues_enabled")
1032 .is_some_and(|flag| !flag.is_boolean())
1033 {
1034 StepState::unknown("legacy issue intake flag is unreadable")
1035 } else if body["issues_enabled"] == false || access == Some("disabled") {
1036 StepState::not("issue intake is disabled; see setup guide step 3g")
1037 } else if access == Some("private") {
1038 StepState::not("issue intake is restricted; see setup guide step 3g")
1039 } else {
1040 StepState::ok_with_limitation(
1041 "issue intake is enabled",
1042 GITLAB_PRIVATE_REPORTING_LIMITATION,
1043 )
1044 }
1045 }
1046 Api::Missing => {
1047 StepState::unknown(format!("{path}: issue intake is unreadable (404)"))
1048 }
1049 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
1050 })
1051 }
1052
1053 "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1054 Api::Ok(body) => {
1055 let found = body["default_branch"].as_str().unwrap_or("");
1056 if found == trunk {
1057 StepState::ok(format!("{trunk} is the default branch"))
1058 } else {
1059 StepState::not(format!("the default branch is {found}"))
1060 }
1061 }
1062 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1063 Api::Failed(err) => StepState::unknown(err),
1064 }),
1065 "single-trunk" => {
1066 for candidate in ctx.retired_branches() {
1067 let candidate = candidate.as_str();
1068 if candidate == trunk {
1069 continue;
1070 }
1071 match api_get(
1072 ctx,
1073 run,
1074 &format!("projects/{project}/repository/branches/{candidate}"),
1075 )? {
1076 Api::Missing => {}
1077 Api::Ok(_) => {
1078 return Ok(StepState::not(format!("a {candidate} branch still exists")));
1079 }
1080 Api::Failed(err) => return Ok(StepState::unknown(err)),
1081 }
1082 }
1083 Ok(StepState::ok(
1084 "no long-lived branch besides the trunk remains",
1085 ))
1086 }
1087 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1088 Api::Ok(body) => {
1089 if body["remove_source_branch_after_merge"]
1090 .as_bool()
1091 .unwrap_or(false)
1092 {
1093 StepState::ok("a merged branch is deleted by the forge")
1094 } else {
1095 StepState::not("a merged branch outlives its merge")
1096 }
1097 }
1098 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1099 Api::Failed(err) => StepState::unknown(err),
1100 }),
1101 "auto-merge" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1102 Api::Ok(body) => {
1103 if body["only_allow_merge_if_pipeline_succeeds"]
1104 .as_bool()
1105 .unwrap_or(false)
1106 {
1107 StepState::ok_with_limitation(
1108 "a request may merge itself once its pipeline passes",
1109 GITLAB_AUTO_MERGE_LIMITATION,
1110 )
1111 } else {
1112 StepState::not(
1113 "the pipeline requirement auto-merge rides on is off; protect-trunk asserts it",
1114 )
1115 }
1116 }
1117 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1118 Api::Failed(err) => StepState::unknown(err),
1119 }),
1120 "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1121 Api::Ok(body) => {
1122 if body["jobs_enabled"] == true {
1123 StepState::ok("pipelines are enabled")
1124 } else {
1125 StepState::not("pipelines are disabled")
1126 }
1127 }
1128 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1129 Api::Failed(err) => StepState::unknown(err),
1130 }),
1131 "install-bot" => {
1132 let mut active = false;
1138 let mut exhausted = false;
1139 for page in 1..=10u32 {
1140 let path = format!(
1141 "projects/{project}/access_tokens?state=active&per_page=100&page={page}"
1142 );
1143 let list = match api_get(ctx, run, &path)? {
1144 Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1145 Api::Missing => Vec::new(),
1146 Api::Failed(err) => return Ok(StepState::unknown(err)),
1147 };
1148 active = active
1149 || list.iter().any(|token| {
1150 token["name"] == "release-bot"
1151 && token["revoked"] == false
1152 && token["active"] != false
1153 });
1154 if list.len() < 100 {
1155 exhausted = true;
1156 }
1157 if active || exhausted {
1158 break;
1159 }
1160 }
1161 if !active {
1162 return Ok(if exhausted {
1163 StepState::not("no active release-bot token exists")
1164 } else {
1165 StepState::unknown(
1166 "the token listing did not exhaust within ten pages; nothing was decided",
1167 )
1168 });
1169 }
1170 Ok(
1174 match api_get(
1175 ctx,
1176 run,
1177 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1178 )? {
1179 Api::Ok(_) => StepState::ok(
1180 "an active release-bot token exists and its variable is stored",
1181 ),
1182 Api::Missing => StepState::not(
1183 "an active release-bot token exists with no stored variable; a rerun revokes and replaces it",
1184 ),
1185 Api::Failed(err) => StepState::unknown(err),
1186 },
1187 )
1188 }
1189 "bot-secrets" => Ok(
1190 match api_get(
1191 ctx,
1192 run,
1193 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1194 )? {
1195 Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
1196 Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
1197 Api::Failed(err) => StepState::unknown(err),
1198 },
1199 ),
1200 "protect-trunk" => {
1201 let protection = match api_get(
1202 ctx,
1203 run,
1204 &format!("projects/{project}/protected_branches/{trunk}"),
1205 )? {
1206 Api::Ok(body) => body,
1207 Api::Missing => {
1208 return Ok(StepState::not(format!("{trunk} is not protected")));
1209 }
1210 Api::Failed(err) => return Ok(StepState::unknown(err)),
1211 };
1212 let grants = protection["push_access_levels"]
1216 .as_array()
1217 .cloned()
1218 .unwrap_or_default();
1219 let policy = ctx.protection();
1220 let no_push =
1221 grants.len() == 1 && grants[0]["access_level"] == policy.gitlab.push_access_level;
1222 let merges = protection["merge_access_levels"]
1226 .as_array()
1227 .cloned()
1228 .unwrap_or_default();
1229 let can_merge =
1230 merges.len() == 1 && merges[0]["access_level"] == policy.gitlab.merge_access_level;
1231 let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
1232 Api::Ok(body) => body,
1233 Api::Missing | Api::Failed(_) => Value::Null,
1234 };
1235 let mut faults = Vec::new();
1236 if !no_push {
1237 faults.push(format!(
1238 "{trunk} still takes a direct push: the forge honors the most permissive of {} push grants",
1239 grants.len()
1240 ));
1241 }
1242 if !can_merge {
1243 faults.push(format!(
1244 "{trunk} merge grants are not exactly the one owned maintainer level"
1245 ));
1246 }
1247 if protection["allow_force_push"] != false {
1248 faults.push(format!("{trunk} allows force pushes"));
1249 }
1250 if settings["only_allow_merge_if_pipeline_succeeds"] != true {
1251 faults.push("the pipeline requirement is off".to_owned());
1252 }
1253 if settings["merge_method"] != policy.gitlab.merge_method.as_str() {
1254 faults.push("the merge method is not fast-forward".to_owned());
1255 }
1256 if settings["squash_option"] != policy.gitlab.squash_option.as_str() {
1257 faults.push("merge requests do not always squash".to_owned());
1258 }
1259 if settings["squash_commit_template"] != policy.gitlab.squash_commit_template.as_str() {
1260 faults.push("the squash template is not the merge request's title".to_owned());
1261 }
1262 Ok(if faults.is_empty() {
1263 StepState::ok_with_limitation(
1264 format!("{trunk} holds the release-merge shape"),
1265 GITLAB_TITLE_LIMITATION,
1266 )
1267 } else {
1268 StepState::not(faults.join("; "))
1269 })
1270 }
1271 "protect-tags" => Ok(
1272 match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
1273 Api::Ok(_) => {
1274 StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
1275 }
1276 Api::Missing => StepState::not("v* is not protected"),
1277 Api::Failed(err) => StepState::unknown(err),
1278 },
1279 ),
1280 "protect-release-lines" => Ok(
1281 match api_get(
1282 ctx,
1283 run,
1284 &format!("projects/{project}/protected_branches/release%2F%2A"),
1285 )? {
1286 Api::Ok(body) => {
1287 let level_ok = |levels: &Value| {
1288 levels
1289 .as_array()
1290 .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
1291 };
1292 if body["allow_force_push"] != false {
1293 StepState::not("release/* allows force pushes")
1294 } else if !level_ok(&body["push_access_levels"])
1295 || !level_ok(&body["merge_access_levels"])
1296 {
1297 StepState::not(
1301 "release/* grants are not exactly the owned maintainer levels",
1302 )
1303 } else {
1304 StepState::ok("release/* refuses force pushes and deletion by git clients")
1305 }
1306 }
1307 Api::Missing => StepState::inapplicable(
1308 "release/* is unprotected; optional — applied only where older lines exist",
1309 ),
1310 Api::Failed(err) => StepState::unknown(err),
1311 },
1312 ),
1313 "protections-check" => {
1314 let mut failures = Vec::new();
1317 let mut unknowns = Vec::new();
1318 let mut limitations: Vec<String> = Vec::new();
1321 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
1322 match gitlab(ctx, owned, run)? {
1323 StepState::Satisfied {
1324 limitation: found, ..
1325 } => limitations.extend(found),
1326 StepState::Inapplicable { .. } => {}
1327 StepState::Unsatisfied { detail } => {
1328 failures.push(format!("{owned}: {detail}"));
1329 }
1330 StepState::Unknown { detail } => {
1331 unknowns.push(format!("{owned}: {detail}"));
1332 }
1333 }
1334 }
1335 Ok(if !failures.is_empty() {
1336 StepState::not(failures.join("; "))
1337 } else if !unknowns.is_empty() {
1338 StepState::unknown(unknowns.join("; "))
1339 } else {
1340 StepState::Satisfied {
1341 detail: "the protections hold, as far as this forge enforces them".into(),
1342 limitation: if limitations.is_empty() {
1343 None
1344 } else {
1345 Some(limitations.join("; "))
1346 },
1347 }
1348 })
1349 }
1350 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1351 }
1352}