1use serde_json::Value;
11
12use crate::detect::Forge;
13use crate::error::RkError;
14use crate::setup::app_jwt::{self, AppApi};
15use crate::setup::context::Ctx;
16use crate::setup::process::{Exec, Outcome};
17use crate::setup::workflow_jobs;
18
19pub type Runner<'a> = dyn FnMut(&Exec) -> Result<Outcome, RkError> + 'a;
22
23const 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";
37
38#[derive(Debug)]
40pub enum StepState {
41 Satisfied {
44 detail: String,
46 limitation: Option<String>,
48 },
49 Unsatisfied {
51 detail: String,
53 },
54 Inapplicable {
57 detail: String,
59 },
60 Unknown {
62 detail: String,
64 },
65}
66
67impl StepState {
68 #[must_use]
70 pub const fn satisfied(&self) -> bool {
71 matches!(self, Self::Satisfied { .. })
72 }
73
74 fn ok(detail: impl Into<String>) -> Self {
75 Self::Satisfied {
76 detail: detail.into(),
77 limitation: None,
78 }
79 }
80
81 fn ok_with_limitation(detail: impl Into<String>, limitation: impl Into<String>) -> Self {
82 Self::Satisfied {
83 detail: detail.into(),
84 limitation: Some(limitation.into()),
85 }
86 }
87
88 fn not(detail: impl Into<String>) -> Self {
89 Self::Unsatisfied {
90 detail: detail.into(),
91 }
92 }
93
94 fn inapplicable(detail: impl Into<String>) -> Self {
95 Self::Inapplicable {
96 detail: detail.into(),
97 }
98 }
99
100 fn unknown(detail: impl Into<String>) -> Self {
101 Self::Unknown {
102 detail: detail.into(),
103 }
104 }
105}
106
107enum Api {
109 Ok(Value),
111 Missing,
113 Failed(String),
115}
116
117pub fn observe(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
124 if step == "package-check" {
125 return package_check(ctx, run);
126 }
127 if step == "branch-reminder" {
128 return Ok(branch_reminder_state(ctx));
129 }
130 if step == "forge-version" {
131 return forge_version(ctx, run);
132 }
133 match ctx.forge {
134 Forge::Github => github(ctx, step, run),
135 Forge::Gitlab => gitlab(ctx, step, run),
136 }
137}
138
139const POLICY_DESTINATION: &str = "SECURITY.md";
143
144const 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";
149
150const BASH_LIMITATION: &str = "the make dist tarball is not inspected: git archive honours export-ignore, so SECURITY.md inclusion is unproved; inspect the generated tarball before publishing";
153
154fn package_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
163 let (program, args): (&str, &[&str]) = match ctx.tech {
164 Some("rust") => ("cargo", &["publish", "--dry-run", "--allow-dirty"]),
165 Some("python") => ("python3", &["-m", "build"]),
166 Some("bash") => {
167 return Ok(StepState::ok_with_limitation(
168 "no registry for this technology; there is nothing to package",
169 BASH_LIMITATION,
170 ));
171 }
172 Some(other) => {
173 return Ok(StepState::unknown(format!(
174 "no packaging check is defined for {other}"
175 )));
176 }
177 None => {
178 return Ok(StepState::unknown(
179 "no version file names a technology; see rk binding --list",
180 ));
181 }
182 };
183 let outcome = run(&cargo_exec(ctx, program, args))?;
184 if !outcome.success() {
185 return Ok(StepState::not(format!(
186 "the packaging check failed: {}",
187 last_line(&outcome.stderr)
188 )));
189 }
190 let built = "the package builds and passes the registry's dry run";
191 Ok(match ctx.tech {
192 Some("rust") => policy_in_the_crate(ctx, run, built)?,
193 _ => StepState::ok_with_limitation(built, PYTHON_LIMITATION),
194 })
195}
196
197fn cargo_exec(ctx: &Ctx, program: &str, args: &[&str]) -> Exec {
199 Exec {
200 program: program.into(),
201 args: args.iter().map(Into::into).collect(),
202 env: ctx.child_env("package-check"),
203 cwd: ctx.target.as_std_path().to_path_buf(),
204 stdin: None,
205 }
206}
207
208fn policy_in_the_crate(ctx: &Ctx, run: &mut Runner, built: &str) -> Result<StepState, RkError> {
219 let metadata = run(&cargo_exec(
220 ctx,
221 "cargo",
222 &["metadata", "--no-deps", "--format-version", "1"],
223 ))?;
224 if !metadata.success() {
225 return Ok(StepState::unknown(format!(
226 "{built}, and the policy check could not run: cargo metadata failed: {}",
227 last_line(&metadata.stderr)
228 )));
229 }
230 let root_manifest = ctx.target.as_std_path().join("Cargo.toml");
231 let selected = sole_root_package(&metadata.stdout, &root_manifest);
232 let Some(manifest) = selected else {
233 return Ok(StepState::ok_with_limitation(
234 built,
235 format!(
236 "{POLICY_DESTINATION} inclusion is 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"
237 ),
238 ));
239 };
240 let listing = run(&cargo_exec(
241 ctx,
242 "cargo",
243 &[
244 "package",
245 "--list",
246 "--allow-dirty",
247 "--manifest-path",
248 &manifest,
249 ],
250 ))?;
251 if !listing.success() {
252 return Ok(StepState::unknown(format!(
253 "{built}, and the policy check could not run: cargo package --list failed: {}",
254 last_line(&listing.stderr)
255 )));
256 }
257 let carried = String::from_utf8_lossy(&listing.stdout)
260 .lines()
261 .any(|line| line.trim() == POLICY_DESTINATION);
262 Ok(if carried {
263 StepState::ok(format!(
264 "{built}, and the published package carries {POLICY_DESTINATION}"
265 ))
266 } else {
267 StepState::not(format!(
268 "{built}, but the published package omits {POLICY_DESTINATION}: add /{POLICY_DESTINATION} to [package].include, remove the [package].exclude entry matching it, or stop ignoring the file"
269 ))
270 })
271}
272
273fn sole_root_package(metadata: &[u8], root_manifest: &std::path::Path) -> Option<String> {
276 let document: Value = serde_json::from_slice(metadata).ok()?;
277 let defaults: Vec<&str> = document
278 .get("workspace_default_members")?
279 .as_array()?
280 .iter()
281 .filter_map(Value::as_str)
282 .collect();
283 let [only] = defaults.as_slice() else {
284 return None;
285 };
286 let manifest = document
287 .get("packages")?
288 .as_array()?
289 .iter()
290 .find(|package| package.get("id").and_then(Value::as_str) == Some(*only))?
291 .get("manifest_path")?
292 .as_str()?;
293 let same =
296 std::fs::canonicalize(manifest).ok()? == std::fs::canonicalize(root_manifest).ok()?;
297 same.then(|| manifest.to_owned())
298}
299
300fn branch_reminder_state(ctx: &Ctx) -> StepState {
303 use crate::setup::branch_reminder::{HookState, observe_hook};
304 match observe_hook(&ctx.target) {
305 HookState::Installed => {
306 StepState::ok("the post-merge hook carries the release-kit reminder")
307 }
308 HookState::Absent => StepState::not("no post-merge hook is installed"),
309 HookState::Foreign => {
310 StepState::not("a post-merge hook exists without the release-kit marker")
311 }
312 HookState::Drifted => StepState::not("the reminder hook drifted from this binary's body"),
313 HookState::Unreadable(detail) => StepState::unknown(detail),
314 }
315}
316
317pub const GITLAB_VERSION_FLOOR: (u64, u64) = (18, 2);
324
325const GITLAB_EDITIONS: [&str; 2] = ["ee", "ce"];
328
329fn version_refusal(found: &str, prerelease: Option<&str>) -> String {
332 let (major, minor) = GITLAB_VERSION_FLOOR;
333 let mut said = vec![format!(
334 "this GitLab instance reports {found}; the convention needs {major}.{minor} or newer"
335 )];
336 if let Some(suffix) = prerelease {
337 said.push(format!(
338 "the -{suffix} suffix is a pre-release, and nothing proves the feature shipped in it, so this step fails closed"
339 ));
340 }
341 said.push(format!(
342 "the merge-request pipeline triggers a child pipeline with `strategy: mirror`, which GitLab added in {major}.{minor}"
343 ));
344 said.push(
345 "below it the child's status never reaches the parent pipeline, so a failing project job merges".to_owned(),
346 );
347 said.push(format!(
348 "upgrade the instance to {major}.{minor} or newer, or host the project on gitlab.com"
349 ));
350 said.join("; ")
351}
352
353fn forge_version(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
359 if ctx.forge == Forge::Github {
360 return Ok(StepState::ok(
361 "github.com is a rolling service and declares no version floor",
362 ));
363 }
364 let body = match api_get(ctx, run, "version")? {
365 Api::Ok(body) => body,
366 Api::Missing => {
367 return Ok(StepState::unknown(
368 "this instance answers no GET /version; the floor cannot be read. Check that glab is authenticated against it: glab auth login",
369 ));
370 }
371 Api::Failed(err) => {
372 return Ok(StepState::unknown(format!(
373 "the version could not be read: {err}. Check that glab is authenticated against this instance: glab auth login"
374 )));
375 }
376 };
377 let Some(found) = body["version"].as_str() else {
378 return Ok(StepState::unknown(
379 "the forge answer carries no version field; the floor cannot be read. Check that glab is authenticated against this instance: glab auth login",
380 ));
381 };
382 let (number, suffix) = found
383 .split_once('-')
384 .map_or((found, None), |(n, s)| (n, Some(s)));
385 let mut parts = number.split('.');
386 let parsed = parts
387 .next()
388 .and_then(|major| major.parse::<u64>().ok())
389 .zip(parts.next().and_then(|minor| minor.parse::<u64>().ok()));
390 let Some(pair) = parsed else {
391 return Ok(StepState::unknown(format!(
392 "the forge reports the version as '{found}', which names no major and minor pair; the floor cannot be read"
393 )));
394 };
395 if let Some(suffix) = suffix.filter(|s| !GITLAB_EDITIONS.contains(s)) {
396 return Ok(StepState::not(version_refusal(found, Some(suffix))));
397 }
398 if pair < GITLAB_VERSION_FLOOR {
399 return Ok(StepState::not(version_refusal(found, None)));
400 }
401 let (major, minor) = GITLAB_VERSION_FLOOR;
402 Ok(StepState::ok(format!(
403 "this instance reports {found}, at or above the {major}.{minor} floor"
404 )))
405}
406
407pub fn single_trunk_guard(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
417 let trunk = ctx.trunk();
418 for candidate in ctx.retired_branches() {
419 let candidate = candidate.as_str();
420 if candidate == trunk {
421 continue;
422 }
423 let state = match ctx.forge {
424 Forge::Github => github_candidate_guard(ctx, run, candidate)?,
425 Forge::Gitlab => gitlab_candidate_guard(ctx, run, candidate)?,
426 };
427 if !state.satisfied() {
428 return Ok(state);
429 }
430 }
431 Ok(StepState::ok(
432 "every candidate branch is absent, or an ancestor of the trunk",
433 ))
434}
435
436fn github_candidate_guard(
438 ctx: &Ctx,
439 run: &mut Runner,
440 candidate: &str,
441) -> Result<StepState, RkError> {
442 let trunk = ctx.trunk();
443 match api_get(
444 ctx,
445 run,
446 &format!("repos/{}/git/ref/heads/{candidate}", ctx.repo),
447 )? {
448 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
449 Api::Failed(err) => return Ok(StepState::unknown(err)),
450 Api::Ok(_) => {}
451 }
452 match api_get(
453 ctx,
454 run,
455 &format!("repos/{}/compare/{candidate}...{trunk}", ctx.repo),
456 )? {
457 Api::Ok(body) => {
458 let status = body["status"].as_str().unwrap_or("");
459 Ok(if matches!(status, "ahead" | "identical") {
460 StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
461 } else {
462 StepState::not(format!(
463 "{candidate} is not an ancestor of {trunk} ({status}); deleting it would lose work"
464 ))
465 })
466 }
467 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
468 Api::Failed(err) => Ok(StepState::unknown(err)),
469 }
470}
471
472fn gitlab_candidate_guard(
474 ctx: &Ctx,
475 run: &mut Runner,
476 candidate: &str,
477) -> Result<StepState, RkError> {
478 let trunk = ctx.trunk();
479 let project = ctx.repo.replace('/', "%2F");
480 match api_get(
481 ctx,
482 run,
483 &format!("projects/{project}/repository/branches/{candidate}"),
484 )? {
485 Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
486 Api::Failed(err) => return Ok(StepState::unknown(err)),
487 Api::Ok(_) => {}
488 }
489 match api_get(
490 ctx,
491 run,
492 &format!("projects/{project}/repository/compare?from={trunk}&to={candidate}"),
493 )? {
494 Api::Ok(body) => {
495 let ahead = body["commits"]
496 .as_array()
497 .is_some_and(|list| !list.is_empty());
498 Ok(if ahead {
499 StepState::not(format!(
500 "{candidate} carries commits {trunk} does not; deleting it would lose work"
501 ))
502 } else {
503 StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
504 })
505 }
506 Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
507 Api::Failed(err) => Ok(StepState::unknown(err)),
508 }
509}
510
511fn api_get(ctx: &Ctx, run: &mut Runner, path: &str) -> Result<Api, RkError> {
513 let exec = Exec {
514 program: ctx.cli.clone().into_os_string(),
515 args: vec!["api".into(), path.into()],
516 env: ctx.child_env("observe"),
517 cwd: ctx.target.as_std_path().to_path_buf(),
518 stdin: None,
519 };
520 let outcome = run(&exec)?;
521 if outcome.success() {
522 return Ok(
523 serde_json::from_slice::<Value>(&outcome.stdout).map_or_else(
524 |_| Api::Failed("the forge answer did not parse as JSON".into()),
525 Api::Ok,
526 ),
527 );
528 }
529 let stderr = String::from_utf8_lossy(&outcome.stderr).into_owned();
530 if stderr.contains("404") {
531 Ok(Api::Missing)
532 } else {
533 Ok(Api::Failed(last_line(&outcome.stderr)))
534 }
535}
536
537fn last_line(bytes: &[u8]) -> String {
539 String::from_utf8_lossy(bytes)
540 .lines()
541 .rev()
542 .find(|line| !line.trim().is_empty())
543 .unwrap_or("no output")
544 .to_owned()
545}
546
547#[allow(
548 clippy::too_many_lines,
549 reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
550)]
551fn github(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
552 let trunk = ctx.trunk();
553 let repo = &ctx.repo;
554 match step {
555 "private-vulnerability-reporting" => {
556 let visibility_path = format!("repos/{repo}");
557 match api_get(ctx, run, &visibility_path)? {
558 Api::Ok(body) => match body["private"].as_bool() {
559 Some(true) => {
560 return Ok(StepState::inapplicable(
561 "private vulnerability reporting is available for public repositories",
562 ));
563 }
564 Some(false) => {}
565 None => {
566 return Ok(StepState::unknown(format!(
567 "{visibility_path}: repository visibility is unreadable"
568 )));
569 }
570 },
571 Api::Missing => {
572 return Ok(StepState::unknown(format!(
573 "{visibility_path}: repository visibility is unreadable (404)"
574 )));
575 }
576 Api::Failed(err) => {
577 return Ok(StepState::unknown(format!("{visibility_path}: {err}")));
578 }
579 }
580 let path = format!("repos/{repo}/private-vulnerability-reporting");
581 Ok(match api_get(ctx, run, &path)? {
582 Api::Ok(body) => match body["enabled"].as_bool() {
583 Some(true) => StepState::ok("private vulnerability reporting is enabled"),
584 Some(false) => StepState::not("private vulnerability reporting is disabled"),
585 None => StepState::unknown(format!("{path}: enabled is unreadable")),
586 },
587 Api::Missing => {
588 StepState::unknown(format!("{path}: reporting state is unreadable (404)"))
589 }
590 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
591 })
592 }
593
594 "default-branch" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
595 Api::Ok(body) => {
596 let found = body["default_branch"].as_str().unwrap_or("");
597 if found == trunk {
598 StepState::ok(format!("{trunk} is the default branch"))
599 } else {
600 StepState::not(format!("the default branch is {found}"))
601 }
602 }
603 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
604 Api::Failed(err) => StepState::unknown(err),
605 }),
606 "single-trunk" => {
607 for candidate in ctx.retired_branches() {
608 let candidate = candidate.as_str();
609 if candidate == trunk {
610 continue;
611 }
612 match api_get(ctx, run, &format!("repos/{repo}/git/ref/heads/{candidate}"))? {
613 Api::Missing => {}
614 Api::Ok(_) => {
615 return Ok(StepState::not(format!("a {candidate} branch still exists")));
616 }
617 Api::Failed(err) => return Ok(StepState::unknown(err)),
618 }
619 }
620 Ok(StepState::ok(
621 "no long-lived branch besides the trunk remains",
622 ))
623 }
624 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
625 Api::Ok(body) => {
626 if body["delete_branch_on_merge"].as_bool().unwrap_or(false) {
627 StepState::ok("a merged branch is deleted by the forge")
628 } else {
629 StepState::not("a merged branch outlives its merge")
630 }
631 }
632 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
633 Api::Failed(err) => StepState::unknown(err),
634 }),
635 "auto-merge" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
636 Api::Ok(body) => {
637 if body["allow_auto_merge"].as_bool().unwrap_or(false) {
638 StepState::ok("a request may merge itself once its checks pass")
639 } else {
640 StepState::not("a request cannot merge itself; the auto-merge switch is off")
641 }
642 }
643 Api::Missing => StepState::not(format!("the forge does not know {repo}")),
644 Api::Failed(err) => StepState::unknown(err),
645 }),
646 "ci-permissions" => Ok(
647 match api_get(
648 ctx,
649 run,
650 &format!("repos/{repo}/actions/permissions/workflow"),
651 )? {
652 Api::Ok(body) => {
653 let write = body["default_workflow_permissions"] == "write";
654 let approve = body["can_approve_pull_request_reviews"] == true;
655 if write && approve {
656 StepState::ok("CI may write and open requests")
657 } else {
658 StepState::not(format!(
659 "workflow permissions are {} with request approval {}",
660 body["default_workflow_permissions"],
661 body["can_approve_pull_request_reviews"]
662 ))
663 }
664 }
665 Api::Missing => StepState::not("no workflow permissions are readable"),
666 Api::Failed(err) => StepState::unknown(err),
667 },
668 ),
669 "bot-secrets" => Ok(
670 match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
671 Api::Ok(body) => {
672 let names: Vec<&str> = body["secrets"]
673 .as_array()
674 .map(|list| {
675 list.iter()
676 .filter_map(|secret| secret["name"].as_str())
677 .collect()
678 })
679 .unwrap_or_default();
680 let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
681 if wanted.iter().all(|name| names.contains(name)) {
682 StepState::ok("both bot secrets are stored")
683 } else if names.is_empty() {
684 StepState::not("no bot secrets are stored")
685 } else {
686 StepState::not(format!("stored secrets: {}", names.join(", ")))
687 }
688 }
689 Api::Missing => StepState::not("no secrets are readable"),
690 Api::Failed(err) => StepState::unknown(err),
691 },
692 ),
693 "protect-trunk" => github_trunk_ruleset(ctx, run),
694 "protect-tags" => github_ruleset(
695 ctx,
696 run,
697 ctx.tag_ruleset(),
698 "tag",
699 "refs/tags/v*",
700 &["deletion", "update"],
701 ),
702 "protect-release-lines" => {
703 match github_ruleset_body(ctx, run, ctx.lines_ruleset())? {
704 RulesetLookup::Absent => {
705 return Ok(StepState::inapplicable(
706 "release/* is unprotected; optional — applied only where older lines exist",
707 ));
708 }
709 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
710 RulesetLookup::Found(_) => {}
711 }
712 github_ruleset(
713 ctx,
714 run,
715 ctx.lines_ruleset(),
716 "branch",
717 "refs/heads/release/*",
718 &["deletion", "non_fast_forward"],
719 )
720 }
721 "protections-check" => {
722 let mut failures = Vec::new();
726 let mut unknowns = Vec::new();
727 let mut limitations: Vec<String> = Vec::new();
729 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
730 match github(ctx, owned, run)? {
731 StepState::Satisfied {
732 limitation: found, ..
733 } => limitations.extend(found),
734 StepState::Inapplicable { .. } => {}
735 StepState::Unsatisfied { detail } => {
736 failures.push(format!("{owned}: {detail}"));
737 }
738 StepState::Unknown { detail } => {
739 unknowns.push(format!("{owned}: {detail}"));
740 }
741 }
742 }
743 match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
744 Api::Ok(body) => {
745 let owned = [
746 ctx.trunk_ruleset().to_owned(),
747 ctx.tag_ruleset().to_owned(),
748 ctx.lines_ruleset().to_owned(),
749 ];
750 for ruleset in body.as_array().into_iter().flatten() {
751 let name = ruleset["name"].as_str().unwrap_or("");
752 if !owned.iter().any(|expected| expected == name) {
753 failures.push(format!("a ruleset no step owns: {name}"));
754 }
755 }
756 }
757 Api::Missing | Api::Failed(_) => {
758 unknowns.push("the ruleset inventory is not readable".to_owned());
759 }
760 }
761 Ok(if !failures.is_empty() {
762 StepState::not(failures.join("; "))
763 } else if !unknowns.is_empty() {
764 StepState::unknown(unknowns.join("; "))
765 } else {
766 StepState::Satisfied {
767 detail: "exactly the owned protections, with those rules".into(),
768 limitation: if limitations.is_empty() {
769 None
770 } else {
771 Some(limitations.join("; "))
772 },
773 }
774 })
775 }
776 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
777 }
778}
779
780#[must_use]
788pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
789 match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
790 AppApi::Ok(body) => {
791 let id = body["id"].as_i64().unwrap_or_default();
792 StepState::ok(format!("installation {id} covers {}", ctx.repo))
793 }
794 AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
795 AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
796 }
797}
798
799fn github_ruleset(
804 ctx: &Ctx,
805 run: &mut Runner,
806 name: &str,
807 target: &str,
808 include: &str,
809 rules: &[&str],
810) -> Result<StepState, RkError> {
811 let detail = match github_ruleset_body(ctx, run, name)? {
812 RulesetLookup::Found(detail) => detail,
813 RulesetLookup::Absent => {
814 return Ok(StepState::not(format!("no ruleset named {name}")));
815 }
816 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
817 };
818 if detail["enforcement"] != "active" {
819 return Ok(StepState::not(format!("{name} is not active")));
820 }
821 if detail["target"] != target {
824 return Ok(StepState::not(format!(
825 "{name} does not target {target} refs"
826 )));
827 }
828 if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
829 return Ok(StepState::not(format!(
830 "{name} does not cover {include} alone"
831 )));
832 }
833 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
834 return Ok(StepState::not(format!(
835 "{name} excludes refs from its own coverage"
836 )));
837 }
838 let mut held: Vec<&str> = detail["rules"]
839 .as_array()
840 .map(|list| {
841 list.iter()
842 .filter_map(|rule| rule["type"].as_str())
843 .collect()
844 })
845 .unwrap_or_default();
846 held.sort_unstable();
847 let mut expected: Vec<&str> = rules.to_vec();
848 expected.sort_unstable();
849 if held == expected {
850 Ok(StepState::ok(format!(
851 "{name} is active with exactly its rules"
852 )))
853 } else {
854 Ok(StepState::not(format!(
855 "{name} carries the rules [{}] where the setup owns [{}]",
856 held.join(", "),
857 expected.join(", ")
858 )))
859 }
860}
861
862fn unowned_rule_faults(rules: &[Value], owned: &[String]) -> Vec<String> {
877 rules
878 .iter()
879 .filter_map(|rule| rule["type"].as_str())
880 .filter(|kind| !owned.iter().any(|name| name == kind))
881 .map(|kind| {
882 if kind == "merge_queue" {
883 MERGE_QUEUE_FAULT.to_owned()
884 } else {
885 format!("an unowned rule is present: {kind}")
886 }
887 })
888 .collect()
889}
890
891fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
892 let trunk = ctx.trunk();
893 let name = ctx.trunk_ruleset().to_owned();
894 let detail = match github_ruleset_body(ctx, run, &name)? {
895 RulesetLookup::Found(detail) => detail,
896 RulesetLookup::Absent => {
897 return Ok(StepState::not(format!("no ruleset named {name}")));
898 }
899 RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
900 };
901 let rules = detail["rules"].as_array().cloned().unwrap_or_default();
902 let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
903 let mut faults = Vec::new();
904 if detail["enforcement"] != "active" {
905 faults.push(format!("{name} is not active"));
906 }
907 if detail["target"] != "branch" {
911 faults.push(format!("{name} does not target branches"));
912 }
913 let expected_ref = serde_json::json!([format!("refs/heads/{trunk}")]);
914 if detail["conditions"]["ref_name"]["include"] != expected_ref {
915 faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
916 }
917 if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
920 faults.push(format!("{name} excludes refs from its own coverage"));
921 }
922 if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
923 faults.push("a bypass actor is named".to_owned());
924 }
925 for required in &ctx.protection().owned_trunk_rules {
926 if !has(required) {
927 faults.push(format!("the {required} rule is missing"));
928 }
929 }
930 faults.extend(unowned_rule_faults(
931 &rules,
932 &ctx.protection().owned_trunk_rules,
933 ));
934 if let Some(request) = rules.iter().find(|rule| rule["type"] == "pull_request") {
935 if request["parameters"]["allowed_merge_methods"]
936 != serde_json::json!(ctx.protection().allowed_merge_methods)
937 {
938 faults.push("the merge method is not exactly a squash merge".to_owned());
939 }
940 }
941 if let Some(checks) = rules
942 .iter()
943 .find(|rule| rule["type"] == "required_status_checks")
944 {
945 if checks["parameters"]["strict_required_status_checks_policy"]
946 != ctx.protection().strict_required_status_checks
947 {
948 faults.push(STALE_MERGE_FAULT.to_owned());
949 }
950 let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
951 .as_array()
952 .map(|list| {
953 list.iter()
954 .filter_map(|check| check["context"].as_str())
955 .collect()
956 })
957 .unwrap_or_default();
958 if contexts.is_empty() {
963 faults.push("no status check is required".to_owned());
964 } else if let Some(expected) = &ctx.required_check {
965 let mut held = contexts.clone();
966 held.sort_unstable();
967 let title_check = ctx.title_check();
968 let mut owned_contexts = [expected.as_str(), title_check];
969 owned_contexts.sort_unstable();
970 if held != owned_contexts {
971 faults.push(format!(
972 "the required checks are [{}] where the setup owns [{}]",
973 contexts.join(", "),
974 owned_contexts.join(", ")
975 ));
976 }
977 } else if !contexts.contains(&ctx.title_check()) {
978 faults.push(format!("the {} check is not required", ctx.title_check()));
979 }
980 }
981 match squash_merge_sources(ctx, run)? {
982 MergeSources::Owned => {}
983 MergeSources::Faults(proven) => faults.extend(proven),
984 MergeSources::Unreadable(err) => {
988 if faults.is_empty() {
989 return Ok(StepState::unknown(err));
990 }
991 }
992 }
993 if let Some(shape) = gate_faults(ctx) {
994 faults.push(shape);
995 }
996 if !faults.is_empty() {
997 return Ok(StepState::not(faults.join("; ")));
998 }
999 Ok(StepState::ok(format!(
1000 "{name} holds the release-merge shape"
1001 )))
1002}
1003
1004fn gate_faults(ctx: &Ctx) -> Option<String> {
1014 let check = ctx.required_check.as_deref()?;
1015 workflow_jobs::faults(
1016 &workflow_jobs::read_gate(&ctx.target, check, ctx.trunk()),
1017 check,
1018 ctx.trunk(),
1019 )
1020}
1021
1022enum MergeSources {
1024 Owned,
1026 Faults(Vec<String>),
1028 Unreadable(String),
1030}
1031
1032fn squash_merge_sources(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
1039 Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
1040 Api::Ok(body) => {
1041 let mut faults = Vec::new();
1042 let owned_title = ctx.protection().github.squash_title_source.as_str();
1043 let owned_body = ctx.protection().github.squash_body_source.as_str();
1044 if body["squash_merge_commit_title"] != owned_title {
1045 faults.push(format!(
1046 "the squash title source is {} where the setup owns {owned_title}",
1047 body["squash_merge_commit_title"]
1048 ));
1049 }
1050 if body["squash_merge_commit_message"] != owned_body {
1051 faults.push(format!(
1052 "the squash message source is {} where the setup owns {owned_body}",
1053 body["squash_merge_commit_message"]
1054 ));
1055 }
1056 if faults.is_empty() {
1057 MergeSources::Owned
1058 } else {
1059 MergeSources::Faults(faults)
1060 }
1061 }
1062 Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
1063 Api::Failed(err) => MergeSources::Unreadable(err),
1064 })
1065}
1066
1067enum RulesetLookup {
1070 Found(Value),
1072 Absent,
1075 Unreadable(String),
1077}
1078
1079fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
1081 let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
1085 Api::Ok(body) => body,
1086 Api::Missing => {
1087 return Ok(RulesetLookup::Unreadable(
1088 "the ruleset inventory is not readable".into(),
1089 ));
1090 }
1091 Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
1092 };
1093 let id = list
1094 .as_array()
1095 .into_iter()
1096 .flatten()
1097 .find(|ruleset| ruleset["name"] == name)
1098 .and_then(|ruleset| ruleset["id"].as_i64());
1099 let Some(id) = id else {
1100 return Ok(RulesetLookup::Absent);
1101 };
1102 match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
1103 Api::Ok(body) => Ok(RulesetLookup::Found(body)),
1104 Api::Missing => Ok(RulesetLookup::Unreadable(format!(
1108 "the {name} detail is not readable"
1109 ))),
1110 Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
1111 }
1112}
1113
1114const 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";
1118
1119const GITLAB_TAG_LIMITATION: &str =
1121 "an Owner or Maintainer can still delete a protected tag through the UI or API";
1122
1123const 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";
1128
1129const 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";
1131
1132const 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";
1135
1136#[allow(
1137 clippy::too_many_lines,
1138 reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
1139)]
1140fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
1141 let trunk = ctx.trunk();
1142 let project = ctx.repo.replace('/', "%2F");
1143 match step {
1144 "private-vulnerability-reporting" => {
1145 let path = format!("projects/{project}");
1146 Ok(match api_get(ctx, run, &path)? {
1147 Api::Ok(body) => {
1148 let access = body["issues_access_level"].as_str();
1149 if !matches!(access, Some("enabled" | "private" | "disabled")) {
1150 StepState::unknown("issue intake access is unreadable")
1151 } else if body
1152 .get("issues_enabled")
1153 .is_some_and(|flag| !flag.is_boolean())
1154 {
1155 StepState::unknown("legacy issue intake flag is unreadable")
1156 } else if body["issues_enabled"] == false || access == Some("disabled") {
1157 StepState::not("issue intake is disabled; see setup guide step 3g")
1158 } else if access == Some("private") {
1159 StepState::not("issue intake is restricted; see setup guide step 3g")
1160 } else {
1161 StepState::ok_with_limitation(
1162 "issue intake is enabled",
1163 GITLAB_PRIVATE_REPORTING_LIMITATION,
1164 )
1165 }
1166 }
1167 Api::Missing => {
1168 StepState::unknown(format!("{path}: issue intake is unreadable (404)"))
1169 }
1170 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
1171 })
1172 }
1173
1174 "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1175 Api::Ok(body) => {
1176 let found = body["default_branch"].as_str().unwrap_or("");
1177 if found == trunk {
1178 StepState::ok(format!("{trunk} is the default branch"))
1179 } else {
1180 StepState::not(format!("the default branch is {found}"))
1181 }
1182 }
1183 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1184 Api::Failed(err) => StepState::unknown(err),
1185 }),
1186 "single-trunk" => {
1187 for candidate in ctx.retired_branches() {
1188 let candidate = candidate.as_str();
1189 if candidate == trunk {
1190 continue;
1191 }
1192 match api_get(
1193 ctx,
1194 run,
1195 &format!("projects/{project}/repository/branches/{candidate}"),
1196 )? {
1197 Api::Missing => {}
1198 Api::Ok(_) => {
1199 return Ok(StepState::not(format!("a {candidate} branch still exists")));
1200 }
1201 Api::Failed(err) => return Ok(StepState::unknown(err)),
1202 }
1203 }
1204 Ok(StepState::ok(
1205 "no long-lived branch besides the trunk remains",
1206 ))
1207 }
1208 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1209 Api::Ok(body) => {
1210 if body["remove_source_branch_after_merge"]
1211 .as_bool()
1212 .unwrap_or(false)
1213 {
1214 StepState::ok("a merged branch is deleted by the forge")
1215 } else {
1216 StepState::not("a merged branch outlives its merge")
1217 }
1218 }
1219 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1220 Api::Failed(err) => StepState::unknown(err),
1221 }),
1222 "auto-merge" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1223 Api::Ok(body) => {
1224 if body["only_allow_merge_if_pipeline_succeeds"]
1225 .as_bool()
1226 .unwrap_or(false)
1227 {
1228 StepState::ok_with_limitation(
1229 "a request may merge itself once its pipeline passes",
1230 GITLAB_AUTO_MERGE_LIMITATION,
1231 )
1232 } else {
1233 StepState::not(
1234 "the pipeline requirement auto-merge rides on is off; protect-trunk asserts it",
1235 )
1236 }
1237 }
1238 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1239 Api::Failed(err) => StepState::unknown(err),
1240 }),
1241 "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1242 Api::Ok(body) => {
1243 if body["jobs_enabled"] == true {
1244 StepState::ok("pipelines are enabled")
1245 } else {
1246 StepState::not("pipelines are disabled")
1247 }
1248 }
1249 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1250 Api::Failed(err) => StepState::unknown(err),
1251 }),
1252 "install-bot" => {
1253 let mut active = false;
1259 let mut exhausted = false;
1260 for page in 1..=10u32 {
1261 let path = format!(
1262 "projects/{project}/access_tokens?state=active&per_page=100&page={page}"
1263 );
1264 let list = match api_get(ctx, run, &path)? {
1265 Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1266 Api::Missing => Vec::new(),
1267 Api::Failed(err) => return Ok(StepState::unknown(err)),
1268 };
1269 active = active
1270 || list.iter().any(|token| {
1271 token["name"] == "release-bot"
1272 && token["revoked"] == false
1273 && token["active"] != false
1274 });
1275 if list.len() < 100 {
1276 exhausted = true;
1277 }
1278 if active || exhausted {
1279 break;
1280 }
1281 }
1282 if !active {
1283 return Ok(if exhausted {
1284 StepState::not("no active release-bot token exists")
1285 } else {
1286 StepState::unknown(
1287 "the token listing did not exhaust within ten pages; nothing was decided",
1288 )
1289 });
1290 }
1291 Ok(
1295 match api_get(
1296 ctx,
1297 run,
1298 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1299 )? {
1300 Api::Ok(_) => StepState::ok(
1301 "an active release-bot token exists and its variable is stored",
1302 ),
1303 Api::Missing => StepState::not(
1304 "an active release-bot token exists with no stored variable; a rerun revokes and replaces it",
1305 ),
1306 Api::Failed(err) => StepState::unknown(err),
1307 },
1308 )
1309 }
1310 "bot-secrets" => Ok(
1311 match api_get(
1312 ctx,
1313 run,
1314 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1315 )? {
1316 Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
1317 Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
1318 Api::Failed(err) => StepState::unknown(err),
1319 },
1320 ),
1321 "protect-trunk" => {
1322 let protection = match api_get(
1323 ctx,
1324 run,
1325 &format!("projects/{project}/protected_branches/{trunk}"),
1326 )? {
1327 Api::Ok(body) => body,
1328 Api::Missing => {
1329 return Ok(StepState::not(format!("{trunk} is not protected")));
1330 }
1331 Api::Failed(err) => return Ok(StepState::unknown(err)),
1332 };
1333 let grants = protection["push_access_levels"]
1337 .as_array()
1338 .cloned()
1339 .unwrap_or_default();
1340 let policy = ctx.protection();
1341 let no_push =
1342 grants.len() == 1 && grants[0]["access_level"] == policy.gitlab.push_access_level;
1343 let merges = protection["merge_access_levels"]
1347 .as_array()
1348 .cloned()
1349 .unwrap_or_default();
1350 let can_merge =
1351 merges.len() == 1 && merges[0]["access_level"] == policy.gitlab.merge_access_level;
1352 let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
1353 Api::Ok(body) => body,
1354 Api::Missing | Api::Failed(_) => Value::Null,
1355 };
1356 let mut faults = Vec::new();
1357 if !no_push {
1358 faults.push(format!(
1359 "{trunk} still takes a direct push: the forge honors the most permissive of {} push grants",
1360 grants.len()
1361 ));
1362 }
1363 if !can_merge {
1364 faults.push(format!(
1365 "{trunk} merge grants are not exactly the one owned maintainer level"
1366 ));
1367 }
1368 if protection["allow_force_push"] != false {
1369 faults.push(format!("{trunk} allows force pushes"));
1370 }
1371 if settings["only_allow_merge_if_pipeline_succeeds"] != true {
1372 faults.push("the pipeline requirement is off".to_owned());
1373 }
1374 if settings["merge_method"] != policy.gitlab.merge_method.as_str() {
1375 faults.push("the merge method is not fast-forward".to_owned());
1376 }
1377 if settings["squash_option"] != policy.gitlab.squash_option.as_str() {
1378 faults.push("merge requests do not always squash".to_owned());
1379 }
1380 if settings["squash_commit_template"] != policy.gitlab.squash_commit_template.as_str() {
1381 faults.push("the squash template is not the merge request's title".to_owned());
1382 }
1383 Ok(if faults.is_empty() {
1384 StepState::ok_with_limitation(
1385 format!("{trunk} holds the release-merge shape"),
1386 GITLAB_TITLE_LIMITATION,
1387 )
1388 } else {
1389 StepState::not(faults.join("; "))
1390 })
1391 }
1392 "protect-tags" => Ok(
1393 match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
1394 Api::Ok(_) => {
1395 StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
1396 }
1397 Api::Missing => StepState::not("v* is not protected"),
1398 Api::Failed(err) => StepState::unknown(err),
1399 },
1400 ),
1401 "protect-release-lines" => Ok(
1402 match api_get(
1403 ctx,
1404 run,
1405 &format!("projects/{project}/protected_branches/release%2F%2A"),
1406 )? {
1407 Api::Ok(body) => {
1408 let level_ok = |levels: &Value| {
1409 levels
1410 .as_array()
1411 .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
1412 };
1413 if body["allow_force_push"] != false {
1414 StepState::not("release/* allows force pushes")
1415 } else if !level_ok(&body["push_access_levels"])
1416 || !level_ok(&body["merge_access_levels"])
1417 {
1418 StepState::not(
1422 "release/* grants are not exactly the owned maintainer levels",
1423 )
1424 } else {
1425 StepState::ok("release/* refuses force pushes and deletion by git clients")
1426 }
1427 }
1428 Api::Missing => StepState::inapplicable(
1429 "release/* is unprotected; optional — applied only where older lines exist",
1430 ),
1431 Api::Failed(err) => StepState::unknown(err),
1432 },
1433 ),
1434 "protections-check" => {
1435 let mut failures = Vec::new();
1438 let mut unknowns = Vec::new();
1439 let mut limitations: Vec<String> = Vec::new();
1442 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
1443 match gitlab(ctx, owned, run)? {
1444 StepState::Satisfied {
1445 limitation: found, ..
1446 } => limitations.extend(found),
1447 StepState::Inapplicable { .. } => {}
1448 StepState::Unsatisfied { detail } => {
1449 failures.push(format!("{owned}: {detail}"));
1450 }
1451 StepState::Unknown { detail } => {
1452 unknowns.push(format!("{owned}: {detail}"));
1453 }
1454 }
1455 }
1456 Ok(if !failures.is_empty() {
1457 StepState::not(failures.join("; "))
1458 } else if !unknowns.is_empty() {
1459 StepState::unknown(unknowns.join("; "))
1460 } else {
1461 StepState::Satisfied {
1462 detail: "the protections hold, as far as this forge enforces them".into(),
1463 limitation: if limitations.is_empty() {
1464 None
1465 } else {
1466 Some(limitations.join("; "))
1467 },
1468 }
1469 })
1470 }
1471 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1472 }
1473}