1use serde_json::Value;
11
12use crate::cargo_package::{self, Answer, POLICY_DESTINATION, Probe};
13use crate::detect::Forge;
14use crate::error::RkError;
15use crate::setup::app_jwt::{self, AppApi};
16use crate::setup::context::Ctx;
17use crate::setup::process::{Exec, Outcome};
18use crate::setup::workflow_jobs;
19
20pub type Runner<'a> = dyn FnMut(&Exec) -> Result<Outcome, RkError> + 'a;
23
24const GITLAB_PRIVATE_REPORTING_LIMITATION: &str = "GitLab has no project-level private reporting switch; the reporter must enable confidentiality; this proves project feature access, not successful submission by every external reporter";
40
41#[derive(Debug)]
43pub enum StepState {
44 Satisfied {
47 detail: String,
49 limitation: Option<String>,
51 },
52 Unsatisfied {
54 detail: String,
56 },
57 Inapplicable {
60 detail: String,
62 },
63 Unknown {
65 detail: String,
67 },
68}
69
70impl StepState {
71 #[must_use]
73 pub fn detail(&self) -> &str {
74 match self {
75 Self::Satisfied { detail, .. }
76 | Self::Unsatisfied { detail }
77 | Self::Inapplicable { detail }
78 | Self::Unknown { detail } => detail,
79 }
80 }
81
82 #[must_use]
84 pub const fn satisfied(&self) -> bool {
85 matches!(self, Self::Satisfied { .. })
86 }
87
88 fn ok(detail: impl Into<String>) -> Self {
89 Self::Satisfied {
90 detail: detail.into(),
91 limitation: None,
92 }
93 }
94
95 fn ok_with_limitation(detail: impl Into<String>, limitation: impl Into<String>) -> Self {
96 Self::Satisfied {
97 detail: detail.into(),
98 limitation: Some(limitation.into()),
99 }
100 }
101
102 fn not(detail: impl Into<String>) -> Self {
103 Self::Unsatisfied {
104 detail: detail.into(),
105 }
106 }
107
108 fn inapplicable(detail: impl Into<String>) -> Self {
109 Self::Inapplicable {
110 detail: detail.into(),
111 }
112 }
113
114 fn unknown(detail: impl Into<String>) -> Self {
115 Self::Unknown {
116 detail: detail.into(),
117 }
118 }
119}
120
121pub(crate) enum Api {
123 Ok(Value),
125 Missing,
127 Failed(String),
129}
130
131pub fn observe(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
138 if step == "package-check" {
139 return package_check(ctx, run);
140 }
141 if step == "branch-reminder" {
142 return Ok(branch_reminder_state(ctx));
143 }
144 if step == "forge-version" {
145 return forge_version(ctx, run);
146 }
147 match ctx.forge {
148 Some(Forge::Github) => github(ctx, step, run),
149 Some(Forge::Gitlab) => gitlab(ctx, step, run),
150 None => Ok(StepState::inapplicable(
154 "the profile names no forge this release drives",
155 )),
156 }
157}
158
159const PYTHON_LIMITATION: &str = "sdist and wheel policy inclusion is unproved: PEP 517 leaves the file set to the build backend and the two outputs can differ; inspect both before publishing";
164
165const BASH_LIMITATION: &str = "the make dist tarball is not inspected: git archive honours export-ignore, so SECURITY.md inclusion is unproved; inspect the generated tarball before publishing";
168
169fn package_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
180 let policy = ctx.reporting_policy();
181 let (program, args): (&str, &[&str]) = match ctx.tech {
182 Some("rust") => ("cargo", &["publish", "--dry-run", "--allow-dirty"]),
183 Some("python") => ("python3", &["-m", "build"]),
184 Some("bash") => {
185 let built = "no registry for this technology; there is nothing to package";
186 return Ok(if policy {
187 StepState::ok_with_limitation(built, BASH_LIMITATION)
188 } else {
189 StepState::ok(built)
190 });
191 }
192 Some(other) => {
193 return Ok(StepState::unknown(format!(
194 "no packaging check is defined for {other}"
195 )));
196 }
197 None => {
198 return Ok(StepState::unknown(
199 "no version file names a technology; see rk binding --list",
200 ));
201 }
202 };
203 let outcome = run(&cargo_exec(ctx, program, args))?;
204 if !outcome.success() {
205 return Ok(StepState::not(format!(
206 "the packaging check failed: {}",
207 last_line(&outcome.stderr)
208 )));
209 }
210 let built = "the package builds and passes the registry's dry run";
211 Ok(match ctx.tech {
212 Some("rust") => policy_in_the_crate(ctx, run, built)?,
213 _ if policy => StepState::ok_with_limitation(built, PYTHON_LIMITATION),
214 _ => StepState::ok(built),
215 })
216}
217
218fn cargo_exec(ctx: &Ctx, program: &str, args: &[&str]) -> Exec {
220 Exec {
221 program: program.into(),
222 args: args.iter().map(Into::into).collect(),
223 env: ctx.child_env("package-check"),
224 cwd: ctx.target.as_std_path().to_path_buf(),
225 stdin: None,
226 }
227}
228
229fn policy_in_the_crate(ctx: &Ctx, run: &mut Runner, built: &str) -> Result<StepState, RkError> {
245 let policy = ctx.reporting_policy();
246 let root_manifest = ctx.target.as_std_path().join("Cargo.toml");
247 let probe = cargo_package::probe(&root_manifest, &[], |args| {
248 run(&cargo_exec(ctx, "cargo", args)).map(|outcome| Answer {
249 success: outcome.success(),
250 stdout: outcome.stdout,
251 stderr: outcome.stderr,
252 })
253 })?;
254 let listing = match probe {
255 Probe::Listed(listing) => listing,
256 Probe::OtherShape => {
257 let unproved = if policy {
258 format!(
259 "{POLICY_DESTINATION} inclusion and the absence of release-kit's own files are"
260 )
261 } else {
262 "the absence of release-kit's own files is".to_owned()
263 };
264 return Ok(StepState::ok_with_limitation(
265 built,
266 format!(
267 "{unproved} unproved: the package check lists files only for a single default package rooted at the target, and this workspace selects a different shape; inspect the published archive before releasing"
268 ),
269 ));
270 }
271 Probe::MetadataFailed(stderr) => {
272 return Ok(StepState::unknown(format!(
273 "{built}, and the listing check could not run: cargo metadata failed: {}",
274 last_line(&stderr)
275 )));
276 }
277 Probe::ListingFailed(stderr) => {
278 return Ok(StepState::unknown(format!(
279 "{built}, and the listing check could not run: cargo package --list failed: {}",
280 last_line(&stderr)
281 )));
282 }
283 };
284 let forbidden = cargo_package::release_kit_paths(ctx.landed_destinations());
285 let shipped = listing.shipped(&forbidden);
286 Ok(boundary_state(
287 built,
288 policy,
289 !policy || listing.carries(POLICY_DESTINATION),
290 &shipped,
291 ))
292}
293
294fn boundary_state(built: &str, policy: bool, carried: bool, shipped: &[&str]) -> StepState {
298 const WHY: &str = "and release-plz attributes every commit touching one of them to the package, so a landing that rewrites one asks for a release with no code change";
299 let policy_fix = format!(
300 "add /{POLICY_DESTINATION} to [package].include, remove the [package].exclude entry matching it, or stop ignoring the file"
301 );
302 let own = || {
303 let entries = cargo_package::exclude_entries(shipped)
304 .iter()
305 .map(|entry| format!("\"{entry}\""))
306 .collect::<Vec<_>>()
307 .join(", ");
308 (
309 shipped.join(", "),
310 format!("add {entries} to [package].exclude, or drop them from [package].include"),
311 )
312 };
313 match (carried, shipped.is_empty()) {
314 (true, true) if policy => StepState::ok(format!(
315 "{built}, and the published package carries {POLICY_DESTINATION} and no file release-kit lands or owns"
316 )),
317 (true, true) => StepState::ok(format!(
318 "{built}, and the published package ships no file release-kit lands or owns"
319 )),
320 (false, true) => StepState::not(format!(
321 "{built}, but the published package omits {POLICY_DESTINATION}: {policy_fix}"
322 )),
323 (true, false) => {
324 let (list, fix) = own();
325 StepState::not(format!(
326 "{built}, but the published package ships files release-kit lands or owns ({list}), {WHY}: {fix}"
327 ))
328 }
329 (false, false) => {
330 let (list, fix) = own();
331 StepState::not(format!(
332 "{built}, but the published package omits {POLICY_DESTINATION} and ships files release-kit lands or owns ({list}), {WHY}: {policy_fix}; and {fix}"
333 ))
334 }
335 }
336}
337
338fn branch_reminder_state(ctx: &Ctx) -> StepState {
341 use crate::setup::branch_reminder::{HookState, observe_hook};
342 match observe_hook(&ctx.target) {
343 HookState::Installed => {
344 StepState::ok("the post-merge hook carries the release-kit reminder")
345 }
346 HookState::Absent => StepState::not("no post-merge hook is installed"),
347 HookState::Foreign => {
348 StepState::not("a post-merge hook exists without the release-kit marker")
349 }
350 HookState::Drifted => StepState::not("the reminder hook drifted from this binary's body"),
351 HookState::Unreadable(detail) => StepState::unknown(detail),
352 }
353}
354
355pub const GITLAB_VERSION_FLOOR: (u64, u64) = (18, 2);
362
363const GITLAB_EDITIONS: [&str; 2] = ["ee", "ce"];
366
367fn version_refusal(found: &str, prerelease: Option<&str>) -> String {
370 let (major, minor) = GITLAB_VERSION_FLOOR;
371 let mut said = vec![format!(
372 "this GitLab instance reports {found}; the convention needs {major}.{minor} or newer"
373 )];
374 if let Some(suffix) = prerelease {
375 said.push(format!(
376 "the -{suffix} suffix is a pre-release, and nothing proves the feature shipped in it, so this step fails closed"
377 ));
378 }
379 said.push(format!(
380 "the merge-request pipeline triggers a child pipeline with `strategy: mirror`, which GitLab added in {major}.{minor}"
381 ));
382 said.push(
383 "below it the child's status never reaches the parent pipeline, so a failing project job merges".to_owned(),
384 );
385 said.push(format!(
386 "upgrade the instance to {major}.{minor} or newer, or host the project on gitlab.com"
387 ));
388 said.join("; ")
389}
390
391fn forge_version(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
397 if ctx.forge == Some(Forge::Github) {
398 return Ok(StepState::ok(
399 "github.com is a rolling service and declares no version floor",
400 ));
401 }
402 let body = match api_get(ctx, run, "version")? {
403 Api::Ok(body) => body,
404 Api::Missing => {
405 return Ok(StepState::unknown(
406 "this instance answers no GET /version; the floor cannot be read. Check that glab is authenticated against it: glab auth login",
407 ));
408 }
409 Api::Failed(err) => {
410 return Ok(StepState::unknown(format!(
411 "the version could not be read: {err}. Check that glab is authenticated against this instance: glab auth login"
412 )));
413 }
414 };
415 let Some(found) = body["version"].as_str() else {
416 return Ok(StepState::unknown(
417 "the forge answer carries no version field; the floor cannot be read. Check that glab is authenticated against this instance: glab auth login",
418 ));
419 };
420 let (number, suffix) = found
421 .split_once('-')
422 .map_or((found, None), |(n, s)| (n, Some(s)));
423 let mut parts = number.split('.');
424 let parsed = parts
425 .next()
426 .and_then(|major| major.parse::<u64>().ok())
427 .zip(parts.next().and_then(|minor| minor.parse::<u64>().ok()));
428 let Some(pair) = parsed else {
429 return Ok(StepState::unknown(format!(
430 "the forge reports the version as '{found}', which names no major and minor pair; the floor cannot be read"
431 )));
432 };
433 if let Some(suffix) = suffix.filter(|s| !GITLAB_EDITIONS.contains(s)) {
434 return Ok(StepState::not(version_refusal(found, Some(suffix))));
435 }
436 if pair < GITLAB_VERSION_FLOOR {
437 return Ok(StepState::not(version_refusal(found, None)));
438 }
439 let (major, minor) = GITLAB_VERSION_FLOOR;
440 Ok(StepState::ok(format!(
441 "this instance reports {found}, at or above the {major}.{minor} floor"
442 )))
443}
444
445pub fn single_trunk_guard(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
455 let trunk = ctx.trunk();
456 for candidate in ctx.retired_branches() {
457 let candidate = candidate.as_str();
458 if candidate == trunk {
459 continue;
460 }
461 let state = match ctx.forge {
462 Some(Forge::Github) => github_candidate_guard(ctx, run, candidate)?,
463 Some(Forge::Gitlab) => gitlab_candidate_guard(ctx, run, candidate)?,
464 None => StepState::unknown("the profile names no forge this release drives"),
467 };
468 if !state.satisfied() {
469 return Ok(state);
470 }
471 }
472 Ok(StepState::ok(
473 "every candidate branch is absent, or an ancestor of the trunk",
474 ))
475}
476
477fn github_candidate_guard(
479 ctx: &Ctx,
480 run: &mut Runner,
481 candidate: &str,
482) -> Result<StepState, RkError> {
483 let trunk = ctx.trunk();
484 match api_get(
485 ctx,
486 run,
487 &format!("repos/{}/git/ref/heads/{candidate}", ctx.repo),
488 )? {
489 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
490 Api::Failed(err) => return Ok(StepState::unknown(err)),
491 Api::Ok(_) => {}
492 }
493 match api_get(
494 ctx,
495 run,
496 &format!("repos/{}/compare/{candidate}...{trunk}", ctx.repo),
497 )? {
498 Api::Ok(body) => {
499 let status = body["status"].as_str().unwrap_or("");
500 Ok(if matches!(status, "ahead" | "identical") {
501 StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
502 } else {
503 StepState::not(format!(
504 "{candidate} is not an ancestor of {trunk} ({status}); deleting it would lose work"
505 ))
506 })
507 }
508 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
509 Api::Failed(err) => Ok(StepState::unknown(err)),
510 }
511}
512
513fn gitlab_candidate_guard(
515 ctx: &Ctx,
516 run: &mut Runner,
517 candidate: &str,
518) -> Result<StepState, RkError> {
519 let trunk = ctx.trunk();
520 let project = ctx.repo.replace('/', "%2F");
521 match api_get(
522 ctx,
523 run,
524 &format!("projects/{project}/repository/branches/{candidate}"),
525 )? {
526 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
527 Api::Failed(err) => return Ok(StepState::unknown(err)),
528 Api::Ok(_) => {}
529 }
530 match api_get(
531 ctx,
532 run,
533 &format!("projects/{project}/repository/compare?from={trunk}&to={candidate}"),
534 )? {
535 Api::Ok(body) => {
536 let ahead = body["commits"]
537 .as_array()
538 .is_some_and(|list| !list.is_empty());
539 Ok(if ahead {
540 StepState::not(format!(
541 "{candidate} carries commits {trunk} does not; deleting it would lose work"
542 ))
543 } else {
544 StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
545 })
546 }
547 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
548 Api::Failed(err) => Ok(StepState::unknown(err)),
549 }
550}
551
552pub(crate) fn api_get(ctx: &Ctx, run: &mut Runner, path: &str) -> Result<Api, RkError> {
554 let exec = Exec {
555 program: ctx.cli.clone().into_os_string(),
556 args: vec!["api".into(), path.into()],
557 env: ctx.child_env("observe"),
558 cwd: ctx.target.as_std_path().to_path_buf(),
559 stdin: None,
560 };
561 let outcome = run(&exec)?;
562 if outcome.success() {
563 return Ok(
564 serde_json::from_slice::<Value>(&outcome.stdout).map_or_else(
565 |_| Api::Failed("the forge answer did not parse as JSON".into()),
566 Api::Ok,
567 ),
568 );
569 }
570 let stderr = String::from_utf8_lossy(&outcome.stderr).into_owned();
571 if stderr.contains("404") {
572 Ok(Api::Missing)
573 } else {
574 Ok(Api::Failed(last_line(&outcome.stderr)))
575 }
576}
577
578fn last_line(bytes: &[u8]) -> String {
580 String::from_utf8_lossy(bytes)
581 .lines()
582 .rev()
583 .find(|line| !line.trim().is_empty())
584 .unwrap_or("no output")
585 .to_owned()
586}
587
588#[allow(
589 clippy::too_many_lines,
590 reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
591)]
592fn github(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
593 let trunk = ctx.trunk();
594 let repo = &ctx.repo;
595 match step {
596 "private-vulnerability-reporting" => {
597 let visibility_path = format!("repos/{repo}");
598 match api_get(ctx, run, &visibility_path)? {
599 Api::Ok(body) => match body["private"].as_bool() {
600 Some(true) => {
601 return Ok(StepState::inapplicable(
602 "private vulnerability reporting is available for public repositories",
603 ));
604 }
605 Some(false) => {}
606 None => {
607 return Ok(StepState::unknown(format!(
608 "{visibility_path}: repository visibility is unreadable"
609 )));
610 }
611 },
612 Api::Missing => {
613 return Ok(StepState::unknown(format!(
614 "{visibility_path}: repository visibility is unreadable (404)"
615 )));
616 }
617 Api::Failed(err) => {
618 return Ok(StepState::unknown(format!("{visibility_path}: {err}")));
619 }
620 }
621 let path = format!("repos/{repo}/private-vulnerability-reporting");
622 Ok(match api_get(ctx, run, &path)? {
623 Api::Ok(body) => match body["enabled"].as_bool() {
624 Some(true) => StepState::ok("private vulnerability reporting is enabled"),
625 Some(false) => StepState::not("private vulnerability reporting is disabled"),
626 None => StepState::unknown(format!("{path}: enabled is unreadable")),
627 },
628 Api::Missing => {
629 StepState::unknown(format!("{path}: reporting state is unreadable (404)"))
630 }
631 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
632 })
633 }
634
635 "default-branch" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
636 Api::Ok(body) => {
637 let found = body["default_branch"].as_str().unwrap_or("");
638 if found == trunk {
639 StepState::ok(format!("{trunk} is the default branch"))
640 } else {
641 StepState::not(format!("the default branch is {found}"))
642 }
643 }
644 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
645 Api::Failed(err) => StepState::unknown(err),
646 }),
647 "single-trunk" => {
648 for candidate in ctx.retired_branches() {
649 let candidate = candidate.as_str();
650 if candidate == trunk {
651 continue;
652 }
653 match api_get(ctx, run, &format!("repos/{repo}/git/ref/heads/{candidate}"))? {
654 Api::Missing => {}
655 Api::Ok(_) => {
656 return Ok(StepState::not(format!("a {candidate} branch still exists")));
657 }
658 Api::Failed(err) => return Ok(StepState::unknown(err)),
659 }
660 }
661 Ok(StepState::ok(
662 "no long-lived branch besides the trunk remains",
663 ))
664 }
665 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
666 Api::Ok(body) => {
667 if body["delete_branch_on_merge"].as_bool().unwrap_or(false) {
668 StepState::ok("a merged branch is deleted by the forge")
669 } else {
670 StepState::not("a merged branch outlives its merge")
671 }
672 }
673 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
674 Api::Failed(err) => StepState::unknown(err),
675 }),
676 "auto-merge" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
677 Api::Ok(body) => {
678 if body["allow_auto_merge"].as_bool().unwrap_or(false) {
679 StepState::ok("a request may merge itself once its checks pass")
680 } else {
681 StepState::not("a request cannot merge itself; the auto-merge switch is off")
682 }
683 }
684 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
685 Api::Failed(err) => StepState::unknown(err),
686 }),
687 "ci-permissions" => Ok(
688 match api_get(
689 ctx,
690 run,
691 &format!("repos/{repo}/actions/permissions/workflow"),
692 )? {
693 Api::Ok(body) => {
694 let write = body["default_workflow_permissions"] == "write";
695 let approve = body["can_approve_pull_request_reviews"] == true;
696 if write && approve {
697 StepState::ok("CI may write and open requests")
698 } else {
699 StepState::not(format!(
700 "workflow permissions are {} with request approval {}",
701 body["default_workflow_permissions"],
702 body["can_approve_pull_request_reviews"]
703 ))
704 }
705 }
706 Api::Missing => StepState::not("no workflow permissions are readable"),
707 Api::Failed(err) => StepState::unknown(err),
708 },
709 ),
710 "bot-secrets" => Ok(
711 match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
712 Api::Ok(body) => {
713 let names: Vec<&str> = body["secrets"]
714 .as_array()
715 .map(|list| {
716 list.iter()
717 .filter_map(|secret| secret["name"].as_str())
718 .collect()
719 })
720 .unwrap_or_default();
721 let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
722 if wanted.iter().all(|name| names.contains(name)) {
723 StepState::ok("both bot secrets are stored")
724 } else if names.is_empty() {
725 StepState::not("no bot secrets are stored")
726 } else {
727 StepState::not(format!("stored secrets: {}", names.join(", ")))
728 }
729 }
730 Api::Missing => StepState::not("no secrets are readable"),
731 Api::Failed(err) => StepState::unknown(err),
732 },
733 ),
734 "protect-trunk" => github_trunk_ruleset(ctx, run),
735 "protect-tags" => github_ruleset(
736 ctx,
737 run,
738 ctx.tag_ruleset(),
739 "tag",
740 "refs/tags/v*",
741 &["deletion", "update"],
742 ),
743 "protect-release-lines" => {
744 match github_ruleset_body(ctx, run, ctx.lines_ruleset())? {
745 RulesetLookup::Absent => {
746 return Ok(StepState::inapplicable(
747 "release/* is unprotected; optional — applied only where older lines exist",
748 ));
749 }
750 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
751 RulesetLookup::Found(_) => {}
752 }
753 github_ruleset(
754 ctx,
755 run,
756 ctx.lines_ruleset(),
757 "branch",
758 "refs/heads/release/*",
759 &["deletion", "non_fast_forward"],
760 )
761 }
762 "protections-check" => {
763 let mut failures = Vec::new();
767 let mut unknowns = Vec::new();
768 let mut limitations: Vec<String> = Vec::new();
770 for owned in owned_protections(ctx) {
771 match github(ctx, owned, run)? {
772 StepState::Satisfied {
773 limitation: found, ..
774 } => limitations.extend(found),
775 StepState::Inapplicable { .. } => {}
776 StepState::Unsatisfied { detail } => {
777 failures.push(format!("{owned}: {detail}"));
778 }
779 StepState::Unknown { detail } => {
780 unknowns.push(format!("{owned}: {detail}"));
781 }
782 }
783 }
784 match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
785 Api::Ok(body) => {
786 let owned = [
787 ctx.trunk_ruleset().to_owned(),
788 ctx.safety_ruleset().to_owned(),
789 ctx.tag_ruleset().to_owned(),
790 ctx.lines_ruleset().to_owned(),
791 ];
792 for ruleset in body.as_array().into_iter().flatten() {
793 let name = ruleset["name"].as_str().unwrap_or("");
794 if !owned.iter().any(|expected| expected == name) {
795 failures.push(format!("a ruleset no step owns: {name}"));
796 }
797 }
798 }
799 Api::Missing | Api::Failed(_) => {
800 unknowns.push("the ruleset inventory is not readable".to_owned());
801 }
802 }
803 Ok(if !failures.is_empty() {
804 StepState::not(failures.join("; "))
805 } else if !unknowns.is_empty() {
806 StepState::unknown(unknowns.join("; "))
807 } else {
808 StepState::Satisfied {
809 detail: "exactly the owned protections, with those rules".into(),
810 limitation: if limitations.is_empty() {
811 None
812 } else {
813 Some(limitations.join("; "))
814 },
815 }
816 })
817 }
818 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
819 }
820}
821
822fn owned_protections(ctx: &Ctx) -> Vec<&'static str> {
826 let mut owned = vec!["protect-trunk"];
827 if ctx.tag_protection() {
828 owned.push("protect-tags");
829 }
830 owned.push("protect-release-lines");
831 owned
832}
833
834#[must_use]
842pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
843 match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
844 AppApi::Ok(body) => {
845 let id = body["id"].as_i64().unwrap_or_default();
846 let held = &body["permissions"];
847 let short: Vec<String> = minimum_grant(ctx)
848 .into_iter()
849 .filter(|(key, level)| held[key] != *level)
850 .map(|(key, level)| format!("{key}: {level}"))
851 .collect();
852 if short.is_empty() {
853 StepState::ok(format!("installation {id} covers {}", ctx.repo))
854 } else {
855 StepState::not(format!(
860 "installation {id} covers {} and does not hold [{}]; approve the App's updated permissions on the installation's own settings page",
861 ctx.repo,
862 short.join(", ")
863 ))
864 }
865 }
866 AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
867 AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
868 }
869}
870
871fn minimum_grant(ctx: &Ctx) -> Vec<(&'static str, &'static str)> {
879 let mut grant = vec![("contents", "write"), ("pull_requests", "write")];
880 if ctx.integration() == crate::landing::Integration::Local
881 && ctx.profile.release.style == Some(crate::landing::Style::Trunk)
882 {
883 grant.push(("checks", "read"));
884 }
885 grant
886}
887
888fn github_ruleset(
893 ctx: &Ctx,
894 run: &mut Runner,
895 name: &str,
896 target: &str,
897 include: &str,
898 rules: &[&str],
899) -> Result<StepState, RkError> {
900 let detail = match github_ruleset_body(ctx, run, name)? {
901 RulesetLookup::Found(detail) => detail,
902 RulesetLookup::Absent => {
903 return Ok(StepState::not(format!("no ruleset named {name}")));
904 }
905 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
906 };
907 if detail["enforcement"] != "active" {
908 return Ok(StepState::not(format!("{name} is not active")));
909 }
910 if detail["target"] != target {
913 return Ok(StepState::not(format!(
914 "{name} does not target {target} refs"
915 )));
916 }
917 if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
918 return Ok(StepState::not(format!(
919 "{name} does not cover {include} alone"
920 )));
921 }
922 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
923 return Ok(StepState::not(format!(
924 "{name} excludes refs from its own coverage"
925 )));
926 }
927 let mut held: Vec<&str> = detail["rules"]
928 .as_array()
929 .map(|list| {
930 list.iter()
931 .filter_map(|rule| rule["type"].as_str())
932 .collect()
933 })
934 .unwrap_or_default();
935 held.sort_unstable();
936 let mut expected: Vec<&str> = rules.to_vec();
937 expected.sort_unstable();
938 if held == expected {
939 Ok(StepState::ok(format!(
940 "{name} is active with exactly its rules"
941 )))
942 } else {
943 Ok(StepState::not(format!(
944 "{name} carries the rules [{}] where the setup owns [{}]",
945 held.join(", "),
946 expected.join(", ")
947 )))
948 }
949}
950
951fn unowned_rule_faults(rules: &[Value], owned: &[String]) -> Vec<String> {
966 rules
967 .iter()
968 .filter_map(|rule| rule["type"].as_str())
969 .filter(|kind| !owned.iter().any(|name| name == kind))
970 .map(|kind| {
971 if kind == "merge_queue" {
972 MERGE_QUEUE_FAULT.to_owned()
973 } else {
974 format!("an unowned rule is present: {kind}")
975 }
976 })
977 .collect()
978}
979
980fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
981 let trunk = ctx.trunk();
982 let name = ctx.trunk_ruleset().to_owned();
983 let detail = match github_ruleset_body(ctx, run, &name)? {
984 RulesetLookup::Found(detail) => detail,
985 RulesetLookup::Absent => {
986 return Ok(StepState::not(format!("no ruleset named {name}")));
987 }
988 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
989 };
990 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
991 let mut faults = Vec::new();
992 if detail["enforcement"] != "active" {
993 faults.push(format!("{name} is not active"));
994 }
995 if detail["target"] != "branch" {
999 faults.push(format!("{name} does not target branches"));
1000 }
1001 let expected_ref = serde_json::json!([format!("refs/heads/{trunk}")]);
1002 if detail["conditions"]["ref_name"]["include"] != expected_ref {
1003 faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
1004 }
1005 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
1008 faults.push(format!("{name} excludes refs from its own coverage"));
1009 }
1010 let expected_bypass = super::context::github_bypass_actors(&ctx.protection().bypass_actors);
1011 if detail["bypass_actors"] != expected_bypass {
1012 faults.push("the bypass actors do not match the recorded authority".to_owned());
1013 }
1014 faults.extend(trunk_rule_faults(ctx, &rules, &name));
1015 faults.extend(merge_method_fault(ctx, &rules));
1016 if let Some(checks) = rules
1017 .iter()
1018 .find(|rule| rule["type"] == "required_status_checks")
1019 {
1020 if checks["parameters"]["strict_required_status_checks_policy"]
1021 != ctx.protection().strict_required_status_checks
1022 {
1023 faults.push(STALE_MERGE_FAULT.to_owned());
1024 }
1025 let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
1026 .as_array()
1027 .map(|list| {
1028 list.iter()
1029 .filter_map(|check| check["context"].as_str())
1030 .collect()
1031 })
1032 .unwrap_or_default();
1033 if contexts.is_empty() {
1038 faults.push("no status check is required".to_owned());
1039 } else if let Some(expected) = &ctx.required_check {
1040 let mut held = contexts.clone();
1041 held.sort_unstable();
1042 let title_check = ctx.title_check();
1043 let mut owned_contexts = [expected.as_str(), title_check];
1044 owned_contexts.sort_unstable();
1045 if held != owned_contexts {
1046 faults.push(format!(
1047 "the required checks are [{}] where the setup owns [{}]",
1048 contexts.join(", "),
1049 owned_contexts.join(", ")
1050 ));
1051 }
1052 } else if !contexts.contains(&ctx.title_check()) {
1053 faults.push(format!("the {} check is not required", ctx.title_check()));
1054 }
1055 }
1056 match repository_merge_settings(ctx, run)? {
1057 MergeSources::Owned => {}
1058 MergeSources::Faults(proven) => faults.extend(proven),
1059 MergeSources::Unreadable(err) => {
1063 if faults.is_empty() {
1064 return Ok(StepState::unknown(err));
1065 }
1066 }
1067 }
1068 match github_safety_ruleset(ctx, run)? {
1069 SafetyRuleset::Owned => {}
1070 SafetyRuleset::Faults(proven) => faults.extend(proven),
1071 SafetyRuleset::Unreadable(err) => {
1072 if faults.is_empty() {
1073 return Ok(StepState::unknown(err));
1074 }
1075 }
1076 }
1077 if let Some(shape) = gate_faults(ctx) {
1078 faults.push(shape);
1079 }
1080 if !faults.is_empty() {
1081 return Ok(StepState::not(faults.join("; ")));
1082 }
1083 Ok(StepState::ok(format!(
1084 "{name} holds the release-merge shape beside {}",
1085 ctx.safety_ruleset()
1086 )))
1087}
1088
1089fn merge_method_fault(ctx: &Ctx, rules: &[Value]) -> Option<String> {
1092 let request = rules.iter().find(|rule| rule["type"] == "pull_request")?;
1093 (request["parameters"]["allowed_merge_methods"]
1094 != serde_json::json!(ctx.protection().allowed_merge_methods))
1095 .then(|| {
1096 format!(
1097 "the merge method is not exactly a {} merge",
1098 ctx.merge_form().as_str()
1099 )
1100 })
1101}
1102
1103fn trunk_rule_faults(ctx: &Ctx, rules: &[Value], name: &str) -> Vec<String> {
1111 let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
1112 let mut faults = Vec::new();
1113 let mut accounted: Vec<String> = ctx
1114 .protection()
1115 .owned_trunk_rules
1116 .iter()
1117 .filter(|rule| crate::config::REQUEST_RULES.contains(&rule.as_str()))
1118 .cloned()
1119 .collect();
1120 for required in &accounted {
1121 if !has(required) {
1122 faults.push(format!("the {required} rule is missing"));
1123 }
1124 }
1125 for stray in crate::config::SAFETY_RULES {
1126 if has(stray) {
1127 faults.push(format!(
1128 "the {stray} rule sits in {name}, where a bypass actor excuses it"
1129 ));
1130 }
1131 accounted.push(stray.to_owned());
1132 }
1133 faults.extend(unowned_rule_faults(rules, &accounted));
1134 faults
1135}
1136
1137enum SafetyRuleset {
1139 Owned,
1140 Faults(Vec<String>),
1141 Unreadable(String),
1142}
1143
1144fn github_safety_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<SafetyRuleset, RkError> {
1152 let trunk = ctx.trunk();
1153 let name = ctx.safety_ruleset().to_owned();
1154 let detail = match github_ruleset_body(ctx, run, &name)? {
1155 RulesetLookup::Found(detail) => detail,
1156 RulesetLookup::Absent => {
1157 return Ok(SafetyRuleset::Faults(vec![format!(
1158 "no ruleset named {name} holds the trunk against deletion and force-push"
1159 )]));
1160 }
1161 RulesetLookup::Unreadable(err) => return Ok(SafetyRuleset::Unreadable(err)),
1162 };
1163 let mut faults = Vec::new();
1164 if detail["enforcement"] != "active" {
1165 faults.push(format!("{name} is not active"));
1166 }
1167 if detail["target"] != "branch" {
1168 faults.push(format!("{name} does not target branches"));
1169 }
1170 if detail["conditions"]["ref_name"]["include"]
1171 != serde_json::json!([format!("refs/heads/{trunk}")])
1172 {
1173 faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
1174 }
1175 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
1176 faults.push(format!("{name} excludes refs from its own coverage"));
1177 }
1178 if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
1179 faults.push(format!(
1180 "{name} names a bypass actor, so deletion and force-push hold against nobody"
1181 ));
1182 }
1183 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
1184 let safety_rules: Vec<String> = ctx
1185 .protection()
1186 .owned_trunk_rules
1187 .iter()
1188 .filter(|rule| crate::config::SAFETY_RULES.contains(&rule.as_str()))
1189 .cloned()
1190 .collect();
1191 for required in &safety_rules {
1192 if !rules.iter().any(|rule| rule["type"] == required.as_str()) {
1193 faults.push(format!("the {required} rule is missing from {name}"));
1194 }
1195 }
1196 faults.extend(unowned_rule_faults(&rules, &safety_rules));
1197 if faults.is_empty() {
1198 Ok(SafetyRuleset::Owned)
1199 } else {
1200 Ok(SafetyRuleset::Faults(faults))
1201 }
1202}
1203
1204fn gate_faults(ctx: &Ctx) -> Option<String> {
1214 if !ctx.forge_gate() {
1215 return None;
1216 }
1217 let check = ctx.required_check.as_deref()?;
1218 let shape = workflow_jobs::faults(
1219 &workflow_jobs::read_gate(&ctx.target, check, ctx.trunk()),
1220 check,
1221 ctx.trunk(),
1222 );
1223 let waking = (ctx.integration() == crate::landing::Integration::Local)
1228 .then_some(ctx.required_workflow.as_deref())
1229 .flatten()
1230 .and_then(|workflow| {
1231 workflow_jobs::waking_workflow_fault(&ctx.target, workflow, check, ctx.trunk())
1232 });
1233 match (shape, waking) {
1234 (None, None) => None,
1235 (Some(one), None) | (None, Some(one)) => Some(one),
1236 (Some(shape), Some(waking)) => Some(format!("{shape}; {waking}")),
1237 }
1238}
1239
1240enum MergeSources {
1242 Owned,
1244 Faults(Vec<String>),
1246 Unreadable(String),
1248}
1249
1250fn repository_merge_settings(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
1265 let squash = ctx.merge_form() == crate::landing::MergeForm::Squash;
1266 Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
1267 Api::Ok(body) => {
1268 let mut faults = Vec::new();
1269 if squash {
1270 let owned_title = ctx.protection().github.squash_title_source.as_str();
1271 let owned_body = ctx.protection().github.squash_body_source.as_str();
1272 if body["squash_merge_commit_title"] != owned_title {
1273 faults.push(format!(
1274 "the squash title source is {} where the setup owns {owned_title}",
1275 body["squash_merge_commit_title"]
1276 ));
1277 }
1278 if body["squash_merge_commit_message"] != owned_body {
1279 faults.push(format!(
1280 "the squash message source is {} where the setup owns {owned_body}",
1281 body["squash_merge_commit_message"]
1282 ));
1283 }
1284 } else if let Some(fault) = rebase_switch_fault(body["allow_rebase_merge"].as_bool()) {
1285 faults.push(fault);
1286 }
1287 if faults.is_empty() {
1288 MergeSources::Owned
1289 } else {
1290 MergeSources::Faults(faults)
1291 }
1292 }
1293 Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
1294 Api::Failed(err) => MergeSources::Unreadable(err),
1295 })
1296}
1297
1298fn rebase_switch_fault(allowed: Option<bool>) -> Option<String> {
1301 match allowed {
1302 Some(true) => None,
1303 Some(false) => Some(
1304 "the repository disallows rebase merges (allow_rebase_merge is false), so the ruleset's one merge method is refused and no request can merge"
1305 .to_owned(),
1306 ),
1307 None => Some("the repository's allow_rebase_merge switch is missing or unreadable".to_owned()),
1308 }
1309}
1310
1311enum RulesetLookup {
1314 Found(Value),
1316 Absent,
1319 Unreadable(String),
1321}
1322
1323fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
1325 let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
1329 Api::Ok(body) => body,
1330 Api::Missing => {
1331 return Ok(RulesetLookup::Unreadable(
1332 "the ruleset inventory is not readable".into(),
1333 ));
1334 }
1335 Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
1336 };
1337 let id = list
1338 .as_array()
1339 .into_iter()
1340 .flatten()
1341 .find(|ruleset| ruleset["name"] == name)
1342 .and_then(|ruleset| ruleset["id"].as_i64());
1343 let Some(id) = id else {
1344 return Ok(RulesetLookup::Absent);
1345 };
1346 match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
1347 Api::Ok(body) => Ok(RulesetLookup::Found(body)),
1348 Api::Missing => Ok(RulesetLookup::Unreadable(format!(
1352 "the {name} detail is not readable"
1353 ))),
1354 Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
1355 }
1356}
1357
1358const 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";
1362
1363const GITLAB_TAG_LIMITATION: &str =
1365 "an Owner or Maintainer can still delete a protected tag through the UI or API";
1366
1367const 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";
1372
1373const 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";
1375
1376const 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";
1379
1380fn pipeline_fault(forge_gate: bool, observed: Option<bool>) -> Option<String> {
1385 match (forge_gate, observed) {
1386 (true, Some(true)) | (false, Some(false)) => None,
1387 (true, Some(false)) => Some("the pipeline requirement is off".to_owned()),
1388 (false, Some(true)) => Some(
1389 "the pipeline requirement is on, and no forge gate lands to run a pipeline, so no merge request can merge"
1390 .to_owned(),
1391 ),
1392 (_, None) => Some("the pipeline requirement is missing or unreadable".to_owned()),
1393 }
1394}
1395
1396const GITLAB_COMMIT_LIMITATION: &str =
1400 "the commit gate stops accident, not authority: a merge request runs its own CI configuration";
1401
1402#[allow(
1403 clippy::too_many_lines,
1404 reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
1405)]
1406fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
1407 let trunk = ctx.trunk();
1408 let project = ctx.repo.replace('/', "%2F");
1409 match step {
1410 "private-vulnerability-reporting" => {
1411 let path = format!("projects/{project}");
1412 Ok(match api_get(ctx, run, &path)? {
1413 Api::Ok(body) => {
1414 let access = body["issues_access_level"].as_str();
1415 if !matches!(access, Some("enabled" | "private" | "disabled")) {
1416 StepState::unknown("issue intake access is unreadable")
1417 } else if body
1418 .get("issues_enabled")
1419 .is_some_and(|flag| !flag.is_boolean())
1420 {
1421 StepState::unknown("legacy issue intake flag is unreadable")
1422 } else if body["issues_enabled"] == false || access == Some("disabled") {
1423 StepState::not("issue intake is disabled; see setup guide step 3g")
1424 } else if access == Some("private") {
1425 StepState::not("issue intake is restricted; see setup guide step 3g")
1426 } else {
1427 StepState::ok_with_limitation(
1428 "issue intake is enabled",
1429 GITLAB_PRIVATE_REPORTING_LIMITATION,
1430 )
1431 }
1432 }
1433 Api::Missing => {
1434 StepState::unknown(format!("{path}: issue intake is unreadable (404)"))
1435 }
1436 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
1437 })
1438 }
1439
1440 "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1441 Api::Ok(body) => {
1442 let found = body["default_branch"].as_str().unwrap_or("");
1443 if found == trunk {
1444 StepState::ok(format!("{trunk} is the default branch"))
1445 } else {
1446 StepState::not(format!("the default branch is {found}"))
1447 }
1448 }
1449 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1450 Api::Failed(err) => StepState::unknown(err),
1451 }),
1452 "single-trunk" => {
1453 for candidate in ctx.retired_branches() {
1454 let candidate = candidate.as_str();
1455 if candidate == trunk {
1456 continue;
1457 }
1458 match api_get(
1459 ctx,
1460 run,
1461 &format!("projects/{project}/repository/branches/{candidate}"),
1462 )? {
1463 Api::Missing => {}
1464 Api::Ok(_) => {
1465 return Ok(StepState::not(format!("a {candidate} branch still exists")));
1466 }
1467 Api::Failed(err) => return Ok(StepState::unknown(err)),
1468 }
1469 }
1470 Ok(StepState::ok(
1471 "no long-lived branch besides the trunk remains",
1472 ))
1473 }
1474 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1475 Api::Ok(body) => {
1476 if body["remove_source_branch_after_merge"]
1477 .as_bool()
1478 .unwrap_or(false)
1479 {
1480 StepState::ok("a merged branch is deleted by the forge")
1481 } else {
1482 StepState::not("a merged branch outlives its merge")
1483 }
1484 }
1485 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1486 Api::Failed(err) => StepState::unknown(err),
1487 }),
1488 "auto-merge" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1489 Api::Ok(body) => {
1490 if body["only_allow_merge_if_pipeline_succeeds"]
1491 .as_bool()
1492 .unwrap_or(false)
1493 {
1494 StepState::ok_with_limitation(
1495 "a request may merge itself once its pipeline passes",
1496 GITLAB_AUTO_MERGE_LIMITATION,
1497 )
1498 } else {
1499 StepState::not(
1500 "the pipeline requirement auto-merge rides on is off; protect-trunk asserts it",
1501 )
1502 }
1503 }
1504 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1505 Api::Failed(err) => StepState::unknown(err),
1506 }),
1507 "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1508 Api::Ok(body) => {
1509 if body["jobs_enabled"] == true {
1510 StepState::ok("pipelines are enabled")
1511 } else {
1512 StepState::not("pipelines are disabled")
1513 }
1514 }
1515 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1516 Api::Failed(err) => StepState::unknown(err),
1517 }),
1518 "install-bot" => {
1519 let mut active = false;
1525 let mut exhausted = false;
1526 for page in 1..=10u32 {
1527 let path = format!(
1528 "projects/{project}/access_tokens?state=active&per_page=100&page={page}"
1529 );
1530 let list = match api_get(ctx, run, &path)? {
1531 Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1532 Api::Missing => Vec::new(),
1533 Api::Failed(err) => return Ok(StepState::unknown(err)),
1534 };
1535 active = active
1536 || list.iter().any(|token| {
1537 token["name"] == "release-bot"
1538 && token["revoked"] == false
1539 && token["active"] != false
1540 });
1541 if list.len() < 100 {
1542 exhausted = true;
1543 }
1544 if active || exhausted {
1545 break;
1546 }
1547 }
1548 if !active {
1549 return Ok(if exhausted {
1550 StepState::not("no active release-bot token exists")
1551 } else {
1552 StepState::unknown(
1553 "the token listing did not exhaust within ten pages; nothing was decided",
1554 )
1555 });
1556 }
1557 Ok(
1561 match api_get(
1562 ctx,
1563 run,
1564 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1565 )? {
1566 Api::Ok(_) => StepState::ok(
1567 "an active release-bot token exists and its variable is stored",
1568 ),
1569 Api::Missing => StepState::not(
1570 "an active release-bot token exists with no stored variable; a rerun revokes and replaces it",
1571 ),
1572 Api::Failed(err) => StepState::unknown(err),
1573 },
1574 )
1575 }
1576 "bot-secrets" => Ok(
1577 match api_get(
1578 ctx,
1579 run,
1580 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1581 )? {
1582 Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
1583 Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
1584 Api::Failed(err) => StepState::unknown(err),
1585 },
1586 ),
1587 "protect-trunk" => {
1588 let protection = match api_get(
1589 ctx,
1590 run,
1591 &format!("projects/{project}/protected_branches/{trunk}"),
1592 )? {
1593 Api::Ok(body) => body,
1594 Api::Missing => {
1595 return Ok(StepState::not(format!("{trunk} is not protected")));
1596 }
1597 Api::Failed(err) => return Ok(StepState::unknown(err)),
1598 };
1599 let grants = protection["push_access_levels"]
1603 .as_array()
1604 .cloned()
1605 .unwrap_or_default();
1606 let policy = ctx.protection();
1607 let no_push =
1608 grants.len() == 1 && grants[0]["access_level"] == policy.gitlab.push_access_level;
1609 let merges = protection["merge_access_levels"]
1613 .as_array()
1614 .cloned()
1615 .unwrap_or_default();
1616 let can_merge =
1617 merges.len() == 1 && merges[0]["access_level"] == policy.gitlab.merge_access_level;
1618 let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
1619 Api::Ok(body) => body,
1620 Api::Missing | Api::Failed(_) => Value::Null,
1621 };
1622 let mut faults = Vec::new();
1623 if !no_push {
1624 faults.push(format!(
1625 "{trunk} still takes a direct push: the forge honors the most permissive of {} push grants",
1626 grants.len()
1627 ));
1628 }
1629 if !can_merge {
1630 faults.push(format!(
1631 "{trunk} merge grants are not exactly the one owned maintainer level"
1632 ));
1633 }
1634 if protection["allow_force_push"] != false {
1635 faults.push(format!("{trunk} allows force pushes"));
1636 }
1637 if let Some(fault) = pipeline_fault(
1642 ctx.forge_gate(),
1643 settings["only_allow_merge_if_pipeline_succeeds"].as_bool(),
1644 ) {
1645 faults.push(fault);
1646 }
1647 if settings["merge_method"] != policy.gitlab.merge_method.as_str() {
1648 faults.push("the merge method is not fast-forward".to_owned());
1649 }
1650 let squash = ctx.merge_form() == crate::landing::MergeForm::Squash;
1651 if settings["squash_option"] != policy.gitlab.squash_option.as_str() {
1652 faults.push(if squash {
1653 "merge requests do not always squash".to_owned()
1654 } else {
1655 "merge requests may squash, so a request's commits would not keep their own messages".to_owned()
1656 });
1657 }
1658 if squash
1661 && settings["squash_commit_template"]
1662 != policy.gitlab.squash_commit_template.as_str()
1663 {
1664 faults.push("the squash template is not the merge request's title".to_owned());
1665 }
1666 Ok(if faults.is_empty() {
1667 let detail = format!("{trunk} holds the release-merge shape");
1668 if ctx.forge_gate() {
1669 StepState::ok_with_limitation(
1670 detail,
1671 if squash {
1672 GITLAB_TITLE_LIMITATION
1673 } else {
1674 GITLAB_COMMIT_LIMITATION
1675 },
1676 )
1677 } else {
1678 StepState::ok(detail)
1680 }
1681 } else {
1682 StepState::not(faults.join("; "))
1683 })
1684 }
1685 "protect-tags" => Ok(
1686 match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
1687 Api::Ok(_) => {
1688 StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
1689 }
1690 Api::Missing => StepState::not("v* is not protected"),
1691 Api::Failed(err) => StepState::unknown(err),
1692 },
1693 ),
1694 "protect-release-lines" => Ok(
1695 match api_get(
1696 ctx,
1697 run,
1698 &format!("projects/{project}/protected_branches/release%2F%2A"),
1699 )? {
1700 Api::Ok(body) => {
1701 let level_ok = |levels: &Value| {
1702 levels
1703 .as_array()
1704 .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
1705 };
1706 if body["allow_force_push"] != false {
1707 StepState::not("release/* allows force pushes")
1708 } else if !level_ok(&body["push_access_levels"])
1709 || !level_ok(&body["merge_access_levels"])
1710 {
1711 StepState::not(
1715 "release/* grants are not exactly the owned maintainer levels",
1716 )
1717 } else {
1718 StepState::ok("release/* refuses force pushes and deletion by git clients")
1719 }
1720 }
1721 Api::Missing => StepState::inapplicable(
1722 "release/* is unprotected; optional — applied only where older lines exist",
1723 ),
1724 Api::Failed(err) => StepState::unknown(err),
1725 },
1726 ),
1727 "protections-check" => {
1728 let mut failures = Vec::new();
1731 let mut unknowns = Vec::new();
1732 let mut limitations: Vec<String> = Vec::new();
1735 for owned in owned_protections(ctx) {
1736 match gitlab(ctx, owned, run)? {
1737 StepState::Satisfied {
1738 limitation: found, ..
1739 } => limitations.extend(found),
1740 StepState::Inapplicable { .. } => {}
1741 StepState::Unsatisfied { detail } => {
1742 failures.push(format!("{owned}: {detail}"));
1743 }
1744 StepState::Unknown { detail } => {
1745 unknowns.push(format!("{owned}: {detail}"));
1746 }
1747 }
1748 }
1749 Ok(if !failures.is_empty() {
1750 StepState::not(failures.join("; "))
1751 } else if !unknowns.is_empty() {
1752 StepState::unknown(unknowns.join("; "))
1753 } else {
1754 StepState::Satisfied {
1755 detail: "the protections hold, as far as this forge enforces them".into(),
1756 limitation: if limitations.is_empty() {
1757 None
1758 } else {
1759 Some(limitations.join("; "))
1760 },
1761 }
1762 })
1763 }
1764 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1765 }
1766}
1767
1768#[cfg(test)]
1769mod tests {
1770 #[test]
1772 fn merge_form_rebase_switch_is_judged() {
1773 assert_eq!(super::rebase_switch_fault(Some(true)), None);
1774 assert!(
1775 super::rebase_switch_fault(Some(false))
1776 .is_some_and(|fault| fault.contains("allow_rebase_merge is false"))
1777 );
1778 assert!(
1779 super::rebase_switch_fault(None)
1780 .is_some_and(|fault| fault.contains("missing or unreadable"))
1781 );
1782 }
1783
1784 #[test]
1789 fn release_less_pipeline_requirement_is_judged_both_ways() {
1790 assert_eq!(super::pipeline_fault(true, Some(true)), None);
1791 assert_eq!(super::pipeline_fault(false, Some(false)), None);
1792 for (gate, observed) in [
1793 (true, Some(false)),
1794 (false, Some(true)),
1795 (true, None),
1796 (false, None),
1797 ] {
1798 assert!(
1799 super::pipeline_fault(gate, observed).is_some(),
1800 "gate {gate}, observed {observed:?}"
1801 );
1802 }
1803 }
1804}