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/// §0: the technology's own no-credential packaging check; the one step that
140/// reads its command from the binding rather than from a forge tree.
141fn package_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
142    let (program, args): (&str, &[&str]) = match ctx.tech {
143        Some("rust") => ("cargo", &["publish", "--dry-run", "--allow-dirty"]),
144        Some("python") => ("python3", &["-m", "build"]),
145        Some("bash") => {
146            return Ok(StepState::ok(
147                "no registry for this technology; there is nothing to package",
148            ));
149        }
150        Some(other) => {
151            return Ok(StepState::unknown(format!(
152                "no packaging check is defined for {other}"
153            )));
154        }
155        None => {
156            return Ok(StepState::unknown(
157                "no version file names a technology; see rk binding --list",
158            ));
159        }
160    };
161    let exec = Exec {
162        program: program.into(),
163        args: args.iter().map(Into::into).collect(),
164        env: ctx.child_env("package-check"),
165        cwd: ctx.target.as_std_path().to_path_buf(),
166        stdin: None,
167    };
168    let outcome = run(&exec)?;
169    Ok(if outcome.success() {
170        StepState::ok("the package builds and passes the registry's dry run")
171    } else {
172        StepState::not(format!(
173            "the packaging check failed: {}",
174            last_line(&outcome.stderr)
175        ))
176    })
177}
178
179/// §1: the post-merge reminder hook, judged from the target's own files;
180/// the one step whose observation asks no forge and spawns no CLI.
181fn branch_reminder_state(ctx: &Ctx) -> StepState {
182    use crate::setup::branch_reminder::{HookState, observe_hook};
183    match observe_hook(&ctx.target) {
184        HookState::Installed => {
185            StepState::ok("the post-merge hook carries the release-kit reminder")
186        }
187        HookState::Absent => StepState::not("no post-merge hook is installed"),
188        HookState::Foreign => {
189            StepState::not("a post-merge hook exists without the release-kit marker")
190        }
191        HookState::Drifted => StepState::not("the reminder hook drifted from this binary's body"),
192        HookState::Unreadable(detail) => StepState::unknown(detail),
193    }
194}
195
196/// The GitLab version this convention needs, as major and minor.
197///
198/// `trigger: strategy: mirror` arrived in GitLab 18.2, and the merge-request
199/// pipeline's `project-jobs` bridge rests on it: below the floor the child
200/// pipeline's status never reaches the parent, so a failing project job
201/// merges.
202pub const GITLAB_VERSION_FLOOR: (u64, u64) = (18, 2);
203
204/// The two suffixes that name an edition rather than a pre-release. Every
205/// other suffix is a pre-release, and the step fails closed on one.
206const GITLAB_EDITIONS: [&str; 2] = ["ee", "ce"];
207
208/// The refusal an instance below the floor reads: the reading, the reason,
209/// and the fix.
210fn version_refusal(found: &str, prerelease: Option<&str>) -> String {
211    let (major, minor) = GITLAB_VERSION_FLOOR;
212    let mut said = vec![format!(
213        "this GitLab instance reports {found}; the convention needs {major}.{minor} or newer"
214    )];
215    if let Some(suffix) = prerelease {
216        said.push(format!(
217            "the -{suffix} suffix is a pre-release, and nothing proves the feature shipped in it, so this step fails closed"
218        ));
219    }
220    said.push(format!(
221        "the merge-request pipeline triggers a child pipeline with `strategy: mirror`, which GitLab added in {major}.{minor}"
222    ));
223    said.push(
224        "below it the child's status never reaches the parent pipeline, so a failing project job merges".to_owned(),
225    );
226    said.push(format!(
227        "upgrade the instance to {major}.{minor} or newer, or host the project on gitlab.com"
228    ));
229    said.join("; ")
230}
231
232/// §3: the forge's own version against the convention's floor.
233///
234/// GitHub is a rolling service and is answered without a call. GitLab is one
235/// read-only `GET /version`, and every failure to read is `Unknown`, which
236/// blocks the `protect-trunk` prerequisite exactly as `Unsatisfied` does.
237fn forge_version(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
238    if ctx.forge == Forge::Github {
239        return Ok(StepState::ok(
240            "github.com is a rolling service and declares no version floor",
241        ));
242    }
243    let body = match api_get(ctx, run, "version")? {
244        Api::Ok(body) => body,
245        Api::Missing => {
246            return Ok(StepState::unknown(
247                "this instance answers no GET /version; the floor cannot be read. Check that glab is authenticated against it: glab auth login",
248            ));
249        }
250        Api::Failed(err) => {
251            return Ok(StepState::unknown(format!(
252                "the version could not be read: {err}. Check that glab is authenticated against this instance: glab auth login"
253            )));
254        }
255    };
256    let Some(found) = body["version"].as_str() else {
257        return Ok(StepState::unknown(
258            "the forge answer carries no version field; the floor cannot be read. Check that glab is authenticated against this instance: glab auth login",
259        ));
260    };
261    let (number, suffix) = found
262        .split_once('-')
263        .map_or((found, None), |(n, s)| (n, Some(s)));
264    let mut parts = number.split('.');
265    let parsed = parts
266        .next()
267        .and_then(|major| major.parse::<u64>().ok())
268        .zip(parts.next().and_then(|minor| minor.parse::<u64>().ok()));
269    let Some(pair) = parsed else {
270        return Ok(StepState::unknown(format!(
271            "the forge reports the version as '{found}', which names no major and minor pair; the floor cannot be read"
272        )));
273    };
274    if let Some(suffix) = suffix.filter(|s| !GITLAB_EDITIONS.contains(s)) {
275        return Ok(StepState::not(version_refusal(found, Some(suffix))));
276    }
277    if pair < GITLAB_VERSION_FLOOR {
278        return Ok(StepState::not(version_refusal(found, None)));
279    }
280    let (major, minor) = GITLAB_VERSION_FLOOR;
281    Ok(StepState::ok(format!(
282        "this instance reports {found}, at or above the {major}.{minor} floor"
283    )))
284}
285
286/// The destructive step's own guard: whether deleting a candidate branch
287/// can lose work.
288///
289/// `Satisfied` means every candidate is already gone or is an ancestor of
290/// the trunk; `Unsatisfied` means the deletion must refuse.
291///
292/// # Errors
293///
294/// Propagates executor failures.
295pub fn single_trunk_guard(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
296    let trunk = ctx.trunk();
297    for candidate in ctx.retired_branches() {
298        let candidate = candidate.as_str();
299        if candidate == trunk {
300            continue;
301        }
302        let state = match ctx.forge {
303            Forge::Github => github_candidate_guard(ctx, run, candidate)?,
304            Forge::Gitlab => gitlab_candidate_guard(ctx, run, candidate)?,
305        };
306        if !state.satisfied() {
307            return Ok(state);
308        }
309    }
310    Ok(StepState::ok(
311        "every candidate branch is absent, or an ancestor of the trunk",
312    ))
313}
314
315/// One candidate branch's ancestry, on GitHub.
316fn github_candidate_guard(
317    ctx: &Ctx,
318    run: &mut Runner,
319    candidate: &str,
320) -> Result<StepState, RkError> {
321    let trunk = ctx.trunk();
322    match api_get(
323        ctx,
324        run,
325        &format!("repos/{}/git/ref/heads/{candidate}", ctx.repo),
326    )? {
327        Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
328        Api::Failed(err) => return Ok(StepState::unknown(err)),
329        Api::Ok(_) => {}
330    }
331    match api_get(
332        ctx,
333        run,
334        &format!("repos/{}/compare/{candidate}...{trunk}", ctx.repo),
335    )? {
336        Api::Ok(body) => {
337            let status = body["status"].as_str().unwrap_or("");
338            Ok(if matches!(status, "ahead" | "identical") {
339                StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
340            } else {
341                StepState::not(format!(
342                    "{candidate} is not an ancestor of {trunk} ({status}); deleting it would lose work"
343                ))
344            })
345        }
346        Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
347        Api::Failed(err) => Ok(StepState::unknown(err)),
348    }
349}
350
351/// One candidate branch's ancestry, on GitLab.
352fn gitlab_candidate_guard(
353    ctx: &Ctx,
354    run: &mut Runner,
355    candidate: &str,
356) -> Result<StepState, RkError> {
357    let trunk = ctx.trunk();
358    let project = ctx.repo.replace('/', "%2F");
359    match api_get(
360        ctx,
361        run,
362        &format!("projects/{project}/repository/branches/{candidate}"),
363    )? {
364        Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
365        Api::Failed(err) => return Ok(StepState::unknown(err)),
366        Api::Ok(_) => {}
367    }
368    match api_get(
369        ctx,
370        run,
371        &format!("projects/{project}/repository/compare?from={trunk}&to={candidate}"),
372    )? {
373        Api::Ok(body) => {
374            let ahead = body["commits"]
375                .as_array()
376                .is_some_and(|list| !list.is_empty());
377            Ok(if ahead {
378                StepState::not(format!(
379                    "{candidate} carries commits {trunk} does not; deleting it would lose work"
380                ))
381            } else {
382                StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
383            })
384        }
385        Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
386        Api::Failed(err) => Ok(StepState::unknown(err)),
387    }
388}
389
390/// One captured, read-only forge API call.
391fn api_get(ctx: &Ctx, run: &mut Runner, path: &str) -> Result<Api, RkError> {
392    let exec = Exec {
393        program: ctx.cli.clone().into_os_string(),
394        args: vec!["api".into(), path.into()],
395        env: ctx.child_env("observe"),
396        cwd: ctx.target.as_std_path().to_path_buf(),
397        stdin: None,
398    };
399    let outcome = run(&exec)?;
400    if outcome.success() {
401        return Ok(
402            serde_json::from_slice::<Value>(&outcome.stdout).map_or_else(
403                |_| Api::Failed("the forge answer did not parse as JSON".into()),
404                Api::Ok,
405            ),
406        );
407    }
408    let stderr = String::from_utf8_lossy(&outcome.stderr).into_owned();
409    if stderr.contains("404") {
410        Ok(Api::Missing)
411    } else {
412        Ok(Api::Failed(last_line(&outcome.stderr)))
413    }
414}
415
416/// The last non-empty line of a byte stream, for one-line detail fields.
417fn last_line(bytes: &[u8]) -> String {
418    String::from_utf8_lossy(bytes)
419        .lines()
420        .rev()
421        .find(|line| !line.trim().is_empty())
422        .unwrap_or("no output")
423        .to_owned()
424}
425
426#[allow(
427    clippy::too_many_lines,
428    reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
429)]
430fn github(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
431    let trunk = ctx.trunk();
432    let repo = &ctx.repo;
433    match step {
434        "private-vulnerability-reporting" => {
435            let visibility_path = format!("repos/{repo}");
436            match api_get(ctx, run, &visibility_path)? {
437                Api::Ok(body) => match body["private"].as_bool() {
438                    Some(true) => {
439                        return Ok(StepState::inapplicable(
440                            "private vulnerability reporting is available for public repositories",
441                        ));
442                    }
443                    Some(false) => {}
444                    None => {
445                        return Ok(StepState::unknown(format!(
446                            "{visibility_path}: repository visibility is unreadable"
447                        )));
448                    }
449                },
450                Api::Missing => {
451                    return Ok(StepState::unknown(format!(
452                        "{visibility_path}: repository visibility is unreadable (404)"
453                    )));
454                }
455                Api::Failed(err) => {
456                    return Ok(StepState::unknown(format!("{visibility_path}: {err}")));
457                }
458            }
459            let path = format!("repos/{repo}/private-vulnerability-reporting");
460            Ok(match api_get(ctx, run, &path)? {
461                Api::Ok(body) => match body["enabled"].as_bool() {
462                    Some(true) => StepState::ok("private vulnerability reporting is enabled"),
463                    Some(false) => StepState::not("private vulnerability reporting is disabled"),
464                    None => StepState::unknown(format!("{path}: enabled is unreadable")),
465                },
466                Api::Missing => {
467                    StepState::unknown(format!("{path}: reporting state is unreadable (404)"))
468                }
469                Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
470            })
471        }
472
473        "default-branch" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
474            Api::Ok(body) => {
475                let found = body["default_branch"].as_str().unwrap_or("");
476                if found == trunk {
477                    StepState::ok(format!("{trunk} is the default branch"))
478                } else {
479                    StepState::not(format!("the default branch is {found}"))
480                }
481            }
482            Api::Missing => StepState::not(format!("the forge does not know {repo}")),
483            Api::Failed(err) => StepState::unknown(err),
484        }),
485        "single-trunk" => {
486            for candidate in ctx.retired_branches() {
487                let candidate = candidate.as_str();
488                if candidate == trunk {
489                    continue;
490                }
491                match api_get(ctx, run, &format!("repos/{repo}/git/ref/heads/{candidate}"))? {
492                    Api::Missing => {}
493                    Api::Ok(_) => {
494                        return Ok(StepState::not(format!("a {candidate} branch still exists")));
495                    }
496                    Api::Failed(err) => return Ok(StepState::unknown(err)),
497                }
498            }
499            Ok(StepState::ok(
500                "no long-lived branch besides the trunk remains",
501            ))
502        }
503        "merge-cleanup" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
504            Api::Ok(body) => {
505                if body["delete_branch_on_merge"].as_bool().unwrap_or(false) {
506                    StepState::ok("a merged branch is deleted by the forge")
507                } else {
508                    StepState::not("a merged branch outlives its merge")
509                }
510            }
511            Api::Missing => StepState::not(format!("the forge does not know {repo}")),
512            Api::Failed(err) => StepState::unknown(err),
513        }),
514        "auto-merge" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
515            Api::Ok(body) => {
516                if body["allow_auto_merge"].as_bool().unwrap_or(false) {
517                    StepState::ok("a request may merge itself once its checks pass")
518                } else {
519                    StepState::not("a request cannot merge itself; the auto-merge switch is off")
520                }
521            }
522            Api::Missing => StepState::not(format!("the forge does not know {repo}")),
523            Api::Failed(err) => StepState::unknown(err),
524        }),
525        "ci-permissions" => Ok(
526            match api_get(
527                ctx,
528                run,
529                &format!("repos/{repo}/actions/permissions/workflow"),
530            )? {
531                Api::Ok(body) => {
532                    let write = body["default_workflow_permissions"] == "write";
533                    let approve = body["can_approve_pull_request_reviews"] == true;
534                    if write && approve {
535                        StepState::ok("CI may write and open requests")
536                    } else {
537                        StepState::not(format!(
538                            "workflow permissions are {} with request approval {}",
539                            body["default_workflow_permissions"],
540                            body["can_approve_pull_request_reviews"]
541                        ))
542                    }
543                }
544                Api::Missing => StepState::not("no workflow permissions are readable"),
545                Api::Failed(err) => StepState::unknown(err),
546            },
547        ),
548        "bot-secrets" => Ok(
549            match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
550                Api::Ok(body) => {
551                    let names: Vec<&str> = body["secrets"]
552                        .as_array()
553                        .map(|list| {
554                            list.iter()
555                                .filter_map(|secret| secret["name"].as_str())
556                                .collect()
557                        })
558                        .unwrap_or_default();
559                    let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
560                    if wanted.iter().all(|name| names.contains(name)) {
561                        StepState::ok("both bot secrets are stored")
562                    } else if names.is_empty() {
563                        StepState::not("no bot secrets are stored")
564                    } else {
565                        StepState::not(format!("stored secrets: {}", names.join(", ")))
566                    }
567                }
568                Api::Missing => StepState::not("no secrets are readable"),
569                Api::Failed(err) => StepState::unknown(err),
570            },
571        ),
572        "protect-trunk" => github_trunk_ruleset(ctx, run),
573        "protect-tags" => github_ruleset(
574            ctx,
575            run,
576            ctx.tag_ruleset(),
577            "tag",
578            "refs/tags/v*",
579            &["deletion", "update"],
580        ),
581        "protect-release-lines" => {
582            match github_ruleset_body(ctx, run, ctx.lines_ruleset())? {
583                RulesetLookup::Absent => {
584                    return Ok(StepState::inapplicable(
585                        "release/* is unprotected; optional — applied only where older lines exist",
586                    ));
587                }
588                RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
589                RulesetLookup::Found(_) => {}
590            }
591            github_ruleset(
592                ctx,
593                run,
594                ctx.lines_ruleset(),
595                "branch",
596                "refs/heads/release/*",
597                &["deletion", "non_fast_forward"],
598            )
599        }
600        "protections-check" => {
601            // Confirmed drift and unreadable answers stay apart: a proven
602            // mismatch is drift even beside an outage, and an outage with
603            // nothing proven wrong stays unknown, never drift.
604            let mut failures = Vec::new();
605            let mut unknowns = Vec::new();
606            // Every satisfied step's limitation survives the aggregate.
607            let mut limitations: Vec<String> = Vec::new();
608            for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
609                match github(ctx, owned, run)? {
610                    StepState::Satisfied {
611                        limitation: found, ..
612                    } => limitations.extend(found),
613                    StepState::Inapplicable { .. } => {}
614                    StepState::Unsatisfied { detail } => {
615                        failures.push(format!("{owned}: {detail}"));
616                    }
617                    StepState::Unknown { detail } => {
618                        unknowns.push(format!("{owned}: {detail}"));
619                    }
620                }
621            }
622            match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
623                Api::Ok(body) => {
624                    let owned = [
625                        ctx.trunk_ruleset().to_owned(),
626                        ctx.tag_ruleset().to_owned(),
627                        ctx.lines_ruleset().to_owned(),
628                    ];
629                    for ruleset in body.as_array().into_iter().flatten() {
630                        let name = ruleset["name"].as_str().unwrap_or("");
631                        if !owned.iter().any(|expected| expected == name) {
632                            failures.push(format!("a ruleset no step owns: {name}"));
633                        }
634                    }
635                }
636                Api::Missing | Api::Failed(_) => {
637                    unknowns.push("the ruleset inventory is not readable".to_owned());
638                }
639            }
640            Ok(if !failures.is_empty() {
641                StepState::not(failures.join("; "))
642            } else if !unknowns.is_empty() {
643                StepState::unknown(unknowns.join("; "))
644            } else {
645                StepState::Satisfied {
646                    detail: "exactly the owned protections, with those rules".into(),
647                    limitation: if limitations.is_empty() {
648                        None
649                    } else {
650                        Some(limitations.join("; "))
651                    },
652                }
653            })
654        }
655        _ => Ok(StepState::unknown(format!("no observation for {step}"))),
656    }
657}
658
659/// The installation, observed as the App itself.
660///
661/// The forge serves `repos/{owner}/{repo}/installation` to an App JWT and
662/// to nothing a user can hold. The caller mints `jwt` — once per run, with
663/// the token and the key bytes already registered as redaction needles —
664/// which is why this lives outside the name dispatch above: an observation
665/// entered without that token has no honest answer.
666#[must_use]
667pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
668    match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
669        AppApi::Ok(body) => {
670            let id = body["id"].as_i64().unwrap_or_default();
671            StepState::ok(format!("installation {id} covers {}", ctx.repo))
672        }
673        AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
674        AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
675    }
676}
677
678/// A plain ruleset: active, and carrying exactly the expected rule types —
679/// not one fewer, and not one more, because an extra rule here is a rule the
680/// setup cannot reproduce or explain and can block the very push the method
681/// depends on.
682fn github_ruleset(
683    ctx: &Ctx,
684    run: &mut Runner,
685    name: &str,
686    target: &str,
687    include: &str,
688    rules: &[&str],
689) -> Result<StepState, RkError> {
690    let detail = match github_ruleset_body(ctx, run, name)? {
691        RulesetLookup::Found(detail) => detail,
692        RulesetLookup::Absent => {
693            return Ok(StepState::not(format!("no ruleset named {name}")));
694        }
695        RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
696    };
697    if detail["enforcement"] != "active" {
698        return Ok(StepState::not(format!("{name} is not active")));
699    }
700    // The name proves nothing: the ruleset must cover exactly the declared
701    // refs, or the protection it reports exists somewhere else.
702    if detail["target"] != target {
703        return Ok(StepState::not(format!(
704            "{name} does not target {target} refs"
705        )));
706    }
707    if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
708        return Ok(StepState::not(format!(
709            "{name} does not cover {include} alone"
710        )));
711    }
712    if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
713        return Ok(StepState::not(format!(
714            "{name} excludes refs from its own coverage"
715        )));
716    }
717    let mut held: Vec<&str> = detail["rules"]
718        .as_array()
719        .map(|list| {
720            list.iter()
721                .filter_map(|rule| rule["type"].as_str())
722                .collect()
723        })
724        .unwrap_or_default();
725    held.sort_unstable();
726    let mut expected: Vec<&str> = rules.to_vec();
727    expected.sort_unstable();
728    if held == expected {
729        Ok(StepState::ok(format!(
730            "{name} is active with exactly its rules"
731        )))
732    } else {
733        Ok(StepState::not(format!(
734            "{name} carries the rules [{}] where the setup owns [{}]",
735            held.join(", "),
736            expected.join(", ")
737        )))
738    }
739}
740
741// The trunk ruleset is checked for the shape a release merge needs.
742// The rule kinds the setup writes and can reproduce come from
743// `protection.owned_trunk_rules`, floored to contain all four. The set
744// also drives the missing-rule fault, so a kind this convention refuses
745// must stay out of it: adding one would demand that rule on every target.
746// The floor is what stops a target dropping one it needs.
747
748/// A fault line for every rule on the trunk that the setup does not own.
749///
750/// The merge queue gets its own text, because this convention refuses one
751/// deliberately and the operator needs the consequence and the remedy. Every
752/// other unowned kind reads generically: an unowned rule is one the setup
753/// cannot reproduce or explain, and it can block the very merge the method
754/// depends on.
755fn unowned_rule_faults(rules: &[Value], owned: &[String]) -> Vec<String> {
756    rules
757        .iter()
758        .filter_map(|rule| rule["type"].as_str())
759        .filter(|kind| !owned.iter().any(|name| name == kind))
760        .map(|kind| {
761            if kind == "merge_queue" {
762                MERGE_QUEUE_FAULT.to_owned()
763            } else {
764                format!("an unowned rule is present: {kind}")
765            }
766        })
767        .collect()
768}
769
770fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
771    let trunk = ctx.trunk();
772    let name = ctx.trunk_ruleset().to_owned();
773    let detail = match github_ruleset_body(ctx, run, &name)? {
774        RulesetLookup::Found(detail) => detail,
775        RulesetLookup::Absent => {
776            return Ok(StepState::not(format!("no ruleset named {name}")));
777        }
778        RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
779    };
780    let rules = detail["rules"].as_array().cloned().unwrap_or_default();
781    let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
782    let mut faults = Vec::new();
783    if detail["enforcement"] != "active" {
784        faults.push(format!("{name} is not active"));
785    }
786    // The name proves nothing: a ruleset applies only where its conditions
787    // say, so a right-named ruleset covering another ref would otherwise
788    // read as a protected trunk.
789    if detail["target"] != "branch" {
790        faults.push(format!("{name} does not target branches"));
791    }
792    let expected_ref = serde_json::json!([format!("refs/heads/{trunk}")]);
793    if detail["conditions"]["ref_name"]["include"] != expected_ref {
794        faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
795    }
796    // A matching exclusion negates the include, so the owned shape is an
797    // exclusion list that is exactly empty.
798    if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
799        faults.push(format!("{name} excludes refs from its own coverage"));
800    }
801    if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
802        faults.push("a bypass actor is named".to_owned());
803    }
804    for required in &ctx.protection().owned_trunk_rules {
805        if !has(required) {
806            faults.push(format!("the {required} rule is missing"));
807        }
808    }
809    faults.extend(unowned_rule_faults(
810        &rules,
811        &ctx.protection().owned_trunk_rules,
812    ));
813    if let Some(request) = rules.iter().find(|rule| rule["type"] == "pull_request") {
814        if request["parameters"]["allowed_merge_methods"]
815            != serde_json::json!(ctx.protection().allowed_merge_methods)
816        {
817            faults.push("the merge method is not exactly a squash merge".to_owned());
818        }
819    }
820    if let Some(checks) = rules
821        .iter()
822        .find(|rule| rule["type"] == "required_status_checks")
823    {
824        if checks["parameters"]["strict_required_status_checks_policy"]
825            != ctx.protection().strict_required_status_checks
826        {
827            faults.push(STALE_MERGE_FAULT.to_owned());
828        }
829        let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
830            .as_array()
831            .map(|list| {
832                list.iter()
833                    .filter_map(|check| check["context"].as_str())
834                    .collect()
835            })
836            .unwrap_or_default();
837        // Where the expected check is known, the context set must be exactly
838        // it plus the title check: an extra stale context does not fail a
839        // merge, it hangs one, and a missing title check lets an
840        // unconventional squash title land on the trunk.
841        if contexts.is_empty() {
842            faults.push("no status check is required".to_owned());
843        } else if let Some(expected) = &ctx.required_check {
844            let mut held = contexts.clone();
845            held.sort_unstable();
846            let title_check = ctx.title_check();
847            let mut owned_contexts = [expected.as_str(), title_check];
848            owned_contexts.sort_unstable();
849            if held != owned_contexts {
850                faults.push(format!(
851                    "the required checks are [{}] where the setup owns [{}]",
852                    contexts.join(", "),
853                    owned_contexts.join(", ")
854                ));
855            }
856        } else if !contexts.contains(&ctx.title_check()) {
857            faults.push(format!("the {} check is not required", ctx.title_check()));
858        }
859    }
860    match squash_merge_sources(ctx, run)? {
861        MergeSources::Owned => {}
862        MergeSources::Faults(proven) => faults.extend(proven),
863        // Proven drift wins over an outage: an unreadable settings read
864        // downgrades the answer to unknown only when nothing above it was
865        // proven wrong.
866        MergeSources::Unreadable(err) => {
867            if faults.is_empty() {
868                return Ok(StepState::unknown(err));
869            }
870        }
871    }
872    if let Some(shape) = gate_faults(ctx) {
873        faults.push(shape);
874    }
875    if !faults.is_empty() {
876        return Ok(StepState::not(faults.join("; ")));
877    }
878    Ok(StepState::ok(format!(
879        "{name} holds the release-merge shape"
880    )))
881}
882
883/// The ways the named gate is shaped so that it cannot report a blocking
884/// answer. A required check that never reports is a broken trunk
885/// protection, not a weaker guarantee, so each of these is a fault rather
886/// than a limitation. Read only where the check is named: without the flag
887/// the observation knows no gate.
888///
889/// It judges the gate alone. Which other jobs a project means to block a
890/// merge is intent, no file states it, and `forges/github.md` carries that
891/// as a convention instead.
892fn gate_faults(ctx: &Ctx) -> Option<String> {
893    let check = ctx.required_check.as_deref()?;
894    workflow_jobs::faults(
895        &workflow_jobs::read_gate(&ctx.target, check, ctx.trunk()),
896        check,
897        ctx.trunk(),
898    )
899}
900
901/// What the repository's squash message settings hold.
902enum MergeSources {
903    /// The request's title and body, as the setup owns.
904    Owned,
905    /// Proven other values, one fault line each.
906    Faults(Vec<String>),
907    /// The settings could not be read.
908    Unreadable(String),
909}
910
911/// The squash message sources, repository settings beside the ruleset:
912/// with the title source unset, a one-commit request offers that commit's
913/// own subject as the trunk's message, which the bot then reads for the
914/// version; with the message source on another value, the trunk's body is
915/// not the request's description the content gates judged. One GET
916/// answers for both, each faulted by name.
917fn squash_merge_sources(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
918    Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
919        Api::Ok(body) => {
920            let mut faults = Vec::new();
921            let owned_title = ctx.protection().github.squash_title_source.as_str();
922            let owned_body = ctx.protection().github.squash_body_source.as_str();
923            if body["squash_merge_commit_title"] != owned_title {
924                faults.push(format!(
925                    "the squash title source is {} where the setup owns {owned_title}",
926                    body["squash_merge_commit_title"]
927                ));
928            }
929            if body["squash_merge_commit_message"] != owned_body {
930                faults.push(format!(
931                    "the squash message source is {} where the setup owns {owned_body}",
932                    body["squash_merge_commit_message"]
933                ));
934            }
935            if faults.is_empty() {
936                MergeSources::Owned
937            } else {
938                MergeSources::Faults(faults)
939            }
940        }
941        Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
942        Api::Failed(err) => MergeSources::Unreadable(err),
943    })
944}
945
946/// One ruleset lookup by name: found, provably absent, or unreadable —
947/// an unreadable inventory must never read as an absent ruleset.
948enum RulesetLookup {
949    /// The ruleset exists; its detail body.
950    Found(Value),
951    /// The inventory was read successfully and no ruleset carries the
952    /// name.
953    Absent,
954    /// The inventory or the detail could not be read.
955    Unreadable(String),
956}
957
958/// A ruleset's detail body by name.
959fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
960    // A 404 on the collection is an unreachable inventory — a missing
961    // repository or an unauthorized read — never an empty one: an empty
962    // inventory answers 200 with an empty list.
963    let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
964        Api::Ok(body) => body,
965        Api::Missing => {
966            return Ok(RulesetLookup::Unreadable(
967                "the ruleset inventory is not readable".into(),
968            ));
969        }
970        Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
971    };
972    let id = list
973        .as_array()
974        .into_iter()
975        .flatten()
976        .find(|ruleset| ruleset["name"] == name)
977        .and_then(|ruleset| ruleset["id"].as_i64());
978    let Some(id) = id else {
979        return Ok(RulesetLookup::Absent);
980    };
981    match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
982        Api::Ok(body) => Ok(RulesetLookup::Found(body)),
983        // A listed id that answers 404 is not proof of absence either — the
984        // forge also answers 404 for an unauthorized read — so a rerun
985        // decides, rather than a false drift.
986        Api::Missing => Ok(RulesetLookup::Unreadable(format!(
987            "the {name} detail is not readable"
988        ))),
989        Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
990    }
991}
992
993/// The GitLab limitation the `auto-merge` step reports: the forge has no
994/// project-level switch, so the observation reads the pipeline requirement
995/// the trunk protection asserts.
996const 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";
997
998/// The GitLab limitation `protect-tags` and `protections-check` report.
999const GITLAB_TAG_LIMITATION: &str =
1000    "an Owner or Maintainer can still delete a protected tag through the UI or API";
1001
1002/// The fault a merge queue on the trunk reads as: what is enabled, what it
1003/// costs, and how to undo it. This convention refuses a queue rather than
1004/// owning one, so the operator needs the consequence rather than a rule
1005/// type's bare name.
1006const 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";
1007
1008/// The freshness defect is independent of an absent required check.
1009const 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";
1010
1011/// The GitLab limitation `protect-trunk` and `protections-check` report:
1012/// the title gate rides the request's own pipeline on this forge.
1013const 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";
1014
1015#[allow(
1016    clippy::too_many_lines,
1017    reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
1018)]
1019fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
1020    let trunk = ctx.trunk();
1021    let project = ctx.repo.replace('/', "%2F");
1022    match step {
1023        "private-vulnerability-reporting" => {
1024            let path = format!("projects/{project}");
1025            Ok(match api_get(ctx, run, &path)? {
1026                Api::Ok(body) => {
1027                    let access = body["issues_access_level"].as_str();
1028                    if !matches!(access, Some("enabled" | "private" | "disabled")) {
1029                        StepState::unknown("issue intake access is unreadable")
1030                    } else if body
1031                        .get("issues_enabled")
1032                        .is_some_and(|flag| !flag.is_boolean())
1033                    {
1034                        StepState::unknown("legacy issue intake flag is unreadable")
1035                    } else if body["issues_enabled"] == false || access == Some("disabled") {
1036                        StepState::not("issue intake is disabled; see setup guide step 3g")
1037                    } else if access == Some("private") {
1038                        StepState::not("issue intake is restricted; see setup guide step 3g")
1039                    } else {
1040                        StepState::ok_with_limitation(
1041                            "issue intake is enabled",
1042                            GITLAB_PRIVATE_REPORTING_LIMITATION,
1043                        )
1044                    }
1045                }
1046                Api::Missing => {
1047                    StepState::unknown(format!("{path}: issue intake is unreadable (404)"))
1048                }
1049                Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
1050            })
1051        }
1052
1053        "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1054            Api::Ok(body) => {
1055                let found = body["default_branch"].as_str().unwrap_or("");
1056                if found == trunk {
1057                    StepState::ok(format!("{trunk} is the default branch"))
1058                } else {
1059                    StepState::not(format!("the default branch is {found}"))
1060                }
1061            }
1062            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1063            Api::Failed(err) => StepState::unknown(err),
1064        }),
1065        "single-trunk" => {
1066            for candidate in ctx.retired_branches() {
1067                let candidate = candidate.as_str();
1068                if candidate == trunk {
1069                    continue;
1070                }
1071                match api_get(
1072                    ctx,
1073                    run,
1074                    &format!("projects/{project}/repository/branches/{candidate}"),
1075                )? {
1076                    Api::Missing => {}
1077                    Api::Ok(_) => {
1078                        return Ok(StepState::not(format!("a {candidate} branch still exists")));
1079                    }
1080                    Api::Failed(err) => return Ok(StepState::unknown(err)),
1081                }
1082            }
1083            Ok(StepState::ok(
1084                "no long-lived branch besides the trunk remains",
1085            ))
1086        }
1087        "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1088            Api::Ok(body) => {
1089                if body["remove_source_branch_after_merge"]
1090                    .as_bool()
1091                    .unwrap_or(false)
1092                {
1093                    StepState::ok("a merged branch is deleted by the forge")
1094                } else {
1095                    StepState::not("a merged branch outlives its merge")
1096                }
1097            }
1098            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1099            Api::Failed(err) => StepState::unknown(err),
1100        }),
1101        "auto-merge" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1102            Api::Ok(body) => {
1103                if body["only_allow_merge_if_pipeline_succeeds"]
1104                    .as_bool()
1105                    .unwrap_or(false)
1106                {
1107                    StepState::ok_with_limitation(
1108                        "a request may merge itself once its pipeline passes",
1109                        GITLAB_AUTO_MERGE_LIMITATION,
1110                    )
1111                } else {
1112                    StepState::not(
1113                        "the pipeline requirement auto-merge rides on is off; protect-trunk asserts it",
1114                    )
1115                }
1116            }
1117            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1118            Api::Failed(err) => StepState::unknown(err),
1119        }),
1120        "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1121            Api::Ok(body) => {
1122                if body["jobs_enabled"] == true {
1123                    StepState::ok("pipelines are enabled")
1124                } else {
1125                    StepState::not("pipelines are disabled")
1126                }
1127            }
1128            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1129            Api::Failed(err) => StepState::unknown(err),
1130        }),
1131        "install-bot" => {
1132            // The listing paginates, exactly as the script's does: an
1133            // active token past the first page must not read as absent, or
1134            // verification would contradict the apply it verifies. Absence
1135            // is only reported once a short page proves the listing was
1136            // exhausted; a bound reached on a full page is an unknown.
1137            let mut active = false;
1138            let mut exhausted = false;
1139            for page in 1..=10u32 {
1140                let path = format!(
1141                    "projects/{project}/access_tokens?state=active&per_page=100&page={page}"
1142                );
1143                let list = match api_get(ctx, run, &path)? {
1144                    Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1145                    Api::Missing => Vec::new(),
1146                    Api::Failed(err) => return Ok(StepState::unknown(err)),
1147                };
1148                active = active
1149                    || list.iter().any(|token| {
1150                        token["name"] == "release-bot"
1151                            && token["revoked"] == false
1152                            && token["active"] != false
1153                    });
1154                if list.len() < 100 {
1155                    exhausted = true;
1156                }
1157                if active || exhausted {
1158                    break;
1159                }
1160            }
1161            if !active {
1162                return Ok(if exhausted {
1163                    StepState::not("no active release-bot token exists")
1164                } else {
1165                    StepState::unknown(
1166                        "the token listing did not exhaust within ten pages; nothing was decided",
1167                    )
1168                });
1169            }
1170            // A token whose stored variable has gone missing is a stranded
1171            // identity — its value is unrecoverable — so the step is only
1172            // satisfied when both halves hold, and a rerun rotates.
1173            Ok(
1174                match api_get(
1175                    ctx,
1176                    run,
1177                    &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1178                )? {
1179                    Api::Ok(_) => StepState::ok(
1180                        "an active release-bot token exists and its variable is stored",
1181                    ),
1182                    Api::Missing => StepState::not(
1183                        "an active release-bot token exists with no stored variable; a rerun revokes and replaces it",
1184                    ),
1185                    Api::Failed(err) => StepState::unknown(err),
1186                },
1187            )
1188        }
1189        "bot-secrets" => Ok(
1190            match api_get(
1191                ctx,
1192                run,
1193                &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1194            )? {
1195                Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
1196                Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
1197                Api::Failed(err) => StepState::unknown(err),
1198            },
1199        ),
1200        "protect-trunk" => {
1201            let protection = match api_get(
1202                ctx,
1203                run,
1204                &format!("projects/{project}/protected_branches/{trunk}"),
1205            )? {
1206                Api::Ok(body) => body,
1207                Api::Missing => {
1208                    return Ok(StepState::not(format!("{trunk} is not protected")));
1209                }
1210                Api::Failed(err) => return Ok(StepState::unknown(err)),
1211            };
1212            // Exactly one push grant, and it is the no-access entry: the
1213            // forge honors the most permissive grant, so a second entry
1214            // beside access level 0 is a branch that still takes a push.
1215            let grants = protection["push_access_levels"]
1216                .as_array()
1217                .cloned()
1218                .unwrap_or_default();
1219            let policy = ctx.protection();
1220            let no_push =
1221                grants.len() == 1 && grants[0]["access_level"] == policy.gitlab.push_access_level;
1222            // The merge grant is owned exactly too: a merge level of 0 keeps
1223            // every release request unmergeable while the push shape reads
1224            // clean, so both halves are checked.
1225            let merges = protection["merge_access_levels"]
1226                .as_array()
1227                .cloned()
1228                .unwrap_or_default();
1229            let can_merge =
1230                merges.len() == 1 && merges[0]["access_level"] == policy.gitlab.merge_access_level;
1231            let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
1232                Api::Ok(body) => body,
1233                Api::Missing | Api::Failed(_) => Value::Null,
1234            };
1235            let mut faults = Vec::new();
1236            if !no_push {
1237                faults.push(format!(
1238                    "{trunk} still takes a direct push: the forge honors the most permissive of {} push grants",
1239                    grants.len()
1240                ));
1241            }
1242            if !can_merge {
1243                faults.push(format!(
1244                    "{trunk} merge grants are not exactly the one owned maintainer level"
1245                ));
1246            }
1247            if protection["allow_force_push"] != false {
1248                faults.push(format!("{trunk} allows force pushes"));
1249            }
1250            if settings["only_allow_merge_if_pipeline_succeeds"] != true {
1251                faults.push("the pipeline requirement is off".to_owned());
1252            }
1253            if settings["merge_method"] != policy.gitlab.merge_method.as_str() {
1254                faults.push("the merge method is not fast-forward".to_owned());
1255            }
1256            if settings["squash_option"] != policy.gitlab.squash_option.as_str() {
1257                faults.push("merge requests do not always squash".to_owned());
1258            }
1259            if settings["squash_commit_template"] != policy.gitlab.squash_commit_template.as_str() {
1260                faults.push("the squash template is not the merge request's title".to_owned());
1261            }
1262            Ok(if faults.is_empty() {
1263                StepState::ok_with_limitation(
1264                    format!("{trunk} holds the release-merge shape"),
1265                    GITLAB_TITLE_LIMITATION,
1266                )
1267            } else {
1268                StepState::not(faults.join("; "))
1269            })
1270        }
1271        "protect-tags" => Ok(
1272            match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
1273                Api::Ok(_) => {
1274                    StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
1275                }
1276                Api::Missing => StepState::not("v* is not protected"),
1277                Api::Failed(err) => StepState::unknown(err),
1278            },
1279        ),
1280        "protect-release-lines" => Ok(
1281            match api_get(
1282                ctx,
1283                run,
1284                &format!("projects/{project}/protected_branches/release%2F%2A"),
1285            )? {
1286                Api::Ok(body) => {
1287                    let level_ok = |levels: &Value| {
1288                        levels
1289                            .as_array()
1290                            .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
1291                    };
1292                    if body["allow_force_push"] != false {
1293                        StepState::not("release/* allows force pushes")
1294                    } else if !level_ok(&body["push_access_levels"])
1295                        || !level_ok(&body["merge_access_levels"])
1296                    {
1297                        // A push level of 0 blocks the documented
1298                        // cherry-pick-by-push path while force-push reads
1299                        // clean, so the grant shape is owned exactly.
1300                        StepState::not(
1301                            "release/* grants are not exactly the owned maintainer levels",
1302                        )
1303                    } else {
1304                        StepState::ok("release/* refuses force pushes and deletion by git clients")
1305                    }
1306                }
1307                Api::Missing => StepState::inapplicable(
1308                    "release/* is unprotected; optional — applied only where older lines exist",
1309                ),
1310                Api::Failed(err) => StepState::unknown(err),
1311            },
1312        ),
1313        "protections-check" => {
1314            // Same separation as the sibling forge: proven drift wins,
1315            // an outage with nothing proven wrong stays unknown.
1316            let mut failures = Vec::new();
1317            let mut unknowns = Vec::new();
1318            // Every satisfied step's limitation survives the aggregate: a
1319            // first limitation must not shadow a second.
1320            let mut limitations: Vec<String> = Vec::new();
1321            for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
1322                match gitlab(ctx, owned, run)? {
1323                    StepState::Satisfied {
1324                        limitation: found, ..
1325                    } => limitations.extend(found),
1326                    StepState::Inapplicable { .. } => {}
1327                    StepState::Unsatisfied { detail } => {
1328                        failures.push(format!("{owned}: {detail}"));
1329                    }
1330                    StepState::Unknown { detail } => {
1331                        unknowns.push(format!("{owned}: {detail}"));
1332                    }
1333                }
1334            }
1335            Ok(if !failures.is_empty() {
1336                StepState::not(failures.join("; "))
1337            } else if !unknowns.is_empty() {
1338                StepState::unknown(unknowns.join("; "))
1339            } else {
1340                StepState::Satisfied {
1341                    detail: "the protections hold, as far as this forge enforces them".into(),
1342                    limitation: if limitations.is_empty() {
1343                        None
1344                    } else {
1345                        Some(limitations.join("; "))
1346                    },
1347                }
1348            })
1349        }
1350        _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1351    }
1352}