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 BUILD_CONTRACT: &str = "dist/<name>.tar.gz and dist/<name>.tar.gz.sha256";
169
170fn bash_limitation(command: &str) -> String {
172 format!(
173 "the {command} tarball is not inspected: its contents, SECURITY.md inclusion among them, are unproved; run {command} and list the tarball before the first release"
174 )
175}
176
177fn bash_build_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
185 let command = ctx
186 .profile
187 .release
188 .build
189 .as_ref()
190 .and_then(|build| build.command.as_deref())
191 .unwrap_or(crate::profile::BuildAnswer::COMMAND);
192 let mut words = command.split_whitespace();
193 let program = words.next().unwrap_or_default();
194 let limitation = bash_limitation(command);
195 let env = ctx.child_env("package-check");
196 let runnable = |path: &std::path::Path| {
198 use std::os::unix::fs::PermissionsExt as _;
199 std::fs::metadata(path)
200 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
201 };
202 let on_path = |name: &str| {
203 env.iter()
204 .find(|(key, _)| key == "PATH")
205 .map(|(_, value)| value.clone())
206 .or_else(|| std::env::var_os("PATH"))
207 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| runnable(&dir.join(name))))
208 };
209 if program == "make" {
210 let makefile = ["GNUmakefile", "makefile", "Makefile"]
211 .iter()
212 .any(|name| ctx.target.join(name).is_file());
213 if !makefile {
214 return Ok(StepState::not(format!(
215 "the build command is {command}, and the target holds no Makefile, GNUmakefile, or makefile; write one whose target produces {BUILD_CONTRACT}, or set profile.release.build.command to the command that does"
216 )));
217 }
218 if !on_path("make") {
219 return Ok(StepState::ok_with_limitation(
220 format!(
221 "{command} has its makefile; make is not on this host, so the target was not resolved"
222 ),
223 limitation,
224 ));
225 }
226 let mut args: Vec<&str> = vec!["-n"];
227 args.extend(words);
228 let outcome = run(&cargo_exec(ctx, "make", &args))?;
229 if !outcome.success() {
230 return Ok(StepState::not(format!(
231 "make -n does not resolve the build command {command}: {}; give the makefile the target that produces {BUILD_CONTRACT}, or set profile.release.build.command",
232 last_line(&outcome.stderr)
233 )));
234 }
235 return Ok(StepState::ok_with_limitation(
236 format!("{command} resolves in the target's makefile"),
237 limitation,
238 ));
239 }
240 if program.contains('/') {
243 if !runnable(ctx.target.join(program).as_std_path()) {
244 return Ok(StepState::not(format!(
245 "the build command is {command}, and {program} is not an executable file in the target; make it one that produces {BUILD_CONTRACT}, or set profile.release.build.command"
246 )));
247 }
248 return Ok(StepState::ok_with_limitation(
249 format!(
250 "{command} is the project's own build command, and {program} is executable here; it owes {BUILD_CONTRACT}"
251 ),
252 limitation,
253 ));
254 }
255 let resolves = on_path(program);
256 Ok(StepState::ok_with_limitation(
257 if resolves {
258 format!(
259 "{command} is the project's own build command, and {program} resolves here; it owes {BUILD_CONTRACT}"
260 )
261 } else {
262 format!(
263 "{command} is the project's own build command, and {program} does not resolve on this host, where profile.release.build.packages need not be installed; it owes {BUILD_CONTRACT}"
264 )
265 },
266 limitation,
267 ))
268}
269
270fn package_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
281 if let Some(reason) = super::steps::no_registry(ctx) {
284 return Ok(StepState::ok(reason));
285 }
286 let policy = ctx.reporting_policy();
287 let (program, args): (&str, &[&str]) = match ctx.tech {
288 Some("rust") => ("cargo", &["publish", "--dry-run", "--allow-dirty"]),
289 Some("python") => ("python3", &["-m", "build"]),
290 Some("bash") => return bash_build_check(ctx, run),
291 Some(other) => {
292 return Ok(StepState::unknown(format!(
293 "no packaging check is defined for {other}"
294 )));
295 }
296 None => {
297 return Ok(StepState::unknown(
298 "no version file names a technology; see rk binding --list",
299 ));
300 }
301 };
302 let outcome = run(&cargo_exec(ctx, program, args))?;
303 if !outcome.success() {
304 return Ok(StepState::not(format!(
305 "the packaging check failed: {}",
306 last_line(&outcome.stderr)
307 )));
308 }
309 let built = "the package builds and passes the registry's dry run";
310 Ok(match ctx.tech {
311 Some("rust") => policy_in_the_crate(ctx, run, built)?,
312 _ if policy => StepState::ok_with_limitation(built, PYTHON_LIMITATION),
313 _ => StepState::ok(built),
314 })
315}
316
317fn cargo_exec(ctx: &Ctx, program: &str, args: &[&str]) -> Exec {
319 Exec {
320 program: program.into(),
321 args: args.iter().map(Into::into).collect(),
322 env: ctx.child_env("package-check"),
323 cwd: ctx.target.as_std_path().to_path_buf(),
324 stdin: None,
325 }
326}
327
328fn policy_in_the_crate(ctx: &Ctx, run: &mut Runner, built: &str) -> Result<StepState, RkError> {
344 let policy = ctx.reporting_policy();
345 let root_manifest = ctx.target.as_std_path().join("Cargo.toml");
346 let probe = cargo_package::probe(&root_manifest, &[], |args| {
347 run(&cargo_exec(ctx, "cargo", args)).map(|outcome| Answer {
348 success: outcome.success(),
349 stdout: outcome.stdout,
350 stderr: outcome.stderr,
351 })
352 })?;
353 let listing = match probe {
354 Probe::Listed(listing) => listing,
355 Probe::OtherShape => {
356 let unproved = if policy {
357 format!(
358 "{POLICY_DESTINATION} inclusion and the absence of release-kit's own files are"
359 )
360 } else {
361 "the absence of release-kit's own files is".to_owned()
362 };
363 return Ok(StepState::ok_with_limitation(
364 built,
365 format!(
366 "{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"
367 ),
368 ));
369 }
370 Probe::MetadataFailed(stderr) => {
371 return Ok(StepState::unknown(format!(
372 "{built}, and the listing check could not run: cargo metadata failed: {}",
373 last_line(&stderr)
374 )));
375 }
376 Probe::ListingFailed(stderr) => {
377 return Ok(StepState::unknown(format!(
378 "{built}, and the listing check could not run: cargo package --list failed: {}",
379 last_line(&stderr)
380 )));
381 }
382 };
383 let forbidden = cargo_package::release_kit_paths(ctx.landed_destinations());
384 let shipped = listing.shipped(&forbidden);
385 Ok(boundary_state(
386 built,
387 policy,
388 !policy || listing.carries(POLICY_DESTINATION),
389 &shipped,
390 ))
391}
392
393fn boundary_state(built: &str, policy: bool, carried: bool, shipped: &[&str]) -> StepState {
397 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";
398 let policy_fix = format!(
399 "add /{POLICY_DESTINATION} to [package].include, remove the [package].exclude entry matching it, or stop ignoring the file"
400 );
401 let own = || {
402 let entries = cargo_package::exclude_entries(shipped)
403 .iter()
404 .map(|entry| format!("\"{entry}\""))
405 .collect::<Vec<_>>()
406 .join(", ");
407 (
408 shipped.join(", "),
409 format!("add {entries} to [package].exclude, or drop them from [package].include"),
410 )
411 };
412 match (carried, shipped.is_empty()) {
413 (true, true) if policy => StepState::ok(format!(
414 "{built}, and the published package carries {POLICY_DESTINATION} and no file release-kit lands or owns"
415 )),
416 (true, true) => StepState::ok(format!(
417 "{built}, and the published package ships no file release-kit lands or owns"
418 )),
419 (false, true) => StepState::not(format!(
420 "{built}, but the published package omits {POLICY_DESTINATION}: {policy_fix}"
421 )),
422 (true, false) => {
423 let (list, fix) = own();
424 StepState::not(format!(
425 "{built}, but the published package ships files release-kit lands or owns ({list}), {WHY}: {fix}"
426 ))
427 }
428 (false, false) => {
429 let (list, fix) = own();
430 StepState::not(format!(
431 "{built}, but the published package omits {POLICY_DESTINATION} and ships files release-kit lands or owns ({list}), {WHY}: {policy_fix}; and {fix}"
432 ))
433 }
434 }
435}
436
437fn branch_reminder_state(ctx: &Ctx) -> StepState {
440 use crate::setup::branch_reminder::{HookState, observe_hook};
441 match observe_hook(&ctx.target) {
442 HookState::Installed => {
443 StepState::ok("the post-merge hook carries the release-kit reminder")
444 }
445 HookState::Absent => StepState::not("no post-merge hook is installed"),
446 HookState::Foreign => {
447 StepState::not("a post-merge hook exists without the release-kit marker")
448 }
449 HookState::Drifted => StepState::not("the reminder hook drifted from this binary's body"),
450 HookState::Unreadable(detail) => StepState::unknown(detail),
451 }
452}
453
454pub const GITLAB_VERSION_FLOOR: (u64, u64) = (18, 2);
461
462const GITLAB_EDITIONS: [&str; 2] = ["ee", "ce"];
467
468fn version_refusal(found: &str, prerelease: Option<&str>, on_gitlab_com: bool) -> String {
472 let (major, minor) = GITLAB_VERSION_FLOOR;
473 let mut said = vec![format!(
474 "this GitLab instance reports {found}; the convention needs {major}.{minor} or newer"
475 )];
476 if let Some(suffix) = prerelease {
477 said.push(format!(
478 "the -{suffix} suffix is a pre-release, and nothing proves the feature shipped in it, so this step fails closed"
479 ));
480 }
481 said.push(format!(
482 "the merge-request pipeline triggers a child pipeline with `strategy: mirror`, which GitLab added in {major}.{minor}"
483 ));
484 said.push(
485 "below it the child's status never reaches the parent pipeline, so a failing project job merges".to_owned(),
486 );
487 if on_gitlab_com {
488 said.push(format!(
489 "gitlab.com runs below {major}.{minor} only until its next deployment; rerun the check after it"
490 ));
491 } else {
492 said.push(format!(
493 "upgrade the instance to {major}.{minor} or newer, or host the project on gitlab.com"
494 ));
495 }
496 said.join("; ")
497}
498
499fn forge_version(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
505 if ctx.forge == Some(Forge::Github) {
506 return Ok(StepState::ok(
507 "github.com is a rolling service and declares no version floor",
508 ));
509 }
510 let body = match api_get(ctx, run, "version")? {
511 Api::Ok(body) => body,
512 Api::Missing => {
513 return Ok(StepState::unknown(
514 "this instance answers no GET /version; the floor cannot be read. Check that glab is authenticated against it: glab auth login",
515 ));
516 }
517 Api::Failed(err) => {
518 return Ok(StepState::unknown(format!(
519 "the version could not be read: {err}. Check that glab is authenticated against this instance: glab auth login"
520 )));
521 }
522 };
523 let Some(found) = body["version"].as_str() else {
524 return Ok(StepState::unknown(
525 "the forge answer carries no version field; the floor cannot be read. Check that glab is authenticated against this instance: glab auth login",
526 ));
527 };
528 let (number, suffix) = found
529 .split_once('-')
530 .map_or((found, None), |(n, s)| (n, Some(s)));
531 let mut parts = number.split('.');
532 let parsed = parts
533 .next()
534 .and_then(|major| major.parse::<u64>().ok())
535 .zip(parts.next().and_then(|minor| minor.parse::<u64>().ok()));
536 let Some(pair) = parsed else {
537 return Ok(StepState::unknown(format!(
538 "the forge reports the version as '{found}', which names no major and minor pair; the floor cannot be read"
539 )));
540 };
541 let on_gitlab_com = ctx.on_gitlab_com();
546 let prerelease = suffix.filter(|s| !GITLAB_EDITIONS.contains(s));
547 if let Some(suffix) = prerelease.filter(|_| !on_gitlab_com) {
548 return Ok(StepState::not(version_refusal(found, Some(suffix), false)));
549 }
550 if pair < GITLAB_VERSION_FLOOR {
551 return Ok(StepState::not(version_refusal(found, None, on_gitlab_com)));
552 }
553 let (major, minor) = GITLAB_VERSION_FLOOR;
554 Ok(StepState::ok(prerelease.map_or_else(
555 || format!("this instance reports {found}, at or above the {major}.{minor} floor"),
556 |suffix| {
557 format!(
558 "gitlab.com reports {found}, at or above the {major}.{minor} floor; the -{suffix} suffix is the SaaS deployment's own"
559 )
560 },
561 )))
562}
563
564pub fn single_trunk_guard(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
574 let trunk = ctx.trunk();
575 for candidate in ctx.retired_branches() {
576 let candidate = candidate.as_str();
577 if candidate == trunk {
578 continue;
579 }
580 let state = match ctx.forge {
581 Some(Forge::Github) => github_candidate_guard(ctx, run, candidate)?,
582 Some(Forge::Gitlab) => gitlab_candidate_guard(ctx, run, candidate)?,
583 None => StepState::unknown("the profile names no forge this release drives"),
586 };
587 if !state.satisfied() {
588 return Ok(state);
589 }
590 }
591 Ok(StepState::ok(
592 "every candidate branch is absent, or an ancestor of the trunk",
593 ))
594}
595
596fn github_candidate_guard(
598 ctx: &Ctx,
599 run: &mut Runner,
600 candidate: &str,
601) -> Result<StepState, RkError> {
602 let trunk = ctx.trunk();
603 match api_get(
604 ctx,
605 run,
606 &format!("repos/{}/git/ref/heads/{candidate}", ctx.repo),
607 )? {
608 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
609 Api::Failed(err) => return Ok(StepState::unknown(err)),
610 Api::Ok(_) => {}
611 }
612 match api_get(
613 ctx,
614 run,
615 &format!("repos/{}/compare/{candidate}...{trunk}", ctx.repo),
616 )? {
617 Api::Ok(body) => {
618 let status = body["status"].as_str().unwrap_or("");
619 Ok(if matches!(status, "ahead" | "identical") {
620 StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
621 } else {
622 StepState::not(format!(
623 "{candidate} is not an ancestor of {trunk} ({status}); deleting it would lose work"
624 ))
625 })
626 }
627 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
628 Api::Failed(err) => Ok(StepState::unknown(err)),
629 }
630}
631
632fn gitlab_candidate_guard(
634 ctx: &Ctx,
635 run: &mut Runner,
636 candidate: &str,
637) -> Result<StepState, RkError> {
638 let trunk = ctx.trunk();
639 let project = ctx.repo.replace('/', "%2F");
640 match api_get(
641 ctx,
642 run,
643 &format!("projects/{project}/repository/branches/{candidate}"),
644 )? {
645 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
646 Api::Failed(err) => return Ok(StepState::unknown(err)),
647 Api::Ok(_) => {}
648 }
649 match api_get(
650 ctx,
651 run,
652 &format!("projects/{project}/repository/compare?from={trunk}&to={candidate}"),
653 )? {
654 Api::Ok(body) => {
655 let ahead = body["commits"]
656 .as_array()
657 .is_some_and(|list| !list.is_empty());
658 Ok(if ahead {
659 StepState::not(format!(
660 "{candidate} carries commits {trunk} does not; deleting it would lose work"
661 ))
662 } else {
663 StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
664 })
665 }
666 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
667 Api::Failed(err) => Ok(StepState::unknown(err)),
668 }
669}
670
671pub(crate) fn api_get(ctx: &Ctx, run: &mut Runner, path: &str) -> Result<Api, RkError> {
673 let exec = Exec {
674 program: ctx.cli.clone().into_os_string(),
675 args: vec!["api".into(), path.into()],
676 env: ctx.child_env("observe"),
677 cwd: ctx.target.as_std_path().to_path_buf(),
678 stdin: None,
679 };
680 let outcome = run(&exec)?;
681 if outcome.success() {
682 return Ok(
683 serde_json::from_slice::<Value>(&outcome.stdout).map_or_else(
684 |_| Api::Failed("the forge answer did not parse as JSON".into()),
685 Api::Ok,
686 ),
687 );
688 }
689 let stderr = String::from_utf8_lossy(&outcome.stderr).into_owned();
690 if stderr.contains("404") {
691 Ok(Api::Missing)
692 } else {
693 Ok(Api::Failed(last_line(&outcome.stderr)))
694 }
695}
696
697fn last_line(bytes: &[u8]) -> String {
699 String::from_utf8_lossy(bytes)
700 .lines()
701 .rev()
702 .find(|line| !line.trim().is_empty())
703 .unwrap_or("no output")
704 .to_owned()
705}
706
707#[allow(
708 clippy::too_many_lines,
709 reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
710)]
711fn github(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
712 let trunk = ctx.trunk();
713 let repo = &ctx.repo;
714 match step {
715 "private-vulnerability-reporting" => {
716 let visibility_path = format!("repos/{repo}");
717 match api_get(ctx, run, &visibility_path)? {
718 Api::Ok(body) => match body["private"].as_bool() {
719 Some(true) => {
720 return Ok(StepState::inapplicable(
721 "private vulnerability reporting is available for public repositories",
722 ));
723 }
724 Some(false) => {}
725 None => {
726 return Ok(StepState::unknown(format!(
727 "{visibility_path}: repository visibility is unreadable"
728 )));
729 }
730 },
731 Api::Missing => {
732 return Ok(StepState::unknown(format!(
733 "{visibility_path}: repository visibility is unreadable (404)"
734 )));
735 }
736 Api::Failed(err) => {
737 return Ok(StepState::unknown(format!("{visibility_path}: {err}")));
738 }
739 }
740 let path = format!("repos/{repo}/private-vulnerability-reporting");
741 Ok(match api_get(ctx, run, &path)? {
742 Api::Ok(body) => match body["enabled"].as_bool() {
743 Some(true) => StepState::ok("private vulnerability reporting is enabled"),
744 Some(false) => StepState::not("private vulnerability reporting is disabled"),
745 None => StepState::unknown(format!("{path}: enabled is unreadable")),
746 },
747 Api::Missing => {
748 StepState::unknown(format!("{path}: reporting state is unreadable (404)"))
749 }
750 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
751 })
752 }
753
754 "default-branch" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
755 Api::Ok(body) => {
756 let found = body["default_branch"].as_str().unwrap_or("");
757 if found == trunk {
758 StepState::ok(format!("{trunk} is the default branch"))
759 } else {
760 StepState::not(format!("the default branch is {found}"))
761 }
762 }
763 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
764 Api::Failed(err) => StepState::unknown(err),
765 }),
766 "single-trunk" => {
767 for candidate in ctx.retired_branches() {
768 let candidate = candidate.as_str();
769 if candidate == trunk {
770 continue;
771 }
772 match api_get(ctx, run, &format!("repos/{repo}/git/ref/heads/{candidate}"))? {
773 Api::Missing => {}
774 Api::Ok(_) => {
775 return Ok(StepState::not(format!("a {candidate} branch still exists")));
776 }
777 Api::Failed(err) => return Ok(StepState::unknown(err)),
778 }
779 }
780 Ok(StepState::ok(
781 "no long-lived branch besides the trunk remains",
782 ))
783 }
784 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
785 Api::Ok(body) => {
786 if body["delete_branch_on_merge"].as_bool().unwrap_or(false) {
787 StepState::ok("a merged branch is deleted by the forge")
788 } else {
789 StepState::not("a merged branch outlives its merge")
790 }
791 }
792 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
793 Api::Failed(err) => StepState::unknown(err),
794 }),
795 "auto-merge" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
796 Api::Ok(body) => {
797 if body["allow_auto_merge"].as_bool().unwrap_or(false) {
798 StepState::ok("a request may merge itself once its checks pass")
799 } else {
800 StepState::not("a request cannot merge itself; the auto-merge switch is off")
801 }
802 }
803 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
804 Api::Failed(err) => StepState::unknown(err),
805 }),
806 "ci-permissions" => Ok(
807 match api_get(
808 ctx,
809 run,
810 &format!("repos/{repo}/actions/permissions/workflow"),
811 )? {
812 Api::Ok(body) => {
813 let write = body["default_workflow_permissions"] == "write";
814 let approve = body["can_approve_pull_request_reviews"] == true;
815 if write && approve {
816 StepState::ok("CI may write and open requests")
817 } else {
818 StepState::not(format!(
819 "workflow permissions are {} with request approval {}",
820 body["default_workflow_permissions"],
821 body["can_approve_pull_request_reviews"]
822 ))
823 }
824 }
825 Api::Missing => StepState::not("no workflow permissions are readable"),
826 Api::Failed(err) => StepState::unknown(err),
827 },
828 ),
829 "bot-secrets" => Ok(
830 match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
831 Api::Ok(body) => {
832 let names: Vec<&str> = body["secrets"]
833 .as_array()
834 .map(|list| {
835 list.iter()
836 .filter_map(|secret| secret["name"].as_str())
837 .collect()
838 })
839 .unwrap_or_default();
840 let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
841 if wanted.iter().all(|name| names.contains(name)) {
842 StepState::ok("both bot secrets are stored")
843 } else if names.is_empty() {
844 StepState::not("no bot secrets are stored")
845 } else {
846 StepState::not(format!("stored secrets: {}", names.join(", ")))
847 }
848 }
849 Api::Missing => StepState::not("no secrets are readable"),
850 Api::Failed(err) => StepState::unknown(err),
851 },
852 ),
853 "protect-trunk" => github_trunk_ruleset(ctx, run),
854 "protect-tags" => github_ruleset(
855 ctx,
856 run,
857 ctx.tag_ruleset(),
858 "tag",
859 "refs/tags/v*",
860 &["deletion", "update"],
861 ),
862 "protect-release-lines" => {
863 match github_ruleset_body(ctx, run, ctx.lines_ruleset())? {
864 RulesetLookup::Absent => {
865 return Ok(StepState::inapplicable(
866 "release/* is unprotected; optional โ applied only where older lines exist",
867 ));
868 }
869 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
870 RulesetLookup::Found(_) => {}
871 }
872 github_ruleset(
873 ctx,
874 run,
875 ctx.lines_ruleset(),
876 "branch",
877 "refs/heads/release/*",
878 &["deletion", "non_fast_forward"],
879 )
880 }
881 "protections-check" => {
882 let mut failures = Vec::new();
886 let mut unknowns = Vec::new();
887 let mut limitations: Vec<String> = Vec::new();
889 for owned in owned_protections(ctx) {
890 match github(ctx, owned, run)? {
891 StepState::Satisfied {
892 limitation: found, ..
893 } => limitations.extend(found),
894 StepState::Inapplicable { .. } => {}
895 StepState::Unsatisfied { detail } => {
896 failures.push(format!("{owned}: {detail}"));
897 }
898 StepState::Unknown { detail } => {
899 unknowns.push(format!("{owned}: {detail}"));
900 }
901 }
902 }
903 match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
904 Api::Ok(body) => {
905 let owned = [
906 ctx.trunk_ruleset().to_owned(),
907 ctx.safety_ruleset().to_owned(),
908 ctx.tag_ruleset().to_owned(),
909 ctx.lines_ruleset().to_owned(),
910 ];
911 for ruleset in body.as_array().into_iter().flatten() {
912 let name = ruleset["name"].as_str().unwrap_or("");
913 if !owned.iter().any(|expected| expected == name) {
914 failures.push(format!("a ruleset no step owns: {name}"));
915 }
916 }
917 }
918 Api::Missing | Api::Failed(_) => {
919 unknowns.push("the ruleset inventory is not readable".to_owned());
920 }
921 }
922 Ok(if !failures.is_empty() {
923 StepState::not(failures.join("; "))
924 } else if !unknowns.is_empty() {
925 StepState::unknown(unknowns.join("; "))
926 } else {
927 StepState::Satisfied {
928 detail: "exactly the owned protections, with those rules".into(),
929 limitation: if limitations.is_empty() {
930 None
931 } else {
932 Some(limitations.join("; "))
933 },
934 }
935 })
936 }
937 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
938 }
939}
940
941fn owned_protections(ctx: &Ctx) -> Vec<&'static str> {
945 let mut owned = vec!["protect-trunk"];
946 if ctx.tag_protection() {
947 owned.push("protect-tags");
948 }
949 owned.push("protect-release-lines");
950 owned
951}
952
953#[must_use]
961pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
962 match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
963 AppApi::Ok(body) => {
964 let id = body["id"].as_i64().unwrap_or_default();
965 let held = &body["permissions"];
966 let short: Vec<String> = minimum_grant(ctx)
967 .into_iter()
968 .filter(|(key, level)| held[key] != *level)
969 .map(|(key, level)| format!("{key}: {level}"))
970 .collect();
971 if short.is_empty() {
972 StepState::ok(format!("installation {id} covers {}", ctx.repo))
973 } else {
974 StepState::not(format!(
979 "installation {id} covers {} and does not hold [{}]; approve the App's updated permissions on the installation's own settings page",
980 ctx.repo,
981 short.join(", ")
982 ))
983 }
984 }
985 AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
986 AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
987 }
988}
989
990fn minimum_grant(ctx: &Ctx) -> Vec<(&'static str, &'static str)> {
998 let mut grant = vec![("contents", "write"), ("pull_requests", "write")];
999 if ctx.integration() == crate::landing::Integration::Local
1000 && ctx.profile.release.style == Some(crate::landing::Style::Trunk)
1001 {
1002 grant.push(("checks", "read"));
1003 }
1004 grant
1005}
1006
1007fn github_ruleset(
1012 ctx: &Ctx,
1013 run: &mut Runner,
1014 name: &str,
1015 target: &str,
1016 include: &str,
1017 rules: &[&str],
1018) -> Result<StepState, RkError> {
1019 let detail = match github_ruleset_body(ctx, run, name)? {
1020 RulesetLookup::Found(detail) => detail,
1021 RulesetLookup::Absent => {
1022 return Ok(StepState::not(format!("no ruleset named {name}")));
1023 }
1024 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
1025 };
1026 if detail["enforcement"] != "active" {
1027 return Ok(StepState::not(format!("{name} is not active")));
1028 }
1029 if detail["target"] != target {
1032 return Ok(StepState::not(format!(
1033 "{name} does not target {target} refs"
1034 )));
1035 }
1036 if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
1037 return Ok(StepState::not(format!(
1038 "{name} does not cover {include} alone"
1039 )));
1040 }
1041 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
1042 return Ok(StepState::not(format!(
1043 "{name} excludes refs from its own coverage"
1044 )));
1045 }
1046 let mut held: Vec<&str> = detail["rules"]
1047 .as_array()
1048 .map(|list| {
1049 list.iter()
1050 .filter_map(|rule| rule["type"].as_str())
1051 .collect()
1052 })
1053 .unwrap_or_default();
1054 held.sort_unstable();
1055 let mut expected: Vec<&str> = rules.to_vec();
1056 expected.sort_unstable();
1057 if held == expected {
1058 Ok(StepState::ok(format!(
1059 "{name} is active with exactly its rules"
1060 )))
1061 } else {
1062 Ok(StepState::not(format!(
1063 "{name} carries the rules [{}] where the setup owns [{}]",
1064 held.join(", "),
1065 expected.join(", ")
1066 )))
1067 }
1068}
1069
1070fn unowned_rule_faults(rules: &[Value], owned: &[String]) -> Vec<String> {
1085 rules
1086 .iter()
1087 .filter_map(|rule| rule["type"].as_str())
1088 .filter(|kind| !owned.iter().any(|name| name == kind))
1089 .map(|kind| {
1090 if kind == "merge_queue" {
1091 MERGE_QUEUE_FAULT.to_owned()
1092 } else {
1093 format!("an unowned rule is present: {kind}")
1094 }
1095 })
1096 .collect()
1097}
1098
1099fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
1100 let trunk = ctx.trunk();
1101 let name = ctx.trunk_ruleset().to_owned();
1102 let detail = match github_ruleset_body(ctx, run, &name)? {
1103 RulesetLookup::Found(detail) => detail,
1104 RulesetLookup::Absent => {
1105 return Ok(StepState::not(format!("no ruleset named {name}")));
1106 }
1107 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
1108 };
1109 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
1110 let mut faults = Vec::new();
1111 if detail["enforcement"] != "active" {
1112 faults.push(format!("{name} is not active"));
1113 }
1114 if detail["target"] != "branch" {
1118 faults.push(format!("{name} does not target branches"));
1119 }
1120 let expected_ref = serde_json::json!([format!("refs/heads/{trunk}")]);
1121 if detail["conditions"]["ref_name"]["include"] != expected_ref {
1122 faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
1123 }
1124 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
1127 faults.push(format!("{name} excludes refs from its own coverage"));
1128 }
1129 let expected_bypass = super::context::github_bypass_actors(&ctx.protection().bypass_actors);
1130 if detail["bypass_actors"] != expected_bypass {
1131 faults.push("the bypass actors do not match the recorded authority".to_owned());
1132 }
1133 faults.extend(trunk_rule_faults(ctx, &rules, &name));
1134 faults.extend(merge_method_fault(ctx, &rules));
1135 if let Some(checks) = rules
1136 .iter()
1137 .find(|rule| rule["type"] == "required_status_checks")
1138 {
1139 if checks["parameters"]["strict_required_status_checks_policy"]
1140 != ctx.protection().strict_required_status_checks
1141 {
1142 faults.push(STALE_MERGE_FAULT.to_owned());
1143 }
1144 let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
1145 .as_array()
1146 .map(|list| {
1147 list.iter()
1148 .filter_map(|check| check["context"].as_str())
1149 .collect()
1150 })
1151 .unwrap_or_default();
1152 if contexts.is_empty() {
1157 faults.push("no status check is required".to_owned());
1158 } else if let Some(expected) = &ctx.required_check {
1159 let mut held = contexts.clone();
1160 held.sort_unstable();
1161 let title_check = ctx.title_check();
1162 let mut owned_contexts = [expected.as_str(), title_check];
1163 owned_contexts.sort_unstable();
1164 if held != owned_contexts {
1165 faults.push(format!(
1166 "the required checks are [{}] where the setup owns [{}]",
1167 contexts.join(", "),
1168 owned_contexts.join(", ")
1169 ));
1170 }
1171 } else if !contexts.contains(&ctx.title_check()) {
1172 faults.push(format!("the {} check is not required", ctx.title_check()));
1173 }
1174 }
1175 match repository_merge_settings(ctx, run)? {
1176 MergeSources::Owned => {}
1177 MergeSources::Faults(proven) => faults.extend(proven),
1178 MergeSources::Unreadable(err) => {
1182 if faults.is_empty() {
1183 return Ok(StepState::unknown(err));
1184 }
1185 }
1186 }
1187 match github_safety_ruleset(ctx, run)? {
1188 SafetyRuleset::Owned => {}
1189 SafetyRuleset::Faults(proven) => faults.extend(proven),
1190 SafetyRuleset::Unreadable(err) => {
1191 if faults.is_empty() {
1192 return Ok(StepState::unknown(err));
1193 }
1194 }
1195 }
1196 if let Some(shape) = gate_faults(ctx) {
1197 faults.push(shape);
1198 }
1199 if !faults.is_empty() {
1200 return Ok(StepState::not(faults.join("; ")));
1201 }
1202 Ok(StepState::ok(format!(
1203 "{name} holds the release-merge shape beside {}",
1204 ctx.safety_ruleset()
1205 )))
1206}
1207
1208fn merge_method_fault(ctx: &Ctx, rules: &[Value]) -> Option<String> {
1211 let request = rules.iter().find(|rule| rule["type"] == "pull_request")?;
1212 (request["parameters"]["allowed_merge_methods"]
1213 != serde_json::json!(ctx.protection().allowed_merge_methods))
1214 .then(|| {
1215 format!(
1216 "the merge method is not exactly a {} merge",
1217 ctx.merge_form().as_str()
1218 )
1219 })
1220}
1221
1222fn trunk_rule_faults(ctx: &Ctx, rules: &[Value], name: &str) -> Vec<String> {
1230 let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
1231 let mut faults = Vec::new();
1232 let mut accounted: Vec<String> = ctx
1233 .protection()
1234 .owned_trunk_rules
1235 .iter()
1236 .filter(|rule| crate::config::REQUEST_RULES.contains(&rule.as_str()))
1237 .cloned()
1238 .collect();
1239 for required in &accounted {
1240 if !has(required) {
1241 faults.push(format!("the {required} rule is missing"));
1242 }
1243 }
1244 for stray in crate::config::SAFETY_RULES {
1245 if has(stray) {
1246 faults.push(format!(
1247 "the {stray} rule sits in {name}, where a bypass actor excuses it"
1248 ));
1249 }
1250 accounted.push(stray.to_owned());
1251 }
1252 faults.extend(unowned_rule_faults(rules, &accounted));
1253 faults
1254}
1255
1256enum SafetyRuleset {
1258 Owned,
1259 Faults(Vec<String>),
1260 Unreadable(String),
1261}
1262
1263fn github_safety_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<SafetyRuleset, RkError> {
1271 let trunk = ctx.trunk();
1272 let name = ctx.safety_ruleset().to_owned();
1273 let detail = match github_ruleset_body(ctx, run, &name)? {
1274 RulesetLookup::Found(detail) => detail,
1275 RulesetLookup::Absent => {
1276 return Ok(SafetyRuleset::Faults(vec![format!(
1277 "no ruleset named {name} holds the trunk against deletion and force-push"
1278 )]));
1279 }
1280 RulesetLookup::Unreadable(err) => return Ok(SafetyRuleset::Unreadable(err)),
1281 };
1282 let mut faults = Vec::new();
1283 if detail["enforcement"] != "active" {
1284 faults.push(format!("{name} is not active"));
1285 }
1286 if detail["target"] != "branch" {
1287 faults.push(format!("{name} does not target branches"));
1288 }
1289 if detail["conditions"]["ref_name"]["include"]
1290 != serde_json::json!([format!("refs/heads/{trunk}")])
1291 {
1292 faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
1293 }
1294 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
1295 faults.push(format!("{name} excludes refs from its own coverage"));
1296 }
1297 if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
1298 faults.push(format!(
1299 "{name} names a bypass actor, so deletion and force-push hold against nobody"
1300 ));
1301 }
1302 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
1303 let safety_rules: Vec<String> = ctx
1304 .protection()
1305 .owned_trunk_rules
1306 .iter()
1307 .filter(|rule| crate::config::SAFETY_RULES.contains(&rule.as_str()))
1308 .cloned()
1309 .collect();
1310 for required in &safety_rules {
1311 if !rules.iter().any(|rule| rule["type"] == required.as_str()) {
1312 faults.push(format!("the {required} rule is missing from {name}"));
1313 }
1314 }
1315 faults.extend(unowned_rule_faults(&rules, &safety_rules));
1316 if faults.is_empty() {
1317 Ok(SafetyRuleset::Owned)
1318 } else {
1319 Ok(SafetyRuleset::Faults(faults))
1320 }
1321}
1322
1323fn gate_faults(ctx: &Ctx) -> Option<String> {
1333 if !ctx.forge_gate() {
1334 return None;
1335 }
1336 let check = ctx.required_check.as_deref()?;
1337 let shape = workflow_jobs::faults(
1338 &workflow_jobs::read_gate(&ctx.target, check, ctx.trunk()),
1339 check,
1340 ctx.trunk(),
1341 );
1342 let waking = (ctx.integration() == crate::landing::Integration::Local)
1347 .then_some(ctx.required_workflow.as_deref())
1348 .flatten()
1349 .and_then(|workflow| {
1350 workflow_jobs::waking_workflow_fault(&ctx.target, workflow, check, ctx.trunk())
1351 });
1352 match (shape, waking) {
1353 (None, None) => None,
1354 (Some(one), None) | (None, Some(one)) => Some(one),
1355 (Some(shape), Some(waking)) => Some(format!("{shape}; {waking}")),
1356 }
1357}
1358
1359enum MergeSources {
1361 Owned,
1363 Faults(Vec<String>),
1365 Unreadable(String),
1367}
1368
1369fn repository_merge_settings(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
1384 let squash = ctx.merge_form() == crate::landing::MergeForm::Squash;
1385 Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
1386 Api::Ok(body) => {
1387 let mut faults = Vec::new();
1388 if squash {
1389 let owned_title = ctx.protection().github.squash_title_source.as_str();
1390 let owned_body = ctx.protection().github.squash_body_source.as_str();
1391 if body["squash_merge_commit_title"] != owned_title {
1392 faults.push(format!(
1393 "the squash title source is {} where the setup owns {owned_title}",
1394 body["squash_merge_commit_title"]
1395 ));
1396 }
1397 if body["squash_merge_commit_message"] != owned_body {
1398 faults.push(format!(
1399 "the squash message source is {} where the setup owns {owned_body}",
1400 body["squash_merge_commit_message"]
1401 ));
1402 }
1403 } else if let Some(fault) = rebase_switch_fault(body["allow_rebase_merge"].as_bool()) {
1404 faults.push(fault);
1405 }
1406 if faults.is_empty() {
1407 MergeSources::Owned
1408 } else {
1409 MergeSources::Faults(faults)
1410 }
1411 }
1412 Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
1413 Api::Failed(err) => MergeSources::Unreadable(err),
1414 })
1415}
1416
1417fn rebase_switch_fault(allowed: Option<bool>) -> Option<String> {
1420 match allowed {
1421 Some(true) => None,
1422 Some(false) => Some(
1423 "the repository disallows rebase merges (allow_rebase_merge is false), so the ruleset's one merge method is refused and no request can merge"
1424 .to_owned(),
1425 ),
1426 None => Some("the repository's allow_rebase_merge switch is missing or unreadable".to_owned()),
1427 }
1428}
1429
1430enum RulesetLookup {
1433 Found(Value),
1435 Absent,
1438 Unreadable(String),
1440}
1441
1442fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
1444 let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
1448 Api::Ok(body) => body,
1449 Api::Missing => {
1450 return Ok(RulesetLookup::Unreadable(
1451 "the ruleset inventory is not readable".into(),
1452 ));
1453 }
1454 Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
1455 };
1456 let id = list
1457 .as_array()
1458 .into_iter()
1459 .flatten()
1460 .find(|ruleset| ruleset["name"] == name)
1461 .and_then(|ruleset| ruleset["id"].as_i64());
1462 let Some(id) = id else {
1463 return Ok(RulesetLookup::Absent);
1464 };
1465 match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
1466 Api::Ok(body) => Ok(RulesetLookup::Found(body)),
1467 Api::Missing => Ok(RulesetLookup::Unreadable(format!(
1471 "the {name} detail is not readable"
1472 ))),
1473 Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
1474 }
1475}
1476
1477const 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";
1481
1482const GITLAB_TAG_LIMITATION: &str =
1484 "an Owner or Maintainer can still delete a protected tag through the UI or API";
1485
1486const 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";
1491
1492const 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";
1494
1495const 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";
1498
1499fn pipeline_fault(forge_gate: bool, observed: Option<bool>) -> Option<String> {
1504 match (forge_gate, observed) {
1505 (true, Some(true)) | (false, Some(false)) => None,
1506 (true, Some(false)) => Some("the pipeline requirement is off".to_owned()),
1507 (false, Some(true)) => Some(
1508 "the pipeline requirement is on, and no forge gate lands to run a pipeline, so no merge request can merge"
1509 .to_owned(),
1510 ),
1511 (_, None) => Some("the pipeline requirement is missing or unreadable".to_owned()),
1512 }
1513}
1514
1515const GITLAB_COMMIT_LIMITATION: &str =
1519 "the commit gate stops accident, not authority: a merge request runs its own CI configuration";
1520
1521enum GitlabIdentity {
1524 Token,
1525 ServiceAccount(u64),
1526}
1527
1528fn gitlab_identity_form(
1533 ctx: &Ctx,
1534 run: &mut Runner,
1535) -> Result<Result<GitlabIdentity, StepState>, RkError> {
1536 if !ctx.on_gitlab_com() {
1537 return Ok(Ok(GitlabIdentity::Token));
1538 }
1539 let root = ctx.repo.split('/').next().unwrap_or_default();
1540 let namespace = match api_get(ctx, run, &format!("namespaces/{root}"))? {
1541 Api::Ok(body) => body,
1542 Api::Missing => {
1543 return Ok(Err(StepState::not(format!(
1544 "the forge does not know the namespace {root}"
1545 ))));
1546 }
1547 Api::Failed(err) => return Ok(Err(StepState::unknown(err))),
1548 };
1549 let plan = namespace["plan"].as_str().unwrap_or_default();
1550 let kind = namespace["kind"].as_str().unwrap_or_default();
1551 let paid = ["premium", "ultimate", "silver", "gold"]
1552 .iter()
1553 .any(|tier| plan.starts_with(tier))
1554 || plan == "opensource";
1555 Ok(match (plan, kind) {
1556 _ if paid => Ok(GitlabIdentity::Token),
1557 ("free", "group") => namespace["id"].as_u64().map_or_else(
1558 || {
1559 Err(StepState::unknown(format!(
1560 "the namespace {root} answers no id"
1561 )))
1562 },
1563 |id| Ok(GitlabIdentity::ServiceAccount(id)),
1564 ),
1565 ("free", "user") => Err(StepState::not(format!(
1566 "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"
1567 ))),
1568 _ => Err(StepState::unknown(format!(
1569 "the namespace {root} reads plan {plan:?} and kind {kind:?}, which name no identity form"
1570 ))),
1571 })
1572}
1573
1574pub fn gitlab_bot_is_a_service_account(
1583 ctx: &Ctx,
1584 run: &mut Runner,
1585) -> Result<Result<bool, String>, RkError> {
1586 Ok(match gitlab_identity_form(ctx, run)? {
1587 Ok(form) => Ok(matches!(form, GitlabIdentity::ServiceAccount(_))),
1588 Err(state) => Err(state.detail().to_owned()),
1589 })
1590}
1591
1592fn gitlab_bot_variable(
1594 ctx: &Ctx,
1595 run: &mut Runner,
1596 project: &str,
1597 holds: &str,
1598) -> Result<StepState, RkError> {
1599 Ok(
1600 match api_get(
1601 ctx,
1602 run,
1603 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1604 )? {
1605 Api::Ok(_) => StepState::ok(format!("{holds} and its variable is stored")),
1606 Api::Missing => StepState::not(format!(
1607 "{holds} with no stored variable; a rerun replaces its token"
1608 )),
1609 Api::Failed(err) => StepState::unknown(err),
1610 },
1611 )
1612}
1613
1614fn gitlab_listing(
1618 ctx: &Ctx,
1619 run: &mut Runner,
1620 path: &str,
1621 found: impl Fn(&serde_json::Value) -> bool,
1622) -> Result<Result<Option<serde_json::Value>, StepState>, RkError> {
1623 for page in 1..=10u32 {
1624 let list = match api_get(ctx, run, &format!("{path}&per_page=100&page={page}"))? {
1625 Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1626 Api::Missing => Vec::new(),
1627 Api::Failed(err) => return Ok(Err(StepState::unknown(err))),
1628 };
1629 if let Some(hit) = list.iter().find(|entry| found(entry)) {
1630 return Ok(Ok(Some(hit.clone())));
1631 }
1632 if list.len() < 100 {
1633 return Ok(Ok(None));
1634 }
1635 }
1636 Ok(Err(StepState::unknown(
1637 "the listing did not exhaust within ten pages; nothing was decided",
1638 )))
1639}
1640
1641fn gitlab_all(
1644 ctx: &Ctx,
1645 run: &mut Runner,
1646 path: &str,
1647) -> Result<Result<Vec<serde_json::Value>, StepState>, RkError> {
1648 let mut all = Vec::new();
1649 for page in 1..=10u32 {
1650 let list = match api_get(ctx, run, &format!("{path}&per_page=100&page={page}"))? {
1651 Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1652 Api::Missing => Vec::new(),
1653 Api::Failed(err) => return Ok(Err(StepState::unknown(err))),
1654 };
1655 let short = list.len() < 100;
1656 all.extend(list);
1657 if short {
1658 return Ok(Ok(all));
1659 }
1660 }
1661 Ok(Err(StepState::unknown(
1662 "the listing did not exhaust within ten pages; nothing was decided",
1663 )))
1664}
1665
1666const BOT_MARKER: &str = "release-kit bot identity";
1669
1670fn gitlab_rename(live: &str, name: &str) -> StepState {
1673 StepState::not(format!(
1674 "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"
1675 ))
1676}
1677
1678fn gitlab_live_account(
1683 ctx: &Ctx,
1684 run: &mut Runner,
1685 project: &str,
1686 group: u64,
1687 default: &str,
1688 name: &str,
1689) -> Result<Option<StepState>, RkError> {
1690 let accounts = match gitlab_all(
1691 ctx,
1692 run,
1693 &format!("groups/{group}/service_accounts?order_by=id"),
1694 )? {
1695 Ok(accounts) => accounts,
1696 Err(state) => return Ok(Some(state)),
1697 };
1698 let members = match gitlab_all(
1699 ctx,
1700 run,
1701 &format!("projects/{project}/members/all?order_by=id"),
1702 )? {
1703 Ok(members) => members,
1704 Err(state) => return Ok(Some(state)),
1705 };
1706 for account in &accounts {
1707 let (Some(id), Some(user)) = (account["id"].as_u64(), account["username"].as_str()) else {
1708 continue;
1709 };
1710 if user == name
1711 || !members
1712 .iter()
1713 .any(|member| member["id"].as_u64() == Some(id))
1714 {
1715 continue;
1716 }
1717 if user == default {
1718 return Ok(Some(gitlab_rename(user, name)));
1719 }
1720 let path =
1721 format!("groups/{group}/service_accounts/{id}/personal_access_tokens?state=active");
1722 let tokens = match gitlab_all(ctx, run, &path)? {
1723 Ok(tokens) => tokens,
1724 Err(state) => return Ok(Some(state)),
1725 };
1726 if tokens
1727 .iter()
1728 .any(|token| token["description"] == BOT_MARKER && token["active"] != false)
1729 {
1730 return Ok(Some(gitlab_rename(user, name)));
1731 }
1732 }
1733 Ok(None)
1734}
1735
1736fn gitlab_install_bot(ctx: &Ctx, run: &mut Runner, project: &str) -> Result<StepState, RkError> {
1741 let form = match gitlab_identity_form(ctx, run)? {
1742 Ok(form) => form,
1743 Err(state) => return Ok(state),
1744 };
1745 match form {
1746 GitlabIdentity::Token => {
1747 let name = ctx.bot_name().unwrap_or("release-bot");
1748 let path = format!("projects/{project}/access_tokens?state=active");
1749 let tokens = match gitlab_all(ctx, run, &path)? {
1750 Ok(tokens) => tokens,
1751 Err(state) => return Ok(state),
1752 };
1753 let active: Vec<_> = tokens
1754 .iter()
1755 .filter(|token| token["revoked"] == false && token["active"] != false)
1756 .collect();
1757 let live = active.iter().find(|token| {
1760 token["name"] != name
1761 && (token["description"] == BOT_MARKER || token["name"] == "release-bot")
1762 });
1763 if let Some(live) = live.and_then(|token| token["name"].as_str()) {
1764 return Ok(gitlab_rename(live, name));
1765 }
1766 if !active.iter().any(|token| token["name"] == name) {
1767 return Ok(StepState::not(format!("no active {name} token exists")));
1768 }
1769 gitlab_bot_variable(ctx, run, project, &format!("an active {name} token exists"))
1770 }
1771 GitlabIdentity::ServiceAccount(group) => {
1772 let project_id = match api_get(ctx, run, &format!("projects/{project}"))? {
1773 Api::Ok(body) => body["id"].as_u64(),
1774 Api::Missing => {
1775 return Ok(StepState::not(format!(
1776 "the forge does not know {}",
1777 ctx.repo
1778 )));
1779 }
1780 Api::Failed(err) => return Ok(StepState::unknown(err)),
1781 };
1782 let Some(project_id) = project_id else {
1783 return Ok(StepState::unknown("the project answers no id"));
1784 };
1785 let name = ctx
1786 .bot_name()
1787 .map_or_else(|| format!("release-bot-{project_id}"), str::to_owned);
1788 let path = format!("groups/{group}/service_accounts?order_by=id");
1789 let account = match gitlab_listing(ctx, run, &path, |account| {
1790 account["username"] == name.as_str()
1791 })? {
1792 Ok(account) => account,
1793 Err(state) => return Ok(state),
1794 };
1795 let default = format!("release-bot-{project_id}");
1798 if let Some(state) = gitlab_live_account(ctx, run, project, group, &default, &name)? {
1799 return Ok(state);
1800 }
1801 let Some(id) = account.and_then(|account| account["id"].as_u64()) else {
1802 return Ok(StepState::not(format!(
1803 "no service account named {name} exists"
1804 )));
1805 };
1806 let level = match api_get(ctx, run, &format!("projects/{project}/members/all/{id}"))? {
1807 Api::Ok(body) => body["access_level"].as_u64().unwrap_or(0),
1808 Api::Missing => {
1809 return Ok(StepState::not(format!(
1810 "the service account {name} is not a member of this project"
1811 )));
1812 }
1813 Api::Failed(err) => return Ok(StepState::unknown(err)),
1814 };
1815 if level < 40 {
1816 return Ok(StepState::not(format!(
1817 "the service account {name} is a member of this project below Maintainer"
1818 )));
1819 }
1820 gitlab_bot_variable(
1821 ctx,
1822 run,
1823 project,
1824 &format!("a service account named {name} is a Maintainer of this project"),
1825 )
1826 }
1827 }
1828}
1829
1830#[allow(
1831 clippy::too_many_lines,
1832 reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
1833)]
1834fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
1835 let trunk = ctx.trunk();
1836 let project = ctx.repo.replace('/', "%2F");
1837 match step {
1838 "private-vulnerability-reporting" => {
1839 let path = format!("projects/{project}");
1840 Ok(match api_get(ctx, run, &path)? {
1841 Api::Ok(body) => {
1842 let access = body["issues_access_level"].as_str();
1843 if !matches!(access, Some("enabled" | "private" | "disabled")) {
1844 StepState::unknown("issue intake access is unreadable")
1845 } else if body
1846 .get("issues_enabled")
1847 .is_some_and(|flag| !flag.is_boolean())
1848 {
1849 StepState::unknown("legacy issue intake flag is unreadable")
1850 } else if body["issues_enabled"] == false || access == Some("disabled") {
1851 StepState::not("issue intake is disabled; see setup guide step 3g")
1852 } else if access == Some("private") {
1853 StepState::not("issue intake is restricted; see setup guide step 3g")
1854 } else {
1855 StepState::ok_with_limitation(
1856 "issue intake is enabled",
1857 GITLAB_PRIVATE_REPORTING_LIMITATION,
1858 )
1859 }
1860 }
1861 Api::Missing => {
1862 StepState::unknown(format!("{path}: issue intake is unreadable (404)"))
1863 }
1864 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
1865 })
1866 }
1867
1868 "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1869 Api::Ok(body) => {
1870 let found = body["default_branch"].as_str().unwrap_or("");
1871 if found == trunk {
1872 StepState::ok(format!("{trunk} is the default branch"))
1873 } else {
1874 StepState::not(format!("the default branch is {found}"))
1875 }
1876 }
1877 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1878 Api::Failed(err) => StepState::unknown(err),
1879 }),
1880 "single-trunk" => {
1881 for candidate in ctx.retired_branches() {
1882 let candidate = candidate.as_str();
1883 if candidate == trunk {
1884 continue;
1885 }
1886 match api_get(
1887 ctx,
1888 run,
1889 &format!("projects/{project}/repository/branches/{candidate}"),
1890 )? {
1891 Api::Missing => {}
1892 Api::Ok(_) => {
1893 return Ok(StepState::not(format!("a {candidate} branch still exists")));
1894 }
1895 Api::Failed(err) => return Ok(StepState::unknown(err)),
1896 }
1897 }
1898 Ok(StepState::ok(
1899 "no long-lived branch besides the trunk remains",
1900 ))
1901 }
1902 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1903 Api::Ok(body) => {
1904 if body["remove_source_branch_after_merge"]
1905 .as_bool()
1906 .unwrap_or(false)
1907 {
1908 StepState::ok("a merged branch is deleted by the forge")
1909 } else {
1910 StepState::not("a merged branch outlives its merge")
1911 }
1912 }
1913 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1914 Api::Failed(err) => StepState::unknown(err),
1915 }),
1916 "auto-merge" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1917 Api::Ok(body) => {
1918 if body["only_allow_merge_if_pipeline_succeeds"]
1919 .as_bool()
1920 .unwrap_or(false)
1921 {
1922 StepState::ok_with_limitation(
1923 "a request may merge itself once its pipeline passes",
1924 GITLAB_AUTO_MERGE_LIMITATION,
1925 )
1926 } else {
1927 StepState::not(
1928 "the pipeline requirement auto-merge rides on is off; protect-trunk asserts it",
1929 )
1930 }
1931 }
1932 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1933 Api::Failed(err) => StepState::unknown(err),
1934 }),
1935 "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1936 Api::Ok(body) => {
1937 if body["jobs_enabled"] == true {
1938 StepState::ok("pipelines are enabled")
1939 } else {
1940 StepState::not("pipelines are disabled")
1941 }
1942 }
1943 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1944 Api::Failed(err) => StepState::unknown(err),
1945 }),
1946 "install-bot" => gitlab_install_bot(ctx, run, &project),
1947 "bot-secrets" => Ok(
1948 match api_get(
1949 ctx,
1950 run,
1951 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1952 )? {
1953 Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
1954 Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
1955 Api::Failed(err) => StepState::unknown(err),
1956 },
1957 ),
1958 "protect-trunk" => {
1959 let protection = match api_get(
1960 ctx,
1961 run,
1962 &format!("projects/{project}/protected_branches/{trunk}"),
1963 )? {
1964 Api::Ok(body) => body,
1965 Api::Missing => {
1966 return Ok(StepState::not(format!("{trunk} is not protected")));
1967 }
1968 Api::Failed(err) => return Ok(StepState::unknown(err)),
1969 };
1970 let grants = protection["push_access_levels"]
1974 .as_array()
1975 .cloned()
1976 .unwrap_or_default();
1977 let policy = ctx.protection();
1978 let no_push =
1979 grants.len() == 1 && grants[0]["access_level"] == policy.gitlab.push_access_level;
1980 let merges = protection["merge_access_levels"]
1984 .as_array()
1985 .cloned()
1986 .unwrap_or_default();
1987 let can_merge =
1988 merges.len() == 1 && merges[0]["access_level"] == policy.gitlab.merge_access_level;
1989 let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
1990 Api::Ok(body) => body,
1991 Api::Missing | Api::Failed(_) => Value::Null,
1992 };
1993 let mut faults = Vec::new();
1994 if !no_push {
1995 faults.push(format!(
1996 "{trunk} still takes a direct push: the forge honors the most permissive of {} push grants",
1997 grants.len()
1998 ));
1999 }
2000 if !can_merge {
2001 faults.push(format!(
2002 "{trunk} merge grants are not exactly the one owned maintainer level"
2003 ));
2004 }
2005 if protection["allow_force_push"] != false {
2006 faults.push(format!("{trunk} allows force pushes"));
2007 }
2008 if let Some(fault) = pipeline_fault(
2013 ctx.forge_gate(),
2014 settings["only_allow_merge_if_pipeline_succeeds"].as_bool(),
2015 ) {
2016 faults.push(fault);
2017 }
2018 if settings["merge_method"] != policy.gitlab.merge_method.as_str() {
2019 faults.push("the merge method is not fast-forward".to_owned());
2020 }
2021 let squash = ctx.merge_form() == crate::landing::MergeForm::Squash;
2022 if settings["squash_option"] != policy.gitlab.squash_option.as_str() {
2023 faults.push(if squash {
2024 "merge requests do not always squash".to_owned()
2025 } else {
2026 "merge requests may squash, so a request's commits would not keep their own messages".to_owned()
2027 });
2028 }
2029 if squash
2032 && settings["squash_commit_template"]
2033 != policy.gitlab.squash_commit_template.as_str()
2034 {
2035 faults.push("the squash template is not the merge request's title".to_owned());
2036 }
2037 Ok(if faults.is_empty() {
2038 let detail = format!("{trunk} holds the release-merge shape");
2039 if ctx.forge_gate() {
2040 StepState::ok_with_limitation(
2041 detail,
2042 if squash {
2043 GITLAB_TITLE_LIMITATION
2044 } else {
2045 GITLAB_COMMIT_LIMITATION
2046 },
2047 )
2048 } else {
2049 StepState::ok(detail)
2051 }
2052 } else {
2053 StepState::not(faults.join("; "))
2054 })
2055 }
2056 "protect-tags" => Ok(
2057 match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
2058 Api::Ok(_) => {
2059 StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
2060 }
2061 Api::Missing => StepState::not("v* is not protected"),
2062 Api::Failed(err) => StepState::unknown(err),
2063 },
2064 ),
2065 "protect-release-lines" => Ok(
2066 match api_get(
2067 ctx,
2068 run,
2069 &format!("projects/{project}/protected_branches/release%2F%2A"),
2070 )? {
2071 Api::Ok(body) => {
2072 let level_ok = |levels: &Value| {
2073 levels
2074 .as_array()
2075 .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
2076 };
2077 if body["allow_force_push"] != false {
2078 StepState::not("release/* allows force pushes")
2079 } else if !level_ok(&body["push_access_levels"])
2080 || !level_ok(&body["merge_access_levels"])
2081 {
2082 StepState::not(
2086 "release/* grants are not exactly the owned maintainer levels",
2087 )
2088 } else {
2089 StepState::ok("release/* refuses force pushes and deletion by git clients")
2090 }
2091 }
2092 Api::Missing => StepState::inapplicable(
2093 "release/* is unprotected; optional โ applied only where older lines exist",
2094 ),
2095 Api::Failed(err) => StepState::unknown(err),
2096 },
2097 ),
2098 "protections-check" => {
2099 let mut failures = Vec::new();
2102 let mut unknowns = Vec::new();
2103 let mut limitations: Vec<String> = Vec::new();
2106 for owned in owned_protections(ctx) {
2107 match gitlab(ctx, owned, run)? {
2108 StepState::Satisfied {
2109 limitation: found, ..
2110 } => limitations.extend(found),
2111 StepState::Inapplicable { .. } => {}
2112 StepState::Unsatisfied { detail } => {
2113 failures.push(format!("{owned}: {detail}"));
2114 }
2115 StepState::Unknown { detail } => {
2116 unknowns.push(format!("{owned}: {detail}"));
2117 }
2118 }
2119 }
2120 Ok(if !failures.is_empty() {
2121 StepState::not(failures.join("; "))
2122 } else if !unknowns.is_empty() {
2123 StepState::unknown(unknowns.join("; "))
2124 } else {
2125 StepState::Satisfied {
2126 detail: "the protections hold, as far as this forge enforces them".into(),
2127 limitation: if limitations.is_empty() {
2128 None
2129 } else {
2130 Some(limitations.join("; "))
2131 },
2132 }
2133 })
2134 }
2135 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
2136 }
2137}
2138
2139#[cfg(test)]
2140mod tests {
2141 #[test]
2143 fn merge_form_rebase_switch_is_judged() {
2144 assert_eq!(super::rebase_switch_fault(Some(true)), None);
2145 assert!(
2146 super::rebase_switch_fault(Some(false))
2147 .is_some_and(|fault| fault.contains("allow_rebase_merge is false"))
2148 );
2149 assert!(
2150 super::rebase_switch_fault(None)
2151 .is_some_and(|fault| fault.contains("missing or unreadable"))
2152 );
2153 }
2154
2155 #[test]
2160 fn release_less_pipeline_requirement_is_judged_both_ways() {
2161 assert_eq!(super::pipeline_fault(true, Some(true)), None);
2162 assert_eq!(super::pipeline_fault(false, Some(false)), None);
2163 for (gate, observed) in [
2164 (true, Some(false)),
2165 (false, Some(true)),
2166 (true, None),
2167 (false, None),
2168 ] {
2169 assert!(
2170 super::pipeline_fault(gate, observed).is_some(),
2171 "gate {gate}, observed {observed:?}"
2172 );
2173 }
2174 }
2175}