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