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