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