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