1use serde_json::Value;
11
12use crate::cargo_package::{self, Answer, POLICY_DESTINATION, Probe};
13use crate::detect::Forge;
14use crate::error::RkError;
15use crate::setup::app_jwt::{self, AppApi};
16use crate::setup::context::Ctx;
17use crate::setup::process::{Exec, Outcome};
18use crate::setup::workflow_jobs;
19
20pub type Runner<'a> = dyn FnMut(&Exec) -> Result<Outcome, RkError> + 'a;
23
24const 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";
40
41#[derive(Debug)]
43pub enum StepState {
44 Satisfied {
47 detail: String,
49 limitation: Option<String>,
51 },
52 Unsatisfied {
54 detail: String,
56 },
57 Inapplicable {
60 detail: String,
62 },
63 Unknown {
65 detail: String,
67 },
68}
69
70impl StepState {
71 #[must_use]
73 pub fn detail(&self) -> &str {
74 match self {
75 Self::Satisfied { detail, .. }
76 | Self::Unsatisfied { detail }
77 | Self::Inapplicable { detail }
78 | Self::Unknown { detail } => detail,
79 }
80 }
81
82 #[must_use]
84 pub const fn satisfied(&self) -> bool {
85 matches!(self, Self::Satisfied { .. })
86 }
87
88 fn ok(detail: impl Into<String>) -> Self {
89 Self::Satisfied {
90 detail: detail.into(),
91 limitation: None,
92 }
93 }
94
95 fn ok_with_limitation(detail: impl Into<String>, limitation: impl Into<String>) -> Self {
96 Self::Satisfied {
97 detail: detail.into(),
98 limitation: Some(limitation.into()),
99 }
100 }
101
102 fn not(detail: impl Into<String>) -> Self {
103 Self::Unsatisfied {
104 detail: detail.into(),
105 }
106 }
107
108 fn inapplicable(detail: impl Into<String>) -> Self {
109 Self::Inapplicable {
110 detail: detail.into(),
111 }
112 }
113
114 fn unknown(detail: impl Into<String>) -> Self {
115 Self::Unknown {
116 detail: detail.into(),
117 }
118 }
119}
120
121pub(crate) enum Api {
123 Ok(Value),
125 Missing,
127 Failed(String),
129}
130
131pub fn observe(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
138 if step == "package-check" {
139 return package_check(ctx, run);
140 }
141 if step == "branch-reminder" {
142 return Ok(branch_reminder_state(ctx));
143 }
144 if step == "forge-version" {
145 return forge_version(ctx, run);
146 }
147 match ctx.forge {
148 Some(Forge::Github) => github(ctx, step, run),
149 Some(Forge::Gitlab) => gitlab(ctx, step, run),
150 None => Ok(StepState::inapplicable(
154 "the profile names no forge this release drives",
155 )),
156 }
157}
158
159const PYTHON_LIMITATION: &str = "sdist and wheel policy inclusion is unproved: PEP 517 leaves the file set to the build backend and the two outputs can differ; inspect both before publishing";
164
165const BASH_LIMITATION: &str = "the make dist tarball is not inspected: git archive honours export-ignore, so SECURITY.md inclusion is unproved; inspect the generated tarball before publishing";
168
169fn package_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
180 if let Some(reason) = super::steps::no_registry(ctx) {
183 return Ok(StepState::ok(reason));
184 }
185 let policy = ctx.reporting_policy();
186 let (program, args): (&str, &[&str]) = match ctx.tech {
187 Some("rust") => ("cargo", &["publish", "--dry-run", "--allow-dirty"]),
188 Some("python") => ("python3", &["-m", "build"]),
189 Some("bash") => {
190 let built = "no registry for this technology; there is nothing to package";
191 return Ok(if policy {
192 StepState::ok_with_limitation(built, BASH_LIMITATION)
193 } else {
194 StepState::ok(built)
195 });
196 }
197 Some(other) => {
198 return Ok(StepState::unknown(format!(
199 "no packaging check is defined for {other}"
200 )));
201 }
202 None => {
203 return Ok(StepState::unknown(
204 "no version file names a technology; see rk binding --list",
205 ));
206 }
207 };
208 let outcome = run(&cargo_exec(ctx, program, args))?;
209 if !outcome.success() {
210 return Ok(StepState::not(format!(
211 "the packaging check failed: {}",
212 last_line(&outcome.stderr)
213 )));
214 }
215 let built = "the package builds and passes the registry's dry run";
216 Ok(match ctx.tech {
217 Some("rust") => policy_in_the_crate(ctx, run, built)?,
218 _ if policy => StepState::ok_with_limitation(built, PYTHON_LIMITATION),
219 _ => StepState::ok(built),
220 })
221}
222
223fn cargo_exec(ctx: &Ctx, program: &str, args: &[&str]) -> Exec {
225 Exec {
226 program: program.into(),
227 args: args.iter().map(Into::into).collect(),
228 env: ctx.child_env("package-check"),
229 cwd: ctx.target.as_std_path().to_path_buf(),
230 stdin: None,
231 }
232}
233
234fn policy_in_the_crate(ctx: &Ctx, run: &mut Runner, built: &str) -> Result<StepState, RkError> {
250 let policy = ctx.reporting_policy();
251 let root_manifest = ctx.target.as_std_path().join("Cargo.toml");
252 let probe = cargo_package::probe(&root_manifest, &[], |args| {
253 run(&cargo_exec(ctx, "cargo", args)).map(|outcome| Answer {
254 success: outcome.success(),
255 stdout: outcome.stdout,
256 stderr: outcome.stderr,
257 })
258 })?;
259 let listing = match probe {
260 Probe::Listed(listing) => listing,
261 Probe::OtherShape => {
262 let unproved = if policy {
263 format!(
264 "{POLICY_DESTINATION} inclusion and the absence of release-kit's own files are"
265 )
266 } else {
267 "the absence of release-kit's own files is".to_owned()
268 };
269 return Ok(StepState::ok_with_limitation(
270 built,
271 format!(
272 "{unproved} unproved: the package check lists files only for a single default package rooted at the target, and this workspace selects a different shape; inspect the published archive before releasing"
273 ),
274 ));
275 }
276 Probe::MetadataFailed(stderr) => {
277 return Ok(StepState::unknown(format!(
278 "{built}, and the listing check could not run: cargo metadata failed: {}",
279 last_line(&stderr)
280 )));
281 }
282 Probe::ListingFailed(stderr) => {
283 return Ok(StepState::unknown(format!(
284 "{built}, and the listing check could not run: cargo package --list failed: {}",
285 last_line(&stderr)
286 )));
287 }
288 };
289 let forbidden = cargo_package::release_kit_paths(ctx.landed_destinations());
290 let shipped = listing.shipped(&forbidden);
291 Ok(boundary_state(
292 built,
293 policy,
294 !policy || listing.carries(POLICY_DESTINATION),
295 &shipped,
296 ))
297}
298
299fn boundary_state(built: &str, policy: bool, carried: bool, shipped: &[&str]) -> StepState {
303 const WHY: &str = "and release-plz attributes every commit touching one of them to the package, so a landing that rewrites one asks for a release with no code change";
304 let policy_fix = format!(
305 "add /{POLICY_DESTINATION} to [package].include, remove the [package].exclude entry matching it, or stop ignoring the file"
306 );
307 let own = || {
308 let entries = cargo_package::exclude_entries(shipped)
309 .iter()
310 .map(|entry| format!("\"{entry}\""))
311 .collect::<Vec<_>>()
312 .join(", ");
313 (
314 shipped.join(", "),
315 format!("add {entries} to [package].exclude, or drop them from [package].include"),
316 )
317 };
318 match (carried, shipped.is_empty()) {
319 (true, true) if policy => StepState::ok(format!(
320 "{built}, and the published package carries {POLICY_DESTINATION} and no file release-kit lands or owns"
321 )),
322 (true, true) => StepState::ok(format!(
323 "{built}, and the published package ships no file release-kit lands or owns"
324 )),
325 (false, true) => StepState::not(format!(
326 "{built}, but the published package omits {POLICY_DESTINATION}: {policy_fix}"
327 )),
328 (true, false) => {
329 let (list, fix) = own();
330 StepState::not(format!(
331 "{built}, but the published package ships files release-kit lands or owns ({list}), {WHY}: {fix}"
332 ))
333 }
334 (false, false) => {
335 let (list, fix) = own();
336 StepState::not(format!(
337 "{built}, but the published package omits {POLICY_DESTINATION} and ships files release-kit lands or owns ({list}), {WHY}: {policy_fix}; and {fix}"
338 ))
339 }
340 }
341}
342
343fn branch_reminder_state(ctx: &Ctx) -> StepState {
346 use crate::setup::branch_reminder::{HookState, observe_hook};
347 match observe_hook(&ctx.target) {
348 HookState::Installed => {
349 StepState::ok("the post-merge hook carries the release-kit reminder")
350 }
351 HookState::Absent => StepState::not("no post-merge hook is installed"),
352 HookState::Foreign => {
353 StepState::not("a post-merge hook exists without the release-kit marker")
354 }
355 HookState::Drifted => StepState::not("the reminder hook drifted from this binary's body"),
356 HookState::Unreadable(detail) => StepState::unknown(detail),
357 }
358}
359
360pub const GITLAB_VERSION_FLOOR: (u64, u64) = (18, 2);
367
368const GITLAB_EDITIONS: [&str; 2] = ["ee", "ce"];
373
374fn version_refusal(found: &str, prerelease: Option<&str>, on_gitlab_com: bool) -> String {
378 let (major, minor) = GITLAB_VERSION_FLOOR;
379 let mut said = vec![format!(
380 "this GitLab instance reports {found}; the convention needs {major}.{minor} or newer"
381 )];
382 if let Some(suffix) = prerelease {
383 said.push(format!(
384 "the -{suffix} suffix is a pre-release, and nothing proves the feature shipped in it, so this step fails closed"
385 ));
386 }
387 said.push(format!(
388 "the merge-request pipeline triggers a child pipeline with `strategy: mirror`, which GitLab added in {major}.{minor}"
389 ));
390 said.push(
391 "below it the child's status never reaches the parent pipeline, so a failing project job merges".to_owned(),
392 );
393 if on_gitlab_com {
394 said.push(format!(
395 "gitlab.com runs below {major}.{minor} only until its next deployment; rerun the check after it"
396 ));
397 } else {
398 said.push(format!(
399 "upgrade the instance to {major}.{minor} or newer, or host the project on gitlab.com"
400 ));
401 }
402 said.join("; ")
403}
404
405fn forge_version(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
411 if ctx.forge == Some(Forge::Github) {
412 return Ok(StepState::ok(
413 "github.com is a rolling service and declares no version floor",
414 ));
415 }
416 let body = match api_get(ctx, run, "version")? {
417 Api::Ok(body) => body,
418 Api::Missing => {
419 return Ok(StepState::unknown(
420 "this instance answers no GET /version; the floor cannot be read. Check that glab is authenticated against it: glab auth login",
421 ));
422 }
423 Api::Failed(err) => {
424 return Ok(StepState::unknown(format!(
425 "the version could not be read: {err}. Check that glab is authenticated against this instance: glab auth login"
426 )));
427 }
428 };
429 let Some(found) = body["version"].as_str() else {
430 return Ok(StepState::unknown(
431 "the forge answer carries no version field; the floor cannot be read. Check that glab is authenticated against this instance: glab auth login",
432 ));
433 };
434 let (number, suffix) = found
435 .split_once('-')
436 .map_or((found, None), |(n, s)| (n, Some(s)));
437 let mut parts = number.split('.');
438 let parsed = parts
439 .next()
440 .and_then(|major| major.parse::<u64>().ok())
441 .zip(parts.next().and_then(|minor| minor.parse::<u64>().ok()));
442 let Some(pair) = parsed else {
443 return Ok(StepState::unknown(format!(
444 "the forge reports the version as '{found}', which names no major and minor pair; the floor cannot be read"
445 )));
446 };
447 let on_gitlab_com = ctx.on_gitlab_com();
452 let prerelease = suffix.filter(|s| !GITLAB_EDITIONS.contains(s));
453 if let Some(suffix) = prerelease.filter(|_| !on_gitlab_com) {
454 return Ok(StepState::not(version_refusal(found, Some(suffix), false)));
455 }
456 if pair < GITLAB_VERSION_FLOOR {
457 return Ok(StepState::not(version_refusal(found, None, on_gitlab_com)));
458 }
459 let (major, minor) = GITLAB_VERSION_FLOOR;
460 Ok(StepState::ok(prerelease.map_or_else(
461 || format!("this instance reports {found}, at or above the {major}.{minor} floor"),
462 |suffix| {
463 format!(
464 "gitlab.com reports {found}, at or above the {major}.{minor} floor; the -{suffix} suffix is the SaaS deployment's own"
465 )
466 },
467 )))
468}
469
470pub fn single_trunk_guard(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
480 let trunk = ctx.trunk();
481 for candidate in ctx.retired_branches() {
482 let candidate = candidate.as_str();
483 if candidate == trunk {
484 continue;
485 }
486 let state = match ctx.forge {
487 Some(Forge::Github) => github_candidate_guard(ctx, run, candidate)?,
488 Some(Forge::Gitlab) => gitlab_candidate_guard(ctx, run, candidate)?,
489 None => StepState::unknown("the profile names no forge this release drives"),
492 };
493 if !state.satisfied() {
494 return Ok(state);
495 }
496 }
497 Ok(StepState::ok(
498 "every candidate branch is absent, or an ancestor of the trunk",
499 ))
500}
501
502fn github_candidate_guard(
504 ctx: &Ctx,
505 run: &mut Runner,
506 candidate: &str,
507) -> Result<StepState, RkError> {
508 let trunk = ctx.trunk();
509 match api_get(
510 ctx,
511 run,
512 &format!("repos/{}/git/ref/heads/{candidate}", ctx.repo),
513 )? {
514 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
515 Api::Failed(err) => return Ok(StepState::unknown(err)),
516 Api::Ok(_) => {}
517 }
518 match api_get(
519 ctx,
520 run,
521 &format!("repos/{}/compare/{candidate}...{trunk}", ctx.repo),
522 )? {
523 Api::Ok(body) => {
524 let status = body["status"].as_str().unwrap_or("");
525 Ok(if matches!(status, "ahead" | "identical") {
526 StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
527 } else {
528 StepState::not(format!(
529 "{candidate} is not an ancestor of {trunk} ({status}); deleting it would lose work"
530 ))
531 })
532 }
533 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
534 Api::Failed(err) => Ok(StepState::unknown(err)),
535 }
536}
537
538fn gitlab_candidate_guard(
540 ctx: &Ctx,
541 run: &mut Runner,
542 candidate: &str,
543) -> Result<StepState, RkError> {
544 let trunk = ctx.trunk();
545 let project = ctx.repo.replace('/', "%2F");
546 match api_get(
547 ctx,
548 run,
549 &format!("projects/{project}/repository/branches/{candidate}"),
550 )? {
551 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
552 Api::Failed(err) => return Ok(StepState::unknown(err)),
553 Api::Ok(_) => {}
554 }
555 match api_get(
556 ctx,
557 run,
558 &format!("projects/{project}/repository/compare?from={trunk}&to={candidate}"),
559 )? {
560 Api::Ok(body) => {
561 let ahead = body["commits"]
562 .as_array()
563 .is_some_and(|list| !list.is_empty());
564 Ok(if ahead {
565 StepState::not(format!(
566 "{candidate} carries commits {trunk} does not; deleting it would lose work"
567 ))
568 } else {
569 StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
570 })
571 }
572 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
573 Api::Failed(err) => Ok(StepState::unknown(err)),
574 }
575}
576
577pub(crate) fn api_get(ctx: &Ctx, run: &mut Runner, path: &str) -> Result<Api, RkError> {
579 let exec = Exec {
580 program: ctx.cli.clone().into_os_string(),
581 args: vec!["api".into(), path.into()],
582 env: ctx.child_env("observe"),
583 cwd: ctx.target.as_std_path().to_path_buf(),
584 stdin: None,
585 };
586 let outcome = run(&exec)?;
587 if outcome.success() {
588 return Ok(
589 serde_json::from_slice::<Value>(&outcome.stdout).map_or_else(
590 |_| Api::Failed("the forge answer did not parse as JSON".into()),
591 Api::Ok,
592 ),
593 );
594 }
595 let stderr = String::from_utf8_lossy(&outcome.stderr).into_owned();
596 if stderr.contains("404") {
597 Ok(Api::Missing)
598 } else {
599 Ok(Api::Failed(last_line(&outcome.stderr)))
600 }
601}
602
603fn last_line(bytes: &[u8]) -> String {
605 String::from_utf8_lossy(bytes)
606 .lines()
607 .rev()
608 .find(|line| !line.trim().is_empty())
609 .unwrap_or("no output")
610 .to_owned()
611}
612
613#[allow(
614 clippy::too_many_lines,
615 reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
616)]
617fn github(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
618 let trunk = ctx.trunk();
619 let repo = &ctx.repo;
620 match step {
621 "private-vulnerability-reporting" => {
622 let visibility_path = format!("repos/{repo}");
623 match api_get(ctx, run, &visibility_path)? {
624 Api::Ok(body) => match body["private"].as_bool() {
625 Some(true) => {
626 return Ok(StepState::inapplicable(
627 "private vulnerability reporting is available for public repositories",
628 ));
629 }
630 Some(false) => {}
631 None => {
632 return Ok(StepState::unknown(format!(
633 "{visibility_path}: repository visibility is unreadable"
634 )));
635 }
636 },
637 Api::Missing => {
638 return Ok(StepState::unknown(format!(
639 "{visibility_path}: repository visibility is unreadable (404)"
640 )));
641 }
642 Api::Failed(err) => {
643 return Ok(StepState::unknown(format!("{visibility_path}: {err}")));
644 }
645 }
646 let path = format!("repos/{repo}/private-vulnerability-reporting");
647 Ok(match api_get(ctx, run, &path)? {
648 Api::Ok(body) => match body["enabled"].as_bool() {
649 Some(true) => StepState::ok("private vulnerability reporting is enabled"),
650 Some(false) => StepState::not("private vulnerability reporting is disabled"),
651 None => StepState::unknown(format!("{path}: enabled is unreadable")),
652 },
653 Api::Missing => {
654 StepState::unknown(format!("{path}: reporting state is unreadable (404)"))
655 }
656 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
657 })
658 }
659
660 "default-branch" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
661 Api::Ok(body) => {
662 let found = body["default_branch"].as_str().unwrap_or("");
663 if found == trunk {
664 StepState::ok(format!("{trunk} is the default branch"))
665 } else {
666 StepState::not(format!("the default branch is {found}"))
667 }
668 }
669 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
670 Api::Failed(err) => StepState::unknown(err),
671 }),
672 "single-trunk" => {
673 for candidate in ctx.retired_branches() {
674 let candidate = candidate.as_str();
675 if candidate == trunk {
676 continue;
677 }
678 match api_get(ctx, run, &format!("repos/{repo}/git/ref/heads/{candidate}"))? {
679 Api::Missing => {}
680 Api::Ok(_) => {
681 return Ok(StepState::not(format!("a {candidate} branch still exists")));
682 }
683 Api::Failed(err) => return Ok(StepState::unknown(err)),
684 }
685 }
686 Ok(StepState::ok(
687 "no long-lived branch besides the trunk remains",
688 ))
689 }
690 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
691 Api::Ok(body) => {
692 if body["delete_branch_on_merge"].as_bool().unwrap_or(false) {
693 StepState::ok("a merged branch is deleted by the forge")
694 } else {
695 StepState::not("a merged branch outlives its merge")
696 }
697 }
698 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
699 Api::Failed(err) => StepState::unknown(err),
700 }),
701 "auto-merge" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
702 Api::Ok(body) => {
703 if body["allow_auto_merge"].as_bool().unwrap_or(false) {
704 StepState::ok("a request may merge itself once its checks pass")
705 } else {
706 StepState::not("a request cannot merge itself; the auto-merge switch is off")
707 }
708 }
709 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
710 Api::Failed(err) => StepState::unknown(err),
711 }),
712 "ci-permissions" => Ok(
713 match api_get(
714 ctx,
715 run,
716 &format!("repos/{repo}/actions/permissions/workflow"),
717 )? {
718 Api::Ok(body) => {
719 let write = body["default_workflow_permissions"] == "write";
720 let approve = body["can_approve_pull_request_reviews"] == true;
721 if write && approve {
722 StepState::ok("CI may write and open requests")
723 } else {
724 StepState::not(format!(
725 "workflow permissions are {} with request approval {}",
726 body["default_workflow_permissions"],
727 body["can_approve_pull_request_reviews"]
728 ))
729 }
730 }
731 Api::Missing => StepState::not("no workflow permissions are readable"),
732 Api::Failed(err) => StepState::unknown(err),
733 },
734 ),
735 "bot-secrets" => Ok(
736 match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
737 Api::Ok(body) => {
738 let names: Vec<&str> = body["secrets"]
739 .as_array()
740 .map(|list| {
741 list.iter()
742 .filter_map(|secret| secret["name"].as_str())
743 .collect()
744 })
745 .unwrap_or_default();
746 let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
747 if wanted.iter().all(|name| names.contains(name)) {
748 StepState::ok("both bot secrets are stored")
749 } else if names.is_empty() {
750 StepState::not("no bot secrets are stored")
751 } else {
752 StepState::not(format!("stored secrets: {}", names.join(", ")))
753 }
754 }
755 Api::Missing => StepState::not("no secrets are readable"),
756 Api::Failed(err) => StepState::unknown(err),
757 },
758 ),
759 "protect-trunk" => github_trunk_ruleset(ctx, run),
760 "protect-tags" => github_ruleset(
761 ctx,
762 run,
763 ctx.tag_ruleset(),
764 "tag",
765 "refs/tags/v*",
766 &["deletion", "update"],
767 ),
768 "protect-release-lines" => {
769 match github_ruleset_body(ctx, run, ctx.lines_ruleset())? {
770 RulesetLookup::Absent => {
771 return Ok(StepState::inapplicable(
772 "release/* is unprotected; optional — applied only where older lines exist",
773 ));
774 }
775 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
776 RulesetLookup::Found(_) => {}
777 }
778 github_ruleset(
779 ctx,
780 run,
781 ctx.lines_ruleset(),
782 "branch",
783 "refs/heads/release/*",
784 &["deletion", "non_fast_forward"],
785 )
786 }
787 "protections-check" => {
788 let mut failures = Vec::new();
792 let mut unknowns = Vec::new();
793 let mut limitations: Vec<String> = Vec::new();
795 for owned in owned_protections(ctx) {
796 match github(ctx, owned, run)? {
797 StepState::Satisfied {
798 limitation: found, ..
799 } => limitations.extend(found),
800 StepState::Inapplicable { .. } => {}
801 StepState::Unsatisfied { detail } => {
802 failures.push(format!("{owned}: {detail}"));
803 }
804 StepState::Unknown { detail } => {
805 unknowns.push(format!("{owned}: {detail}"));
806 }
807 }
808 }
809 match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
810 Api::Ok(body) => {
811 let owned = [
812 ctx.trunk_ruleset().to_owned(),
813 ctx.safety_ruleset().to_owned(),
814 ctx.tag_ruleset().to_owned(),
815 ctx.lines_ruleset().to_owned(),
816 ];
817 for ruleset in body.as_array().into_iter().flatten() {
818 let name = ruleset["name"].as_str().unwrap_or("");
819 if !owned.iter().any(|expected| expected == name) {
820 failures.push(format!("a ruleset no step owns: {name}"));
821 }
822 }
823 }
824 Api::Missing | Api::Failed(_) => {
825 unknowns.push("the ruleset inventory is not readable".to_owned());
826 }
827 }
828 Ok(if !failures.is_empty() {
829 StepState::not(failures.join("; "))
830 } else if !unknowns.is_empty() {
831 StepState::unknown(unknowns.join("; "))
832 } else {
833 StepState::Satisfied {
834 detail: "exactly the owned protections, with those rules".into(),
835 limitation: if limitations.is_empty() {
836 None
837 } else {
838 Some(limitations.join("; "))
839 },
840 }
841 })
842 }
843 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
844 }
845}
846
847fn owned_protections(ctx: &Ctx) -> Vec<&'static str> {
851 let mut owned = vec!["protect-trunk"];
852 if ctx.tag_protection() {
853 owned.push("protect-tags");
854 }
855 owned.push("protect-release-lines");
856 owned
857}
858
859#[must_use]
867pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
868 match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
869 AppApi::Ok(body) => {
870 let id = body["id"].as_i64().unwrap_or_default();
871 let held = &body["permissions"];
872 let short: Vec<String> = minimum_grant(ctx)
873 .into_iter()
874 .filter(|(key, level)| held[key] != *level)
875 .map(|(key, level)| format!("{key}: {level}"))
876 .collect();
877 if short.is_empty() {
878 StepState::ok(format!("installation {id} covers {}", ctx.repo))
879 } else {
880 StepState::not(format!(
885 "installation {id} covers {} and does not hold [{}]; approve the App's updated permissions on the installation's own settings page",
886 ctx.repo,
887 short.join(", ")
888 ))
889 }
890 }
891 AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
892 AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
893 }
894}
895
896fn minimum_grant(ctx: &Ctx) -> Vec<(&'static str, &'static str)> {
904 let mut grant = vec![("contents", "write"), ("pull_requests", "write")];
905 if ctx.integration() == crate::landing::Integration::Local
906 && ctx.profile.release.style == Some(crate::landing::Style::Trunk)
907 {
908 grant.push(("checks", "read"));
909 }
910 grant
911}
912
913fn github_ruleset(
918 ctx: &Ctx,
919 run: &mut Runner,
920 name: &str,
921 target: &str,
922 include: &str,
923 rules: &[&str],
924) -> Result<StepState, RkError> {
925 let detail = match github_ruleset_body(ctx, run, name)? {
926 RulesetLookup::Found(detail) => detail,
927 RulesetLookup::Absent => {
928 return Ok(StepState::not(format!("no ruleset named {name}")));
929 }
930 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
931 };
932 if detail["enforcement"] != "active" {
933 return Ok(StepState::not(format!("{name} is not active")));
934 }
935 if detail["target"] != target {
938 return Ok(StepState::not(format!(
939 "{name} does not target {target} refs"
940 )));
941 }
942 if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
943 return Ok(StepState::not(format!(
944 "{name} does not cover {include} alone"
945 )));
946 }
947 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
948 return Ok(StepState::not(format!(
949 "{name} excludes refs from its own coverage"
950 )));
951 }
952 let mut held: Vec<&str> = detail["rules"]
953 .as_array()
954 .map(|list| {
955 list.iter()
956 .filter_map(|rule| rule["type"].as_str())
957 .collect()
958 })
959 .unwrap_or_default();
960 held.sort_unstable();
961 let mut expected: Vec<&str> = rules.to_vec();
962 expected.sort_unstable();
963 if held == expected {
964 Ok(StepState::ok(format!(
965 "{name} is active with exactly its rules"
966 )))
967 } else {
968 Ok(StepState::not(format!(
969 "{name} carries the rules [{}] where the setup owns [{}]",
970 held.join(", "),
971 expected.join(", ")
972 )))
973 }
974}
975
976fn unowned_rule_faults(rules: &[Value], owned: &[String]) -> Vec<String> {
991 rules
992 .iter()
993 .filter_map(|rule| rule["type"].as_str())
994 .filter(|kind| !owned.iter().any(|name| name == kind))
995 .map(|kind| {
996 if kind == "merge_queue" {
997 MERGE_QUEUE_FAULT.to_owned()
998 } else {
999 format!("an unowned rule is present: {kind}")
1000 }
1001 })
1002 .collect()
1003}
1004
1005fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
1006 let trunk = ctx.trunk();
1007 let name = ctx.trunk_ruleset().to_owned();
1008 let detail = match github_ruleset_body(ctx, run, &name)? {
1009 RulesetLookup::Found(detail) => detail,
1010 RulesetLookup::Absent => {
1011 return Ok(StepState::not(format!("no ruleset named {name}")));
1012 }
1013 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
1014 };
1015 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
1016 let mut faults = Vec::new();
1017 if detail["enforcement"] != "active" {
1018 faults.push(format!("{name} is not active"));
1019 }
1020 if detail["target"] != "branch" {
1024 faults.push(format!("{name} does not target branches"));
1025 }
1026 let expected_ref = serde_json::json!([format!("refs/heads/{trunk}")]);
1027 if detail["conditions"]["ref_name"]["include"] != expected_ref {
1028 faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
1029 }
1030 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
1033 faults.push(format!("{name} excludes refs from its own coverage"));
1034 }
1035 let expected_bypass = super::context::github_bypass_actors(&ctx.protection().bypass_actors);
1036 if detail["bypass_actors"] != expected_bypass {
1037 faults.push("the bypass actors do not match the recorded authority".to_owned());
1038 }
1039 faults.extend(trunk_rule_faults(ctx, &rules, &name));
1040 faults.extend(merge_method_fault(ctx, &rules));
1041 if let Some(checks) = rules
1042 .iter()
1043 .find(|rule| rule["type"] == "required_status_checks")
1044 {
1045 if checks["parameters"]["strict_required_status_checks_policy"]
1046 != ctx.protection().strict_required_status_checks
1047 {
1048 faults.push(STALE_MERGE_FAULT.to_owned());
1049 }
1050 let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
1051 .as_array()
1052 .map(|list| {
1053 list.iter()
1054 .filter_map(|check| check["context"].as_str())
1055 .collect()
1056 })
1057 .unwrap_or_default();
1058 if contexts.is_empty() {
1063 faults.push("no status check is required".to_owned());
1064 } else if let Some(expected) = &ctx.required_check {
1065 let mut held = contexts.clone();
1066 held.sort_unstable();
1067 let title_check = ctx.title_check();
1068 let mut owned_contexts = [expected.as_str(), title_check];
1069 owned_contexts.sort_unstable();
1070 if held != owned_contexts {
1071 faults.push(format!(
1072 "the required checks are [{}] where the setup owns [{}]",
1073 contexts.join(", "),
1074 owned_contexts.join(", ")
1075 ));
1076 }
1077 } else if !contexts.contains(&ctx.title_check()) {
1078 faults.push(format!("the {} check is not required", ctx.title_check()));
1079 }
1080 }
1081 match repository_merge_settings(ctx, run)? {
1082 MergeSources::Owned => {}
1083 MergeSources::Faults(proven) => faults.extend(proven),
1084 MergeSources::Unreadable(err) => {
1088 if faults.is_empty() {
1089 return Ok(StepState::unknown(err));
1090 }
1091 }
1092 }
1093 match github_safety_ruleset(ctx, run)? {
1094 SafetyRuleset::Owned => {}
1095 SafetyRuleset::Faults(proven) => faults.extend(proven),
1096 SafetyRuleset::Unreadable(err) => {
1097 if faults.is_empty() {
1098 return Ok(StepState::unknown(err));
1099 }
1100 }
1101 }
1102 if let Some(shape) = gate_faults(ctx) {
1103 faults.push(shape);
1104 }
1105 if !faults.is_empty() {
1106 return Ok(StepState::not(faults.join("; ")));
1107 }
1108 Ok(StepState::ok(format!(
1109 "{name} holds the release-merge shape beside {}",
1110 ctx.safety_ruleset()
1111 )))
1112}
1113
1114fn merge_method_fault(ctx: &Ctx, rules: &[Value]) -> Option<String> {
1117 let request = rules.iter().find(|rule| rule["type"] == "pull_request")?;
1118 (request["parameters"]["allowed_merge_methods"]
1119 != serde_json::json!(ctx.protection().allowed_merge_methods))
1120 .then(|| {
1121 format!(
1122 "the merge method is not exactly a {} merge",
1123 ctx.merge_form().as_str()
1124 )
1125 })
1126}
1127
1128fn trunk_rule_faults(ctx: &Ctx, rules: &[Value], name: &str) -> Vec<String> {
1136 let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
1137 let mut faults = Vec::new();
1138 let mut accounted: Vec<String> = ctx
1139 .protection()
1140 .owned_trunk_rules
1141 .iter()
1142 .filter(|rule| crate::config::REQUEST_RULES.contains(&rule.as_str()))
1143 .cloned()
1144 .collect();
1145 for required in &accounted {
1146 if !has(required) {
1147 faults.push(format!("the {required} rule is missing"));
1148 }
1149 }
1150 for stray in crate::config::SAFETY_RULES {
1151 if has(stray) {
1152 faults.push(format!(
1153 "the {stray} rule sits in {name}, where a bypass actor excuses it"
1154 ));
1155 }
1156 accounted.push(stray.to_owned());
1157 }
1158 faults.extend(unowned_rule_faults(rules, &accounted));
1159 faults
1160}
1161
1162enum SafetyRuleset {
1164 Owned,
1165 Faults(Vec<String>),
1166 Unreadable(String),
1167}
1168
1169fn github_safety_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<SafetyRuleset, RkError> {
1177 let trunk = ctx.trunk();
1178 let name = ctx.safety_ruleset().to_owned();
1179 let detail = match github_ruleset_body(ctx, run, &name)? {
1180 RulesetLookup::Found(detail) => detail,
1181 RulesetLookup::Absent => {
1182 return Ok(SafetyRuleset::Faults(vec![format!(
1183 "no ruleset named {name} holds the trunk against deletion and force-push"
1184 )]));
1185 }
1186 RulesetLookup::Unreadable(err) => return Ok(SafetyRuleset::Unreadable(err)),
1187 };
1188 let mut faults = Vec::new();
1189 if detail["enforcement"] != "active" {
1190 faults.push(format!("{name} is not active"));
1191 }
1192 if detail["target"] != "branch" {
1193 faults.push(format!("{name} does not target branches"));
1194 }
1195 if detail["conditions"]["ref_name"]["include"]
1196 != serde_json::json!([format!("refs/heads/{trunk}")])
1197 {
1198 faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
1199 }
1200 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
1201 faults.push(format!("{name} excludes refs from its own coverage"));
1202 }
1203 if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
1204 faults.push(format!(
1205 "{name} names a bypass actor, so deletion and force-push hold against nobody"
1206 ));
1207 }
1208 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
1209 let safety_rules: Vec<String> = ctx
1210 .protection()
1211 .owned_trunk_rules
1212 .iter()
1213 .filter(|rule| crate::config::SAFETY_RULES.contains(&rule.as_str()))
1214 .cloned()
1215 .collect();
1216 for required in &safety_rules {
1217 if !rules.iter().any(|rule| rule["type"] == required.as_str()) {
1218 faults.push(format!("the {required} rule is missing from {name}"));
1219 }
1220 }
1221 faults.extend(unowned_rule_faults(&rules, &safety_rules));
1222 if faults.is_empty() {
1223 Ok(SafetyRuleset::Owned)
1224 } else {
1225 Ok(SafetyRuleset::Faults(faults))
1226 }
1227}
1228
1229fn gate_faults(ctx: &Ctx) -> Option<String> {
1239 if !ctx.forge_gate() {
1240 return None;
1241 }
1242 let check = ctx.required_check.as_deref()?;
1243 let shape = workflow_jobs::faults(
1244 &workflow_jobs::read_gate(&ctx.target, check, ctx.trunk()),
1245 check,
1246 ctx.trunk(),
1247 );
1248 let waking = (ctx.integration() == crate::landing::Integration::Local)
1253 .then_some(ctx.required_workflow.as_deref())
1254 .flatten()
1255 .and_then(|workflow| {
1256 workflow_jobs::waking_workflow_fault(&ctx.target, workflow, check, ctx.trunk())
1257 });
1258 match (shape, waking) {
1259 (None, None) => None,
1260 (Some(one), None) | (None, Some(one)) => Some(one),
1261 (Some(shape), Some(waking)) => Some(format!("{shape}; {waking}")),
1262 }
1263}
1264
1265enum MergeSources {
1267 Owned,
1269 Faults(Vec<String>),
1271 Unreadable(String),
1273}
1274
1275fn repository_merge_settings(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
1290 let squash = ctx.merge_form() == crate::landing::MergeForm::Squash;
1291 Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
1292 Api::Ok(body) => {
1293 let mut faults = Vec::new();
1294 if squash {
1295 let owned_title = ctx.protection().github.squash_title_source.as_str();
1296 let owned_body = ctx.protection().github.squash_body_source.as_str();
1297 if body["squash_merge_commit_title"] != owned_title {
1298 faults.push(format!(
1299 "the squash title source is {} where the setup owns {owned_title}",
1300 body["squash_merge_commit_title"]
1301 ));
1302 }
1303 if body["squash_merge_commit_message"] != owned_body {
1304 faults.push(format!(
1305 "the squash message source is {} where the setup owns {owned_body}",
1306 body["squash_merge_commit_message"]
1307 ));
1308 }
1309 } else if let Some(fault) = rebase_switch_fault(body["allow_rebase_merge"].as_bool()) {
1310 faults.push(fault);
1311 }
1312 if faults.is_empty() {
1313 MergeSources::Owned
1314 } else {
1315 MergeSources::Faults(faults)
1316 }
1317 }
1318 Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
1319 Api::Failed(err) => MergeSources::Unreadable(err),
1320 })
1321}
1322
1323fn rebase_switch_fault(allowed: Option<bool>) -> Option<String> {
1326 match allowed {
1327 Some(true) => None,
1328 Some(false) => Some(
1329 "the repository disallows rebase merges (allow_rebase_merge is false), so the ruleset's one merge method is refused and no request can merge"
1330 .to_owned(),
1331 ),
1332 None => Some("the repository's allow_rebase_merge switch is missing or unreadable".to_owned()),
1333 }
1334}
1335
1336enum RulesetLookup {
1339 Found(Value),
1341 Absent,
1344 Unreadable(String),
1346}
1347
1348fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
1350 let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
1354 Api::Ok(body) => body,
1355 Api::Missing => {
1356 return Ok(RulesetLookup::Unreadable(
1357 "the ruleset inventory is not readable".into(),
1358 ));
1359 }
1360 Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
1361 };
1362 let id = list
1363 .as_array()
1364 .into_iter()
1365 .flatten()
1366 .find(|ruleset| ruleset["name"] == name)
1367 .and_then(|ruleset| ruleset["id"].as_i64());
1368 let Some(id) = id else {
1369 return Ok(RulesetLookup::Absent);
1370 };
1371 match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
1372 Api::Ok(body) => Ok(RulesetLookup::Found(body)),
1373 Api::Missing => Ok(RulesetLookup::Unreadable(format!(
1377 "the {name} detail is not readable"
1378 ))),
1379 Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
1380 }
1381}
1382
1383const 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";
1387
1388const GITLAB_TAG_LIMITATION: &str =
1390 "an Owner or Maintainer can still delete a protected tag through the UI or API";
1391
1392const 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";
1397
1398const 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";
1400
1401const 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";
1404
1405fn pipeline_fault(forge_gate: bool, observed: Option<bool>) -> Option<String> {
1410 match (forge_gate, observed) {
1411 (true, Some(true)) | (false, Some(false)) => None,
1412 (true, Some(false)) => Some("the pipeline requirement is off".to_owned()),
1413 (false, Some(true)) => Some(
1414 "the pipeline requirement is on, and no forge gate lands to run a pipeline, so no merge request can merge"
1415 .to_owned(),
1416 ),
1417 (_, None) => Some("the pipeline requirement is missing or unreadable".to_owned()),
1418 }
1419}
1420
1421const GITLAB_COMMIT_LIMITATION: &str =
1425 "the commit gate stops accident, not authority: a merge request runs its own CI configuration";
1426
1427#[allow(
1428 clippy::too_many_lines,
1429 reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
1430)]
1431fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
1432 let trunk = ctx.trunk();
1433 let project = ctx.repo.replace('/', "%2F");
1434 match step {
1435 "private-vulnerability-reporting" => {
1436 let path = format!("projects/{project}");
1437 Ok(match api_get(ctx, run, &path)? {
1438 Api::Ok(body) => {
1439 let access = body["issues_access_level"].as_str();
1440 if !matches!(access, Some("enabled" | "private" | "disabled")) {
1441 StepState::unknown("issue intake access is unreadable")
1442 } else if body
1443 .get("issues_enabled")
1444 .is_some_and(|flag| !flag.is_boolean())
1445 {
1446 StepState::unknown("legacy issue intake flag is unreadable")
1447 } else if body["issues_enabled"] == false || access == Some("disabled") {
1448 StepState::not("issue intake is disabled; see setup guide step 3g")
1449 } else if access == Some("private") {
1450 StepState::not("issue intake is restricted; see setup guide step 3g")
1451 } else {
1452 StepState::ok_with_limitation(
1453 "issue intake is enabled",
1454 GITLAB_PRIVATE_REPORTING_LIMITATION,
1455 )
1456 }
1457 }
1458 Api::Missing => {
1459 StepState::unknown(format!("{path}: issue intake is unreadable (404)"))
1460 }
1461 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
1462 })
1463 }
1464
1465 "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1466 Api::Ok(body) => {
1467 let found = body["default_branch"].as_str().unwrap_or("");
1468 if found == trunk {
1469 StepState::ok(format!("{trunk} is the default branch"))
1470 } else {
1471 StepState::not(format!("the default branch is {found}"))
1472 }
1473 }
1474 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1475 Api::Failed(err) => StepState::unknown(err),
1476 }),
1477 "single-trunk" => {
1478 for candidate in ctx.retired_branches() {
1479 let candidate = candidate.as_str();
1480 if candidate == trunk {
1481 continue;
1482 }
1483 match api_get(
1484 ctx,
1485 run,
1486 &format!("projects/{project}/repository/branches/{candidate}"),
1487 )? {
1488 Api::Missing => {}
1489 Api::Ok(_) => {
1490 return Ok(StepState::not(format!("a {candidate} branch still exists")));
1491 }
1492 Api::Failed(err) => return Ok(StepState::unknown(err)),
1493 }
1494 }
1495 Ok(StepState::ok(
1496 "no long-lived branch besides the trunk remains",
1497 ))
1498 }
1499 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1500 Api::Ok(body) => {
1501 if body["remove_source_branch_after_merge"]
1502 .as_bool()
1503 .unwrap_or(false)
1504 {
1505 StepState::ok("a merged branch is deleted by the forge")
1506 } else {
1507 StepState::not("a merged branch outlives its merge")
1508 }
1509 }
1510 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1511 Api::Failed(err) => StepState::unknown(err),
1512 }),
1513 "auto-merge" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1514 Api::Ok(body) => {
1515 if body["only_allow_merge_if_pipeline_succeeds"]
1516 .as_bool()
1517 .unwrap_or(false)
1518 {
1519 StepState::ok_with_limitation(
1520 "a request may merge itself once its pipeline passes",
1521 GITLAB_AUTO_MERGE_LIMITATION,
1522 )
1523 } else {
1524 StepState::not(
1525 "the pipeline requirement auto-merge rides on is off; protect-trunk asserts it",
1526 )
1527 }
1528 }
1529 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1530 Api::Failed(err) => StepState::unknown(err),
1531 }),
1532 "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1533 Api::Ok(body) => {
1534 if body["jobs_enabled"] == true {
1535 StepState::ok("pipelines are enabled")
1536 } else {
1537 StepState::not("pipelines are disabled")
1538 }
1539 }
1540 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1541 Api::Failed(err) => StepState::unknown(err),
1542 }),
1543 "install-bot" => {
1544 let mut active = false;
1550 let mut exhausted = false;
1551 for page in 1..=10u32 {
1552 let path = format!(
1553 "projects/{project}/access_tokens?state=active&per_page=100&page={page}"
1554 );
1555 let list = match api_get(ctx, run, &path)? {
1556 Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1557 Api::Missing => Vec::new(),
1558 Api::Failed(err) => return Ok(StepState::unknown(err)),
1559 };
1560 active = active
1561 || list.iter().any(|token| {
1562 token["name"] == "release-bot"
1563 && token["revoked"] == false
1564 && token["active"] != false
1565 });
1566 if list.len() < 100 {
1567 exhausted = true;
1568 }
1569 if active || exhausted {
1570 break;
1571 }
1572 }
1573 if !active {
1574 return Ok(if exhausted {
1575 StepState::not("no active release-bot token exists")
1576 } else {
1577 StepState::unknown(
1578 "the token listing did not exhaust within ten pages; nothing was decided",
1579 )
1580 });
1581 }
1582 Ok(
1586 match api_get(
1587 ctx,
1588 run,
1589 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1590 )? {
1591 Api::Ok(_) => StepState::ok(
1592 "an active release-bot token exists and its variable is stored",
1593 ),
1594 Api::Missing => StepState::not(
1595 "an active release-bot token exists with no stored variable; a rerun revokes and replaces it",
1596 ),
1597 Api::Failed(err) => StepState::unknown(err),
1598 },
1599 )
1600 }
1601 "bot-secrets" => Ok(
1602 match api_get(
1603 ctx,
1604 run,
1605 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1606 )? {
1607 Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
1608 Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
1609 Api::Failed(err) => StepState::unknown(err),
1610 },
1611 ),
1612 "protect-trunk" => {
1613 let protection = match api_get(
1614 ctx,
1615 run,
1616 &format!("projects/{project}/protected_branches/{trunk}"),
1617 )? {
1618 Api::Ok(body) => body,
1619 Api::Missing => {
1620 return Ok(StepState::not(format!("{trunk} is not protected")));
1621 }
1622 Api::Failed(err) => return Ok(StepState::unknown(err)),
1623 };
1624 let grants = protection["push_access_levels"]
1628 .as_array()
1629 .cloned()
1630 .unwrap_or_default();
1631 let policy = ctx.protection();
1632 let no_push =
1633 grants.len() == 1 && grants[0]["access_level"] == policy.gitlab.push_access_level;
1634 let merges = protection["merge_access_levels"]
1638 .as_array()
1639 .cloned()
1640 .unwrap_or_default();
1641 let can_merge =
1642 merges.len() == 1 && merges[0]["access_level"] == policy.gitlab.merge_access_level;
1643 let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
1644 Api::Ok(body) => body,
1645 Api::Missing | Api::Failed(_) => Value::Null,
1646 };
1647 let mut faults = Vec::new();
1648 if !no_push {
1649 faults.push(format!(
1650 "{trunk} still takes a direct push: the forge honors the most permissive of {} push grants",
1651 grants.len()
1652 ));
1653 }
1654 if !can_merge {
1655 faults.push(format!(
1656 "{trunk} merge grants are not exactly the one owned maintainer level"
1657 ));
1658 }
1659 if protection["allow_force_push"] != false {
1660 faults.push(format!("{trunk} allows force pushes"));
1661 }
1662 if let Some(fault) = pipeline_fault(
1667 ctx.forge_gate(),
1668 settings["only_allow_merge_if_pipeline_succeeds"].as_bool(),
1669 ) {
1670 faults.push(fault);
1671 }
1672 if settings["merge_method"] != policy.gitlab.merge_method.as_str() {
1673 faults.push("the merge method is not fast-forward".to_owned());
1674 }
1675 let squash = ctx.merge_form() == crate::landing::MergeForm::Squash;
1676 if settings["squash_option"] != policy.gitlab.squash_option.as_str() {
1677 faults.push(if squash {
1678 "merge requests do not always squash".to_owned()
1679 } else {
1680 "merge requests may squash, so a request's commits would not keep their own messages".to_owned()
1681 });
1682 }
1683 if squash
1686 && settings["squash_commit_template"]
1687 != policy.gitlab.squash_commit_template.as_str()
1688 {
1689 faults.push("the squash template is not the merge request's title".to_owned());
1690 }
1691 Ok(if faults.is_empty() {
1692 let detail = format!("{trunk} holds the release-merge shape");
1693 if ctx.forge_gate() {
1694 StepState::ok_with_limitation(
1695 detail,
1696 if squash {
1697 GITLAB_TITLE_LIMITATION
1698 } else {
1699 GITLAB_COMMIT_LIMITATION
1700 },
1701 )
1702 } else {
1703 StepState::ok(detail)
1705 }
1706 } else {
1707 StepState::not(faults.join("; "))
1708 })
1709 }
1710 "protect-tags" => Ok(
1711 match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
1712 Api::Ok(_) => {
1713 StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
1714 }
1715 Api::Missing => StepState::not("v* is not protected"),
1716 Api::Failed(err) => StepState::unknown(err),
1717 },
1718 ),
1719 "protect-release-lines" => Ok(
1720 match api_get(
1721 ctx,
1722 run,
1723 &format!("projects/{project}/protected_branches/release%2F%2A"),
1724 )? {
1725 Api::Ok(body) => {
1726 let level_ok = |levels: &Value| {
1727 levels
1728 .as_array()
1729 .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
1730 };
1731 if body["allow_force_push"] != false {
1732 StepState::not("release/* allows force pushes")
1733 } else if !level_ok(&body["push_access_levels"])
1734 || !level_ok(&body["merge_access_levels"])
1735 {
1736 StepState::not(
1740 "release/* grants are not exactly the owned maintainer levels",
1741 )
1742 } else {
1743 StepState::ok("release/* refuses force pushes and deletion by git clients")
1744 }
1745 }
1746 Api::Missing => StepState::inapplicable(
1747 "release/* is unprotected; optional — applied only where older lines exist",
1748 ),
1749 Api::Failed(err) => StepState::unknown(err),
1750 },
1751 ),
1752 "protections-check" => {
1753 let mut failures = Vec::new();
1756 let mut unknowns = Vec::new();
1757 let mut limitations: Vec<String> = Vec::new();
1760 for owned in owned_protections(ctx) {
1761 match gitlab(ctx, owned, run)? {
1762 StepState::Satisfied {
1763 limitation: found, ..
1764 } => limitations.extend(found),
1765 StepState::Inapplicable { .. } => {}
1766 StepState::Unsatisfied { detail } => {
1767 failures.push(format!("{owned}: {detail}"));
1768 }
1769 StepState::Unknown { detail } => {
1770 unknowns.push(format!("{owned}: {detail}"));
1771 }
1772 }
1773 }
1774 Ok(if !failures.is_empty() {
1775 StepState::not(failures.join("; "))
1776 } else if !unknowns.is_empty() {
1777 StepState::unknown(unknowns.join("; "))
1778 } else {
1779 StepState::Satisfied {
1780 detail: "the protections hold, as far as this forge enforces them".into(),
1781 limitation: if limitations.is_empty() {
1782 None
1783 } else {
1784 Some(limitations.join("; "))
1785 },
1786 }
1787 })
1788 }
1789 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1790 }
1791}
1792
1793#[cfg(test)]
1794mod tests {
1795 #[test]
1797 fn merge_form_rebase_switch_is_judged() {
1798 assert_eq!(super::rebase_switch_fault(Some(true)), None);
1799 assert!(
1800 super::rebase_switch_fault(Some(false))
1801 .is_some_and(|fault| fault.contains("allow_rebase_merge is false"))
1802 );
1803 assert!(
1804 super::rebase_switch_fault(None)
1805 .is_some_and(|fault| fault.contains("missing or unreadable"))
1806 );
1807 }
1808
1809 #[test]
1814 fn release_less_pipeline_requirement_is_judged_both_ways() {
1815 assert_eq!(super::pipeline_fault(true, Some(true)), None);
1816 assert_eq!(super::pipeline_fault(false, Some(false)), None);
1817 for (gate, observed) in [
1818 (true, Some(false)),
1819 (false, Some(true)),
1820 (true, None),
1821 (false, None),
1822 ] {
1823 assert!(
1824 super::pipeline_fault(gate, observed).is_some(),
1825 "gate {gate}, observed {observed:?}"
1826 );
1827 }
1828 }
1829}