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