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 && 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 if let Some(checks) = rules
941 .iter()
942 .find(|rule| rule["type"] == "required_status_checks")
943 {
944 if checks["parameters"]["strict_required_status_checks_policy"]
945 != ctx.protection().strict_required_status_checks
946 {
947 faults.push(STALE_MERGE_FAULT.to_owned());
948 }
949 let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
950 .as_array()
951 .map(|list| {
952 list.iter()
953 .filter_map(|check| check["context"].as_str())
954 .collect()
955 })
956 .unwrap_or_default();
957 if contexts.is_empty() {
962 faults.push("no status check is required".to_owned());
963 } else if let Some(expected) = &ctx.required_check {
964 let mut held = contexts.clone();
965 held.sort_unstable();
966 let title_check = ctx.title_check();
967 let mut owned_contexts = [expected.as_str(), title_check];
968 owned_contexts.sort_unstable();
969 if held != owned_contexts {
970 faults.push(format!(
971 "the required checks are [{}] where the setup owns [{}]",
972 contexts.join(", "),
973 owned_contexts.join(", ")
974 ));
975 }
976 } else if !contexts.contains(&ctx.title_check()) {
977 faults.push(format!("the {} check is not required", ctx.title_check()));
978 }
979 }
980 match squash_merge_sources(ctx, run)? {
981 MergeSources::Owned => {}
982 MergeSources::Faults(proven) => faults.extend(proven),
983 MergeSources::Unreadable(err) => {
987 if faults.is_empty() {
988 return Ok(StepState::unknown(err));
989 }
990 }
991 }
992 if let Some(shape) = gate_faults(ctx) {
993 faults.push(shape);
994 }
995 if !faults.is_empty() {
996 return Ok(StepState::not(faults.join("; ")));
997 }
998 Ok(StepState::ok(format!(
999 "{name} holds the release-merge shape"
1000 )))
1001}
1002
1003fn gate_faults(ctx: &Ctx) -> Option<String> {
1013 let check = ctx.required_check.as_deref()?;
1014 workflow_jobs::faults(
1015 &workflow_jobs::read_gate(&ctx.target, check, ctx.trunk()),
1016 check,
1017 ctx.trunk(),
1018 )
1019}
1020
1021enum MergeSources {
1023 Owned,
1025 Faults(Vec<String>),
1027 Unreadable(String),
1029}
1030
1031fn squash_merge_sources(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
1038 Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
1039 Api::Ok(body) => {
1040 let mut faults = Vec::new();
1041 let owned_title = ctx.protection().github.squash_title_source.as_str();
1042 let owned_body = ctx.protection().github.squash_body_source.as_str();
1043 if body["squash_merge_commit_title"] != owned_title {
1044 faults.push(format!(
1045 "the squash title source is {} where the setup owns {owned_title}",
1046 body["squash_merge_commit_title"]
1047 ));
1048 }
1049 if body["squash_merge_commit_message"] != owned_body {
1050 faults.push(format!(
1051 "the squash message source is {} where the setup owns {owned_body}",
1052 body["squash_merge_commit_message"]
1053 ));
1054 }
1055 if faults.is_empty() {
1056 MergeSources::Owned
1057 } else {
1058 MergeSources::Faults(faults)
1059 }
1060 }
1061 Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
1062 Api::Failed(err) => MergeSources::Unreadable(err),
1063 })
1064}
1065
1066enum RulesetLookup {
1069 Found(Value),
1071 Absent,
1074 Unreadable(String),
1076}
1077
1078fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
1080 let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
1084 Api::Ok(body) => body,
1085 Api::Missing => {
1086 return Ok(RulesetLookup::Unreadable(
1087 "the ruleset inventory is not readable".into(),
1088 ));
1089 }
1090 Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
1091 };
1092 let id = list
1093 .as_array()
1094 .into_iter()
1095 .flatten()
1096 .find(|ruleset| ruleset["name"] == name)
1097 .and_then(|ruleset| ruleset["id"].as_i64());
1098 let Some(id) = id else {
1099 return Ok(RulesetLookup::Absent);
1100 };
1101 match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
1102 Api::Ok(body) => Ok(RulesetLookup::Found(body)),
1103 Api::Missing => Ok(RulesetLookup::Unreadable(format!(
1107 "the {name} detail is not readable"
1108 ))),
1109 Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
1110 }
1111}
1112
1113const 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";
1117
1118const GITLAB_TAG_LIMITATION: &str =
1120 "an Owner or Maintainer can still delete a protected tag through the UI or API";
1121
1122const 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";
1127
1128const 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";
1130
1131const 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";
1134
1135#[allow(
1136 clippy::too_many_lines,
1137 reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
1138)]
1139fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
1140 let trunk = ctx.trunk();
1141 let project = ctx.repo.replace('/', "%2F");
1142 match step {
1143 "private-vulnerability-reporting" => {
1144 let path = format!("projects/{project}");
1145 Ok(match api_get(ctx, run, &path)? {
1146 Api::Ok(body) => {
1147 let access = body["issues_access_level"].as_str();
1148 if !matches!(access, Some("enabled" | "private" | "disabled")) {
1149 StepState::unknown("issue intake access is unreadable")
1150 } else if body
1151 .get("issues_enabled")
1152 .is_some_and(|flag| !flag.is_boolean())
1153 {
1154 StepState::unknown("legacy issue intake flag is unreadable")
1155 } else if body["issues_enabled"] == false || access == Some("disabled") {
1156 StepState::not("issue intake is disabled; see setup guide step 3g")
1157 } else if access == Some("private") {
1158 StepState::not("issue intake is restricted; see setup guide step 3g")
1159 } else {
1160 StepState::ok_with_limitation(
1161 "issue intake is enabled",
1162 GITLAB_PRIVATE_REPORTING_LIMITATION,
1163 )
1164 }
1165 }
1166 Api::Missing => {
1167 StepState::unknown(format!("{path}: issue intake is unreadable (404)"))
1168 }
1169 Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
1170 })
1171 }
1172
1173 "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1174 Api::Ok(body) => {
1175 let found = body["default_branch"].as_str().unwrap_or("");
1176 if found == trunk {
1177 StepState::ok(format!("{trunk} is the default branch"))
1178 } else {
1179 StepState::not(format!("the default branch is {found}"))
1180 }
1181 }
1182 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1183 Api::Failed(err) => StepState::unknown(err),
1184 }),
1185 "single-trunk" => {
1186 for candidate in ctx.retired_branches() {
1187 let candidate = candidate.as_str();
1188 if candidate == trunk {
1189 continue;
1190 }
1191 match api_get(
1192 ctx,
1193 run,
1194 &format!("projects/{project}/repository/branches/{candidate}"),
1195 )? {
1196 Api::Missing => {}
1197 Api::Ok(_) => {
1198 return Ok(StepState::not(format!("a {candidate} branch still exists")));
1199 }
1200 Api::Failed(err) => return Ok(StepState::unknown(err)),
1201 }
1202 }
1203 Ok(StepState::ok(
1204 "no long-lived branch besides the trunk remains",
1205 ))
1206 }
1207 "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1208 Api::Ok(body) => {
1209 if body["remove_source_branch_after_merge"]
1210 .as_bool()
1211 .unwrap_or(false)
1212 {
1213 StepState::ok("a merged branch is deleted by the forge")
1214 } else {
1215 StepState::not("a merged branch outlives its merge")
1216 }
1217 }
1218 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1219 Api::Failed(err) => StepState::unknown(err),
1220 }),
1221 "auto-merge" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1222 Api::Ok(body) => {
1223 if body["only_allow_merge_if_pipeline_succeeds"]
1224 .as_bool()
1225 .unwrap_or(false)
1226 {
1227 StepState::ok_with_limitation(
1228 "a request may merge itself once its pipeline passes",
1229 GITLAB_AUTO_MERGE_LIMITATION,
1230 )
1231 } else {
1232 StepState::not(
1233 "the pipeline requirement auto-merge rides on is off; protect-trunk asserts it",
1234 )
1235 }
1236 }
1237 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1238 Api::Failed(err) => StepState::unknown(err),
1239 }),
1240 "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1241 Api::Ok(body) => {
1242 if body["jobs_enabled"] == true {
1243 StepState::ok("pipelines are enabled")
1244 } else {
1245 StepState::not("pipelines are disabled")
1246 }
1247 }
1248 Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1249 Api::Failed(err) => StepState::unknown(err),
1250 }),
1251 "install-bot" => {
1252 let mut active = false;
1258 let mut exhausted = false;
1259 for page in 1..=10u32 {
1260 let path = format!(
1261 "projects/{project}/access_tokens?state=active&per_page=100&page={page}"
1262 );
1263 let list = match api_get(ctx, run, &path)? {
1264 Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1265 Api::Missing => Vec::new(),
1266 Api::Failed(err) => return Ok(StepState::unknown(err)),
1267 };
1268 active = active
1269 || list.iter().any(|token| {
1270 token["name"] == "release-bot"
1271 && token["revoked"] == false
1272 && token["active"] != false
1273 });
1274 if list.len() < 100 {
1275 exhausted = true;
1276 }
1277 if active || exhausted {
1278 break;
1279 }
1280 }
1281 if !active {
1282 return Ok(if exhausted {
1283 StepState::not("no active release-bot token exists")
1284 } else {
1285 StepState::unknown(
1286 "the token listing did not exhaust within ten pages; nothing was decided",
1287 )
1288 });
1289 }
1290 Ok(
1294 match api_get(
1295 ctx,
1296 run,
1297 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1298 )? {
1299 Api::Ok(_) => StepState::ok(
1300 "an active release-bot token exists and its variable is stored",
1301 ),
1302 Api::Missing => StepState::not(
1303 "an active release-bot token exists with no stored variable; a rerun revokes and replaces it",
1304 ),
1305 Api::Failed(err) => StepState::unknown(err),
1306 },
1307 )
1308 }
1309 "bot-secrets" => Ok(
1310 match api_get(
1311 ctx,
1312 run,
1313 &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1314 )? {
1315 Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
1316 Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
1317 Api::Failed(err) => StepState::unknown(err),
1318 },
1319 ),
1320 "protect-trunk" => {
1321 let protection = match api_get(
1322 ctx,
1323 run,
1324 &format!("projects/{project}/protected_branches/{trunk}"),
1325 )? {
1326 Api::Ok(body) => body,
1327 Api::Missing => {
1328 return Ok(StepState::not(format!("{trunk} is not protected")));
1329 }
1330 Api::Failed(err) => return Ok(StepState::unknown(err)),
1331 };
1332 let grants = protection["push_access_levels"]
1336 .as_array()
1337 .cloned()
1338 .unwrap_or_default();
1339 let policy = ctx.protection();
1340 let no_push =
1341 grants.len() == 1 && grants[0]["access_level"] == policy.gitlab.push_access_level;
1342 let merges = protection["merge_access_levels"]
1346 .as_array()
1347 .cloned()
1348 .unwrap_or_default();
1349 let can_merge =
1350 merges.len() == 1 && merges[0]["access_level"] == policy.gitlab.merge_access_level;
1351 let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
1352 Api::Ok(body) => body,
1353 Api::Missing | Api::Failed(_) => Value::Null,
1354 };
1355 let mut faults = Vec::new();
1356 if !no_push {
1357 faults.push(format!(
1358 "{trunk} still takes a direct push: the forge honors the most permissive of {} push grants",
1359 grants.len()
1360 ));
1361 }
1362 if !can_merge {
1363 faults.push(format!(
1364 "{trunk} merge grants are not exactly the one owned maintainer level"
1365 ));
1366 }
1367 if protection["allow_force_push"] != false {
1368 faults.push(format!("{trunk} allows force pushes"));
1369 }
1370 if settings["only_allow_merge_if_pipeline_succeeds"] != true {
1371 faults.push("the pipeline requirement is off".to_owned());
1372 }
1373 if settings["merge_method"] != policy.gitlab.merge_method.as_str() {
1374 faults.push("the merge method is not fast-forward".to_owned());
1375 }
1376 if settings["squash_option"] != policy.gitlab.squash_option.as_str() {
1377 faults.push("merge requests do not always squash".to_owned());
1378 }
1379 if settings["squash_commit_template"] != policy.gitlab.squash_commit_template.as_str() {
1380 faults.push("the squash template is not the merge request's title".to_owned());
1381 }
1382 Ok(if faults.is_empty() {
1383 StepState::ok_with_limitation(
1384 format!("{trunk} holds the release-merge shape"),
1385 GITLAB_TITLE_LIMITATION,
1386 )
1387 } else {
1388 StepState::not(faults.join("; "))
1389 })
1390 }
1391 "protect-tags" => Ok(
1392 match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
1393 Api::Ok(_) => {
1394 StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
1395 }
1396 Api::Missing => StepState::not("v* is not protected"),
1397 Api::Failed(err) => StepState::unknown(err),
1398 },
1399 ),
1400 "protect-release-lines" => Ok(
1401 match api_get(
1402 ctx,
1403 run,
1404 &format!("projects/{project}/protected_branches/release%2F%2A"),
1405 )? {
1406 Api::Ok(body) => {
1407 let level_ok = |levels: &Value| {
1408 levels
1409 .as_array()
1410 .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
1411 };
1412 if body["allow_force_push"] != false {
1413 StepState::not("release/* allows force pushes")
1414 } else if !level_ok(&body["push_access_levels"])
1415 || !level_ok(&body["merge_access_levels"])
1416 {
1417 StepState::not(
1421 "release/* grants are not exactly the owned maintainer levels",
1422 )
1423 } else {
1424 StepState::ok("release/* refuses force pushes and deletion by git clients")
1425 }
1426 }
1427 Api::Missing => StepState::inapplicable(
1428 "release/* is unprotected; optional — applied only where older lines exist",
1429 ),
1430 Api::Failed(err) => StepState::unknown(err),
1431 },
1432 ),
1433 "protections-check" => {
1434 let mut failures = Vec::new();
1437 let mut unknowns = Vec::new();
1438 let mut limitations: Vec<String> = Vec::new();
1441 for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
1442 match gitlab(ctx, owned, run)? {
1443 StepState::Satisfied {
1444 limitation: found, ..
1445 } => limitations.extend(found),
1446 StepState::Inapplicable { .. } => {}
1447 StepState::Unsatisfied { detail } => {
1448 failures.push(format!("{owned}: {detail}"));
1449 }
1450 StepState::Unknown { detail } => {
1451 unknowns.push(format!("{owned}: {detail}"));
1452 }
1453 }
1454 }
1455 Ok(if !failures.is_empty() {
1456 StepState::not(failures.join("; "))
1457 } else if !unknowns.is_empty() {
1458 StepState::unknown(unknowns.join("; "))
1459 } else {
1460 StepState::Satisfied {
1461 detail: "the protections hold, as far as this forge enforces them".into(),
1462 limitation: if limitations.is_empty() {
1463 None
1464 } else {
1465 Some(limitations.join("; "))
1466 },
1467 }
1468 })
1469 }
1470 _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1471 }
1472}