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