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