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