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";
163
164const BASH_LIMITATION: &str = "the package is not listed here: its contents, SECURITY.md inclusion among them, are unproved; run rk release build --list before the first release, and name SECURITY.md in profile.release.build.include to ship it";
168
169fn bash_build_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
181 const KEY: &str = "profile.release.build.verify";
182 let Some(command) = ctx
183 .profile
184 .release
185 .build
186 .as_ref()
187 .and_then(|build| build.verify.as_deref())
188 else {
189 return Ok(StepState::ok_with_limitation(
190 format!(
191 "rk release build packs the declared tracked files of HEAD, and {KEY} names no command that proves the package builds"
192 ),
193 BASH_LIMITATION.to_owned(),
194 ));
195 };
196 let limitation = BASH_LIMITATION.to_owned();
197 if !plain_command(command) {
200 return Ok(StepState::ok_with_limitation(
201 format!(
202 "{command} carries shell syntax, so its resolution is not proved here. The build runs it through sh -c in the unpacked package"
203 ),
204 limitation,
205 ));
206 }
207 let mut words = command.split_whitespace();
208 let program = words.next().unwrap_or_default();
209 let env = ctx.child_env("package-check");
210 let runnable = |path: &std::path::Path| {
212 use std::os::unix::fs::PermissionsExt as _;
213 std::fs::metadata(path)
214 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
215 };
216 let on_path = |name: &str| {
217 env.iter()
218 .find(|(key, _)| key == "PATH")
219 .map(|(_, value)| value.clone())
220 .or_else(|| std::env::var_os("PATH"))
221 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| runnable(&dir.join(name))))
222 };
223 if program == "make" {
224 let makefile = ["GNUmakefile", "makefile", "Makefile"]
225 .iter()
226 .any(|name| ctx.target.join(name).is_file());
227 if !makefile {
228 return Ok(StepState::not(format!(
229 "the verify command is {command}, from {KEY}, and the target holds no Makefile, GNUmakefile, or makefile; ship one in the package, or set {KEY} to the command that proves it"
230 )));
231 }
232 if !on_path("make") {
233 return Ok(StepState::ok_with_limitation(
234 format!(
235 "{command} has its makefile; make is not on this host, so the target was not resolved"
236 ),
237 limitation,
238 ));
239 }
240 let mut args: Vec<&str> = vec!["-n"];
241 args.extend(words);
242 let outcome = run(&cargo_exec(ctx, "make", &args))?;
243 if !outcome.success() {
244 return Ok(StepState::not(format!(
245 "make -n does not resolve the verify command {command}: {}; give the makefile that target, or correct {KEY}",
246 last_line(&outcome.stderr)
247 )));
248 }
249 return Ok(StepState::ok_with_limitation(
250 format!("{command} resolves in the target's makefile"),
251 limitation,
252 ));
253 }
254 if program.contains('/') {
257 if !runnable(ctx.target.join(program).as_std_path()) {
258 return Ok(StepState::not(format!(
259 "the verify command is {command}, from {KEY}, and {program} is not an executable file in the target; make it one, or correct {KEY}"
260 )));
261 }
262 return Ok(StepState::ok_with_limitation(
263 format!(
264 "{command} is the project's own verify command, and {program} is executable here; it runs in the unpacked package"
265 ),
266 limitation,
267 ));
268 }
269 let resolves = on_path(program);
270 Ok(StepState::ok_with_limitation(
271 if resolves {
272 format!(
273 "{command} is the project's own verify command, and {program} resolves here; it runs in the unpacked package"
274 )
275 } else {
276 format!(
277 "{command} is the project's own verify command, and {program} does not resolve on this host, where profile.release.build.packages need not be installed; it runs in the unpacked package"
278 )
279 },
280 limitation,
281 ))
282}
283
284fn plain_command(command: &str) -> bool {
288 const SHELL: &[char] = &[
289 '\u{27}', '\u{22}', '\u{60}', '\\', '$', ';', '&', '|', '<', '>', '(', ')', '{', '}', '*',
290 '?', '[', ']', '~', '#', '!', '\n',
291 ];
292 !command.contains(SHELL)
293 && command
294 .split_whitespace()
295 .next()
296 .is_some_and(|program| !program.contains('='))
297}
298
299fn package_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
310 if let Some(reason) = super::steps::no_registry(ctx) {
313 return Ok(StepState::ok(reason));
314 }
315 let (program, args): (&str, &[&str]) = match ctx.tech {
316 Some("rust") => ("cargo", &["publish", "--dry-run", "--allow-dirty"]),
317 Some("python") => return python_build_check(ctx, run, ctx.reporting_policy()),
318 Some("bash") => return bash_build_check(ctx, run),
319 Some(other) => {
320 return Ok(StepState::unknown(format!(
321 "no packaging check is defined for {other}"
322 )));
323 }
324 None => {
325 return Ok(StepState::unknown(
326 "no version file names a technology; see rk binding --list",
327 ));
328 }
329 };
330 let outcome = run(&cargo_exec(ctx, program, args))?;
331 if !outcome.success() {
332 return Ok(StepState::not(format!(
333 "the packaging check failed: {}",
334 last_line(&outcome.stderr)
335 )));
336 }
337 let built = "the package builds and passes the registry's dry run";
338 policy_in_the_crate(ctx, run, built)
339}
340
341fn uv_runs(
346 program: &std::ffi::OsStr,
347 env: &[(std::ffi::OsString, std::ffi::OsString)],
348 cwd: &std::path::Path,
349) -> bool {
350 use std::os::unix::fs::PermissionsExt as _;
351 let runnable = |path: &std::path::Path| {
352 std::fs::metadata(path)
353 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
354 };
355 let path = std::path::Path::new(program);
356 if path.components().count() > 1 {
357 return runnable(&cwd.join(path));
358 }
359 env.iter()
360 .find(|(key, _)| key == "PATH")
361 .map(|(_, value)| value.clone())
362 .or_else(|| std::env::var_os("PATH"))
363 .is_some_and(|paths| {
364 std::env::split_paths(&paths).any(|dir| runnable(&cwd.join(dir).join(path)))
365 })
366}
367
368fn python_build_check(ctx: &Ctx, run: &mut Runner, policy: bool) -> Result<StepState, RkError> {
372 let program = crate::probes::uv_bin();
373 let env = ctx.child_env("package-check");
374 if !uv_runs(&program, &env, ctx.target.as_std_path()) {
375 return Ok(StepState::Unknown {
378 detail: format!(
379 "uv ({}) is not runnable on this host, so the package build was not checked; install uv, or point RK_UV_BIN at it",
380 program.to_string_lossy()
381 ),
382 });
383 }
384 let exec = Exec {
385 program,
386 args: vec!["build".into()],
387 env,
388 cwd: ctx.target.as_std_path().to_path_buf(),
389 stdin: None,
390 };
391 let outcome = run(&exec)?;
392 if !outcome.success() {
393 return Ok(StepState::not(format!(
394 "the packaging check failed: {}",
395 last_line(&outcome.stderr)
396 )));
397 }
398 let listed = |suffix: &str| -> Vec<String> {
399 let mut names: Vec<String> = std::fs::read_dir(ctx.target.join("dist").as_std_path())
400 .into_iter()
401 .flatten()
402 .flatten()
403 .map(|entry| entry.file_name().to_string_lossy().into_owned())
404 .filter(|name| name.ends_with(suffix))
405 .collect();
406 names.sort();
407 names
408 };
409 let (sdists, wheels) = (listed(".tar.gz"), listed(".whl"));
410 if sdists.is_empty() || wheels.is_empty() {
411 return Ok(StepState::not(
412 "uv build left no sdist and wheel pair in dist/; the release uploads both",
413 ));
414 }
415 let built = format!(
416 "uv build wrote the sdist {} and the wheel {}",
417 sdists.join(", "),
418 wheels.join(", ")
419 );
420 Ok(if policy {
421 StepState::ok_with_limitation(built, PYTHON_LIMITATION)
422 } else {
423 StepState::ok(built)
424 })
425}
426
427fn cargo_exec(ctx: &Ctx, program: &str, args: &[&str]) -> Exec {
429 Exec {
430 program: program.into(),
431 args: args.iter().map(Into::into).collect(),
432 env: ctx.child_env("package-check"),
433 cwd: ctx.target.as_std_path().to_path_buf(),
434 stdin: None,
435 }
436}
437
438fn policy_in_the_crate(ctx: &Ctx, run: &mut Runner, built: &str) -> Result<StepState, RkError> {
454 let policy = ctx.reporting_policy();
455 let root_manifest = ctx.target.as_std_path().join("Cargo.toml");
456 let probe = cargo_package::probe(&root_manifest, &[], |args| {
457 run(&cargo_exec(ctx, "cargo", args)).map(|outcome| Answer {
458 success: outcome.success(),
459 stdout: outcome.stdout,
460 stderr: outcome.stderr,
461 })
462 })?;
463 let listing = match probe {
464 Probe::Listed(listing) => listing,
465 Probe::OtherShape => {
466 let unproved = if policy {
467 format!(
468 "{POLICY_DESTINATION} inclusion and the absence of release-kit's own files are"
469 )
470 } else {
471 "the absence of release-kit's own files is".to_owned()
472 };
473 return Ok(StepState::ok_with_limitation(
474 built,
475 format!(
476 "{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"
477 ),
478 ));
479 }
480 Probe::MetadataFailed(stderr) => {
481 return Ok(StepState::unknown(format!(
482 "{built}, and the listing check could not run: cargo metadata failed: {}",
483 last_line(&stderr)
484 )));
485 }
486 Probe::ListingFailed(stderr) => {
487 return Ok(StepState::unknown(format!(
488 "{built}, and the listing check could not run: cargo package --list failed: {}",
489 last_line(&stderr)
490 )));
491 }
492 };
493 let forbidden = cargo_package::release_kit_paths(ctx.landed_destinations());
494 let shipped = listing.shipped(&forbidden);
495 Ok(boundary_state(
496 built,
497 policy,
498 !policy || listing.carries(POLICY_DESTINATION),
499 &shipped,
500 ))
501}
502
503fn boundary_state(built: &str, policy: bool, carried: bool, shipped: &[&str]) -> StepState {
507 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";
508 let policy_fix = format!(
509 "add /{POLICY_DESTINATION} to [package].include, remove the [package].exclude entry matching it, or stop ignoring the file"
510 );
511 let own = || {
512 let entries = cargo_package::exclude_entries(shipped)
513 .iter()
514 .map(|entry| format!("\"{entry}\""))
515 .collect::<Vec<_>>()
516 .join(", ");
517 (
518 shipped.join(", "),
519 format!("add {entries} to [package].exclude, or drop them from [package].include"),
520 )
521 };
522 match (carried, shipped.is_empty()) {
523 (true, true) if policy => StepState::ok(format!(
524 "{built}, and the published package carries {POLICY_DESTINATION} and no file release-kit lands or owns"
525 )),
526 (true, true) => StepState::ok(format!(
527 "{built}, and the published package ships no file release-kit lands or owns"
528 )),
529 (false, true) => StepState::not(format!(
530 "{built}, but the published package omits {POLICY_DESTINATION}: {policy_fix}"
531 )),
532 (true, false) => {
533 let (list, fix) = own();
534 StepState::not(format!(
535 "{built}, but the published package ships files release-kit lands or owns ({list}), {WHY}: {fix}"
536 ))
537 }
538 (false, false) => {
539 let (list, fix) = own();
540 StepState::not(format!(
541 "{built}, but the published package omits {POLICY_DESTINATION} and ships files release-kit lands or owns ({list}), {WHY}: {policy_fix}; and {fix}"
542 ))
543 }
544 }
545}
546
547fn branch_reminder_state(ctx: &Ctx) -> StepState {
550 use crate::setup::branch_reminder::{HookState, observe_hook};
551 match observe_hook(&ctx.target) {
552 HookState::Installed => {
553 StepState::ok("the post-merge hook carries the release-kit reminder")
554 }
555 HookState::Absent => StepState::not("no post-merge hook is installed"),
556 HookState::Foreign => {
557 StepState::not("a post-merge hook exists without the release-kit marker")
558 }
559 HookState::Drifted => StepState::not("the reminder hook drifted from this binary's body"),
560 HookState::Unreadable(detail) => StepState::unknown(detail),
561 }
562}
563
564pub const GITLAB_VERSION_FLOOR: (u64, u64) = (18, 2);
571
572const GITLAB_EDITIONS: [&str; 2] = ["ee", "ce"];
577
578fn version_refusal(found: &str, prerelease: Option<&str>, on_gitlab_com: bool) -> String {
582 let (major, minor) = GITLAB_VERSION_FLOOR;
583 let mut said = vec![format!(
584 "this GitLab instance reports {found}; the convention needs {major}.{minor} or newer"
585 )];
586 if let Some(suffix) = prerelease {
587 said.push(format!(
588 "the -{suffix} suffix is a pre-release, and nothing proves the feature shipped in it, so this step fails closed"
589 ));
590 }
591 said.push(format!(
592 "the merge-request pipeline triggers a child pipeline with `strategy: mirror`, which GitLab added in {major}.{minor}"
593 ));
594 said.push(
595 "below it the child's status never reaches the parent pipeline, so a failing project job merges".to_owned(),
596 );
597 if on_gitlab_com {
598 said.push(format!(
599 "gitlab.com runs below {major}.{minor} only until its next deployment; rerun the check after it"
600 ));
601 } else {
602 said.push(format!(
603 "upgrade the instance to {major}.{minor} or newer, or host the project on gitlab.com"
604 ));
605 }
606 said.join("; ")
607}
608
609fn forge_version(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
615 if ctx.forge == Some(Forge::Github) {
616 return Ok(StepState::ok(
617 "github.com is a rolling service and declares no version floor",
618 ));
619 }
620 let body = match api_get(ctx, run, "version")? {
621 Api::Ok(body) => body,
622 Api::Missing => {
623 return Ok(StepState::unknown(
624 "this instance answers no GET /version; the floor cannot be read. Check that glab is authenticated against it: glab auth login",
625 ));
626 }
627 Api::Failed(err) => {
628 return Ok(StepState::unknown(format!(
629 "the version could not be read: {err}. Check that glab is authenticated against this instance: glab auth login"
630 )));
631 }
632 };
633 let Some(found) = body["version"].as_str() else {
634 return Ok(StepState::unknown(
635 "the forge answer carries no version field; the floor cannot be read. Check that glab is authenticated against this instance: glab auth login",
636 ));
637 };
638 let (number, suffix) = found
639 .split_once('-')
640 .map_or((found, None), |(n, s)| (n, Some(s)));
641 let mut parts = number.split('.');
642 let parsed = parts
643 .next()
644 .and_then(|major| major.parse::<u64>().ok())
645 .zip(parts.next().and_then(|minor| minor.parse::<u64>().ok()));
646 let Some(pair) = parsed else {
647 return Ok(StepState::unknown(format!(
648 "the forge reports the version as '{found}', which names no major and minor pair; the floor cannot be read"
649 )));
650 };
651 let on_gitlab_com = ctx.on_gitlab_com();
656 let prerelease = suffix.filter(|s| !GITLAB_EDITIONS.contains(s));
657 if let Some(suffix) = prerelease.filter(|_| !on_gitlab_com) {
658 return Ok(StepState::not(version_refusal(found, Some(suffix), false)));
659 }
660 if pair < GITLAB_VERSION_FLOOR {
661 return Ok(StepState::not(version_refusal(found, None, on_gitlab_com)));
662 }
663 let (major, minor) = GITLAB_VERSION_FLOOR;
664 Ok(StepState::ok(prerelease.map_or_else(
665 || format!("this instance reports {found}, at or above the {major}.{minor} floor"),
666 |suffix| {
667 format!(
668 "gitlab.com reports {found}, at or above the {major}.{minor} floor; the -{suffix} suffix is the SaaS deployment's own"
669 )
670 },
671 )))
672}
673
674pub fn single_trunk_guard(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
684 let trunk = ctx.trunk();
685 for candidate in ctx.retired_branches() {
686 let candidate = candidate.as_str();
687 if candidate == trunk {
688 continue;
689 }
690 let state = match ctx.forge {
691 Some(Forge::Github) => github_candidate_guard(ctx, run, candidate)?,
692 Some(Forge::Gitlab) => gitlab_candidate_guard(ctx, run, candidate)?,
693 None => StepState::unknown("the profile names no forge this release drives"),
696 };
697 if !state.satisfied() {
698 return Ok(state);
699 }
700 }
701 Ok(StepState::ok(
702 "every candidate branch is absent, or an ancestor of the trunk",
703 ))
704}
705
706fn github_candidate_guard(
708 ctx: &Ctx,
709 run: &mut Runner,
710 candidate: &str,
711) -> Result<StepState, RkError> {
712 let trunk = ctx.trunk();
713 match api_get(
714 ctx,
715 run,
716 &format!("repos/{}/git/ref/heads/{candidate}", ctx.repo),
717 )? {
718 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
719 Api::Failed(err) => return Ok(StepState::unknown(err)),
720 Api::Ok(_) => {}
721 }
722 match api_get(
723 ctx,
724 run,
725 &format!("repos/{}/compare/{candidate}...{trunk}", ctx.repo),
726 )? {
727 Api::Ok(body) => {
728 let status = body["status"].as_str().unwrap_or("");
729 Ok(if matches!(status, "ahead" | "identical") {
730 StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
731 } else {
732 StepState::not(format!(
733 "{candidate} is not an ancestor of {trunk} ({status}); deleting it would lose work"
734 ))
735 })
736 }
737 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
738 Api::Failed(err) => Ok(StepState::unknown(err)),
739 }
740}
741
742fn gitlab_candidate_guard(
744 ctx: &Ctx,
745 run: &mut Runner,
746 candidate: &str,
747) -> Result<StepState, RkError> {
748 let trunk = ctx.trunk();
749 let project = ctx.repo.replace('/', "%2F");
750 match api_get(
751 ctx,
752 run,
753 &format!("projects/{project}/repository/branches/{candidate}"),
754 )? {
755 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
756 Api::Failed(err) => return Ok(StepState::unknown(err)),
757 Api::Ok(_) => {}
758 }
759 match api_get(
760 ctx,
761 run,
762 &format!("projects/{project}/repository/compare?from={trunk}&to={candidate}"),
763 )? {
764 Api::Ok(body) => {
765 let ahead = body["commits"]
766 .as_array()
767 .is_some_and(|list| !list.is_empty());
768 Ok(if ahead {
769 StepState::not(format!(
770 "{candidate} carries commits {trunk} does not; deleting it would lose work"
771 ))
772 } else {
773 StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
774 })
775 }
776 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
777 Api::Failed(err) => Ok(StepState::unknown(err)),
778 }
779}
780
781pub(crate) fn api_get(ctx: &Ctx, run: &mut Runner, path: &str) -> Result<Api, RkError> {
783 let exec = Exec {
784 program: ctx.cli.clone().into_os_string(),
785 args: vec!["api".into(), path.into()],
786 env: ctx.child_env("observe"),
787 cwd: ctx.target.as_std_path().to_path_buf(),
788 stdin: None,
789 };
790 let outcome = run(&exec)?;
791 if outcome.success() {
792 return Ok(
793 serde_json::from_slice::<Value>(&outcome.stdout).map_or_else(
794 |_| Api::Failed("the forge answer did not parse as JSON".into()),
795 Api::Ok,
796 ),
797 );
798 }
799 let stderr = String::from_utf8_lossy(&outcome.stderr).into_owned();
800 if stderr.contains("404") {
801 Ok(Api::Missing)
802 } else {
803 Ok(Api::Failed(last_line(&outcome.stderr)))
804 }
805}
806
807fn last_line(bytes: &[u8]) -> String {
809 String::from_utf8_lossy(bytes)
810 .lines()
811 .rev()
812 .find(|line| !line.trim().is_empty())
813 .unwrap_or("no output")
814 .to_owned()
815}
816
817#[allow(
818 clippy::too_many_lines,
819 reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
820)]
821fn github(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
822 let trunk = ctx.trunk();
823 let repo = &ctx.repo;
824 match step {
825 "private-vulnerability-reporting" => {
826 let visibility_path = format!("repos/{repo}");
827 match api_get(ctx, run, &visibility_path)? {
828 Api::Ok(body) => match body["private"].as_bool() {
829 Some(true) => {
830 return Ok(StepState::inapplicable(
831 "private vulnerability reporting is available for public repositories",
832 ));
833 }
834 Some(false) => {}
835 None => {
836 return Ok(StepState::unknown(format!(
837 "{visibility_path}: repository visibility is unreadable"
838 )));
839 }
840 },
841 Api::Missing => {
842 return Ok(StepState::unknown(format!(
843 "{visibility_path}: repository visibility is unreadable (404)"
844 )));
845 }
846 Api::Failed(err) => {
847 return Ok(StepState::unknown(format!("{visibility_path}: {err}")));
848 }
849 }
850 let path = format!("repos/{repo}/private-vulnerability-reporting");
851 Ok(match api_get(ctx, run, &path)? {
852 Api::Ok(body) => match body["enabled"].as_bool() {
853 Some(true) => StepState::ok("private vulnerability reporting is enabled"),
854 Some(false) => StepState::not("private vulnerability reporting is disabled"),
855 None => StepState::unknown(format!("{path}: enabled is unreadable")),
856 },
857 Api::Missing => {
858 StepState::unknown(format!("{path}: reporting state is unreadable (404)"))
859 }
860 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
861 })
862 }
863
864 "default-branch" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
865 Api::Ok(body) => {
866 let found = body["default_branch"].as_str().unwrap_or("");
867 if found == trunk {
868 StepState::ok(format!("{trunk} is the default branch"))
869 } else {
870 StepState::not(format!("the default branch is {found}"))
871 }
872 }
873 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
874 Api::Failed(err) => StepState::unknown(err),
875 }),
876 "single-trunk" => {
877 for candidate in ctx.retired_branches() {
878 let candidate = candidate.as_str();
879 if candidate == trunk {
880 continue;
881 }
882 match api_get(ctx, run, &format!("repos/{repo}/git/ref/heads/{candidate}"))? {
883 Api::Missing => {}
884 Api::Ok(_) => {
885 return Ok(StepState::not(format!("a {candidate} branch still exists")));
886 }
887 Api::Failed(err) => return Ok(StepState::unknown(err)),
888 }
889 }
890 Ok(StepState::ok(
891 "no long-lived branch besides the trunk remains",
892 ))
893 }
894 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
895 Api::Ok(body) => {
896 if body["delete_branch_on_merge"].as_bool().unwrap_or(false) {
897 StepState::ok("a merged branch is deleted by the forge")
898 } else {
899 StepState::not("a merged branch outlives its merge")
900 }
901 }
902 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
903 Api::Failed(err) => StepState::unknown(err),
904 }),
905 "merge-on-order" => {
906 let requests = match open_requests(ctx, run, &format!("repos/{repo}/pulls?state=open"))?
910 {
911 Ok(requests) => requests,
912 Err(state) => return Ok(state),
913 };
914 if let Some(fault) = armed_fault(ctx, &requests, "head", "ref") {
915 return Ok(StepState::not(fault));
916 }
917 let switch = match api_get(ctx, run, &format!("repos/{repo}"))? {
920 Api::Ok(body) => body["allow_auto_merge"]
921 .as_bool()
922 .map_or("unread", |on| if on { "on" } else { "off" }),
923 Api::Missing => {
924 return Ok(StepState::not(format!("the forge does not know {repo}")));
925 }
926 Api::Failed(_) => "unread",
927 };
928 Ok(StepState::ok(format!(
929 "no release request stands armed to merge itself; the repository's allow_auto_merge switch is {switch}, and no landed job uses it"
930 )))
931 }
932 "ci-permissions" => Ok(
933 match api_get(
934 ctx,
935 run,
936 &format!("repos/{repo}/actions/permissions/workflow"),
937 )? {
938 Api::Ok(body) => {
939 let write = body["default_workflow_permissions"] == "write";
940 let approve = body["can_approve_pull_request_reviews"] == true;
941 if write && approve {
942 StepState::ok("CI may write and open requests")
943 } else {
944 StepState::not(format!(
945 "workflow permissions are {} with request approval {}",
946 body["default_workflow_permissions"],
947 body["can_approve_pull_request_reviews"]
948 ))
949 }
950 }
951 Api::Missing => StepState::not("no workflow permissions are readable"),
952 Api::Failed(err) => StepState::unknown(err),
953 },
954 ),
955 "bot-secrets" => Ok(
956 match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
957 Api::Ok(body) => {
958 let names: Vec<&str> = body["secrets"]
959 .as_array()
960 .map(|list| {
961 list.iter()
962 .filter_map(|secret| secret["name"].as_str())
963 .collect()
964 })
965 .unwrap_or_default();
966 let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
967 if wanted.iter().all(|name| names.contains(name)) {
968 StepState::ok("both bot secrets are stored")
969 } else if names.is_empty() {
970 StepState::not("no bot secrets are stored")
971 } else {
972 StepState::not(format!("stored secrets: {}", names.join(", ")))
973 }
974 }
975 Api::Missing => StepState::not("no secrets are readable"),
976 Api::Failed(err) => StepState::unknown(err),
977 },
978 ),
979 "protect-trunk" => github_trunk_ruleset(ctx, run),
980 "protect-tags" => github_ruleset(
981 ctx,
982 run,
983 ctx.tag_ruleset(),
984 "tag",
985 "refs/tags/v*",
986 &["deletion", "update"],
987 ),
988 "protect-release-lines" => {
989 match github_ruleset_body(ctx, run, ctx.lines_ruleset())? {
990 RulesetLookup::Absent => {
991 return Ok(StepState::inapplicable(
992 "release/* is unprotected; optional โ applied only where older lines exist",
993 ));
994 }
995 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
996 RulesetLookup::Found(_) => {}
997 }
998 github_ruleset(
999 ctx,
1000 run,
1001 ctx.lines_ruleset(),
1002 "branch",
1003 "refs/heads/release/*",
1004 &["deletion", "non_fast_forward"],
1005 )
1006 }
1007 "protections-check" => {
1008 let mut failures = Vec::new();
1012 let mut unknowns = Vec::new();
1013 let mut limitations: Vec<String> = Vec::new();
1015 for owned in owned_protections(ctx) {
1016 match github(ctx, owned, run)? {
1017 StepState::Satisfied {
1018 limitation: found, ..
1019 } => limitations.extend(found),
1020 StepState::Inapplicable { .. } => {}
1021 StepState::Unsatisfied { detail } => {
1022 failures.push(format!("{owned}: {detail}"));
1023 }
1024 StepState::Unknown { detail } => {
1025 unknowns.push(format!("{owned}: {detail}"));
1026 }
1027 }
1028 }
1029 match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
1030 Api::Ok(body) => {
1031 let owned = [
1032 ctx.trunk_ruleset().to_owned(),
1033 ctx.safety_ruleset().to_owned(),
1034 ctx.tag_ruleset().to_owned(),
1035 ctx.lines_ruleset().to_owned(),
1036 ];
1037 for ruleset in body.as_array().into_iter().flatten() {
1038 let name = ruleset["name"].as_str().unwrap_or("");
1039 if !owned.iter().any(|expected| expected == name) {
1040 failures.push(format!("a ruleset no step owns: {name}"));
1041 }
1042 }
1043 }
1044 Api::Missing | Api::Failed(_) => {
1045 unknowns.push("the ruleset inventory is not readable".to_owned());
1046 }
1047 }
1048 Ok(if !failures.is_empty() {
1049 StepState::not(failures.join("; "))
1050 } else if !unknowns.is_empty() {
1051 StepState::unknown(unknowns.join("; "))
1052 } else {
1053 StepState::Satisfied {
1054 detail: "exactly the owned protections, with those rules".into(),
1055 limitation: if limitations.is_empty() {
1056 None
1057 } else {
1058 Some(limitations.join("; "))
1059 },
1060 }
1061 })
1062 }
1063 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1064 }
1065}
1066
1067fn owned_protections(ctx: &Ctx) -> Vec<&'static str> {
1071 let mut owned = vec!["protect-trunk"];
1072 if ctx.tag_protection() {
1073 owned.push("protect-tags");
1074 }
1075 owned.push("protect-release-lines");
1076 owned
1077}
1078
1079#[must_use]
1087pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
1088 match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
1089 AppApi::Ok(body) => {
1090 let id = body["id"].as_i64().unwrap_or_default();
1091 let held = &body["permissions"];
1092 let short: Vec<String> = minimum_grant(ctx)
1093 .into_iter()
1094 .filter(|(key, level)| held[key] != *level)
1095 .map(|(key, level)| format!("{key}: {level}"))
1096 .collect();
1097 if short.is_empty() {
1098 StepState::ok(format!("installation {id} covers {}", ctx.repo))
1099 } else {
1100 StepState::not(format!(
1105 "installation {id} covers {} and does not hold [{}]; approve the App's updated permissions on the installation's own settings page",
1106 ctx.repo,
1107 short.join(", ")
1108 ))
1109 }
1110 }
1111 AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
1112 AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
1113 }
1114}
1115
1116fn minimum_grant(ctx: &Ctx) -> Vec<(&'static str, &'static str)> {
1125 let mut grant = vec![("contents", "write"), ("pull_requests", "write")];
1126 if ctx.profile.release.mode == crate::profile::ReleaseMode::Automatic {
1127 grant.push(("checks", "read"));
1128 }
1129 grant
1130}
1131
1132fn github_ruleset(
1137 ctx: &Ctx,
1138 run: &mut Runner,
1139 name: &str,
1140 target: &str,
1141 include: &str,
1142 rules: &[&str],
1143) -> Result<StepState, RkError> {
1144 let detail = match github_ruleset_body(ctx, run, name)? {
1145 RulesetLookup::Found(detail) => detail,
1146 RulesetLookup::Absent => {
1147 return Ok(StepState::not(format!("no ruleset named {name}")));
1148 }
1149 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
1150 };
1151 if detail["enforcement"] != "active" {
1152 return Ok(StepState::not(format!("{name} is not active")));
1153 }
1154 if detail["target"] != target {
1157 return Ok(StepState::not(format!(
1158 "{name} does not target {target} refs"
1159 )));
1160 }
1161 if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
1162 return Ok(StepState::not(format!(
1163 "{name} does not cover {include} alone"
1164 )));
1165 }
1166 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
1167 return Ok(StepState::not(format!(
1168 "{name} excludes refs from its own coverage"
1169 )));
1170 }
1171 let mut held: Vec<&str> = detail["rules"]
1172 .as_array()
1173 .map(|list| {
1174 list.iter()
1175 .filter_map(|rule| rule["type"].as_str())
1176 .collect()
1177 })
1178 .unwrap_or_default();
1179 held.sort_unstable();
1180 let mut expected: Vec<&str> = rules.to_vec();
1181 expected.sort_unstable();
1182 if held == expected {
1183 Ok(StepState::ok(format!(
1184 "{name} is active with exactly its rules"
1185 )))
1186 } else {
1187 Ok(StepState::not(format!(
1188 "{name} carries the rules [{}] where the setup owns [{}]",
1189 held.join(", "),
1190 expected.join(", ")
1191 )))
1192 }
1193}
1194
1195fn unowned_rule_faults(rules: &[Value], owned: &[String]) -> Vec<String> {
1210 rules
1211 .iter()
1212 .filter_map(|rule| rule["type"].as_str())
1213 .filter(|kind| !owned.iter().any(|name| name == kind))
1214 .map(|kind| {
1215 if kind == "merge_queue" {
1216 MERGE_QUEUE_FAULT.to_owned()
1217 } else {
1218 format!("an unowned rule is present: {kind}")
1219 }
1220 })
1221 .collect()
1222}
1223
1224fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
1225 let trunk = ctx.trunk();
1226 let name = ctx.trunk_ruleset().to_owned();
1227 let detail = match github_ruleset_body(ctx, run, &name)? {
1228 RulesetLookup::Found(detail) => detail,
1229 RulesetLookup::Absent => {
1230 return Ok(StepState::not(format!("no ruleset named {name}")));
1231 }
1232 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
1233 };
1234 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
1235 let mut faults = Vec::new();
1236 if detail["enforcement"] != "active" {
1237 faults.push(format!("{name} is not active"));
1238 }
1239 if detail["target"] != "branch" {
1243 faults.push(format!("{name} does not target branches"));
1244 }
1245 let expected_ref = serde_json::json!([format!("refs/heads/{trunk}")]);
1246 if detail["conditions"]["ref_name"]["include"] != expected_ref {
1247 faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
1248 }
1249 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
1252 faults.push(format!("{name} excludes refs from its own coverage"));
1253 }
1254 let expected_bypass = super::context::github_bypass_actors(&ctx.protection().bypass_actors);
1255 if detail["bypass_actors"] != expected_bypass {
1256 faults.push("the bypass actors do not match the recorded authority".to_owned());
1257 }
1258 faults.extend(trunk_rule_faults(ctx, &rules, &name));
1259 faults.extend(merge_method_fault(ctx, &rules));
1260 if let Some(checks) = rules
1261 .iter()
1262 .find(|rule| rule["type"] == "required_status_checks")
1263 {
1264 if checks["parameters"]["strict_required_status_checks_policy"]
1265 != ctx.protection().strict_required_status_checks
1266 {
1267 faults.push(STALE_MERGE_FAULT.to_owned());
1268 }
1269 let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
1270 .as_array()
1271 .map(|list| {
1272 list.iter()
1273 .filter_map(|check| check["context"].as_str())
1274 .collect()
1275 })
1276 .unwrap_or_default();
1277 if contexts.is_empty() {
1282 faults.push("no status check is required".to_owned());
1283 } else if let Some(expected) = &ctx.required_check {
1284 let mut held = contexts.clone();
1285 held.sort_unstable();
1286 let title_check = ctx.title_check();
1287 let mut owned_contexts = [expected.as_str(), title_check];
1288 owned_contexts.sort_unstable();
1289 if held != owned_contexts {
1290 faults.push(format!(
1291 "the required checks are [{}] where the setup owns [{}]",
1292 contexts.join(", "),
1293 owned_contexts.join(", ")
1294 ));
1295 }
1296 } else if !contexts.contains(&ctx.title_check()) {
1297 faults.push(format!("the {} check is not required", ctx.title_check()));
1298 }
1299 }
1300 match repository_merge_settings(ctx, run)? {
1301 MergeSources::Owned => {}
1302 MergeSources::Faults(proven) => faults.extend(proven),
1303 MergeSources::Unreadable(err) => {
1307 if faults.is_empty() {
1308 return Ok(StepState::unknown(err));
1309 }
1310 }
1311 }
1312 match github_safety_ruleset(ctx, run)? {
1313 SafetyRuleset::Owned => {}
1314 SafetyRuleset::Faults(proven) => faults.extend(proven),
1315 SafetyRuleset::Unreadable(err) => {
1316 if faults.is_empty() {
1317 return Ok(StepState::unknown(err));
1318 }
1319 }
1320 }
1321 if let Some(shape) = gate_faults(ctx) {
1322 faults.push(shape);
1323 }
1324 if !faults.is_empty() {
1325 return Ok(StepState::not(faults.join("; ")));
1326 }
1327 Ok(StepState::ok(format!(
1328 "{name} holds the release-merge shape beside {}",
1329 ctx.safety_ruleset()
1330 )))
1331}
1332
1333fn merge_method_fault(ctx: &Ctx, rules: &[Value]) -> Option<String> {
1336 let request = rules.iter().find(|rule| rule["type"] == "pull_request")?;
1337 (request["parameters"]["allowed_merge_methods"]
1338 != serde_json::json!(ctx.protection().allowed_merge_methods))
1339 .then(|| {
1340 format!(
1341 "the merge method is not exactly a {} merge",
1342 ctx.merge_form().as_str()
1343 )
1344 })
1345}
1346
1347fn trunk_rule_faults(ctx: &Ctx, rules: &[Value], name: &str) -> Vec<String> {
1355 let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
1356 let mut faults = Vec::new();
1357 let mut accounted: Vec<String> = ctx
1358 .protection()
1359 .owned_trunk_rules
1360 .iter()
1361 .filter(|rule| crate::config::REQUEST_RULES.contains(&rule.as_str()))
1362 .cloned()
1363 .collect();
1364 for required in &accounted {
1365 if !has(required) {
1366 faults.push(format!("the {required} rule is missing"));
1367 }
1368 }
1369 for stray in crate::config::SAFETY_RULES {
1370 if has(stray) {
1371 faults.push(format!(
1372 "the {stray} rule sits in {name}, where a bypass actor excuses it"
1373 ));
1374 }
1375 accounted.push(stray.to_owned());
1376 }
1377 faults.extend(unowned_rule_faults(rules, &accounted));
1378 faults
1379}
1380
1381enum SafetyRuleset {
1383 Owned,
1384 Faults(Vec<String>),
1385 Unreadable(String),
1386}
1387
1388fn github_safety_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<SafetyRuleset, RkError> {
1396 let trunk = ctx.trunk();
1397 let name = ctx.safety_ruleset().to_owned();
1398 let detail = match github_ruleset_body(ctx, run, &name)? {
1399 RulesetLookup::Found(detail) => detail,
1400 RulesetLookup::Absent => {
1401 return Ok(SafetyRuleset::Faults(vec![format!(
1402 "no ruleset named {name} holds the trunk against deletion and force-push"
1403 )]));
1404 }
1405 RulesetLookup::Unreadable(err) => return Ok(SafetyRuleset::Unreadable(err)),
1406 };
1407 let mut faults = Vec::new();
1408 if detail["enforcement"] != "active" {
1409 faults.push(format!("{name} is not active"));
1410 }
1411 if detail["target"] != "branch" {
1412 faults.push(format!("{name} does not target branches"));
1413 }
1414 if detail["conditions"]["ref_name"]["include"]
1415 != serde_json::json!([format!("refs/heads/{trunk}")])
1416 {
1417 faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
1418 }
1419 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
1420 faults.push(format!("{name} excludes refs from its own coverage"));
1421 }
1422 if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
1423 faults.push(format!(
1424 "{name} names a bypass actor, so deletion and force-push hold against nobody"
1425 ));
1426 }
1427 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
1428 let safety_rules: Vec<String> = ctx
1429 .protection()
1430 .owned_trunk_rules
1431 .iter()
1432 .filter(|rule| crate::config::SAFETY_RULES.contains(&rule.as_str()))
1433 .cloned()
1434 .collect();
1435 for required in &safety_rules {
1436 if !rules.iter().any(|rule| rule["type"] == required.as_str()) {
1437 faults.push(format!("the {required} rule is missing from {name}"));
1438 }
1439 }
1440 faults.extend(unowned_rule_faults(&rules, &safety_rules));
1441 if faults.is_empty() {
1442 Ok(SafetyRuleset::Owned)
1443 } else {
1444 Ok(SafetyRuleset::Faults(faults))
1445 }
1446}
1447
1448fn gate_faults(ctx: &Ctx) -> Option<String> {
1458 if !ctx.forge_gate() {
1459 return None;
1460 }
1461 let check = ctx.required_check.as_deref()?;
1462 workflow_jobs::faults(
1463 &workflow_jobs::read_gate(&ctx.target, check, ctx.trunk()),
1464 check,
1465 ctx.trunk(),
1466 )
1467}
1468
1469enum MergeSources {
1471 Owned,
1473 Faults(Vec<String>),
1475 Unreadable(String),
1477}
1478
1479fn repository_merge_settings(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
1494 let squash = ctx.merge_form() == crate::landing::MergeForm::Squash;
1495 Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
1496 Api::Ok(body) => {
1497 let mut faults = Vec::new();
1498 if squash {
1499 let owned_title = ctx.protection().github.squash_title_source.as_str();
1500 let owned_body = ctx.protection().github.squash_body_source.as_str();
1501 if body["squash_merge_commit_title"] != owned_title {
1502 faults.push(format!(
1503 "the squash title source is {} where the setup owns {owned_title}",
1504 body["squash_merge_commit_title"]
1505 ));
1506 }
1507 if body["squash_merge_commit_message"] != owned_body {
1508 faults.push(format!(
1509 "the squash message source is {} where the setup owns {owned_body}",
1510 body["squash_merge_commit_message"]
1511 ));
1512 }
1513 } else if let Some(fault) = rebase_switch_fault(body["allow_rebase_merge"].as_bool()) {
1514 faults.push(fault);
1515 }
1516 if faults.is_empty() {
1517 MergeSources::Owned
1518 } else {
1519 MergeSources::Faults(faults)
1520 }
1521 }
1522 Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
1523 Api::Failed(err) => MergeSources::Unreadable(err),
1524 })
1525}
1526
1527fn rebase_switch_fault(allowed: Option<bool>) -> Option<String> {
1530 match allowed {
1531 Some(true) => None,
1532 Some(false) => Some(
1533 "the repository disallows rebase merges (allow_rebase_merge is false), so the ruleset's one merge method is refused and no request can merge"
1534 .to_owned(),
1535 ),
1536 None => Some("the repository's allow_rebase_merge switch is missing or unreadable".to_owned()),
1537 }
1538}
1539
1540enum RulesetLookup {
1543 Found(Value),
1545 Absent,
1548 Unreadable(String),
1550}
1551
1552fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
1554 let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
1558 Api::Ok(body) => body,
1559 Api::Missing => {
1560 return Ok(RulesetLookup::Unreadable(
1561 "the ruleset inventory is not readable".into(),
1562 ));
1563 }
1564 Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
1565 };
1566 let id = list
1567 .as_array()
1568 .into_iter()
1569 .flatten()
1570 .find(|ruleset| ruleset["name"] == name)
1571 .and_then(|ruleset| ruleset["id"].as_i64());
1572 let Some(id) = id else {
1573 return Ok(RulesetLookup::Absent);
1574 };
1575 match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
1576 Api::Ok(body) => Ok(RulesetLookup::Found(body)),
1577 Api::Missing => Ok(RulesetLookup::Unreadable(format!(
1581 "the {name} detail is not readable"
1582 ))),
1583 Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
1584 }
1585}
1586
1587fn armed_fault(ctx: &Ctx, requests: &Value, head: &str, field: &str) -> Option<String> {
1596 let prefix = crate::projection::release_driver_shape(ctx.driver())?;
1597 let github = ctx.forge == Some(Forge::Github);
1598 let armed: Vec<String> = requests
1599 .as_array()?
1600 .iter()
1601 .filter(|request| {
1602 let branch = if field.is_empty() {
1603 &request[head]
1604 } else {
1605 &request[head][field]
1606 };
1607 branch.as_str().is_some_and(|name| name.starts_with(prefix))
1608 })
1609 .filter(|request| {
1610 if github {
1611 !request["auto_merge"].is_null()
1612 } else {
1613 request["merge_when_pipeline_succeeds"] == true
1614 || request["auto_merge_enabled"] == true
1615 }
1616 })
1617 .filter_map(|request| {
1618 let number = request[if github { "number" } else { "iid" }].as_u64()?;
1619 let forge = ctx.forge?;
1620 Some(format!(
1621 "{} disarms with {}",
1622 crate::release_state::request_name(forge, number),
1623 crate::release_state::disarm_command(forge, &ctx.repo, number)
1624 ))
1625 })
1626 .collect();
1627 (!armed.is_empty()).then(|| {
1628 format!(
1629 "an open release request stands armed to merge itself, and a release waits for the operator's order: {}; rk setup step merge-on-order --apply disarms every one",
1630 armed.join("; ")
1631 )
1632 })
1633}
1634
1635const GITLAB_TAG_LIMITATION: &str =
1637 "an Owner or Maintainer can still delete a protected tag through the UI or API";
1638
1639const 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";
1644
1645const STALE_MERGE_FAULT: &str = "the trunk permits a merge from a branch that does not carry the trunk's tip; a 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";
1647
1648const 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";
1651
1652fn pipeline_fault(forge_gate: bool, observed: Option<bool>) -> Option<String> {
1657 match (forge_gate, observed) {
1658 (true, Some(true)) | (false, Some(false)) => None,
1659 (true, Some(false)) => Some("the pipeline requirement is off".to_owned()),
1660 (false, Some(true)) => Some(
1661 "the pipeline requirement is on, and no forge gate lands to run a pipeline, so no merge request can merge"
1662 .to_owned(),
1663 ),
1664 (_, None) => Some("the pipeline requirement is missing or unreadable".to_owned()),
1665 }
1666}
1667
1668const GITLAB_COMMIT_LIMITATION: &str =
1672 "the commit gate stops accident, not authority: a merge request runs its own CI configuration";
1673
1674enum GitlabIdentity {
1677 Token,
1678 ServiceAccount(u64),
1679}
1680
1681fn gitlab_identity_form(
1686 ctx: &Ctx,
1687 run: &mut Runner,
1688) -> Result<Result<GitlabIdentity, StepState>, RkError> {
1689 if !ctx.on_gitlab_com() {
1690 return Ok(Ok(GitlabIdentity::Token));
1691 }
1692 let root = ctx.repo.split('/').next().unwrap_or_default();
1693 let namespace = match api_get(ctx, run, &format!("namespaces/{root}"))? {
1694 Api::Ok(body) => body,
1695 Api::Missing => {
1696 return Ok(Err(StepState::not(format!(
1697 "the forge does not know the namespace {root}"
1698 ))));
1699 }
1700 Api::Failed(err) => return Ok(Err(StepState::unknown(err))),
1701 };
1702 let plan = namespace["plan"].as_str().unwrap_or_default();
1703 let kind = namespace["kind"].as_str().unwrap_or_default();
1704 let paid = ["premium", "ultimate", "silver", "gold"]
1705 .iter()
1706 .any(|tier| plan.starts_with(tier))
1707 || plan == "opensource";
1708 Ok(match (plan, kind) {
1709 _ if paid => Ok(GitlabIdentity::Token),
1710 ("free", "group") => namespace["id"].as_u64().map_or_else(
1711 || {
1712 Err(StepState::unknown(format!(
1713 "the namespace {root} answers no id"
1714 )))
1715 },
1716 |id| Ok(GitlabIdentity::ServiceAccount(id)),
1717 ),
1718 ("free", "user") => Err(StepState::not(format!(
1719 "the personal namespace {root} has no bot identity form on GitLab.com Free; move the project under a group, or use a Premium or Ultimate tier"
1720 ))),
1721 _ => Err(StepState::unknown(format!(
1722 "the namespace {root} reads plan {plan:?} and kind {kind:?}, which name no identity form"
1723 ))),
1724 })
1725}
1726
1727pub fn gitlab_bot_is_a_service_account(
1736 ctx: &Ctx,
1737 run: &mut Runner,
1738) -> Result<Result<bool, String>, RkError> {
1739 Ok(match gitlab_identity_form(ctx, run)? {
1740 Ok(form) => Ok(matches!(form, GitlabIdentity::ServiceAccount(_))),
1741 Err(state) => Err(state.detail().to_owned()),
1742 })
1743}
1744
1745fn gitlab_bot_variable(
1747 ctx: &Ctx,
1748 run: &mut Runner,
1749 project: &str,
1750 holds: &str,
1751) -> Result<StepState, RkError> {
1752 Ok(
1753 match api_get(
1754 ctx,
1755 run,
1756 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1757 )? {
1758 Api::Ok(_) => StepState::ok(format!("{holds} and its variable is stored")),
1759 Api::Missing => StepState::not(format!(
1760 "{holds} with no stored variable; a rerun replaces its token"
1761 )),
1762 Api::Failed(err) => StepState::unknown(err),
1763 },
1764 )
1765}
1766
1767fn gitlab_listing(
1771 ctx: &Ctx,
1772 run: &mut Runner,
1773 path: &str,
1774 found: impl Fn(&serde_json::Value) -> bool,
1775) -> Result<Result<Option<serde_json::Value>, StepState>, RkError> {
1776 for page in 1..=10u32 {
1777 let list = match api_get(ctx, run, &format!("{path}&per_page=100&page={page}"))? {
1778 Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1779 Api::Missing => Vec::new(),
1780 Api::Failed(err) => return Ok(Err(StepState::unknown(err))),
1781 };
1782 if let Some(hit) = list.iter().find(|entry| found(entry)) {
1783 return Ok(Ok(Some(hit.clone())));
1784 }
1785 if list.len() < 100 {
1786 return Ok(Ok(None));
1787 }
1788 }
1789 Ok(Err(StepState::unknown(
1790 "the listing did not exhaust within ten pages; nothing was decided",
1791 )))
1792}
1793
1794fn gitlab_all(
1797 ctx: &Ctx,
1798 run: &mut Runner,
1799 path: &str,
1800) -> Result<Result<Vec<serde_json::Value>, StepState>, RkError> {
1801 let mut all = Vec::new();
1802 for page in 1..=10u32 {
1803 let list = match api_get(ctx, run, &format!("{path}&per_page=100&page={page}"))? {
1804 Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1805 Api::Missing => Vec::new(),
1806 Api::Failed(err) => return Ok(Err(StepState::unknown(err))),
1807 };
1808 let short = list.len() < 100;
1809 all.extend(list);
1810 if short {
1811 return Ok(Ok(all));
1812 }
1813 }
1814 Ok(Err(StepState::unknown(
1815 "the listing did not exhaust within ten pages; nothing was decided",
1816 )))
1817}
1818
1819fn open_requests(
1824 ctx: &Ctx,
1825 run: &mut Runner,
1826 path: &str,
1827) -> Result<Result<Value, StepState>, RkError> {
1828 let mut all = Vec::new();
1829 for page in 1..=10u32 {
1830 let list = match api_get(ctx, run, &format!("{path}&per_page=100&page={page}"))? {
1831 Api::Ok(Value::Array(list)) => list,
1832 Api::Ok(_) => {
1833 return Ok(Err(StepState::unknown(
1834 "the open requests did not read as a list; nothing was decided",
1835 )));
1836 }
1837 Api::Missing => {
1838 return Ok(Err(StepState::unknown(
1839 "the forge refused the listing of open requests; nothing was decided",
1840 )));
1841 }
1842 Api::Failed(err) => return Ok(Err(StepState::unknown(err))),
1843 };
1844 let short = list.len() < 100;
1845 all.extend(list);
1846 if short {
1847 return Ok(Ok(Value::Array(all)));
1848 }
1849 }
1850 Ok(Err(StepState::unknown(
1851 "the listing did not exhaust within ten pages; nothing was decided",
1852 )))
1853}
1854
1855const BOT_MARKER: &str = "release-kit bot identity";
1858
1859fn gitlab_rename(live: &str, name: &str) -> StepState {
1862 StepState::not(format!(
1863 "the release-kit bot {live} serves this project, and setup.bot.name asks for {name}; a rename strands the first identity, so revoke it by hand or set the name back"
1864 ))
1865}
1866
1867fn gitlab_live_account(
1872 ctx: &Ctx,
1873 run: &mut Runner,
1874 project: &str,
1875 group: u64,
1876 default: &str,
1877 name: &str,
1878) -> Result<Option<StepState>, RkError> {
1879 let accounts = match gitlab_all(
1880 ctx,
1881 run,
1882 &format!("groups/{group}/service_accounts?order_by=id"),
1883 )? {
1884 Ok(accounts) => accounts,
1885 Err(state) => return Ok(Some(state)),
1886 };
1887 let members = match gitlab_all(
1888 ctx,
1889 run,
1890 &format!("projects/{project}/members/all?order_by=id"),
1891 )? {
1892 Ok(members) => members,
1893 Err(state) => return Ok(Some(state)),
1894 };
1895 for account in &accounts {
1896 let (Some(id), Some(user)) = (account["id"].as_u64(), account["username"].as_str()) else {
1897 continue;
1898 };
1899 if user == name
1900 || !members
1901 .iter()
1902 .any(|member| member["id"].as_u64() == Some(id))
1903 {
1904 continue;
1905 }
1906 if user == default {
1907 return Ok(Some(gitlab_rename(user, name)));
1908 }
1909 let path =
1910 format!("groups/{group}/service_accounts/{id}/personal_access_tokens?state=active");
1911 let tokens = match gitlab_all(ctx, run, &path)? {
1912 Ok(tokens) => tokens,
1913 Err(state) => return Ok(Some(state)),
1914 };
1915 if tokens
1916 .iter()
1917 .any(|token| token["description"] == BOT_MARKER && token["active"] != false)
1918 {
1919 return Ok(Some(gitlab_rename(user, name)));
1920 }
1921 }
1922 Ok(None)
1923}
1924
1925fn gitlab_install_bot(ctx: &Ctx, run: &mut Runner, project: &str) -> Result<StepState, RkError> {
1930 let form = match gitlab_identity_form(ctx, run)? {
1931 Ok(form) => form,
1932 Err(state) => return Ok(state),
1933 };
1934 match form {
1935 GitlabIdentity::Token => {
1936 let name = ctx.bot_name().unwrap_or("release-bot");
1937 let path = format!("projects/{project}/access_tokens?state=active");
1938 let tokens = match gitlab_all(ctx, run, &path)? {
1939 Ok(tokens) => tokens,
1940 Err(state) => return Ok(state),
1941 };
1942 let active: Vec<_> = tokens
1943 .iter()
1944 .filter(|token| token["revoked"] == false && token["active"] != false)
1945 .collect();
1946 let live = active.iter().find(|token| {
1949 token["name"] != name
1950 && (token["description"] == BOT_MARKER || token["name"] == "release-bot")
1951 });
1952 if let Some(live) = live.and_then(|token| token["name"].as_str()) {
1953 return Ok(gitlab_rename(live, name));
1954 }
1955 if !active.iter().any(|token| token["name"] == name) {
1956 return Ok(StepState::not(format!("no active {name} token exists")));
1957 }
1958 gitlab_bot_variable(ctx, run, project, &format!("an active {name} token exists"))
1959 }
1960 GitlabIdentity::ServiceAccount(group) => {
1961 let project_id = match api_get(ctx, run, &format!("projects/{project}"))? {
1962 Api::Ok(body) => body["id"].as_u64(),
1963 Api::Missing => {
1964 return Ok(StepState::not(format!(
1965 "the forge does not know {}",
1966 ctx.repo
1967 )));
1968 }
1969 Api::Failed(err) => return Ok(StepState::unknown(err)),
1970 };
1971 let Some(project_id) = project_id else {
1972 return Ok(StepState::unknown("the project answers no id"));
1973 };
1974 let name = ctx
1975 .bot_name()
1976 .map_or_else(|| format!("release-bot-{project_id}"), str::to_owned);
1977 let path = format!("groups/{group}/service_accounts?order_by=id");
1978 let account = match gitlab_listing(ctx, run, &path, |account| {
1979 account["username"] == name.as_str()
1980 })? {
1981 Ok(account) => account,
1982 Err(state) => return Ok(state),
1983 };
1984 let default = format!("release-bot-{project_id}");
1987 if let Some(state) = gitlab_live_account(ctx, run, project, group, &default, &name)? {
1988 return Ok(state);
1989 }
1990 let Some(id) = account.and_then(|account| account["id"].as_u64()) else {
1991 return Ok(StepState::not(format!(
1992 "no service account named {name} exists"
1993 )));
1994 };
1995 let level = match api_get(ctx, run, &format!("projects/{project}/members/all/{id}"))? {
1996 Api::Ok(body) => body["access_level"].as_u64().unwrap_or(0),
1997 Api::Missing => {
1998 return Ok(StepState::not(format!(
1999 "the service account {name} is not a member of this project"
2000 )));
2001 }
2002 Api::Failed(err) => return Ok(StepState::unknown(err)),
2003 };
2004 if level < 40 {
2005 return Ok(StepState::not(format!(
2006 "the service account {name} is a member of this project below Maintainer"
2007 )));
2008 }
2009 gitlab_bot_variable(
2010 ctx,
2011 run,
2012 project,
2013 &format!("a service account named {name} is a Maintainer of this project"),
2014 )
2015 }
2016 }
2017}
2018
2019#[allow(
2020 clippy::too_many_lines,
2021 reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
2022)]
2023fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
2024 let trunk = ctx.trunk();
2025 let project = ctx.repo.replace('/', "%2F");
2026 match step {
2027 "private-vulnerability-reporting" => {
2028 let path = format!("projects/{project}");
2029 Ok(match api_get(ctx, run, &path)? {
2030 Api::Ok(body) => {
2031 let access = body["issues_access_level"].as_str();
2032 if !matches!(access, Some("enabled" | "private" | "disabled")) {
2033 StepState::unknown("issue intake access is unreadable")
2034 } else if body
2035 .get("issues_enabled")
2036 .is_some_and(|flag| !flag.is_boolean())
2037 {
2038 StepState::unknown("legacy issue intake flag is unreadable")
2039 } else if body["issues_enabled"] == false || access == Some("disabled") {
2040 StepState::not("issue intake is disabled; see setup guide step 3g")
2041 } else if access == Some("private") {
2042 StepState::not("issue intake is restricted; see setup guide step 3g")
2043 } else {
2044 StepState::ok_with_limitation(
2045 "issue intake is enabled",
2046 GITLAB_PRIVATE_REPORTING_LIMITATION,
2047 )
2048 }
2049 }
2050 Api::Missing => {
2051 StepState::unknown(format!("{path}: issue intake is unreadable (404)"))
2052 }
2053 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
2054 })
2055 }
2056
2057 "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
2058 Api::Ok(body) => {
2059 let found = body["default_branch"].as_str().unwrap_or("");
2060 if found == trunk {
2061 StepState::ok(format!("{trunk} is the default branch"))
2062 } else {
2063 StepState::not(format!("the default branch is {found}"))
2064 }
2065 }
2066 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
2067 Api::Failed(err) => StepState::unknown(err),
2068 }),
2069 "single-trunk" => {
2070 for candidate in ctx.retired_branches() {
2071 let candidate = candidate.as_str();
2072 if candidate == trunk {
2073 continue;
2074 }
2075 match api_get(
2076 ctx,
2077 run,
2078 &format!("projects/{project}/repository/branches/{candidate}"),
2079 )? {
2080 Api::Missing => {}
2081 Api::Ok(_) => {
2082 return Ok(StepState::not(format!("a {candidate} branch still exists")));
2083 }
2084 Api::Failed(err) => return Ok(StepState::unknown(err)),
2085 }
2086 }
2087 Ok(StepState::ok(
2088 "no long-lived branch besides the trunk remains",
2089 ))
2090 }
2091 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
2092 Api::Ok(body) => {
2093 if body["remove_source_branch_after_merge"]
2094 .as_bool()
2095 .unwrap_or(false)
2096 {
2097 StepState::ok("a merged branch is deleted by the forge")
2098 } else {
2099 StepState::not("a merged branch outlives its merge")
2100 }
2101 }
2102 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
2103 Api::Failed(err) => StepState::unknown(err),
2104 }),
2105 "merge-on-order" => {
2106 match api_get(ctx, run, &format!("projects/{project}"))? {
2107 Api::Ok(body) => {
2108 if !body["only_allow_merge_if_pipeline_succeeds"]
2109 .as_bool()
2110 .unwrap_or(false)
2111 {
2112 return Ok(StepState::not(
2113 "the pipeline requirement that holds a person's merge behind a passing pipeline is off; rk setup step merge-on-order --apply asserts it",
2114 ));
2115 }
2116 }
2117 Api::Missing => {
2118 return Ok(StepState::not(format!(
2119 "the forge does not know {}",
2120 ctx.repo
2121 )));
2122 }
2123 Api::Failed(err) => return Ok(StepState::unknown(err)),
2124 }
2125 let path = format!("projects/{project}/merge_requests?state=opened");
2126 Ok(match open_requests(ctx, run, &path)? {
2127 Ok(requests) => armed_fault(ctx, &requests, "source_branch", "")
2128 .map_or_else(
2129 || {
2130 StepState::ok(
2131 "no release request stands armed to merge itself, and a merge waits for a passing pipeline",
2132 )
2133 },
2134 StepState::not,
2135 ),
2136 Err(state) => state,
2137 })
2138 }
2139 "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
2140 Api::Ok(body) => {
2141 if body["jobs_enabled"] == true {
2142 StepState::ok("pipelines are enabled")
2143 } else {
2144 StepState::not("pipelines are disabled")
2145 }
2146 }
2147 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
2148 Api::Failed(err) => StepState::unknown(err),
2149 }),
2150 "install-bot" => gitlab_install_bot(ctx, run, &project),
2151 "bot-secrets" => Ok(
2152 match api_get(
2153 ctx,
2154 run,
2155 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
2156 )? {
2157 Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
2158 Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
2159 Api::Failed(err) => StepState::unknown(err),
2160 },
2161 ),
2162 "protect-trunk" => {
2163 let protection = match api_get(
2164 ctx,
2165 run,
2166 &format!("projects/{project}/protected_branches/{trunk}"),
2167 )? {
2168 Api::Ok(body) => body,
2169 Api::Missing => {
2170 return Ok(StepState::not(format!("{trunk} is not protected")));
2171 }
2172 Api::Failed(err) => return Ok(StepState::unknown(err)),
2173 };
2174 let grants = protection["push_access_levels"]
2178 .as_array()
2179 .cloned()
2180 .unwrap_or_default();
2181 let policy = ctx.protection();
2182 let no_push =
2183 grants.len() == 1 && grants[0]["access_level"] == policy.gitlab.push_access_level;
2184 let merges = protection["merge_access_levels"]
2188 .as_array()
2189 .cloned()
2190 .unwrap_or_default();
2191 let can_merge =
2192 merges.len() == 1 && merges[0]["access_level"] == policy.gitlab.merge_access_level;
2193 let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
2194 Api::Ok(body) => body,
2195 Api::Missing | Api::Failed(_) => Value::Null,
2196 };
2197 let mut faults = Vec::new();
2198 if !no_push {
2199 faults.push(format!(
2200 "{trunk} still takes a direct push: the forge honors the most permissive of {} push grants",
2201 grants.len()
2202 ));
2203 }
2204 if !can_merge {
2205 faults.push(format!(
2206 "{trunk} merge grants are not exactly the one owned maintainer level"
2207 ));
2208 }
2209 if protection["allow_force_push"] != false {
2210 faults.push(format!("{trunk} allows force pushes"));
2211 }
2212 if let Some(fault) = pipeline_fault(
2217 ctx.forge_gate(),
2218 settings["only_allow_merge_if_pipeline_succeeds"].as_bool(),
2219 ) {
2220 faults.push(fault);
2221 }
2222 if settings["merge_method"] != policy.gitlab.merge_method.as_str() {
2223 faults.push("the merge method is not fast-forward".to_owned());
2224 }
2225 let squash = ctx.merge_form() == crate::landing::MergeForm::Squash;
2226 if settings["squash_option"] != policy.gitlab.squash_option.as_str() {
2227 faults.push(if squash {
2228 "merge requests do not always squash".to_owned()
2229 } else {
2230 "merge requests may squash, so a request's commits would not keep their own messages".to_owned()
2231 });
2232 }
2233 if squash
2236 && settings["squash_commit_template"]
2237 != policy.gitlab.squash_commit_template.as_str()
2238 {
2239 faults.push("the squash template is not the merge request's title".to_owned());
2240 }
2241 Ok(if faults.is_empty() {
2242 let detail = format!("{trunk} holds the release-merge shape");
2243 if ctx.forge_gate() {
2244 StepState::ok_with_limitation(
2245 detail,
2246 if squash {
2247 GITLAB_TITLE_LIMITATION
2248 } else {
2249 GITLAB_COMMIT_LIMITATION
2250 },
2251 )
2252 } else {
2253 StepState::ok(detail)
2255 }
2256 } else {
2257 StepState::not(faults.join("; "))
2258 })
2259 }
2260 "protect-tags" => Ok(
2261 match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
2262 Api::Ok(_) => {
2263 StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
2264 }
2265 Api::Missing => StepState::not("v* is not protected"),
2266 Api::Failed(err) => StepState::unknown(err),
2267 },
2268 ),
2269 "protect-release-lines" => Ok(
2270 match api_get(
2271 ctx,
2272 run,
2273 &format!("projects/{project}/protected_branches/release%2F%2A"),
2274 )? {
2275 Api::Ok(body) => {
2276 let level_ok = |levels: &Value| {
2277 levels
2278 .as_array()
2279 .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
2280 };
2281 if body["allow_force_push"] != false {
2282 StepState::not("release/* allows force pushes")
2283 } else if !level_ok(&body["push_access_levels"])
2284 || !level_ok(&body["merge_access_levels"])
2285 {
2286 StepState::not(
2290 "release/* grants are not exactly the owned maintainer levels",
2291 )
2292 } else {
2293 StepState::ok("release/* refuses force pushes and deletion by git clients")
2294 }
2295 }
2296 Api::Missing => StepState::inapplicable(
2297 "release/* is unprotected; optional โ applied only where older lines exist",
2298 ),
2299 Api::Failed(err) => StepState::unknown(err),
2300 },
2301 ),
2302 "protections-check" => {
2303 let mut failures = Vec::new();
2306 let mut unknowns = Vec::new();
2307 let mut limitations: Vec<String> = Vec::new();
2310 for owned in owned_protections(ctx) {
2311 match gitlab(ctx, owned, run)? {
2312 StepState::Satisfied {
2313 limitation: found, ..
2314 } => limitations.extend(found),
2315 StepState::Inapplicable { .. } => {}
2316 StepState::Unsatisfied { detail } => {
2317 failures.push(format!("{owned}: {detail}"));
2318 }
2319 StepState::Unknown { detail } => {
2320 unknowns.push(format!("{owned}: {detail}"));
2321 }
2322 }
2323 }
2324 Ok(if !failures.is_empty() {
2325 StepState::not(failures.join("; "))
2326 } else if !unknowns.is_empty() {
2327 StepState::unknown(unknowns.join("; "))
2328 } else {
2329 StepState::Satisfied {
2330 detail: "the protections hold, as far as this forge enforces them".into(),
2331 limitation: if limitations.is_empty() {
2332 None
2333 } else {
2334 Some(limitations.join("; "))
2335 },
2336 }
2337 })
2338 }
2339 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
2340 }
2341}
2342
2343#[cfg(test)]
2344mod tests {
2345 #[test]
2347 fn merge_form_rebase_switch_is_judged() {
2348 assert_eq!(super::rebase_switch_fault(Some(true)), None);
2349 assert!(
2350 super::rebase_switch_fault(Some(false))
2351 .is_some_and(|fault| fault.contains("allow_rebase_merge is false"))
2352 );
2353 assert!(
2354 super::rebase_switch_fault(None)
2355 .is_some_and(|fault| fault.contains("missing or unreadable"))
2356 );
2357 }
2358
2359 #[test]
2364 fn release_less_pipeline_requirement_is_judged_both_ways() {
2365 assert_eq!(super::pipeline_fault(true, Some(true)), None);
2366 assert_eq!(super::pipeline_fault(false, Some(false)), None);
2367 for (gate, observed) in [
2368 (true, Some(false)),
2369 (false, Some(true)),
2370 (true, None),
2371 (false, None),
2372 ] {
2373 assert!(
2374 super::pipeline_fault(gate, observed).is_some(),
2375 "gate {gate}, observed {observed:?}"
2376 );
2377 }
2378 }
2379}