Skip to main content

release_kit/setup/
observe.rs

1//! The observe-and-verify half of every step's lifecycle.
2//!
3//! One implementation per forge and step, called by preview never, by apply
4//! before and after the mutation, and by `check` as its whole job — so the
5//! three modes cannot drift apart, and the mutating half is unreachable from
6//! here by construction: nothing spawned from this module mutates anything —
7//! read-only forge-CLI calls, the technology's own dry-run check, and the
8//! App-credential read [`super::app_jwt`] carries for `install-bot`.
9
10use 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
19/// The executor observes run through: the command layer wraps echoing,
20/// journaling, and redaction around the process adapter.
21pub type Runner<'a> = dyn FnMut(&Exec) -> Result<Outcome, RkError> + 'a;
22
23// The long-lived branch names `single-trunk` retires when each is an
24// ancestor of the trunk come from the target's own configuration, read
25// through `Ctx::retired_branches`. The compiled default is the common
26// default branch and the retired second branch, so a target that names
27// none behaves exactly as it did.
28
29// The landed title check's context is the job in `pr-title.yml` that
30// holds the squash title to the commit convention. The target names it in
31// `protection.title_check`, read through `Ctx::title_check`; the landed
32// job keeps its own name as a source constant, so a target that renames
33// the key without renaming the job breaks its own trunk protection and
34// this observer reports it.
35
36const 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/// What one observation found.
39#[derive(Debug)]
40pub enum StepState {
41    /// The desired state holds; a limitation names what the forge enforces
42    /// less strongly than the step's proof claims.
43    Satisfied {
44        /// What was found, one line.
45        detail: String,
46        /// The weaker guarantee, by name, where the forge enforces less.
47        limitation: Option<String>,
48    },
49    /// The desired state does not hold.
50    Unsatisfied {
51        /// What was found instead.
52        detail: String,
53    },
54    /// Eligibility or an optional step's condition does not hold: nothing
55    /// is proven, and `check` reports it as skipped.
56    Inapplicable {
57        /// Why the step does not apply here.
58        detail: String,
59    },
60    /// The observation could not decide.
61    Unknown {
62        /// Why not.
63        detail: String,
64    },
65}
66
67impl StepState {
68    /// Whether the desired state holds.
69    #[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
107/// One read-only forge API answer.
108enum Api {
109    /// The call succeeded and parsed.
110    Ok(Value),
111    /// The forge answered 404: the thing is not there.
112    Missing,
113    /// The call failed for another reason, with the CLI's own words.
114    Failed(String),
115}
116
117/// Observe one step's desired state.
118///
119/// # Errors
120///
121/// Propagates executor failures; a forge answer that merely disagrees is a
122/// [`StepState`], not an error.
123pub 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        Some(Forge::Github) => github(ctx, step, run),
135        Some(Forge::Gitlab) => gitlab(ctx, step, run),
136        // A forge step at a target with no adapter proves nothing and
137        // reads nothing: the applicability gate states why, and this is
138        // the observation saying the same.
139        None => Ok(StepState::inapplicable(
140            "the profile names no forge this release drives",
141        )),
142    }
143}
144
145/// The policy a consumer must find in the artifact they downloaded. A
146/// reporting policy readable on the forge alone is a policy the consumer
147/// who has only the package cannot follow.
148const POLICY_DESTINATION: &str = "SECURITY.md";
149
150/// What Python's check cannot answer. PEP 517 lets a project choose its
151/// build backend, and an sdist and a wheel can carry different files, so
152/// one `python3 -m build` run supplies no listing contract across both
153/// outputs.
154const 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";
155
156/// What Bash's check cannot answer. Its binding builds the tarball with
157/// `git archive`, where an `export-ignore` attribute drops a tracked file.
158const 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";
159
160/// §0: the technology's own no-credential packaging check; the one step that
161/// reads its command from the binding rather than from a forge tree.
162///
163/// Publishability is the whole of the check for every binding. Policy reach
164/// is asserted only where the binding has a deterministic listing command
165/// the step can run with no credentials, which today is a sole Cargo
166/// package rooted at the target; every other shape reports its successful
167/// packaging result with the unproved inclusion named.
168fn package_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
169    let (program, args): (&str, &[&str]) = match ctx.tech {
170        Some("rust") => ("cargo", &["publish", "--dry-run", "--allow-dirty"]),
171        Some("python") => ("python3", &["-m", "build"]),
172        Some("bash") => {
173            return Ok(StepState::ok_with_limitation(
174                "no registry for this technology; there is nothing to package",
175                BASH_LIMITATION,
176            ));
177        }
178        Some(other) => {
179            return Ok(StepState::unknown(format!(
180                "no packaging check is defined for {other}"
181            )));
182        }
183        None => {
184            return Ok(StepState::unknown(
185                "no version file names a technology; see rk binding --list",
186            ));
187        }
188    };
189    let outcome = run(&cargo_exec(ctx, program, args))?;
190    if !outcome.success() {
191        return Ok(StepState::not(format!(
192            "the packaging check failed: {}",
193            last_line(&outcome.stderr)
194        )));
195    }
196    let built = "the package builds and passes the registry's dry run";
197    Ok(match ctx.tech {
198        Some("rust") => policy_in_the_crate(ctx, run, built)?,
199        _ => StepState::ok_with_limitation(built, PYTHON_LIMITATION),
200    })
201}
202
203/// One no-credential Cargo invocation against the target.
204fn cargo_exec(ctx: &Ctx, program: &str, args: &[&str]) -> Exec {
205    Exec {
206        program: program.into(),
207        args: args.iter().map(Into::into).collect(),
208        env: ctx.child_env("package-check"),
209        cwd: ctx.target.as_std_path().to_path_buf(),
210        stdin: None,
211    }
212}
213
214/// Whether the published crate carries the root policy, for the one shape
215/// Cargo answers unambiguously.
216///
217/// `cargo package --list` prints one path per line for one package and
218/// emits no stable delimiter when it selects several, and a nested package
219/// cannot include a file above its own root. So the listing runs only for a
220/// sole selected default member whose manifest is the target's own
221/// `Cargo.toml`; a virtual workspace, several default members, and a sole
222/// nested member each keep the successful publishability result and name
223/// the limitation instead of claiming a reach they cannot prove.
224fn policy_in_the_crate(ctx: &Ctx, run: &mut Runner, built: &str) -> Result<StepState, RkError> {
225    let metadata = run(&cargo_exec(
226        ctx,
227        "cargo",
228        &["metadata", "--no-deps", "--format-version", "1"],
229    ))?;
230    if !metadata.success() {
231        return Ok(StepState::unknown(format!(
232            "{built}, and the policy check could not run: cargo metadata failed: {}",
233            last_line(&metadata.stderr)
234        )));
235    }
236    let root_manifest = ctx.target.as_std_path().join("Cargo.toml");
237    let selected = sole_root_package(&metadata.stdout, &root_manifest);
238    let Some(manifest) = selected else {
239        return Ok(StepState::ok_with_limitation(
240            built,
241            format!(
242                "{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"
243            ),
244        ));
245    };
246    let listing = run(&cargo_exec(
247        ctx,
248        "cargo",
249        &[
250            "package",
251            "--list",
252            "--allow-dirty",
253            "--manifest-path",
254            &manifest,
255        ],
256    ))?;
257    if !listing.success() {
258        return Ok(StepState::unknown(format!(
259            "{built}, and the policy check could not run: cargo package --list failed: {}",
260            last_line(&listing.stderr)
261        )));
262    }
263    // An exact line, never a substring: `docs/SECURITY.md` and
264    // `SECURITY.md.bak` are different files and neither is the policy.
265    let carried = String::from_utf8_lossy(&listing.stdout)
266        .lines()
267        .any(|line| line.trim() == POLICY_DESTINATION);
268    Ok(if carried {
269        StepState::ok(format!(
270            "{built}, and the published package carries {POLICY_DESTINATION}"
271        ))
272    } else {
273        StepState::not(format!(
274            "{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"
275        ))
276    })
277}
278
279/// The manifest path of the one selected default package rooted at the
280/// target, or `None` for every other workspace shape.
281fn sole_root_package(metadata: &[u8], root_manifest: &std::path::Path) -> Option<String> {
282    let document: Value = serde_json::from_slice(metadata).ok()?;
283    let defaults: Vec<&str> = document
284        .get("workspace_default_members")?
285        .as_array()?
286        .iter()
287        .filter_map(Value::as_str)
288        .collect();
289    let [only] = defaults.as_slice() else {
290        return None;
291    };
292    let manifest = document
293        .get("packages")?
294        .as_array()?
295        .iter()
296        .find(|package| package.get("id").and_then(Value::as_str) == Some(*only))?
297        .get("manifest_path")?
298        .as_str()?;
299    // Compare what each path resolves to, so a symlinked or
300    // differently-spelled target directory still reads as the root.
301    let same =
302        std::fs::canonicalize(manifest).ok()? == std::fs::canonicalize(root_manifest).ok()?;
303    same.then(|| manifest.to_owned())
304}
305
306/// §1: the post-merge reminder hook, judged from the target's own files;
307/// the one step whose observation asks no forge and spawns no CLI.
308fn branch_reminder_state(ctx: &Ctx) -> StepState {
309    use crate::setup::branch_reminder::{HookState, observe_hook};
310    match observe_hook(&ctx.target) {
311        HookState::Installed => {
312            StepState::ok("the post-merge hook carries the release-kit reminder")
313        }
314        HookState::Absent => StepState::not("no post-merge hook is installed"),
315        HookState::Foreign => {
316            StepState::not("a post-merge hook exists without the release-kit marker")
317        }
318        HookState::Drifted => StepState::not("the reminder hook drifted from this binary's body"),
319        HookState::Unreadable(detail) => StepState::unknown(detail),
320    }
321}
322
323/// The GitLab version this convention needs, as major and minor.
324///
325/// `trigger: strategy: mirror` arrived in GitLab 18.2, and the merge-request
326/// pipeline's `project-jobs` bridge rests on it: below the floor the child
327/// pipeline's status never reaches the parent, so a failing project job
328/// merges.
329pub const GITLAB_VERSION_FLOOR: (u64, u64) = (18, 2);
330
331/// The two suffixes that name an edition rather than a pre-release. Every
332/// other suffix is a pre-release, and the step fails closed on one.
333const GITLAB_EDITIONS: [&str; 2] = ["ee", "ce"];
334
335/// The refusal an instance below the floor reads: the reading, the reason,
336/// and the fix.
337fn version_refusal(found: &str, prerelease: Option<&str>) -> String {
338    let (major, minor) = GITLAB_VERSION_FLOOR;
339    let mut said = vec![format!(
340        "this GitLab instance reports {found}; the convention needs {major}.{minor} or newer"
341    )];
342    if let Some(suffix) = prerelease {
343        said.push(format!(
344            "the -{suffix} suffix is a pre-release, and nothing proves the feature shipped in it, so this step fails closed"
345        ));
346    }
347    said.push(format!(
348        "the merge-request pipeline triggers a child pipeline with `strategy: mirror`, which GitLab added in {major}.{minor}"
349    ));
350    said.push(
351        "below it the child's status never reaches the parent pipeline, so a failing project job merges".to_owned(),
352    );
353    said.push(format!(
354        "upgrade the instance to {major}.{minor} or newer, or host the project on gitlab.com"
355    ));
356    said.join("; ")
357}
358
359/// §3: the forge's own version against the convention's floor.
360///
361/// GitHub is a rolling service and is answered without a call. GitLab is one
362/// read-only `GET /version`, and every failure to read is `Unknown`, which
363/// blocks the `protect-trunk` prerequisite exactly as `Unsatisfied` does.
364fn forge_version(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
365    if ctx.forge == Some(Forge::Github) {
366        return Ok(StepState::ok(
367            "github.com is a rolling service and declares no version floor",
368        ));
369    }
370    let body = match api_get(ctx, run, "version")? {
371        Api::Ok(body) => body,
372        Api::Missing => {
373            return Ok(StepState::unknown(
374                "this instance answers no GET /version; the floor cannot be read. Check that glab is authenticated against it: glab auth login",
375            ));
376        }
377        Api::Failed(err) => {
378            return Ok(StepState::unknown(format!(
379                "the version could not be read: {err}. Check that glab is authenticated against this instance: glab auth login"
380            )));
381        }
382    };
383    let Some(found) = body["version"].as_str() else {
384        return Ok(StepState::unknown(
385            "the forge answer carries no version field; the floor cannot be read. Check that glab is authenticated against this instance: glab auth login",
386        ));
387    };
388    let (number, suffix) = found
389        .split_once('-')
390        .map_or((found, None), |(n, s)| (n, Some(s)));
391    let mut parts = number.split('.');
392    let parsed = parts
393        .next()
394        .and_then(|major| major.parse::<u64>().ok())
395        .zip(parts.next().and_then(|minor| minor.parse::<u64>().ok()));
396    let Some(pair) = parsed else {
397        return Ok(StepState::unknown(format!(
398            "the forge reports the version as '{found}', which names no major and minor pair; the floor cannot be read"
399        )));
400    };
401    if let Some(suffix) = suffix.filter(|s| !GITLAB_EDITIONS.contains(s)) {
402        return Ok(StepState::not(version_refusal(found, Some(suffix))));
403    }
404    if pair < GITLAB_VERSION_FLOOR {
405        return Ok(StepState::not(version_refusal(found, None)));
406    }
407    let (major, minor) = GITLAB_VERSION_FLOOR;
408    Ok(StepState::ok(format!(
409        "this instance reports {found}, at or above the {major}.{minor} floor"
410    )))
411}
412
413/// The destructive step's own guard: whether deleting a candidate branch
414/// can lose work.
415///
416/// `Satisfied` means every candidate is already gone or is an ancestor of
417/// the trunk; `Unsatisfied` means the deletion must refuse.
418///
419/// # Errors
420///
421/// Propagates executor failures.
422pub fn single_trunk_guard(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
423    let trunk = ctx.trunk();
424    for candidate in ctx.retired_branches() {
425        let candidate = candidate.as_str();
426        if candidate == trunk {
427            continue;
428        }
429        let state = match ctx.forge {
430            Some(Forge::Github) => github_candidate_guard(ctx, run, candidate)?,
431            Some(Forge::Gitlab) => gitlab_candidate_guard(ctx, run, candidate)?,
432            // A destructive step fails closed, and an absent adapter is
433            // one more thing the guard cannot establish.
434            None => StepState::unknown("the profile names no forge this release drives"),
435        };
436        if !state.satisfied() {
437            return Ok(state);
438        }
439    }
440    Ok(StepState::ok(
441        "every candidate branch is absent, or an ancestor of the trunk",
442    ))
443}
444
445/// One candidate branch's ancestry, on GitHub.
446fn github_candidate_guard(
447    ctx: &Ctx,
448    run: &mut Runner,
449    candidate: &str,
450) -> Result<StepState, RkError> {
451    let trunk = ctx.trunk();
452    match api_get(
453        ctx,
454        run,
455        &format!("repos/{}/git/ref/heads/{candidate}", ctx.repo),
456    )? {
457        Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
458        Api::Failed(err) => return Ok(StepState::unknown(err)),
459        Api::Ok(_) => {}
460    }
461    match api_get(
462        ctx,
463        run,
464        &format!("repos/{}/compare/{candidate}...{trunk}", ctx.repo),
465    )? {
466        Api::Ok(body) => {
467            let status = body["status"].as_str().unwrap_or("");
468            Ok(if matches!(status, "ahead" | "identical") {
469                StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
470            } else {
471                StepState::not(format!(
472                    "{candidate} is not an ancestor of {trunk} ({status}); deleting it would lose work"
473                ))
474            })
475        }
476        Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
477        Api::Failed(err) => Ok(StepState::unknown(err)),
478    }
479}
480
481/// One candidate branch's ancestry, on GitLab.
482fn gitlab_candidate_guard(
483    ctx: &Ctx,
484    run: &mut Runner,
485    candidate: &str,
486) -> Result<StepState, RkError> {
487    let trunk = ctx.trunk();
488    let project = ctx.repo.replace('/', "%2F");
489    match api_get(
490        ctx,
491        run,
492        &format!("projects/{project}/repository/branches/{candidate}"),
493    )? {
494        Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
495        Api::Failed(err) => return Ok(StepState::unknown(err)),
496        Api::Ok(_) => {}
497    }
498    match api_get(
499        ctx,
500        run,
501        &format!("projects/{project}/repository/compare?from={trunk}&to={candidate}"),
502    )? {
503        Api::Ok(body) => {
504            let ahead = body["commits"]
505                .as_array()
506                .is_some_and(|list| !list.is_empty());
507            Ok(if ahead {
508                StepState::not(format!(
509                    "{candidate} carries commits {trunk} does not; deleting it would lose work"
510                ))
511            } else {
512                StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
513            })
514        }
515        Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
516        Api::Failed(err) => Ok(StepState::unknown(err)),
517    }
518}
519
520/// One captured, read-only forge API call.
521fn api_get(ctx: &Ctx, run: &mut Runner, path: &str) -> Result<Api, RkError> {
522    let exec = Exec {
523        program: ctx.cli.clone().into_os_string(),
524        args: vec!["api".into(), path.into()],
525        env: ctx.child_env("observe"),
526        cwd: ctx.target.as_std_path().to_path_buf(),
527        stdin: None,
528    };
529    let outcome = run(&exec)?;
530    if outcome.success() {
531        return Ok(
532            serde_json::from_slice::<Value>(&outcome.stdout).map_or_else(
533                |_| Api::Failed("the forge answer did not parse as JSON".into()),
534                Api::Ok,
535            ),
536        );
537    }
538    let stderr = String::from_utf8_lossy(&outcome.stderr).into_owned();
539    if stderr.contains("404") {
540        Ok(Api::Missing)
541    } else {
542        Ok(Api::Failed(last_line(&outcome.stderr)))
543    }
544}
545
546/// The last non-empty line of a byte stream, for one-line detail fields.
547fn last_line(bytes: &[u8]) -> String {
548    String::from_utf8_lossy(bytes)
549        .lines()
550        .rev()
551        .find(|line| !line.trim().is_empty())
552        .unwrap_or("no output")
553        .to_owned()
554}
555
556#[allow(
557    clippy::too_many_lines,
558    reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
559)]
560fn github(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
561    let trunk = ctx.trunk();
562    let repo = &ctx.repo;
563    match step {
564        "private-vulnerability-reporting" => {
565            let visibility_path = format!("repos/{repo}");
566            match api_get(ctx, run, &visibility_path)? {
567                Api::Ok(body) => match body["private"].as_bool() {
568                    Some(true) => {
569                        return Ok(StepState::inapplicable(
570                            "private vulnerability reporting is available for public repositories",
571                        ));
572                    }
573                    Some(false) => {}
574                    None => {
575                        return Ok(StepState::unknown(format!(
576                            "{visibility_path}: repository visibility is unreadable"
577                        )));
578                    }
579                },
580                Api::Missing => {
581                    return Ok(StepState::unknown(format!(
582                        "{visibility_path}: repository visibility is unreadable (404)"
583                    )));
584                }
585                Api::Failed(err) => {
586                    return Ok(StepState::unknown(format!("{visibility_path}: {err}")));
587                }
588            }
589            let path = format!("repos/{repo}/private-vulnerability-reporting");
590            Ok(match api_get(ctx, run, &path)? {
591                Api::Ok(body) => match body["enabled"].as_bool() {
592                    Some(true) => StepState::ok("private vulnerability reporting is enabled"),
593                    Some(false) => StepState::not("private vulnerability reporting is disabled"),
594                    None => StepState::unknown(format!("{path}: enabled is unreadable")),
595                },
596                Api::Missing => {
597                    StepState::unknown(format!("{path}: reporting state is unreadable (404)"))
598                }
599                Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
600            })
601        }
602
603        "default-branch" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
604            Api::Ok(body) => {
605                let found = body["default_branch"].as_str().unwrap_or("");
606                if found == trunk {
607                    StepState::ok(format!("{trunk} is the default branch"))
608                } else {
609                    StepState::not(format!("the default branch is {found}"))
610                }
611            }
612            Api::Missing => StepState::not(format!("the forge does not know {repo}")),
613            Api::Failed(err) => StepState::unknown(err),
614        }),
615        "single-trunk" => {
616            for candidate in ctx.retired_branches() {
617                let candidate = candidate.as_str();
618                if candidate == trunk {
619                    continue;
620                }
621                match api_get(ctx, run, &format!("repos/{repo}/git/ref/heads/{candidate}"))? {
622                    Api::Missing => {}
623                    Api::Ok(_) => {
624                        return Ok(StepState::not(format!("a {candidate} branch still exists")));
625                    }
626                    Api::Failed(err) => return Ok(StepState::unknown(err)),
627                }
628            }
629            Ok(StepState::ok(
630                "no long-lived branch besides the trunk remains",
631            ))
632        }
633        "merge-cleanup" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
634            Api::Ok(body) => {
635                if body["delete_branch_on_merge"].as_bool().unwrap_or(false) {
636                    StepState::ok("a merged branch is deleted by the forge")
637                } else {
638                    StepState::not("a merged branch outlives its merge")
639                }
640            }
641            Api::Missing => StepState::not(format!("the forge does not know {repo}")),
642            Api::Failed(err) => StepState::unknown(err),
643        }),
644        "auto-merge" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
645            Api::Ok(body) => {
646                if body["allow_auto_merge"].as_bool().unwrap_or(false) {
647                    StepState::ok("a request may merge itself once its checks pass")
648                } else {
649                    StepState::not("a request cannot merge itself; the auto-merge switch is off")
650                }
651            }
652            Api::Missing => StepState::not(format!("the forge does not know {repo}")),
653            Api::Failed(err) => StepState::unknown(err),
654        }),
655        "ci-permissions" => Ok(
656            match api_get(
657                ctx,
658                run,
659                &format!("repos/{repo}/actions/permissions/workflow"),
660            )? {
661                Api::Ok(body) => {
662                    let write = body["default_workflow_permissions"] == "write";
663                    let approve = body["can_approve_pull_request_reviews"] == true;
664                    if write && approve {
665                        StepState::ok("CI may write and open requests")
666                    } else {
667                        StepState::not(format!(
668                            "workflow permissions are {} with request approval {}",
669                            body["default_workflow_permissions"],
670                            body["can_approve_pull_request_reviews"]
671                        ))
672                    }
673                }
674                Api::Missing => StepState::not("no workflow permissions are readable"),
675                Api::Failed(err) => StepState::unknown(err),
676            },
677        ),
678        "bot-secrets" => Ok(
679            match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
680                Api::Ok(body) => {
681                    let names: Vec<&str> = body["secrets"]
682                        .as_array()
683                        .map(|list| {
684                            list.iter()
685                                .filter_map(|secret| secret["name"].as_str())
686                                .collect()
687                        })
688                        .unwrap_or_default();
689                    let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
690                    if wanted.iter().all(|name| names.contains(name)) {
691                        StepState::ok("both bot secrets are stored")
692                    } else if names.is_empty() {
693                        StepState::not("no bot secrets are stored")
694                    } else {
695                        StepState::not(format!("stored secrets: {}", names.join(", ")))
696                    }
697                }
698                Api::Missing => StepState::not("no secrets are readable"),
699                Api::Failed(err) => StepState::unknown(err),
700            },
701        ),
702        "protect-trunk" => github_trunk_ruleset(ctx, run),
703        "protect-tags" => github_ruleset(
704            ctx,
705            run,
706            ctx.tag_ruleset(),
707            "tag",
708            "refs/tags/v*",
709            &["deletion", "update"],
710        ),
711        "protect-release-lines" => {
712            match github_ruleset_body(ctx, run, ctx.lines_ruleset())? {
713                RulesetLookup::Absent => {
714                    return Ok(StepState::inapplicable(
715                        "release/* is unprotected; optional — applied only where older lines exist",
716                    ));
717                }
718                RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
719                RulesetLookup::Found(_) => {}
720            }
721            github_ruleset(
722                ctx,
723                run,
724                ctx.lines_ruleset(),
725                "branch",
726                "refs/heads/release/*",
727                &["deletion", "non_fast_forward"],
728            )
729        }
730        "protections-check" => {
731            // Confirmed drift and unreadable answers stay apart: a proven
732            // mismatch is drift even beside an outage, and an outage with
733            // nothing proven wrong stays unknown, never drift.
734            let mut failures = Vec::new();
735            let mut unknowns = Vec::new();
736            // Every satisfied step's limitation survives the aggregate.
737            let mut limitations: Vec<String> = Vec::new();
738            for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
739                match github(ctx, owned, run)? {
740                    StepState::Satisfied {
741                        limitation: found, ..
742                    } => limitations.extend(found),
743                    StepState::Inapplicable { .. } => {}
744                    StepState::Unsatisfied { detail } => {
745                        failures.push(format!("{owned}: {detail}"));
746                    }
747                    StepState::Unknown { detail } => {
748                        unknowns.push(format!("{owned}: {detail}"));
749                    }
750                }
751            }
752            match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
753                Api::Ok(body) => {
754                    let owned = [
755                        ctx.trunk_ruleset().to_owned(),
756                        ctx.safety_ruleset().to_owned(),
757                        ctx.tag_ruleset().to_owned(),
758                        ctx.lines_ruleset().to_owned(),
759                    ];
760                    for ruleset in body.as_array().into_iter().flatten() {
761                        let name = ruleset["name"].as_str().unwrap_or("");
762                        if !owned.iter().any(|expected| expected == name) {
763                            failures.push(format!("a ruleset no step owns: {name}"));
764                        }
765                    }
766                }
767                Api::Missing | Api::Failed(_) => {
768                    unknowns.push("the ruleset inventory is not readable".to_owned());
769                }
770            }
771            Ok(if !failures.is_empty() {
772                StepState::not(failures.join("; "))
773            } else if !unknowns.is_empty() {
774                StepState::unknown(unknowns.join("; "))
775            } else {
776                StepState::Satisfied {
777                    detail: "exactly the owned protections, with those rules".into(),
778                    limitation: if limitations.is_empty() {
779                        None
780                    } else {
781                        Some(limitations.join("; "))
782                    },
783                }
784            })
785        }
786        _ => Ok(StepState::unknown(format!("no observation for {step}"))),
787    }
788}
789
790/// The installation, observed as the App itself.
791///
792/// The forge serves `repos/{owner}/{repo}/installation` to an App JWT and
793/// to nothing a user can hold. The caller mints `jwt` — once per run, with
794/// the token and the key bytes already registered as redaction needles —
795/// which is why this lives outside the name dispatch above: an observation
796/// entered without that token has no honest answer.
797#[must_use]
798pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
799    match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
800        AppApi::Ok(body) => {
801            let id = body["id"].as_i64().unwrap_or_default();
802            let held = &body["permissions"];
803            let short: Vec<String> = minimum_grant(ctx)
804                .into_iter()
805                .filter(|(key, level)| held[key] != *level)
806                .map(|(key, level)| format!("{key}: {level}"))
807                .collect();
808            if short.is_empty() {
809                StepState::ok(format!("installation {id} covers {}", ctx.repo))
810            } else {
811                // An installation predating a widened grant reads
812                // unsatisfied until its owner approves the new permission
813                // in the App's installation settings; no token this run
814                // can mint grants it.
815                StepState::not(format!(
816                    "installation {id} covers {} and does not hold [{}]; approve the App's updated permissions on the installation's own settings page",
817                    ctx.repo,
818                    short.join(", ")
819                ))
820            }
821        }
822        AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
823        AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
824    }
825}
826
827/// The release App's minimum grant for this target, as the installation
828/// reports it.
829///
830/// Contents and pull requests carry the release itself: the tag, the bump
831/// branch, and the request. A rendered release gate reads a check run
832/// beside them, and that rendering is the one shape that needs the third
833/// permission, so a target that renders no gate is not asked for it.
834fn minimum_grant(ctx: &Ctx) -> Vec<(&'static str, &'static str)> {
835    let mut grant = vec![("contents", "write"), ("pull_requests", "write")];
836    if ctx.integration() == crate::landing::Integration::Local
837        && ctx.profile.release.style == Some(crate::landing::Style::Trunk)
838    {
839        grant.push(("checks", "read"));
840    }
841    grant
842}
843
844/// A plain ruleset: active, and carrying exactly the expected rule types —
845/// not one fewer, and not one more, because an extra rule here is a rule the
846/// setup cannot reproduce or explain and can block the very push the method
847/// depends on.
848fn github_ruleset(
849    ctx: &Ctx,
850    run: &mut Runner,
851    name: &str,
852    target: &str,
853    include: &str,
854    rules: &[&str],
855) -> Result<StepState, RkError> {
856    let detail = match github_ruleset_body(ctx, run, name)? {
857        RulesetLookup::Found(detail) => detail,
858        RulesetLookup::Absent => {
859            return Ok(StepState::not(format!("no ruleset named {name}")));
860        }
861        RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
862    };
863    if detail["enforcement"] != "active" {
864        return Ok(StepState::not(format!("{name} is not active")));
865    }
866    // The name proves nothing: the ruleset must cover exactly the declared
867    // refs, or the protection it reports exists somewhere else.
868    if detail["target"] != target {
869        return Ok(StepState::not(format!(
870            "{name} does not target {target} refs"
871        )));
872    }
873    if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
874        return Ok(StepState::not(format!(
875            "{name} does not cover {include} alone"
876        )));
877    }
878    if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
879        return Ok(StepState::not(format!(
880            "{name} excludes refs from its own coverage"
881        )));
882    }
883    let mut held: Vec<&str> = detail["rules"]
884        .as_array()
885        .map(|list| {
886            list.iter()
887                .filter_map(|rule| rule["type"].as_str())
888                .collect()
889        })
890        .unwrap_or_default();
891    held.sort_unstable();
892    let mut expected: Vec<&str> = rules.to_vec();
893    expected.sort_unstable();
894    if held == expected {
895        Ok(StepState::ok(format!(
896            "{name} is active with exactly its rules"
897        )))
898    } else {
899        Ok(StepState::not(format!(
900            "{name} carries the rules [{}] where the setup owns [{}]",
901            held.join(", "),
902            expected.join(", ")
903        )))
904    }
905}
906
907// The trunk ruleset is checked for the shape a release merge needs.
908// The rule kinds the setup writes and can reproduce come from
909// `protection.owned_trunk_rules`, floored to contain all four. The set
910// also drives the missing-rule fault, so a kind this convention refuses
911// must stay out of it: adding one would demand that rule on every target.
912// The floor is what stops a target dropping one it needs.
913
914/// A fault line for every rule on the trunk that the setup does not own.
915///
916/// The merge queue gets its own text, because this convention refuses one
917/// deliberately and the operator needs the consequence and the remedy. Every
918/// other unowned kind reads generically: an unowned rule is one the setup
919/// cannot reproduce or explain, and it can block the very merge the method
920/// depends on.
921fn unowned_rule_faults(rules: &[Value], owned: &[String]) -> Vec<String> {
922    rules
923        .iter()
924        .filter_map(|rule| rule["type"].as_str())
925        .filter(|kind| !owned.iter().any(|name| name == kind))
926        .map(|kind| {
927            if kind == "merge_queue" {
928                MERGE_QUEUE_FAULT.to_owned()
929            } else {
930                format!("an unowned rule is present: {kind}")
931            }
932        })
933        .collect()
934}
935
936fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
937    let trunk = ctx.trunk();
938    let name = ctx.trunk_ruleset().to_owned();
939    let detail = match github_ruleset_body(ctx, run, &name)? {
940        RulesetLookup::Found(detail) => detail,
941        RulesetLookup::Absent => {
942            return Ok(StepState::not(format!("no ruleset named {name}")));
943        }
944        RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
945    };
946    let rules = detail["rules"].as_array().cloned().unwrap_or_default();
947    let mut faults = Vec::new();
948    if detail["enforcement"] != "active" {
949        faults.push(format!("{name} is not active"));
950    }
951    // The name proves nothing: a ruleset applies only where its conditions
952    // say, so a right-named ruleset covering another ref would otherwise
953    // read as a protected trunk.
954    if detail["target"] != "branch" {
955        faults.push(format!("{name} does not target branches"));
956    }
957    let expected_ref = serde_json::json!([format!("refs/heads/{trunk}")]);
958    if detail["conditions"]["ref_name"]["include"] != expected_ref {
959        faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
960    }
961    // A matching exclusion negates the include, so the owned shape is an
962    // exclusion list that is exactly empty.
963    if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
964        faults.push(format!("{name} excludes refs from its own coverage"));
965    }
966    let expected_bypass = super::context::github_bypass_actors(&ctx.protection().bypass_actors);
967    if detail["bypass_actors"] != expected_bypass {
968        faults.push("the bypass actors do not match the recorded authority".to_owned());
969    }
970    faults.extend(trunk_rule_faults(ctx, &rules, &name));
971    if let Some(request) = rules.iter().find(|rule| rule["type"] == "pull_request")
972        && request["parameters"]["allowed_merge_methods"]
973            != serde_json::json!(ctx.protection().allowed_merge_methods)
974    {
975        faults.push("the merge method is not exactly a squash merge".to_owned());
976    }
977    if let Some(checks) = rules
978        .iter()
979        .find(|rule| rule["type"] == "required_status_checks")
980    {
981        if checks["parameters"]["strict_required_status_checks_policy"]
982            != ctx.protection().strict_required_status_checks
983        {
984            faults.push(STALE_MERGE_FAULT.to_owned());
985        }
986        let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
987            .as_array()
988            .map(|list| {
989                list.iter()
990                    .filter_map(|check| check["context"].as_str())
991                    .collect()
992            })
993            .unwrap_or_default();
994        // Where the expected check is known, the context set must be exactly
995        // it plus the title check: an extra stale context does not fail a
996        // merge, it hangs one, and a missing title check lets an
997        // unconventional squash title land on the trunk.
998        if contexts.is_empty() {
999            faults.push("no status check is required".to_owned());
1000        } else if let Some(expected) = &ctx.required_check {
1001            let mut held = contexts.clone();
1002            held.sort_unstable();
1003            let title_check = ctx.title_check();
1004            let mut owned_contexts = [expected.as_str(), title_check];
1005            owned_contexts.sort_unstable();
1006            if held != owned_contexts {
1007                faults.push(format!(
1008                    "the required checks are [{}] where the setup owns [{}]",
1009                    contexts.join(", "),
1010                    owned_contexts.join(", ")
1011                ));
1012            }
1013        } else if !contexts.contains(&ctx.title_check()) {
1014            faults.push(format!("the {} check is not required", ctx.title_check()));
1015        }
1016    }
1017    match squash_merge_sources(ctx, run)? {
1018        MergeSources::Owned => {}
1019        MergeSources::Faults(proven) => faults.extend(proven),
1020        // Proven drift wins over an outage: an unreadable settings read
1021        // downgrades the answer to unknown only when nothing above it was
1022        // proven wrong.
1023        MergeSources::Unreadable(err) => {
1024            if faults.is_empty() {
1025                return Ok(StepState::unknown(err));
1026            }
1027        }
1028    }
1029    match github_safety_ruleset(ctx, run)? {
1030        SafetyRuleset::Owned => {}
1031        SafetyRuleset::Faults(proven) => faults.extend(proven),
1032        SafetyRuleset::Unreadable(err) => {
1033            if faults.is_empty() {
1034                return Ok(StepState::unknown(err));
1035            }
1036        }
1037    }
1038    if let Some(shape) = gate_faults(ctx) {
1039        faults.push(shape);
1040    }
1041    if !faults.is_empty() {
1042        return Ok(StepState::not(faults.join("; ")));
1043    }
1044    Ok(StepState::ok(format!(
1045        "{name} holds the release-merge shape beside {}",
1046        ctx.safety_ruleset()
1047    )))
1048}
1049
1050/// Which rules the trunk ruleset must carry, and which it must not.
1051///
1052/// Each ruleset carries the half of the owned rules its bypass fits, so the
1053/// trunk ruleset holds what a recorded actor may be excused from. A safety
1054/// rule here is the shape from before the split: it reads as protection
1055/// while inheriting this ruleset's bypass, so it gets its own words rather
1056/// than the generic unowned-rule fault.
1057fn trunk_rule_faults(ctx: &Ctx, rules: &[Value], name: &str) -> Vec<String> {
1058    let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
1059    let mut faults = Vec::new();
1060    let mut accounted: Vec<String> = ctx
1061        .protection()
1062        .owned_trunk_rules
1063        .iter()
1064        .filter(|rule| crate::config::REQUEST_RULES.contains(&rule.as_str()))
1065        .cloned()
1066        .collect();
1067    for required in &accounted {
1068        if !has(required) {
1069            faults.push(format!("the {required} rule is missing"));
1070        }
1071    }
1072    for stray in crate::config::SAFETY_RULES {
1073        if has(stray) {
1074            faults.push(format!(
1075                "the {stray} rule sits in {name}, where a bypass actor excuses it"
1076            ));
1077        }
1078        accounted.push(stray.to_owned());
1079    }
1080    faults.extend(unowned_rule_faults(rules, &accounted));
1081    faults
1082}
1083
1084/// What the safety ruleset's own read answered.
1085enum SafetyRuleset {
1086    Owned,
1087    Faults(Vec<String>),
1088    Unreadable(String),
1089}
1090
1091/// The ruleset no actor is excused from.
1092///
1093/// A bypass actor recorded here would hand whoever it names the deletion
1094/// and the force-push along with the trunk push, which is the one thing the
1095/// split exists to prevent. The rules it carries are the safety half of
1096/// `protection.owned_trunk_rules`, so one key still answers what the setup
1097/// owns on the trunk.
1098fn github_safety_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<SafetyRuleset, RkError> {
1099    let trunk = ctx.trunk();
1100    let name = ctx.safety_ruleset().to_owned();
1101    let detail = match github_ruleset_body(ctx, run, &name)? {
1102        RulesetLookup::Found(detail) => detail,
1103        RulesetLookup::Absent => {
1104            return Ok(SafetyRuleset::Faults(vec![format!(
1105                "no ruleset named {name} holds the trunk against deletion and force-push"
1106            )]));
1107        }
1108        RulesetLookup::Unreadable(err) => return Ok(SafetyRuleset::Unreadable(err)),
1109    };
1110    let mut faults = Vec::new();
1111    if detail["enforcement"] != "active" {
1112        faults.push(format!("{name} is not active"));
1113    }
1114    if detail["target"] != "branch" {
1115        faults.push(format!("{name} does not target branches"));
1116    }
1117    if detail["conditions"]["ref_name"]["include"]
1118        != serde_json::json!([format!("refs/heads/{trunk}")])
1119    {
1120        faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
1121    }
1122    if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
1123        faults.push(format!("{name} excludes refs from its own coverage"));
1124    }
1125    if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
1126        faults.push(format!(
1127            "{name} names a bypass actor, so deletion and force-push hold against nobody"
1128        ));
1129    }
1130    let rules = detail["rules"].as_array().cloned().unwrap_or_default();
1131    let safety_rules: Vec<String> = ctx
1132        .protection()
1133        .owned_trunk_rules
1134        .iter()
1135        .filter(|rule| crate::config::SAFETY_RULES.contains(&rule.as_str()))
1136        .cloned()
1137        .collect();
1138    for required in &safety_rules {
1139        if !rules.iter().any(|rule| rule["type"] == required.as_str()) {
1140            faults.push(format!("the {required} rule is missing from {name}"));
1141        }
1142    }
1143    faults.extend(unowned_rule_faults(&rules, &safety_rules));
1144    if faults.is_empty() {
1145        Ok(SafetyRuleset::Owned)
1146    } else {
1147        Ok(SafetyRuleset::Faults(faults))
1148    }
1149}
1150
1151/// The ways the named gate is shaped so that it cannot report a blocking
1152/// answer. A required check that never reports is a broken trunk
1153/// protection, not a weaker guarantee, so each of these is a fault rather
1154/// than a limitation. Read only where the check is named: without the flag
1155/// the observation knows no gate.
1156///
1157/// It judges the gate alone. Which other jobs a project means to block a
1158/// merge is intent, no file states it, and `forges/github.md` carries that
1159/// as a convention instead.
1160fn gate_faults(ctx: &Ctx) -> Option<String> {
1161    let check = ctx.required_check.as_deref()?;
1162    let shape = workflow_jobs::faults(
1163        &workflow_jobs::read_gate(&ctx.target, check, ctx.trunk()),
1164        check,
1165        ctx.trunk(),
1166    );
1167    // Under local integration the release gate waits on a workflow
1168    // completing and then judges this check. A trigger cannot name a check
1169    // and a required context cannot name a workflow, so nothing but this
1170    // reader proves the two answers describe one file.
1171    let waking = (ctx.integration() == crate::landing::Integration::Local)
1172        .then_some(ctx.required_workflow.as_deref())
1173        .flatten()
1174        .and_then(|workflow| {
1175            workflow_jobs::waking_workflow_fault(&ctx.target, workflow, check, ctx.trunk())
1176        });
1177    match (shape, waking) {
1178        (None, None) => None,
1179        (Some(one), None) | (None, Some(one)) => Some(one),
1180        (Some(shape), Some(waking)) => Some(format!("{shape}; {waking}")),
1181    }
1182}
1183
1184/// What the repository's squash message settings hold.
1185enum MergeSources {
1186    /// The request's title and body, as the setup owns.
1187    Owned,
1188    /// Proven other values, one fault line each.
1189    Faults(Vec<String>),
1190    /// The settings could not be read.
1191    Unreadable(String),
1192}
1193
1194/// The squash message sources, repository settings beside the ruleset:
1195/// with the title source unset, a one-commit request offers that commit's
1196/// own subject as the trunk's message, which the bot then reads for the
1197/// version; with the message source on another value, the trunk's body is
1198/// not the request's description the content gates judged. One GET
1199/// answers for both, each faulted by name.
1200fn squash_merge_sources(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
1201    Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
1202        Api::Ok(body) => {
1203            let mut faults = Vec::new();
1204            let owned_title = ctx.protection().github.squash_title_source.as_str();
1205            let owned_body = ctx.protection().github.squash_body_source.as_str();
1206            if body["squash_merge_commit_title"] != owned_title {
1207                faults.push(format!(
1208                    "the squash title source is {} where the setup owns {owned_title}",
1209                    body["squash_merge_commit_title"]
1210                ));
1211            }
1212            if body["squash_merge_commit_message"] != owned_body {
1213                faults.push(format!(
1214                    "the squash message source is {} where the setup owns {owned_body}",
1215                    body["squash_merge_commit_message"]
1216                ));
1217            }
1218            if faults.is_empty() {
1219                MergeSources::Owned
1220            } else {
1221                MergeSources::Faults(faults)
1222            }
1223        }
1224        Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
1225        Api::Failed(err) => MergeSources::Unreadable(err),
1226    })
1227}
1228
1229/// One ruleset lookup by name: found, provably absent, or unreadable —
1230/// an unreadable inventory must never read as an absent ruleset.
1231enum RulesetLookup {
1232    /// The ruleset exists; its detail body.
1233    Found(Value),
1234    /// The inventory was read successfully and no ruleset carries the
1235    /// name.
1236    Absent,
1237    /// The inventory or the detail could not be read.
1238    Unreadable(String),
1239}
1240
1241/// A ruleset's detail body by name.
1242fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
1243    // A 404 on the collection is an unreachable inventory — a missing
1244    // repository or an unauthorized read — never an empty one: an empty
1245    // inventory answers 200 with an empty list.
1246    let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
1247        Api::Ok(body) => body,
1248        Api::Missing => {
1249            return Ok(RulesetLookup::Unreadable(
1250                "the ruleset inventory is not readable".into(),
1251            ));
1252        }
1253        Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
1254    };
1255    let id = list
1256        .as_array()
1257        .into_iter()
1258        .flatten()
1259        .find(|ruleset| ruleset["name"] == name)
1260        .and_then(|ruleset| ruleset["id"].as_i64());
1261    let Some(id) = id else {
1262        return Ok(RulesetLookup::Absent);
1263    };
1264    match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
1265        Api::Ok(body) => Ok(RulesetLookup::Found(body)),
1266        // A listed id that answers 404 is not proof of absence either — the
1267        // forge also answers 404 for an unauthorized read — so a rerun
1268        // decides, rather than a false drift.
1269        Api::Missing => Ok(RulesetLookup::Unreadable(format!(
1270            "the {name} detail is not readable"
1271        ))),
1272        Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
1273    }
1274}
1275
1276/// The GitLab limitation the `auto-merge` step reports: the forge has no
1277/// project-level switch, so the observation reads the pipeline requirement
1278/// the trunk protection asserts.
1279const 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";
1280
1281/// The GitLab limitation `protect-tags` and `protections-check` report.
1282const GITLAB_TAG_LIMITATION: &str =
1283    "an Owner or Maintainer can still delete a protected tag through the UI or API";
1284
1285/// The fault a merge queue on the trunk reads as: what is enabled, what it
1286/// costs, and how to undo it. This convention refuses a queue rather than
1287/// owning one, so the operator needs the consequence rather than a rule
1288/// type's bare name.
1289const 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";
1290
1291/// The freshness defect is independent of an absent required check.
1292const 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";
1293
1294/// The GitLab limitation `protect-trunk` and `protections-check` report:
1295/// the title gate rides the request's own pipeline on this forge.
1296const 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";
1297
1298#[allow(
1299    clippy::too_many_lines,
1300    reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
1301)]
1302fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
1303    let trunk = ctx.trunk();
1304    let project = ctx.repo.replace('/', "%2F");
1305    match step {
1306        "private-vulnerability-reporting" => {
1307            let path = format!("projects/{project}");
1308            Ok(match api_get(ctx, run, &path)? {
1309                Api::Ok(body) => {
1310                    let access = body["issues_access_level"].as_str();
1311                    if !matches!(access, Some("enabled" | "private" | "disabled")) {
1312                        StepState::unknown("issue intake access is unreadable")
1313                    } else if body
1314                        .get("issues_enabled")
1315                        .is_some_and(|flag| !flag.is_boolean())
1316                    {
1317                        StepState::unknown("legacy issue intake flag is unreadable")
1318                    } else if body["issues_enabled"] == false || access == Some("disabled") {
1319                        StepState::not("issue intake is disabled; see setup guide step 3g")
1320                    } else if access == Some("private") {
1321                        StepState::not("issue intake is restricted; see setup guide step 3g")
1322                    } else {
1323                        StepState::ok_with_limitation(
1324                            "issue intake is enabled",
1325                            GITLAB_PRIVATE_REPORTING_LIMITATION,
1326                        )
1327                    }
1328                }
1329                Api::Missing => {
1330                    StepState::unknown(format!("{path}: issue intake is unreadable (404)"))
1331                }
1332                Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
1333            })
1334        }
1335
1336        "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1337            Api::Ok(body) => {
1338                let found = body["default_branch"].as_str().unwrap_or("");
1339                if found == trunk {
1340                    StepState::ok(format!("{trunk} is the default branch"))
1341                } else {
1342                    StepState::not(format!("the default branch is {found}"))
1343                }
1344            }
1345            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1346            Api::Failed(err) => StepState::unknown(err),
1347        }),
1348        "single-trunk" => {
1349            for candidate in ctx.retired_branches() {
1350                let candidate = candidate.as_str();
1351                if candidate == trunk {
1352                    continue;
1353                }
1354                match api_get(
1355                    ctx,
1356                    run,
1357                    &format!("projects/{project}/repository/branches/{candidate}"),
1358                )? {
1359                    Api::Missing => {}
1360                    Api::Ok(_) => {
1361                        return Ok(StepState::not(format!("a {candidate} branch still exists")));
1362                    }
1363                    Api::Failed(err) => return Ok(StepState::unknown(err)),
1364                }
1365            }
1366            Ok(StepState::ok(
1367                "no long-lived branch besides the trunk remains",
1368            ))
1369        }
1370        "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1371            Api::Ok(body) => {
1372                if body["remove_source_branch_after_merge"]
1373                    .as_bool()
1374                    .unwrap_or(false)
1375                {
1376                    StepState::ok("a merged branch is deleted by the forge")
1377                } else {
1378                    StepState::not("a merged branch outlives its merge")
1379                }
1380            }
1381            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1382            Api::Failed(err) => StepState::unknown(err),
1383        }),
1384        "auto-merge" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1385            Api::Ok(body) => {
1386                if body["only_allow_merge_if_pipeline_succeeds"]
1387                    .as_bool()
1388                    .unwrap_or(false)
1389                {
1390                    StepState::ok_with_limitation(
1391                        "a request may merge itself once its pipeline passes",
1392                        GITLAB_AUTO_MERGE_LIMITATION,
1393                    )
1394                } else {
1395                    StepState::not(
1396                        "the pipeline requirement auto-merge rides on is off; protect-trunk asserts it",
1397                    )
1398                }
1399            }
1400            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1401            Api::Failed(err) => StepState::unknown(err),
1402        }),
1403        "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1404            Api::Ok(body) => {
1405                if body["jobs_enabled"] == true {
1406                    StepState::ok("pipelines are enabled")
1407                } else {
1408                    StepState::not("pipelines are disabled")
1409                }
1410            }
1411            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1412            Api::Failed(err) => StepState::unknown(err),
1413        }),
1414        "install-bot" => {
1415            // The listing paginates, exactly as the script's does: an
1416            // active token past the first page must not read as absent, or
1417            // verification would contradict the apply it verifies. Absence
1418            // is only reported once a short page proves the listing was
1419            // exhausted; a bound reached on a full page is an unknown.
1420            let mut active = false;
1421            let mut exhausted = false;
1422            for page in 1..=10u32 {
1423                let path = format!(
1424                    "projects/{project}/access_tokens?state=active&per_page=100&page={page}"
1425                );
1426                let list = match api_get(ctx, run, &path)? {
1427                    Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1428                    Api::Missing => Vec::new(),
1429                    Api::Failed(err) => return Ok(StepState::unknown(err)),
1430                };
1431                active = active
1432                    || list.iter().any(|token| {
1433                        token["name"] == "release-bot"
1434                            && token["revoked"] == false
1435                            && token["active"] != false
1436                    });
1437                if list.len() < 100 {
1438                    exhausted = true;
1439                }
1440                if active || exhausted {
1441                    break;
1442                }
1443            }
1444            if !active {
1445                return Ok(if exhausted {
1446                    StepState::not("no active release-bot token exists")
1447                } else {
1448                    StepState::unknown(
1449                        "the token listing did not exhaust within ten pages; nothing was decided",
1450                    )
1451                });
1452            }
1453            // A token whose stored variable has gone missing is a stranded
1454            // identity — its value is unrecoverable — so the step is only
1455            // satisfied when both halves hold, and a rerun rotates.
1456            Ok(
1457                match api_get(
1458                    ctx,
1459                    run,
1460                    &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1461                )? {
1462                    Api::Ok(_) => StepState::ok(
1463                        "an active release-bot token exists and its variable is stored",
1464                    ),
1465                    Api::Missing => StepState::not(
1466                        "an active release-bot token exists with no stored variable; a rerun revokes and replaces it",
1467                    ),
1468                    Api::Failed(err) => StepState::unknown(err),
1469                },
1470            )
1471        }
1472        "bot-secrets" => Ok(
1473            match api_get(
1474                ctx,
1475                run,
1476                &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1477            )? {
1478                Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
1479                Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
1480                Api::Failed(err) => StepState::unknown(err),
1481            },
1482        ),
1483        "protect-trunk" => {
1484            let protection = match api_get(
1485                ctx,
1486                run,
1487                &format!("projects/{project}/protected_branches/{trunk}"),
1488            )? {
1489                Api::Ok(body) => body,
1490                Api::Missing => {
1491                    return Ok(StepState::not(format!("{trunk} is not protected")));
1492                }
1493                Api::Failed(err) => return Ok(StepState::unknown(err)),
1494            };
1495            // Exactly one push grant, and it is the no-access entry: the
1496            // forge honors the most permissive grant, so a second entry
1497            // beside access level 0 is a branch that still takes a push.
1498            let grants = protection["push_access_levels"]
1499                .as_array()
1500                .cloned()
1501                .unwrap_or_default();
1502            let policy = ctx.protection();
1503            let no_push =
1504                grants.len() == 1 && grants[0]["access_level"] == policy.gitlab.push_access_level;
1505            // The merge grant is owned exactly too: a merge level of 0 keeps
1506            // every release request unmergeable while the push shape reads
1507            // clean, so both halves are checked.
1508            let merges = protection["merge_access_levels"]
1509                .as_array()
1510                .cloned()
1511                .unwrap_or_default();
1512            let can_merge =
1513                merges.len() == 1 && merges[0]["access_level"] == policy.gitlab.merge_access_level;
1514            let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
1515                Api::Ok(body) => body,
1516                Api::Missing | Api::Failed(_) => Value::Null,
1517            };
1518            let mut faults = Vec::new();
1519            if !no_push {
1520                faults.push(format!(
1521                    "{trunk} still takes a direct push: the forge honors the most permissive of {} push grants",
1522                    grants.len()
1523                ));
1524            }
1525            if !can_merge {
1526                faults.push(format!(
1527                    "{trunk} merge grants are not exactly the one owned maintainer level"
1528                ));
1529            }
1530            if protection["allow_force_push"] != false {
1531                faults.push(format!("{trunk} allows force pushes"));
1532            }
1533            if settings["only_allow_merge_if_pipeline_succeeds"] != true {
1534                faults.push("the pipeline requirement is off".to_owned());
1535            }
1536            if settings["merge_method"] != policy.gitlab.merge_method.as_str() {
1537                faults.push("the merge method is not fast-forward".to_owned());
1538            }
1539            if settings["squash_option"] != policy.gitlab.squash_option.as_str() {
1540                faults.push("merge requests do not always squash".to_owned());
1541            }
1542            if settings["squash_commit_template"] != policy.gitlab.squash_commit_template.as_str() {
1543                faults.push("the squash template is not the merge request's title".to_owned());
1544            }
1545            Ok(if faults.is_empty() {
1546                StepState::ok_with_limitation(
1547                    format!("{trunk} holds the release-merge shape"),
1548                    GITLAB_TITLE_LIMITATION,
1549                )
1550            } else {
1551                StepState::not(faults.join("; "))
1552            })
1553        }
1554        "protect-tags" => Ok(
1555            match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
1556                Api::Ok(_) => {
1557                    StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
1558                }
1559                Api::Missing => StepState::not("v* is not protected"),
1560                Api::Failed(err) => StepState::unknown(err),
1561            },
1562        ),
1563        "protect-release-lines" => Ok(
1564            match api_get(
1565                ctx,
1566                run,
1567                &format!("projects/{project}/protected_branches/release%2F%2A"),
1568            )? {
1569                Api::Ok(body) => {
1570                    let level_ok = |levels: &Value| {
1571                        levels
1572                            .as_array()
1573                            .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
1574                    };
1575                    if body["allow_force_push"] != false {
1576                        StepState::not("release/* allows force pushes")
1577                    } else if !level_ok(&body["push_access_levels"])
1578                        || !level_ok(&body["merge_access_levels"])
1579                    {
1580                        // A push level of 0 blocks the documented
1581                        // cherry-pick-by-push path while force-push reads
1582                        // clean, so the grant shape is owned exactly.
1583                        StepState::not(
1584                            "release/* grants are not exactly the owned maintainer levels",
1585                        )
1586                    } else {
1587                        StepState::ok("release/* refuses force pushes and deletion by git clients")
1588                    }
1589                }
1590                Api::Missing => StepState::inapplicable(
1591                    "release/* is unprotected; optional — applied only where older lines exist",
1592                ),
1593                Api::Failed(err) => StepState::unknown(err),
1594            },
1595        ),
1596        "protections-check" => {
1597            // Same separation as the sibling forge: proven drift wins,
1598            // an outage with nothing proven wrong stays unknown.
1599            let mut failures = Vec::new();
1600            let mut unknowns = Vec::new();
1601            // Every satisfied step's limitation survives the aggregate: a
1602            // first limitation must not shadow a second.
1603            let mut limitations: Vec<String> = Vec::new();
1604            for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
1605                match gitlab(ctx, owned, run)? {
1606                    StepState::Satisfied {
1607                        limitation: found, ..
1608                    } => limitations.extend(found),
1609                    StepState::Inapplicable { .. } => {}
1610                    StepState::Unsatisfied { detail } => {
1611                        failures.push(format!("{owned}: {detail}"));
1612                    }
1613                    StepState::Unknown { detail } => {
1614                        unknowns.push(format!("{owned}: {detail}"));
1615                    }
1616                }
1617            }
1618            Ok(if !failures.is_empty() {
1619                StepState::not(failures.join("; "))
1620            } else if !unknowns.is_empty() {
1621                StepState::unknown(unknowns.join("; "))
1622            } else {
1623                StepState::Satisfied {
1624                    detail: "the protections hold, as far as this forge enforces them".into(),
1625                    limitation: if limitations.is_empty() {
1626                        None
1627                    } else {
1628                        Some(limitations.join("; "))
1629                    },
1630                }
1631            })
1632        }
1633        _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1634    }
1635}