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