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