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