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";
38
39#[derive(Debug)]
41pub enum StepState {
42 Satisfied {
45 detail: String,
47 limitation: Option<String>,
49 },
50 Unsatisfied {
52 detail: String,
54 },
55 Inapplicable {
58 detail: String,
60 },
61 Unknown {
63 detail: String,
65 },
66}
67
68impl StepState {
69 #[must_use]
71 pub const fn satisfied(&self) -> bool {
72 matches!(self, Self::Satisfied { .. })
73 }
74
75 fn ok(detail: impl Into<String>) -> Self {
76 Self::Satisfied {
77 detail: detail.into(),
78 limitation: None,
79 }
80 }
81
82 fn ok_with_limitation(detail: impl Into<String>, limitation: impl Into<String>) -> Self {
83 Self::Satisfied {
84 detail: detail.into(),
85 limitation: Some(limitation.into()),
86 }
87 }
88
89 fn not(detail: impl Into<String>) -> Self {
90 Self::Unsatisfied {
91 detail: detail.into(),
92 }
93 }
94
95 fn inapplicable(detail: impl Into<String>) -> Self {
96 Self::Inapplicable {
97 detail: detail.into(),
98 }
99 }
100
101 fn unknown(detail: impl Into<String>) -> Self {
102 Self::Unknown {
103 detail: detail.into(),
104 }
105 }
106}
107
108enum Api {
110 Ok(Value),
112 Missing,
114 Failed(String),
116}
117
118pub fn observe(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
125 if step == "package-check" {
126 return package_check(ctx, run);
127 }
128 if step == "branch-reminder" {
129 return Ok(branch_reminder_state(ctx));
130 }
131 if step == "forge-version" {
132 return forge_version(ctx, run);
133 }
134 match ctx.forge {
135 Some(Forge::Github) => github(ctx, step, run),
136 Some(Forge::Gitlab) => gitlab(ctx, step, run),
137 None => Ok(StepState::inapplicable(
141 "the profile names no forge this release drives",
142 )),
143 }
144}
145
146const 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";
151
152const 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";
155
156fn package_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
167 let policy = ctx.reporting_policy();
168 let (program, args): (&str, &[&str]) = match ctx.tech {
169 Some("rust") => ("cargo", &["publish", "--dry-run", "--allow-dirty"]),
170 Some("python") => ("python3", &["-m", "build"]),
171 Some("bash") => {
172 let built = "no registry for this technology; there is nothing to package";
173 return Ok(if policy {
174 StepState::ok_with_limitation(built, BASH_LIMITATION)
175 } else {
176 StepState::ok(built)
177 });
178 }
179 Some(other) => {
180 return Ok(StepState::unknown(format!(
181 "no packaging check is defined for {other}"
182 )));
183 }
184 None => {
185 return Ok(StepState::unknown(
186 "no version file names a technology; see rk binding --list",
187 ));
188 }
189 };
190 let outcome = run(&cargo_exec(ctx, program, args))?;
191 if !outcome.success() {
192 return Ok(StepState::not(format!(
193 "the packaging check failed: {}",
194 last_line(&outcome.stderr)
195 )));
196 }
197 let built = "the package builds and passes the registry's dry run";
198 Ok(match ctx.tech {
199 Some("rust") => policy_in_the_crate(ctx, run, built)?,
200 _ if policy => StepState::ok_with_limitation(built, PYTHON_LIMITATION),
201 _ => StepState::ok(built),
202 })
203}
204
205fn cargo_exec(ctx: &Ctx, program: &str, args: &[&str]) -> Exec {
207 Exec {
208 program: program.into(),
209 args: args.iter().map(Into::into).collect(),
210 env: ctx.child_env("package-check"),
211 cwd: ctx.target.as_std_path().to_path_buf(),
212 stdin: None,
213 }
214}
215
216fn policy_in_the_crate(ctx: &Ctx, run: &mut Runner, built: &str) -> Result<StepState, RkError> {
232 let policy = ctx.reporting_policy();
233 let root_manifest = ctx.target.as_std_path().join("Cargo.toml");
234 let probe = cargo_package::probe(&root_manifest, &[], |args| {
235 run(&cargo_exec(ctx, "cargo", args)).map(|outcome| Answer {
236 success: outcome.success(),
237 stdout: outcome.stdout,
238 stderr: outcome.stderr,
239 })
240 })?;
241 let listing = match probe {
242 Probe::Listed(listing) => listing,
243 Probe::OtherShape => {
244 let unproved = if policy {
245 format!(
246 "{POLICY_DESTINATION} inclusion and the absence of release-kit's own files are"
247 )
248 } else {
249 "the absence of release-kit's own files is".to_owned()
250 };
251 return Ok(StepState::ok_with_limitation(
252 built,
253 format!(
254 "{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"
255 ),
256 ));
257 }
258 Probe::MetadataFailed(stderr) => {
259 return Ok(StepState::unknown(format!(
260 "{built}, and the listing check could not run: cargo metadata failed: {}",
261 last_line(&stderr)
262 )));
263 }
264 Probe::ListingFailed(stderr) => {
265 return Ok(StepState::unknown(format!(
266 "{built}, and the listing check could not run: cargo package --list failed: {}",
267 last_line(&stderr)
268 )));
269 }
270 };
271 let forbidden = cargo_package::release_kit_paths(ctx.landed_destinations());
272 let shipped = listing.shipped(&forbidden);
273 Ok(boundary_state(
274 built,
275 policy,
276 !policy || listing.carries(POLICY_DESTINATION),
277 &shipped,
278 ))
279}
280
281fn boundary_state(built: &str, policy: bool, carried: bool, shipped: &[&str]) -> StepState {
285 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";
286 let policy_fix = format!(
287 "add /{POLICY_DESTINATION} to [package].include, remove the [package].exclude entry matching it, or stop ignoring the file"
288 );
289 let own = || {
290 let entries = cargo_package::exclude_entries(shipped)
291 .iter()
292 .map(|entry| format!("\"{entry}\""))
293 .collect::<Vec<_>>()
294 .join(", ");
295 (
296 shipped.join(", "),
297 format!("add {entries} to [package].exclude, or drop them from [package].include"),
298 )
299 };
300 match (carried, shipped.is_empty()) {
301 (true, true) if policy => StepState::ok(format!(
302 "{built}, and the published package carries {POLICY_DESTINATION} and no file release-kit lands or owns"
303 )),
304 (true, true) => StepState::ok(format!(
305 "{built}, and the published package ships no file release-kit lands or owns"
306 )),
307 (false, true) => StepState::not(format!(
308 "{built}, but the published package omits {POLICY_DESTINATION}: {policy_fix}"
309 )),
310 (true, false) => {
311 let (list, fix) = own();
312 StepState::not(format!(
313 "{built}, but the published package ships files release-kit lands or owns ({list}), {WHY}: {fix}"
314 ))
315 }
316 (false, false) => {
317 let (list, fix) = own();
318 StepState::not(format!(
319 "{built}, but the published package omits {POLICY_DESTINATION} and ships files release-kit lands or owns ({list}), {WHY}: {policy_fix}; and {fix}"
320 ))
321 }
322 }
323}
324
325fn branch_reminder_state(ctx: &Ctx) -> StepState {
328 use crate::setup::branch_reminder::{HookState, observe_hook};
329 match observe_hook(&ctx.target) {
330 HookState::Installed => {
331 StepState::ok("the post-merge hook carries the release-kit reminder")
332 }
333 HookState::Absent => StepState::not("no post-merge hook is installed"),
334 HookState::Foreign => {
335 StepState::not("a post-merge hook exists without the release-kit marker")
336 }
337 HookState::Drifted => StepState::not("the reminder hook drifted from this binary's body"),
338 HookState::Unreadable(detail) => StepState::unknown(detail),
339 }
340}
341
342pub const GITLAB_VERSION_FLOOR: (u64, u64) = (18, 2);
349
350const GITLAB_EDITIONS: [&str; 2] = ["ee", "ce"];
353
354fn version_refusal(found: &str, prerelease: Option<&str>) -> String {
357 let (major, minor) = GITLAB_VERSION_FLOOR;
358 let mut said = vec![format!(
359 "this GitLab instance reports {found}; the convention needs {major}.{minor} or newer"
360 )];
361 if let Some(suffix) = prerelease {
362 said.push(format!(
363 "the -{suffix} suffix is a pre-release, and nothing proves the feature shipped in it, so this step fails closed"
364 ));
365 }
366 said.push(format!(
367 "the merge-request pipeline triggers a child pipeline with `strategy: mirror`, which GitLab added in {major}.{minor}"
368 ));
369 said.push(
370 "below it the child's status never reaches the parent pipeline, so a failing project job merges".to_owned(),
371 );
372 said.push(format!(
373 "upgrade the instance to {major}.{minor} or newer, or host the project on gitlab.com"
374 ));
375 said.join("; ")
376}
377
378fn forge_version(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
384 if ctx.forge == Some(Forge::Github) {
385 return Ok(StepState::ok(
386 "github.com is a rolling service and declares no version floor",
387 ));
388 }
389 let body = match api_get(ctx, run, "version")? {
390 Api::Ok(body) => body,
391 Api::Missing => {
392 return Ok(StepState::unknown(
393 "this instance answers no GET /version; the floor cannot be read. Check that glab is authenticated against it: glab auth login",
394 ));
395 }
396 Api::Failed(err) => {
397 return Ok(StepState::unknown(format!(
398 "the version could not be read: {err}. Check that glab is authenticated against this instance: glab auth login"
399 )));
400 }
401 };
402 let Some(found) = body["version"].as_str() else {
403 return Ok(StepState::unknown(
404 "the forge answer carries no version field; the floor cannot be read. Check that glab is authenticated against this instance: glab auth login",
405 ));
406 };
407 let (number, suffix) = found
408 .split_once('-')
409 .map_or((found, None), |(n, s)| (n, Some(s)));
410 let mut parts = number.split('.');
411 let parsed = parts
412 .next()
413 .and_then(|major| major.parse::<u64>().ok())
414 .zip(parts.next().and_then(|minor| minor.parse::<u64>().ok()));
415 let Some(pair) = parsed else {
416 return Ok(StepState::unknown(format!(
417 "the forge reports the version as '{found}', which names no major and minor pair; the floor cannot be read"
418 )));
419 };
420 if let Some(suffix) = suffix.filter(|s| !GITLAB_EDITIONS.contains(s)) {
421 return Ok(StepState::not(version_refusal(found, Some(suffix))));
422 }
423 if pair < GITLAB_VERSION_FLOOR {
424 return Ok(StepState::not(version_refusal(found, None)));
425 }
426 let (major, minor) = GITLAB_VERSION_FLOOR;
427 Ok(StepState::ok(format!(
428 "this instance reports {found}, at or above the {major}.{minor} floor"
429 )))
430}
431
432pub fn single_trunk_guard(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
442 let trunk = ctx.trunk();
443 for candidate in ctx.retired_branches() {
444 let candidate = candidate.as_str();
445 if candidate == trunk {
446 continue;
447 }
448 let state = match ctx.forge {
449 Some(Forge::Github) => github_candidate_guard(ctx, run, candidate)?,
450 Some(Forge::Gitlab) => gitlab_candidate_guard(ctx, run, candidate)?,
451 None => StepState::unknown("the profile names no forge this release drives"),
454 };
455 if !state.satisfied() {
456 return Ok(state);
457 }
458 }
459 Ok(StepState::ok(
460 "every candidate branch is absent, or an ancestor of the trunk",
461 ))
462}
463
464fn github_candidate_guard(
466 ctx: &Ctx,
467 run: &mut Runner,
468 candidate: &str,
469) -> Result<StepState, RkError> {
470 let trunk = ctx.trunk();
471 match api_get(
472 ctx,
473 run,
474 &format!("repos/{}/git/ref/heads/{candidate}", ctx.repo),
475 )? {
476 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
477 Api::Failed(err) => return Ok(StepState::unknown(err)),
478 Api::Ok(_) => {}
479 }
480 match api_get(
481 ctx,
482 run,
483 &format!("repos/{}/compare/{candidate}...{trunk}", ctx.repo),
484 )? {
485 Api::Ok(body) => {
486 let status = body["status"].as_str().unwrap_or("");
487 Ok(if matches!(status, "ahead" | "identical") {
488 StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
489 } else {
490 StepState::not(format!(
491 "{candidate} is not an ancestor of {trunk} ({status}); deleting it would lose work"
492 ))
493 })
494 }
495 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
496 Api::Failed(err) => Ok(StepState::unknown(err)),
497 }
498}
499
500fn gitlab_candidate_guard(
502 ctx: &Ctx,
503 run: &mut Runner,
504 candidate: &str,
505) -> Result<StepState, RkError> {
506 let trunk = ctx.trunk();
507 let project = ctx.repo.replace('/', "%2F");
508 match api_get(
509 ctx,
510 run,
511 &format!("projects/{project}/repository/branches/{candidate}"),
512 )? {
513 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
514 Api::Failed(err) => return Ok(StepState::unknown(err)),
515 Api::Ok(_) => {}
516 }
517 match api_get(
518 ctx,
519 run,
520 &format!("projects/{project}/repository/compare?from={trunk}&to={candidate}"),
521 )? {
522 Api::Ok(body) => {
523 let ahead = body["commits"]
524 .as_array()
525 .is_some_and(|list| !list.is_empty());
526 Ok(if ahead {
527 StepState::not(format!(
528 "{candidate} carries commits {trunk} does not; deleting it would lose work"
529 ))
530 } else {
531 StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
532 })
533 }
534 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
535 Api::Failed(err) => Ok(StepState::unknown(err)),
536 }
537}
538
539fn api_get(ctx: &Ctx, run: &mut Runner, path: &str) -> Result<Api, RkError> {
541 let exec = Exec {
542 program: ctx.cli.clone().into_os_string(),
543 args: vec!["api".into(), path.into()],
544 env: ctx.child_env("observe"),
545 cwd: ctx.target.as_std_path().to_path_buf(),
546 stdin: None,
547 };
548 let outcome = run(&exec)?;
549 if outcome.success() {
550 return Ok(
551 serde_json::from_slice::<Value>(&outcome.stdout).map_or_else(
552 |_| Api::Failed("the forge answer did not parse as JSON".into()),
553 Api::Ok,
554 ),
555 );
556 }
557 let stderr = String::from_utf8_lossy(&outcome.stderr).into_owned();
558 if stderr.contains("404") {
559 Ok(Api::Missing)
560 } else {
561 Ok(Api::Failed(last_line(&outcome.stderr)))
562 }
563}
564
565fn last_line(bytes: &[u8]) -> String {
567 String::from_utf8_lossy(bytes)
568 .lines()
569 .rev()
570 .find(|line| !line.trim().is_empty())
571 .unwrap_or("no output")
572 .to_owned()
573}
574
575#[allow(
576 clippy::too_many_lines,
577 reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
578)]
579fn github(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
580 let trunk = ctx.trunk();
581 let repo = &ctx.repo;
582 match step {
583 "private-vulnerability-reporting" => {
584 let visibility_path = format!("repos/{repo}");
585 match api_get(ctx, run, &visibility_path)? {
586 Api::Ok(body) => match body["private"].as_bool() {
587 Some(true) => {
588 return Ok(StepState::inapplicable(
589 "private vulnerability reporting is available for public repositories",
590 ));
591 }
592 Some(false) => {}
593 None => {
594 return Ok(StepState::unknown(format!(
595 "{visibility_path}: repository visibility is unreadable"
596 )));
597 }
598 },
599 Api::Missing => {
600 return Ok(StepState::unknown(format!(
601 "{visibility_path}: repository visibility is unreadable (404)"
602 )));
603 }
604 Api::Failed(err) => {
605 return Ok(StepState::unknown(format!("{visibility_path}: {err}")));
606 }
607 }
608 let path = format!("repos/{repo}/private-vulnerability-reporting");
609 Ok(match api_get(ctx, run, &path)? {
610 Api::Ok(body) => match body["enabled"].as_bool() {
611 Some(true) => StepState::ok("private vulnerability reporting is enabled"),
612 Some(false) => StepState::not("private vulnerability reporting is disabled"),
613 None => StepState::unknown(format!("{path}: enabled is unreadable")),
614 },
615 Api::Missing => {
616 StepState::unknown(format!("{path}: reporting state is unreadable (404)"))
617 }
618 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
619 })
620 }
621
622 "default-branch" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
623 Api::Ok(body) => {
624 let found = body["default_branch"].as_str().unwrap_or("");
625 if found == trunk {
626 StepState::ok(format!("{trunk} is the default branch"))
627 } else {
628 StepState::not(format!("the default branch is {found}"))
629 }
630 }
631 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
632 Api::Failed(err) => StepState::unknown(err),
633 }),
634 "single-trunk" => {
635 for candidate in ctx.retired_branches() {
636 let candidate = candidate.as_str();
637 if candidate == trunk {
638 continue;
639 }
640 match api_get(ctx, run, &format!("repos/{repo}/git/ref/heads/{candidate}"))? {
641 Api::Missing => {}
642 Api::Ok(_) => {
643 return Ok(StepState::not(format!("a {candidate} branch still exists")));
644 }
645 Api::Failed(err) => return Ok(StepState::unknown(err)),
646 }
647 }
648 Ok(StepState::ok(
649 "no long-lived branch besides the trunk remains",
650 ))
651 }
652 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
653 Api::Ok(body) => {
654 if body["delete_branch_on_merge"].as_bool().unwrap_or(false) {
655 StepState::ok("a merged branch is deleted by the forge")
656 } else {
657 StepState::not("a merged branch outlives its merge")
658 }
659 }
660 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
661 Api::Failed(err) => StepState::unknown(err),
662 }),
663 "auto-merge" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
664 Api::Ok(body) => {
665 if body["allow_auto_merge"].as_bool().unwrap_or(false) {
666 StepState::ok("a request may merge itself once its checks pass")
667 } else {
668 StepState::not("a request cannot merge itself; the auto-merge switch is off")
669 }
670 }
671 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
672 Api::Failed(err) => StepState::unknown(err),
673 }),
674 "ci-permissions" => Ok(
675 match api_get(
676 ctx,
677 run,
678 &format!("repos/{repo}/actions/permissions/workflow"),
679 )? {
680 Api::Ok(body) => {
681 let write = body["default_workflow_permissions"] == "write";
682 let approve = body["can_approve_pull_request_reviews"] == true;
683 if write && approve {
684 StepState::ok("CI may write and open requests")
685 } else {
686 StepState::not(format!(
687 "workflow permissions are {} with request approval {}",
688 body["default_workflow_permissions"],
689 body["can_approve_pull_request_reviews"]
690 ))
691 }
692 }
693 Api::Missing => StepState::not("no workflow permissions are readable"),
694 Api::Failed(err) => StepState::unknown(err),
695 },
696 ),
697 "bot-secrets" => Ok(
698 match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
699 Api::Ok(body) => {
700 let names: Vec<&str> = body["secrets"]
701 .as_array()
702 .map(|list| {
703 list.iter()
704 .filter_map(|secret| secret["name"].as_str())
705 .collect()
706 })
707 .unwrap_or_default();
708 let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
709 if wanted.iter().all(|name| names.contains(name)) {
710 StepState::ok("both bot secrets are stored")
711 } else if names.is_empty() {
712 StepState::not("no bot secrets are stored")
713 } else {
714 StepState::not(format!("stored secrets: {}", names.join(", ")))
715 }
716 }
717 Api::Missing => StepState::not("no secrets are readable"),
718 Api::Failed(err) => StepState::unknown(err),
719 },
720 ),
721 "protect-trunk" => github_trunk_ruleset(ctx, run),
722 "protect-tags" => github_ruleset(
723 ctx,
724 run,
725 ctx.tag_ruleset(),
726 "tag",
727 "refs/tags/v*",
728 &["deletion", "update"],
729 ),
730 "protect-release-lines" => {
731 match github_ruleset_body(ctx, run, ctx.lines_ruleset())? {
732 RulesetLookup::Absent => {
733 return Ok(StepState::inapplicable(
734 "release/* is unprotected; optional — applied only where older lines exist",
735 ));
736 }
737 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
738 RulesetLookup::Found(_) => {}
739 }
740 github_ruleset(
741 ctx,
742 run,
743 ctx.lines_ruleset(),
744 "branch",
745 "refs/heads/release/*",
746 &["deletion", "non_fast_forward"],
747 )
748 }
749 "protections-check" => {
750 let mut failures = Vec::new();
754 let mut unknowns = Vec::new();
755 let mut limitations: Vec<String> = Vec::new();
757 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
758 match github(ctx, owned, run)? {
759 StepState::Satisfied {
760 limitation: found, ..
761 } => limitations.extend(found),
762 StepState::Inapplicable { .. } => {}
763 StepState::Unsatisfied { detail } => {
764 failures.push(format!("{owned}: {detail}"));
765 }
766 StepState::Unknown { detail } => {
767 unknowns.push(format!("{owned}: {detail}"));
768 }
769 }
770 }
771 match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
772 Api::Ok(body) => {
773 let owned = [
774 ctx.trunk_ruleset().to_owned(),
775 ctx.safety_ruleset().to_owned(),
776 ctx.tag_ruleset().to_owned(),
777 ctx.lines_ruleset().to_owned(),
778 ];
779 for ruleset in body.as_array().into_iter().flatten() {
780 let name = ruleset["name"].as_str().unwrap_or("");
781 if !owned.iter().any(|expected| expected == name) {
782 failures.push(format!("a ruleset no step owns: {name}"));
783 }
784 }
785 }
786 Api::Missing | Api::Failed(_) => {
787 unknowns.push("the ruleset inventory is not readable".to_owned());
788 }
789 }
790 Ok(if !failures.is_empty() {
791 StepState::not(failures.join("; "))
792 } else if !unknowns.is_empty() {
793 StepState::unknown(unknowns.join("; "))
794 } else {
795 StepState::Satisfied {
796 detail: "exactly the owned protections, with those rules".into(),
797 limitation: if limitations.is_empty() {
798 None
799 } else {
800 Some(limitations.join("; "))
801 },
802 }
803 })
804 }
805 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
806 }
807}
808
809#[must_use]
817pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
818 match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
819 AppApi::Ok(body) => {
820 let id = body["id"].as_i64().unwrap_or_default();
821 let held = &body["permissions"];
822 let short: Vec<String> = minimum_grant(ctx)
823 .into_iter()
824 .filter(|(key, level)| held[key] != *level)
825 .map(|(key, level)| format!("{key}: {level}"))
826 .collect();
827 if short.is_empty() {
828 StepState::ok(format!("installation {id} covers {}", ctx.repo))
829 } else {
830 StepState::not(format!(
835 "installation {id} covers {} and does not hold [{}]; approve the App's updated permissions on the installation's own settings page",
836 ctx.repo,
837 short.join(", ")
838 ))
839 }
840 }
841 AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
842 AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
843 }
844}
845
846fn minimum_grant(ctx: &Ctx) -> Vec<(&'static str, &'static str)> {
854 let mut grant = vec![("contents", "write"), ("pull_requests", "write")];
855 if ctx.integration() == crate::landing::Integration::Local
856 && ctx.profile.release.style == Some(crate::landing::Style::Trunk)
857 {
858 grant.push(("checks", "read"));
859 }
860 grant
861}
862
863fn github_ruleset(
868 ctx: &Ctx,
869 run: &mut Runner,
870 name: &str,
871 target: &str,
872 include: &str,
873 rules: &[&str],
874) -> Result<StepState, RkError> {
875 let detail = match github_ruleset_body(ctx, run, name)? {
876 RulesetLookup::Found(detail) => detail,
877 RulesetLookup::Absent => {
878 return Ok(StepState::not(format!("no ruleset named {name}")));
879 }
880 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
881 };
882 if detail["enforcement"] != "active" {
883 return Ok(StepState::not(format!("{name} is not active")));
884 }
885 if detail["target"] != target {
888 return Ok(StepState::not(format!(
889 "{name} does not target {target} refs"
890 )));
891 }
892 if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
893 return Ok(StepState::not(format!(
894 "{name} does not cover {include} alone"
895 )));
896 }
897 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
898 return Ok(StepState::not(format!(
899 "{name} excludes refs from its own coverage"
900 )));
901 }
902 let mut held: Vec<&str> = detail["rules"]
903 .as_array()
904 .map(|list| {
905 list.iter()
906 .filter_map(|rule| rule["type"].as_str())
907 .collect()
908 })
909 .unwrap_or_default();
910 held.sort_unstable();
911 let mut expected: Vec<&str> = rules.to_vec();
912 expected.sort_unstable();
913 if held == expected {
914 Ok(StepState::ok(format!(
915 "{name} is active with exactly its rules"
916 )))
917 } else {
918 Ok(StepState::not(format!(
919 "{name} carries the rules [{}] where the setup owns [{}]",
920 held.join(", "),
921 expected.join(", ")
922 )))
923 }
924}
925
926fn unowned_rule_faults(rules: &[Value], owned: &[String]) -> Vec<String> {
941 rules
942 .iter()
943 .filter_map(|rule| rule["type"].as_str())
944 .filter(|kind| !owned.iter().any(|name| name == kind))
945 .map(|kind| {
946 if kind == "merge_queue" {
947 MERGE_QUEUE_FAULT.to_owned()
948 } else {
949 format!("an unowned rule is present: {kind}")
950 }
951 })
952 .collect()
953}
954
955fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
956 let trunk = ctx.trunk();
957 let name = ctx.trunk_ruleset().to_owned();
958 let detail = match github_ruleset_body(ctx, run, &name)? {
959 RulesetLookup::Found(detail) => detail,
960 RulesetLookup::Absent => {
961 return Ok(StepState::not(format!("no ruleset named {name}")));
962 }
963 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
964 };
965 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
966 let mut faults = Vec::new();
967 if detail["enforcement"] != "active" {
968 faults.push(format!("{name} is not active"));
969 }
970 if detail["target"] != "branch" {
974 faults.push(format!("{name} does not target branches"));
975 }
976 let expected_ref = serde_json::json!([format!("refs/heads/{trunk}")]);
977 if detail["conditions"]["ref_name"]["include"] != expected_ref {
978 faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
979 }
980 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
983 faults.push(format!("{name} excludes refs from its own coverage"));
984 }
985 let expected_bypass = super::context::github_bypass_actors(&ctx.protection().bypass_actors);
986 if detail["bypass_actors"] != expected_bypass {
987 faults.push("the bypass actors do not match the recorded authority".to_owned());
988 }
989 faults.extend(trunk_rule_faults(ctx, &rules, &name));
990 if let Some(request) = rules.iter().find(|rule| rule["type"] == "pull_request")
991 && request["parameters"]["allowed_merge_methods"]
992 != serde_json::json!(ctx.protection().allowed_merge_methods)
993 {
994 faults.push("the merge method is not exactly a squash merge".to_owned());
995 }
996 if let Some(checks) = rules
997 .iter()
998 .find(|rule| rule["type"] == "required_status_checks")
999 {
1000 if checks["parameters"]["strict_required_status_checks_policy"]
1001 != ctx.protection().strict_required_status_checks
1002 {
1003 faults.push(STALE_MERGE_FAULT.to_owned());
1004 }
1005 let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
1006 .as_array()
1007 .map(|list| {
1008 list.iter()
1009 .filter_map(|check| check["context"].as_str())
1010 .collect()
1011 })
1012 .unwrap_or_default();
1013 if contexts.is_empty() {
1018 faults.push("no status check is required".to_owned());
1019 } else if let Some(expected) = &ctx.required_check {
1020 let mut held = contexts.clone();
1021 held.sort_unstable();
1022 let title_check = ctx.title_check();
1023 let mut owned_contexts = [expected.as_str(), title_check];
1024 owned_contexts.sort_unstable();
1025 if held != owned_contexts {
1026 faults.push(format!(
1027 "the required checks are [{}] where the setup owns [{}]",
1028 contexts.join(", "),
1029 owned_contexts.join(", ")
1030 ));
1031 }
1032 } else if !contexts.contains(&ctx.title_check()) {
1033 faults.push(format!("the {} check is not required", ctx.title_check()));
1034 }
1035 }
1036 match squash_merge_sources(ctx, run)? {
1037 MergeSources::Owned => {}
1038 MergeSources::Faults(proven) => faults.extend(proven),
1039 MergeSources::Unreadable(err) => {
1043 if faults.is_empty() {
1044 return Ok(StepState::unknown(err));
1045 }
1046 }
1047 }
1048 match github_safety_ruleset(ctx, run)? {
1049 SafetyRuleset::Owned => {}
1050 SafetyRuleset::Faults(proven) => faults.extend(proven),
1051 SafetyRuleset::Unreadable(err) => {
1052 if faults.is_empty() {
1053 return Ok(StepState::unknown(err));
1054 }
1055 }
1056 }
1057 if let Some(shape) = gate_faults(ctx) {
1058 faults.push(shape);
1059 }
1060 if !faults.is_empty() {
1061 return Ok(StepState::not(faults.join("; ")));
1062 }
1063 Ok(StepState::ok(format!(
1064 "{name} holds the release-merge shape beside {}",
1065 ctx.safety_ruleset()
1066 )))
1067}
1068
1069fn trunk_rule_faults(ctx: &Ctx, rules: &[Value], name: &str) -> Vec<String> {
1077 let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
1078 let mut faults = Vec::new();
1079 let mut accounted: Vec<String> = ctx
1080 .protection()
1081 .owned_trunk_rules
1082 .iter()
1083 .filter(|rule| crate::config::REQUEST_RULES.contains(&rule.as_str()))
1084 .cloned()
1085 .collect();
1086 for required in &accounted {
1087 if !has(required) {
1088 faults.push(format!("the {required} rule is missing"));
1089 }
1090 }
1091 for stray in crate::config::SAFETY_RULES {
1092 if has(stray) {
1093 faults.push(format!(
1094 "the {stray} rule sits in {name}, where a bypass actor excuses it"
1095 ));
1096 }
1097 accounted.push(stray.to_owned());
1098 }
1099 faults.extend(unowned_rule_faults(rules, &accounted));
1100 faults
1101}
1102
1103enum SafetyRuleset {
1105 Owned,
1106 Faults(Vec<String>),
1107 Unreadable(String),
1108}
1109
1110fn github_safety_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<SafetyRuleset, RkError> {
1118 let trunk = ctx.trunk();
1119 let name = ctx.safety_ruleset().to_owned();
1120 let detail = match github_ruleset_body(ctx, run, &name)? {
1121 RulesetLookup::Found(detail) => detail,
1122 RulesetLookup::Absent => {
1123 return Ok(SafetyRuleset::Faults(vec![format!(
1124 "no ruleset named {name} holds the trunk against deletion and force-push"
1125 )]));
1126 }
1127 RulesetLookup::Unreadable(err) => return Ok(SafetyRuleset::Unreadable(err)),
1128 };
1129 let mut faults = Vec::new();
1130 if detail["enforcement"] != "active" {
1131 faults.push(format!("{name} is not active"));
1132 }
1133 if detail["target"] != "branch" {
1134 faults.push(format!("{name} does not target branches"));
1135 }
1136 if detail["conditions"]["ref_name"]["include"]
1137 != serde_json::json!([format!("refs/heads/{trunk}")])
1138 {
1139 faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
1140 }
1141 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
1142 faults.push(format!("{name} excludes refs from its own coverage"));
1143 }
1144 if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
1145 faults.push(format!(
1146 "{name} names a bypass actor, so deletion and force-push hold against nobody"
1147 ));
1148 }
1149 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
1150 let safety_rules: Vec<String> = ctx
1151 .protection()
1152 .owned_trunk_rules
1153 .iter()
1154 .filter(|rule| crate::config::SAFETY_RULES.contains(&rule.as_str()))
1155 .cloned()
1156 .collect();
1157 for required in &safety_rules {
1158 if !rules.iter().any(|rule| rule["type"] == required.as_str()) {
1159 faults.push(format!("the {required} rule is missing from {name}"));
1160 }
1161 }
1162 faults.extend(unowned_rule_faults(&rules, &safety_rules));
1163 if faults.is_empty() {
1164 Ok(SafetyRuleset::Owned)
1165 } else {
1166 Ok(SafetyRuleset::Faults(faults))
1167 }
1168}
1169
1170fn gate_faults(ctx: &Ctx) -> Option<String> {
1180 let check = ctx.required_check.as_deref()?;
1181 let shape = workflow_jobs::faults(
1182 &workflow_jobs::read_gate(&ctx.target, check, ctx.trunk()),
1183 check,
1184 ctx.trunk(),
1185 );
1186 let waking = (ctx.integration() == crate::landing::Integration::Local)
1191 .then_some(ctx.required_workflow.as_deref())
1192 .flatten()
1193 .and_then(|workflow| {
1194 workflow_jobs::waking_workflow_fault(&ctx.target, workflow, check, ctx.trunk())
1195 });
1196 match (shape, waking) {
1197 (None, None) => None,
1198 (Some(one), None) | (None, Some(one)) => Some(one),
1199 (Some(shape), Some(waking)) => Some(format!("{shape}; {waking}")),
1200 }
1201}
1202
1203enum MergeSources {
1205 Owned,
1207 Faults(Vec<String>),
1209 Unreadable(String),
1211}
1212
1213fn squash_merge_sources(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
1220 Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
1221 Api::Ok(body) => {
1222 let mut faults = Vec::new();
1223 let owned_title = ctx.protection().github.squash_title_source.as_str();
1224 let owned_body = ctx.protection().github.squash_body_source.as_str();
1225 if body["squash_merge_commit_title"] != owned_title {
1226 faults.push(format!(
1227 "the squash title source is {} where the setup owns {owned_title}",
1228 body["squash_merge_commit_title"]
1229 ));
1230 }
1231 if body["squash_merge_commit_message"] != owned_body {
1232 faults.push(format!(
1233 "the squash message source is {} where the setup owns {owned_body}",
1234 body["squash_merge_commit_message"]
1235 ));
1236 }
1237 if faults.is_empty() {
1238 MergeSources::Owned
1239 } else {
1240 MergeSources::Faults(faults)
1241 }
1242 }
1243 Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
1244 Api::Failed(err) => MergeSources::Unreadable(err),
1245 })
1246}
1247
1248enum RulesetLookup {
1251 Found(Value),
1253 Absent,
1256 Unreadable(String),
1258}
1259
1260fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
1262 let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
1266 Api::Ok(body) => body,
1267 Api::Missing => {
1268 return Ok(RulesetLookup::Unreadable(
1269 "the ruleset inventory is not readable".into(),
1270 ));
1271 }
1272 Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
1273 };
1274 let id = list
1275 .as_array()
1276 .into_iter()
1277 .flatten()
1278 .find(|ruleset| ruleset["name"] == name)
1279 .and_then(|ruleset| ruleset["id"].as_i64());
1280 let Some(id) = id else {
1281 return Ok(RulesetLookup::Absent);
1282 };
1283 match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
1284 Api::Ok(body) => Ok(RulesetLookup::Found(body)),
1285 Api::Missing => Ok(RulesetLookup::Unreadable(format!(
1289 "the {name} detail is not readable"
1290 ))),
1291 Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
1292 }
1293}
1294
1295const 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";
1299
1300const GITLAB_TAG_LIMITATION: &str =
1302 "an Owner or Maintainer can still delete a protected tag through the UI or API";
1303
1304const 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";
1309
1310const 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";
1312
1313const 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";
1316
1317#[allow(
1318 clippy::too_many_lines,
1319 reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
1320)]
1321fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
1322 let trunk = ctx.trunk();
1323 let project = ctx.repo.replace('/', "%2F");
1324 match step {
1325 "private-vulnerability-reporting" => {
1326 let path = format!("projects/{project}");
1327 Ok(match api_get(ctx, run, &path)? {
1328 Api::Ok(body) => {
1329 let access = body["issues_access_level"].as_str();
1330 if !matches!(access, Some("enabled" | "private" | "disabled")) {
1331 StepState::unknown("issue intake access is unreadable")
1332 } else if body
1333 .get("issues_enabled")
1334 .is_some_and(|flag| !flag.is_boolean())
1335 {
1336 StepState::unknown("legacy issue intake flag is unreadable")
1337 } else if body["issues_enabled"] == false || access == Some("disabled") {
1338 StepState::not("issue intake is disabled; see setup guide step 3g")
1339 } else if access == Some("private") {
1340 StepState::not("issue intake is restricted; see setup guide step 3g")
1341 } else {
1342 StepState::ok_with_limitation(
1343 "issue intake is enabled",
1344 GITLAB_PRIVATE_REPORTING_LIMITATION,
1345 )
1346 }
1347 }
1348 Api::Missing => {
1349 StepState::unknown(format!("{path}: issue intake is unreadable (404)"))
1350 }
1351 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
1352 })
1353 }
1354
1355 "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1356 Api::Ok(body) => {
1357 let found = body["default_branch"].as_str().unwrap_or("");
1358 if found == trunk {
1359 StepState::ok(format!("{trunk} is the default branch"))
1360 } else {
1361 StepState::not(format!("the default branch is {found}"))
1362 }
1363 }
1364 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1365 Api::Failed(err) => StepState::unknown(err),
1366 }),
1367 "single-trunk" => {
1368 for candidate in ctx.retired_branches() {
1369 let candidate = candidate.as_str();
1370 if candidate == trunk {
1371 continue;
1372 }
1373 match api_get(
1374 ctx,
1375 run,
1376 &format!("projects/{project}/repository/branches/{candidate}"),
1377 )? {
1378 Api::Missing => {}
1379 Api::Ok(_) => {
1380 return Ok(StepState::not(format!("a {candidate} branch still exists")));
1381 }
1382 Api::Failed(err) => return Ok(StepState::unknown(err)),
1383 }
1384 }
1385 Ok(StepState::ok(
1386 "no long-lived branch besides the trunk remains",
1387 ))
1388 }
1389 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1390 Api::Ok(body) => {
1391 if body["remove_source_branch_after_merge"]
1392 .as_bool()
1393 .unwrap_or(false)
1394 {
1395 StepState::ok("a merged branch is deleted by the forge")
1396 } else {
1397 StepState::not("a merged branch outlives its merge")
1398 }
1399 }
1400 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1401 Api::Failed(err) => StepState::unknown(err),
1402 }),
1403 "auto-merge" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1404 Api::Ok(body) => {
1405 if body["only_allow_merge_if_pipeline_succeeds"]
1406 .as_bool()
1407 .unwrap_or(false)
1408 {
1409 StepState::ok_with_limitation(
1410 "a request may merge itself once its pipeline passes",
1411 GITLAB_AUTO_MERGE_LIMITATION,
1412 )
1413 } else {
1414 StepState::not(
1415 "the pipeline requirement auto-merge rides on is off; protect-trunk asserts it",
1416 )
1417 }
1418 }
1419 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1420 Api::Failed(err) => StepState::unknown(err),
1421 }),
1422 "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1423 Api::Ok(body) => {
1424 if body["jobs_enabled"] == true {
1425 StepState::ok("pipelines are enabled")
1426 } else {
1427 StepState::not("pipelines are disabled")
1428 }
1429 }
1430 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1431 Api::Failed(err) => StepState::unknown(err),
1432 }),
1433 "install-bot" => {
1434 let mut active = false;
1440 let mut exhausted = false;
1441 for page in 1..=10u32 {
1442 let path = format!(
1443 "projects/{project}/access_tokens?state=active&per_page=100&page={page}"
1444 );
1445 let list = match api_get(ctx, run, &path)? {
1446 Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1447 Api::Missing => Vec::new(),
1448 Api::Failed(err) => return Ok(StepState::unknown(err)),
1449 };
1450 active = active
1451 || list.iter().any(|token| {
1452 token["name"] == "release-bot"
1453 && token["revoked"] == false
1454 && token["active"] != false
1455 });
1456 if list.len() < 100 {
1457 exhausted = true;
1458 }
1459 if active || exhausted {
1460 break;
1461 }
1462 }
1463 if !active {
1464 return Ok(if exhausted {
1465 StepState::not("no active release-bot token exists")
1466 } else {
1467 StepState::unknown(
1468 "the token listing did not exhaust within ten pages; nothing was decided",
1469 )
1470 });
1471 }
1472 Ok(
1476 match api_get(
1477 ctx,
1478 run,
1479 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1480 )? {
1481 Api::Ok(_) => StepState::ok(
1482 "an active release-bot token exists and its variable is stored",
1483 ),
1484 Api::Missing => StepState::not(
1485 "an active release-bot token exists with no stored variable; a rerun revokes and replaces it",
1486 ),
1487 Api::Failed(err) => StepState::unknown(err),
1488 },
1489 )
1490 }
1491 "bot-secrets" => Ok(
1492 match api_get(
1493 ctx,
1494 run,
1495 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1496 )? {
1497 Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
1498 Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
1499 Api::Failed(err) => StepState::unknown(err),
1500 },
1501 ),
1502 "protect-trunk" => {
1503 let protection = match api_get(
1504 ctx,
1505 run,
1506 &format!("projects/{project}/protected_branches/{trunk}"),
1507 )? {
1508 Api::Ok(body) => body,
1509 Api::Missing => {
1510 return Ok(StepState::not(format!("{trunk} is not protected")));
1511 }
1512 Api::Failed(err) => return Ok(StepState::unknown(err)),
1513 };
1514 let grants = protection["push_access_levels"]
1518 .as_array()
1519 .cloned()
1520 .unwrap_or_default();
1521 let policy = ctx.protection();
1522 let no_push =
1523 grants.len() == 1 && grants[0]["access_level"] == policy.gitlab.push_access_level;
1524 let merges = protection["merge_access_levels"]
1528 .as_array()
1529 .cloned()
1530 .unwrap_or_default();
1531 let can_merge =
1532 merges.len() == 1 && merges[0]["access_level"] == policy.gitlab.merge_access_level;
1533 let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
1534 Api::Ok(body) => body,
1535 Api::Missing | Api::Failed(_) => Value::Null,
1536 };
1537 let mut faults = Vec::new();
1538 if !no_push {
1539 faults.push(format!(
1540 "{trunk} still takes a direct push: the forge honors the most permissive of {} push grants",
1541 grants.len()
1542 ));
1543 }
1544 if !can_merge {
1545 faults.push(format!(
1546 "{trunk} merge grants are not exactly the one owned maintainer level"
1547 ));
1548 }
1549 if protection["allow_force_push"] != false {
1550 faults.push(format!("{trunk} allows force pushes"));
1551 }
1552 if settings["only_allow_merge_if_pipeline_succeeds"] != true {
1553 faults.push("the pipeline requirement is off".to_owned());
1554 }
1555 if settings["merge_method"] != policy.gitlab.merge_method.as_str() {
1556 faults.push("the merge method is not fast-forward".to_owned());
1557 }
1558 if settings["squash_option"] != policy.gitlab.squash_option.as_str() {
1559 faults.push("merge requests do not always squash".to_owned());
1560 }
1561 if settings["squash_commit_template"] != policy.gitlab.squash_commit_template.as_str() {
1562 faults.push("the squash template is not the merge request's title".to_owned());
1563 }
1564 Ok(if faults.is_empty() {
1565 StepState::ok_with_limitation(
1566 format!("{trunk} holds the release-merge shape"),
1567 GITLAB_TITLE_LIMITATION,
1568 )
1569 } else {
1570 StepState::not(faults.join("; "))
1571 })
1572 }
1573 "protect-tags" => Ok(
1574 match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
1575 Api::Ok(_) => {
1576 StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
1577 }
1578 Api::Missing => StepState::not("v* is not protected"),
1579 Api::Failed(err) => StepState::unknown(err),
1580 },
1581 ),
1582 "protect-release-lines" => Ok(
1583 match api_get(
1584 ctx,
1585 run,
1586 &format!("projects/{project}/protected_branches/release%2F%2A"),
1587 )? {
1588 Api::Ok(body) => {
1589 let level_ok = |levels: &Value| {
1590 levels
1591 .as_array()
1592 .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
1593 };
1594 if body["allow_force_push"] != false {
1595 StepState::not("release/* allows force pushes")
1596 } else if !level_ok(&body["push_access_levels"])
1597 || !level_ok(&body["merge_access_levels"])
1598 {
1599 StepState::not(
1603 "release/* grants are not exactly the owned maintainer levels",
1604 )
1605 } else {
1606 StepState::ok("release/* refuses force pushes and deletion by git clients")
1607 }
1608 }
1609 Api::Missing => StepState::inapplicable(
1610 "release/* is unprotected; optional — applied only where older lines exist",
1611 ),
1612 Api::Failed(err) => StepState::unknown(err),
1613 },
1614 ),
1615 "protections-check" => {
1616 let mut failures = Vec::new();
1619 let mut unknowns = Vec::new();
1620 let mut limitations: Vec<String> = Vec::new();
1623 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
1624 match gitlab(ctx, owned, run)? {
1625 StepState::Satisfied {
1626 limitation: found, ..
1627 } => limitations.extend(found),
1628 StepState::Inapplicable { .. } => {}
1629 StepState::Unsatisfied { detail } => {
1630 failures.push(format!("{owned}: {detail}"));
1631 }
1632 StepState::Unknown { detail } => {
1633 unknowns.push(format!("{owned}: {detail}"));
1634 }
1635 }
1636 }
1637 Ok(if !failures.is_empty() {
1638 StepState::not(failures.join("; "))
1639 } else if !unknowns.is_empty() {
1640 StepState::unknown(unknowns.join("; "))
1641 } else {
1642 StepState::Satisfied {
1643 detail: "the protections hold, as far as this forge enforces them".into(),
1644 limitation: if limitations.is_empty() {
1645 None
1646 } else {
1647 Some(limitations.join("; "))
1648 },
1649 }
1650 })
1651 }
1652 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1653 }
1654}