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 payload 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        Forge::Github => github(ctx, step, run),
135        Forge::Gitlab => gitlab(ctx, step, run),
136    }
137}
138
139/// The policy a consumer must find in the artifact they downloaded. A
140/// reporting policy readable on the forge alone is a policy the consumer
141/// who has only the package cannot follow.
142const POLICY_DESTINATION: &str = "SECURITY.md";
143
144/// What Python's check cannot answer. PEP 517 lets a project choose its
145/// build backend, and an sdist and a wheel can carry different files, so
146/// one `python3 -m build` run supplies no listing contract across both
147/// outputs.
148const 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
150/// What Bash's check cannot answer. Its binding builds the tarball with
151/// `git archive`, where an `export-ignore` attribute drops a tracked file.
152const 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
154/// §0: the technology's own no-credential packaging check; the one step that
155/// reads its command from the binding rather than from a forge tree.
156///
157/// Publishability is the whole of the check for every binding. Policy reach
158/// is asserted only where the binding has a deterministic listing command
159/// the step can run with no credentials, which today is a sole Cargo
160/// package rooted at the target; every other shape reports its successful
161/// packaging result with the unproved inclusion named.
162fn 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
197/// One no-credential Cargo invocation against the target.
198fn 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
208/// Whether the published crate carries the root policy, for the one shape
209/// Cargo answers unambiguously.
210///
211/// `cargo package --list` prints one path per line for one package and
212/// emits no stable delimiter when it selects several, and a nested package
213/// cannot include a file above its own root. So the listing runs only for a
214/// sole selected default member whose manifest is the target's own
215/// `Cargo.toml`; a virtual workspace, several default members, and a sole
216/// nested member each keep the successful publishability result and name
217/// the limitation instead of claiming a reach they cannot prove.
218fn 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    // An exact line, never a substring: `docs/SECURITY.md` and
258    // `SECURITY.md.bak` are different files and neither is the policy.
259    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
273/// The manifest path of the one selected default package rooted at the
274/// target, or `None` for every other workspace shape.
275fn 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    // Compare what each path resolves to, so a symlinked or
294    // differently-spelled target directory still reads as the root.
295    let same =
296        std::fs::canonicalize(manifest).ok()? == std::fs::canonicalize(root_manifest).ok()?;
297    same.then(|| manifest.to_owned())
298}
299
300/// §1: the post-merge reminder hook, judged from the target's own files;
301/// the one step whose observation asks no forge and spawns no CLI.
302fn 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
317/// The GitLab version this convention needs, as major and minor.
318///
319/// `trigger: strategy: mirror` arrived in GitLab 18.2, and the merge-request
320/// pipeline's `project-jobs` bridge rests on it: below the floor the child
321/// pipeline's status never reaches the parent, so a failing project job
322/// merges.
323pub const GITLAB_VERSION_FLOOR: (u64, u64) = (18, 2);
324
325/// The two suffixes that name an edition rather than a pre-release. Every
326/// other suffix is a pre-release, and the step fails closed on one.
327const GITLAB_EDITIONS: [&str; 2] = ["ee", "ce"];
328
329/// The refusal an instance below the floor reads: the reading, the reason,
330/// and the fix.
331fn 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
353/// §3: the forge's own version against the convention's floor.
354///
355/// GitHub is a rolling service and is answered without a call. GitLab is one
356/// read-only `GET /version`, and every failure to read is `Unknown`, which
357/// blocks the `protect-trunk` prerequisite exactly as `Unsatisfied` does.
358fn 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
407/// The destructive step's own guard: whether deleting a candidate branch
408/// can lose work.
409///
410/// `Satisfied` means every candidate is already gone or is an ancestor of
411/// the trunk; `Unsatisfied` means the deletion must refuse.
412///
413/// # Errors
414///
415/// Propagates executor failures.
416pub 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
436/// One candidate branch's ancestry, on GitHub.
437fn 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
472/// One candidate branch's ancestry, on GitLab.
473fn 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
511/// One captured, read-only forge API call.
512fn 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
537/// The last non-empty line of a byte stream, for one-line detail fields.
538fn 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            // Confirmed drift and unreadable answers stay apart: a proven
723            // mismatch is drift even beside an outage, and an outage with
724            // nothing proven wrong stays unknown, never drift.
725            let mut failures = Vec::new();
726            let mut unknowns = Vec::new();
727            // Every satisfied step's limitation survives the aggregate.
728            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/// The installation, observed as the App itself.
781///
782/// The forge serves `repos/{owner}/{repo}/installation` to an App JWT and
783/// to nothing a user can hold. The caller mints `jwt` — once per run, with
784/// the token and the key bytes already registered as redaction needles —
785/// which is why this lives outside the name dispatch above: an observation
786/// entered without that token has no honest answer.
787#[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
799/// A plain ruleset: active, and carrying exactly the expected rule types —
800/// not one fewer, and not one more, because an extra rule here is a rule the
801/// setup cannot reproduce or explain and can block the very push the method
802/// depends on.
803fn 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    // The name proves nothing: the ruleset must cover exactly the declared
822    // refs, or the protection it reports exists somewhere else.
823    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
862// The trunk ruleset is checked for the shape a release merge needs.
863// The rule kinds the setup writes and can reproduce come from
864// `protection.owned_trunk_rules`, floored to contain all four. The set
865// also drives the missing-rule fault, so a kind this convention refuses
866// must stay out of it: adding one would demand that rule on every target.
867// The floor is what stops a target dropping one it needs.
868
869/// A fault line for every rule on the trunk that the setup does not own.
870///
871/// The merge queue gets its own text, because this convention refuses one
872/// deliberately and the operator needs the consequence and the remedy. Every
873/// other unowned kind reads generically: an unowned rule is one the setup
874/// cannot reproduce or explain, and it can block the very merge the method
875/// depends on.
876fn 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    // The name proves nothing: a ruleset applies only where its conditions
908    // say, so a right-named ruleset covering another ref would otherwise
909    // read as a protected trunk.
910    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    // A matching exclusion negates the include, so the owned shape is an
918    // exclusion list that is exactly empty.
919    if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
920        faults.push(format!("{name} excludes refs from its own coverage"));
921    }
922    if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
923        faults.push("a bypass actor is named".to_owned());
924    }
925    for required in &ctx.protection().owned_trunk_rules {
926        if !has(required) {
927            faults.push(format!("the {required} rule is missing"));
928        }
929    }
930    faults.extend(unowned_rule_faults(
931        &rules,
932        &ctx.protection().owned_trunk_rules,
933    ));
934    if let Some(request) = rules.iter().find(|rule| rule["type"] == "pull_request") {
935        if request["parameters"]["allowed_merge_methods"]
936            != serde_json::json!(ctx.protection().allowed_merge_methods)
937        {
938            faults.push("the merge method is not exactly a squash merge".to_owned());
939        }
940    }
941    if let Some(checks) = rules
942        .iter()
943        .find(|rule| rule["type"] == "required_status_checks")
944    {
945        if checks["parameters"]["strict_required_status_checks_policy"]
946            != ctx.protection().strict_required_status_checks
947        {
948            faults.push(STALE_MERGE_FAULT.to_owned());
949        }
950        let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
951            .as_array()
952            .map(|list| {
953                list.iter()
954                    .filter_map(|check| check["context"].as_str())
955                    .collect()
956            })
957            .unwrap_or_default();
958        // Where the expected check is known, the context set must be exactly
959        // it plus the title check: an extra stale context does not fail a
960        // merge, it hangs one, and a missing title check lets an
961        // unconventional squash title land on the trunk.
962        if contexts.is_empty() {
963            faults.push("no status check is required".to_owned());
964        } else if let Some(expected) = &ctx.required_check {
965            let mut held = contexts.clone();
966            held.sort_unstable();
967            let title_check = ctx.title_check();
968            let mut owned_contexts = [expected.as_str(), title_check];
969            owned_contexts.sort_unstable();
970            if held != owned_contexts {
971                faults.push(format!(
972                    "the required checks are [{}] where the setup owns [{}]",
973                    contexts.join(", "),
974                    owned_contexts.join(", ")
975                ));
976            }
977        } else if !contexts.contains(&ctx.title_check()) {
978            faults.push(format!("the {} check is not required", ctx.title_check()));
979        }
980    }
981    match squash_merge_sources(ctx, run)? {
982        MergeSources::Owned => {}
983        MergeSources::Faults(proven) => faults.extend(proven),
984        // Proven drift wins over an outage: an unreadable settings read
985        // downgrades the answer to unknown only when nothing above it was
986        // proven wrong.
987        MergeSources::Unreadable(err) => {
988            if faults.is_empty() {
989                return Ok(StepState::unknown(err));
990            }
991        }
992    }
993    if let Some(shape) = gate_faults(ctx) {
994        faults.push(shape);
995    }
996    if !faults.is_empty() {
997        return Ok(StepState::not(faults.join("; ")));
998    }
999    Ok(StepState::ok(format!(
1000        "{name} holds the release-merge shape"
1001    )))
1002}
1003
1004/// The ways the named gate is shaped so that it cannot report a blocking
1005/// answer. A required check that never reports is a broken trunk
1006/// protection, not a weaker guarantee, so each of these is a fault rather
1007/// than a limitation. Read only where the check is named: without the flag
1008/// the observation knows no gate.
1009///
1010/// It judges the gate alone. Which other jobs a project means to block a
1011/// merge is intent, no file states it, and `forges/github.md` carries that
1012/// as a convention instead.
1013fn gate_faults(ctx: &Ctx) -> Option<String> {
1014    let check = ctx.required_check.as_deref()?;
1015    workflow_jobs::faults(
1016        &workflow_jobs::read_gate(&ctx.target, check, ctx.trunk()),
1017        check,
1018        ctx.trunk(),
1019    )
1020}
1021
1022/// What the repository's squash message settings hold.
1023enum MergeSources {
1024    /// The request's title and body, as the setup owns.
1025    Owned,
1026    /// Proven other values, one fault line each.
1027    Faults(Vec<String>),
1028    /// The settings could not be read.
1029    Unreadable(String),
1030}
1031
1032/// The squash message sources, repository settings beside the ruleset:
1033/// with the title source unset, a one-commit request offers that commit's
1034/// own subject as the trunk's message, which the bot then reads for the
1035/// version; with the message source on another value, the trunk's body is
1036/// not the request's description the content gates judged. One GET
1037/// answers for both, each faulted by name.
1038fn squash_merge_sources(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
1039    Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
1040        Api::Ok(body) => {
1041            let mut faults = Vec::new();
1042            let owned_title = ctx.protection().github.squash_title_source.as_str();
1043            let owned_body = ctx.protection().github.squash_body_source.as_str();
1044            if body["squash_merge_commit_title"] != owned_title {
1045                faults.push(format!(
1046                    "the squash title source is {} where the setup owns {owned_title}",
1047                    body["squash_merge_commit_title"]
1048                ));
1049            }
1050            if body["squash_merge_commit_message"] != owned_body {
1051                faults.push(format!(
1052                    "the squash message source is {} where the setup owns {owned_body}",
1053                    body["squash_merge_commit_message"]
1054                ));
1055            }
1056            if faults.is_empty() {
1057                MergeSources::Owned
1058            } else {
1059                MergeSources::Faults(faults)
1060            }
1061        }
1062        Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
1063        Api::Failed(err) => MergeSources::Unreadable(err),
1064    })
1065}
1066
1067/// One ruleset lookup by name: found, provably absent, or unreadable —
1068/// an unreadable inventory must never read as an absent ruleset.
1069enum RulesetLookup {
1070    /// The ruleset exists; its detail body.
1071    Found(Value),
1072    /// The inventory was read successfully and no ruleset carries the
1073    /// name.
1074    Absent,
1075    /// The inventory or the detail could not be read.
1076    Unreadable(String),
1077}
1078
1079/// A ruleset's detail body by name.
1080fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
1081    // A 404 on the collection is an unreachable inventory — a missing
1082    // repository or an unauthorized read — never an empty one: an empty
1083    // inventory answers 200 with an empty list.
1084    let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
1085        Api::Ok(body) => body,
1086        Api::Missing => {
1087            return Ok(RulesetLookup::Unreadable(
1088                "the ruleset inventory is not readable".into(),
1089            ));
1090        }
1091        Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
1092    };
1093    let id = list
1094        .as_array()
1095        .into_iter()
1096        .flatten()
1097        .find(|ruleset| ruleset["name"] == name)
1098        .and_then(|ruleset| ruleset["id"].as_i64());
1099    let Some(id) = id else {
1100        return Ok(RulesetLookup::Absent);
1101    };
1102    match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
1103        Api::Ok(body) => Ok(RulesetLookup::Found(body)),
1104        // A listed id that answers 404 is not proof of absence either — the
1105        // forge also answers 404 for an unauthorized read — so a rerun
1106        // decides, rather than a false drift.
1107        Api::Missing => Ok(RulesetLookup::Unreadable(format!(
1108            "the {name} detail is not readable"
1109        ))),
1110        Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
1111    }
1112}
1113
1114/// The GitLab limitation the `auto-merge` step reports: the forge has no
1115/// project-level switch, so the observation reads the pipeline requirement
1116/// the trunk protection asserts.
1117const GITLAB_AUTO_MERGE_LIMITATION: &str = "the forge offers no project-level auto-merge switch: availability follows the pipeline requirement protect-trunk asserts, and turning that requirement off removes auto-merge with nothing here reporting it";
1118
1119/// The GitLab limitation `protect-tags` and `protections-check` report.
1120const GITLAB_TAG_LIMITATION: &str =
1121    "an Owner or Maintainer can still delete a protected tag through the UI or API";
1122
1123/// The fault a merge queue on the trunk reads as: what is enabled, what it
1124/// costs, and how to undo it. This convention refuses a queue rather than
1125/// owning one, so the operator needs the consequence rather than a rule
1126/// type's bare name.
1127const MERGE_QUEUE_FAULT: &str = "a merge queue is enabled on the trunk; this convention lands no workflow that triggers on merge_group, so the queue waits on a required check that never reports and drops the request when its CI timeout expires. rk setup step protect-trunk --apply rewrites the ruleset without it";
1128
1129/// The freshness defect is independent of an absent required check.
1130const STALE_MERGE_FAULT: &str = "the trunk permits a merge from a branch that does not carry the trunk's tip; an armed release request can therefore ship a version computed against a trunk that moved. rk setup step protect-trunk --apply rewrites the ruleset with the freshness requirement";
1131
1132/// The GitLab limitation `protect-trunk` and `protections-check` report:
1133/// the title gate rides the request's own pipeline on this forge.
1134const GITLAB_TITLE_LIMITATION: &str = "the title gate stops accident, not authority: a merge request runs its own CI configuration, and a title edit starts no new pipeline";
1135
1136#[allow(
1137    clippy::too_many_lines,
1138    reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
1139)]
1140fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
1141    let trunk = ctx.trunk();
1142    let project = ctx.repo.replace('/', "%2F");
1143    match step {
1144        "private-vulnerability-reporting" => {
1145            let path = format!("projects/{project}");
1146            Ok(match api_get(ctx, run, &path)? {
1147                Api::Ok(body) => {
1148                    let access = body["issues_access_level"].as_str();
1149                    if !matches!(access, Some("enabled" | "private" | "disabled")) {
1150                        StepState::unknown("issue intake access is unreadable")
1151                    } else if body
1152                        .get("issues_enabled")
1153                        .is_some_and(|flag| !flag.is_boolean())
1154                    {
1155                        StepState::unknown("legacy issue intake flag is unreadable")
1156                    } else if body["issues_enabled"] == false || access == Some("disabled") {
1157                        StepState::not("issue intake is disabled; see setup guide step 3g")
1158                    } else if access == Some("private") {
1159                        StepState::not("issue intake is restricted; see setup guide step 3g")
1160                    } else {
1161                        StepState::ok_with_limitation(
1162                            "issue intake is enabled",
1163                            GITLAB_PRIVATE_REPORTING_LIMITATION,
1164                        )
1165                    }
1166                }
1167                Api::Missing => {
1168                    StepState::unknown(format!("{path}: issue intake is unreadable (404)"))
1169                }
1170                Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
1171            })
1172        }
1173
1174        "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1175            Api::Ok(body) => {
1176                let found = body["default_branch"].as_str().unwrap_or("");
1177                if found == trunk {
1178                    StepState::ok(format!("{trunk} is the default branch"))
1179                } else {
1180                    StepState::not(format!("the default branch is {found}"))
1181                }
1182            }
1183            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1184            Api::Failed(err) => StepState::unknown(err),
1185        }),
1186        "single-trunk" => {
1187            for candidate in ctx.retired_branches() {
1188                let candidate = candidate.as_str();
1189                if candidate == trunk {
1190                    continue;
1191                }
1192                match api_get(
1193                    ctx,
1194                    run,
1195                    &format!("projects/{project}/repository/branches/{candidate}"),
1196                )? {
1197                    Api::Missing => {}
1198                    Api::Ok(_) => {
1199                        return Ok(StepState::not(format!("a {candidate} branch still exists")));
1200                    }
1201                    Api::Failed(err) => return Ok(StepState::unknown(err)),
1202                }
1203            }
1204            Ok(StepState::ok(
1205                "no long-lived branch besides the trunk remains",
1206            ))
1207        }
1208        "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1209            Api::Ok(body) => {
1210                if body["remove_source_branch_after_merge"]
1211                    .as_bool()
1212                    .unwrap_or(false)
1213                {
1214                    StepState::ok("a merged branch is deleted by the forge")
1215                } else {
1216                    StepState::not("a merged branch outlives its merge")
1217                }
1218            }
1219            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1220            Api::Failed(err) => StepState::unknown(err),
1221        }),
1222        "auto-merge" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1223            Api::Ok(body) => {
1224                if body["only_allow_merge_if_pipeline_succeeds"]
1225                    .as_bool()
1226                    .unwrap_or(false)
1227                {
1228                    StepState::ok_with_limitation(
1229                        "a request may merge itself once its pipeline passes",
1230                        GITLAB_AUTO_MERGE_LIMITATION,
1231                    )
1232                } else {
1233                    StepState::not(
1234                        "the pipeline requirement auto-merge rides on is off; protect-trunk asserts it",
1235                    )
1236                }
1237            }
1238            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1239            Api::Failed(err) => StepState::unknown(err),
1240        }),
1241        "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1242            Api::Ok(body) => {
1243                if body["jobs_enabled"] == true {
1244                    StepState::ok("pipelines are enabled")
1245                } else {
1246                    StepState::not("pipelines are disabled")
1247                }
1248            }
1249            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1250            Api::Failed(err) => StepState::unknown(err),
1251        }),
1252        "install-bot" => {
1253            // The listing paginates, exactly as the script's does: an
1254            // active token past the first page must not read as absent, or
1255            // verification would contradict the apply it verifies. Absence
1256            // is only reported once a short page proves the listing was
1257            // exhausted; a bound reached on a full page is an unknown.
1258            let mut active = false;
1259            let mut exhausted = false;
1260            for page in 1..=10u32 {
1261                let path = format!(
1262                    "projects/{project}/access_tokens?state=active&per_page=100&page={page}"
1263                );
1264                let list = match api_get(ctx, run, &path)? {
1265                    Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1266                    Api::Missing => Vec::new(),
1267                    Api::Failed(err) => return Ok(StepState::unknown(err)),
1268                };
1269                active = active
1270                    || list.iter().any(|token| {
1271                        token["name"] == "release-bot"
1272                            && token["revoked"] == false
1273                            && token["active"] != false
1274                    });
1275                if list.len() < 100 {
1276                    exhausted = true;
1277                }
1278                if active || exhausted {
1279                    break;
1280                }
1281            }
1282            if !active {
1283                return Ok(if exhausted {
1284                    StepState::not("no active release-bot token exists")
1285                } else {
1286                    StepState::unknown(
1287                        "the token listing did not exhaust within ten pages; nothing was decided",
1288                    )
1289                });
1290            }
1291            // A token whose stored variable has gone missing is a stranded
1292            // identity — its value is unrecoverable — so the step is only
1293            // satisfied when both halves hold, and a rerun rotates.
1294            Ok(
1295                match api_get(
1296                    ctx,
1297                    run,
1298                    &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1299                )? {
1300                    Api::Ok(_) => StepState::ok(
1301                        "an active release-bot token exists and its variable is stored",
1302                    ),
1303                    Api::Missing => StepState::not(
1304                        "an active release-bot token exists with no stored variable; a rerun revokes and replaces it",
1305                    ),
1306                    Api::Failed(err) => StepState::unknown(err),
1307                },
1308            )
1309        }
1310        "bot-secrets" => Ok(
1311            match api_get(
1312                ctx,
1313                run,
1314                &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1315            )? {
1316                Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
1317                Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
1318                Api::Failed(err) => StepState::unknown(err),
1319            },
1320        ),
1321        "protect-trunk" => {
1322            let protection = match api_get(
1323                ctx,
1324                run,
1325                &format!("projects/{project}/protected_branches/{trunk}"),
1326            )? {
1327                Api::Ok(body) => body,
1328                Api::Missing => {
1329                    return Ok(StepState::not(format!("{trunk} is not protected")));
1330                }
1331                Api::Failed(err) => return Ok(StepState::unknown(err)),
1332            };
1333            // Exactly one push grant, and it is the no-access entry: the
1334            // forge honors the most permissive grant, so a second entry
1335            // beside access level 0 is a branch that still takes a push.
1336            let grants = protection["push_access_levels"]
1337                .as_array()
1338                .cloned()
1339                .unwrap_or_default();
1340            let policy = ctx.protection();
1341            let no_push =
1342                grants.len() == 1 && grants[0]["access_level"] == policy.gitlab.push_access_level;
1343            // The merge grant is owned exactly too: a merge level of 0 keeps
1344            // every release request unmergeable while the push shape reads
1345            // clean, so both halves are checked.
1346            let merges = protection["merge_access_levels"]
1347                .as_array()
1348                .cloned()
1349                .unwrap_or_default();
1350            let can_merge =
1351                merges.len() == 1 && merges[0]["access_level"] == policy.gitlab.merge_access_level;
1352            let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
1353                Api::Ok(body) => body,
1354                Api::Missing | Api::Failed(_) => Value::Null,
1355            };
1356            let mut faults = Vec::new();
1357            if !no_push {
1358                faults.push(format!(
1359                    "{trunk} still takes a direct push: the forge honors the most permissive of {} push grants",
1360                    grants.len()
1361                ));
1362            }
1363            if !can_merge {
1364                faults.push(format!(
1365                    "{trunk} merge grants are not exactly the one owned maintainer level"
1366                ));
1367            }
1368            if protection["allow_force_push"] != false {
1369                faults.push(format!("{trunk} allows force pushes"));
1370            }
1371            if settings["only_allow_merge_if_pipeline_succeeds"] != true {
1372                faults.push("the pipeline requirement is off".to_owned());
1373            }
1374            if settings["merge_method"] != policy.gitlab.merge_method.as_str() {
1375                faults.push("the merge method is not fast-forward".to_owned());
1376            }
1377            if settings["squash_option"] != policy.gitlab.squash_option.as_str() {
1378                faults.push("merge requests do not always squash".to_owned());
1379            }
1380            if settings["squash_commit_template"] != policy.gitlab.squash_commit_template.as_str() {
1381                faults.push("the squash template is not the merge request's title".to_owned());
1382            }
1383            Ok(if faults.is_empty() {
1384                StepState::ok_with_limitation(
1385                    format!("{trunk} holds the release-merge shape"),
1386                    GITLAB_TITLE_LIMITATION,
1387                )
1388            } else {
1389                StepState::not(faults.join("; "))
1390            })
1391        }
1392        "protect-tags" => Ok(
1393            match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
1394                Api::Ok(_) => {
1395                    StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
1396                }
1397                Api::Missing => StepState::not("v* is not protected"),
1398                Api::Failed(err) => StepState::unknown(err),
1399            },
1400        ),
1401        "protect-release-lines" => Ok(
1402            match api_get(
1403                ctx,
1404                run,
1405                &format!("projects/{project}/protected_branches/release%2F%2A"),
1406            )? {
1407                Api::Ok(body) => {
1408                    let level_ok = |levels: &Value| {
1409                        levels
1410                            .as_array()
1411                            .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
1412                    };
1413                    if body["allow_force_push"] != false {
1414                        StepState::not("release/* allows force pushes")
1415                    } else if !level_ok(&body["push_access_levels"])
1416                        || !level_ok(&body["merge_access_levels"])
1417                    {
1418                        // A push level of 0 blocks the documented
1419                        // cherry-pick-by-push path while force-push reads
1420                        // clean, so the grant shape is owned exactly.
1421                        StepState::not(
1422                            "release/* grants are not exactly the owned maintainer levels",
1423                        )
1424                    } else {
1425                        StepState::ok("release/* refuses force pushes and deletion by git clients")
1426                    }
1427                }
1428                Api::Missing => StepState::inapplicable(
1429                    "release/* is unprotected; optional — applied only where older lines exist",
1430                ),
1431                Api::Failed(err) => StepState::unknown(err),
1432            },
1433        ),
1434        "protections-check" => {
1435            // Same separation as the sibling forge: proven drift wins,
1436            // an outage with nothing proven wrong stays unknown.
1437            let mut failures = Vec::new();
1438            let mut unknowns = Vec::new();
1439            // Every satisfied step's limitation survives the aggregate: a
1440            // first limitation must not shadow a second.
1441            let mut limitations: Vec<String> = Vec::new();
1442            for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
1443                match gitlab(ctx, owned, run)? {
1444                    StepState::Satisfied {
1445                        limitation: found, ..
1446                    } => limitations.extend(found),
1447                    StepState::Inapplicable { .. } => {}
1448                    StepState::Unsatisfied { detail } => {
1449                        failures.push(format!("{owned}: {detail}"));
1450                    }
1451                    StepState::Unknown { detail } => {
1452                        unknowns.push(format!("{owned}: {detail}"));
1453                    }
1454                }
1455            }
1456            Ok(if !failures.is_empty() {
1457                StepState::not(failures.join("; "))
1458            } else if !unknowns.is_empty() {
1459                StepState::unknown(unknowns.join("; "))
1460            } else {
1461                StepState::Satisfied {
1462                    detail: "the protections hold, as far as this forge enforces them".into(),
1463                    limitation: if limitations.is_empty() {
1464                        None
1465                    } else {
1466                        Some(limitations.join("; "))
1467                    },
1468                }
1469            })
1470        }
1471        _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1472    }
1473}