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::cargo_package::{self, Answer, POLICY_DESTINATION, Probe};
13use crate::detect::Forge;
14use crate::error::RkError;
15use crate::setup::app_jwt::{self, AppApi};
16use crate::setup::context::Ctx;
17use crate::setup::process::{Exec, Outcome};
18use crate::setup::workflow_jobs;
19
20/// The executor observes run through: the command layer wraps echoing,
21/// journaling, and redaction around the process adapter.
22pub type Runner<'a> = dyn FnMut(&Exec) -> Result<Outcome, RkError> + 'a;
23
24// The long-lived branch names `single-trunk` retires when each is an
25// ancestor of the trunk come from the target's own configuration, read
26// through `Ctx::retired_branches`. The compiled default is the common
27// default branch and the retired second branch, so a target that names
28// none behaves exactly as it did.
29
30// The landed title check's context is the job in `pr-title.yml` that
31// holds the squash title to the commit convention. The target names it in
32// `protection.title_check`, read through `Ctx::title_check`; the landed
33// job keeps its own name as a source constant, so a target that renames
34// the key without renaming the job breaks its own trunk protection and
35// this observer reports it.
36
37const GITLAB_PRIVATE_REPORTING_LIMITATION: &str = "GitLab has no project-level private reporting switch; the reporter must enable confidentiality; this proves project feature access, not successful submission by every external reporter";
38
39/// What one observation found.
40#[derive(Debug)]
41pub enum StepState {
42    /// The desired state holds; a limitation names what the forge enforces
43    /// less strongly than the step's proof claims.
44    Satisfied {
45        /// What was found, one line.
46        detail: String,
47        /// The weaker guarantee, by name, where the forge enforces less.
48        limitation: Option<String>,
49    },
50    /// The desired state does not hold.
51    Unsatisfied {
52        /// What was found instead.
53        detail: String,
54    },
55    /// Eligibility or an optional step's condition does not hold: nothing
56    /// is proven, and `check` reports it as skipped.
57    Inapplicable {
58        /// Why the step does not apply here.
59        detail: String,
60    },
61    /// The observation could not decide.
62    Unknown {
63        /// Why not.
64        detail: String,
65    },
66}
67
68impl StepState {
69    /// Whether the desired state holds.
70    #[must_use]
71    pub const fn satisfied(&self) -> bool {
72        matches!(self, Self::Satisfied { .. })
73    }
74
75    fn ok(detail: impl Into<String>) -> Self {
76        Self::Satisfied {
77            detail: detail.into(),
78            limitation: None,
79        }
80    }
81
82    fn ok_with_limitation(detail: impl Into<String>, limitation: impl Into<String>) -> Self {
83        Self::Satisfied {
84            detail: detail.into(),
85            limitation: Some(limitation.into()),
86        }
87    }
88
89    fn not(detail: impl Into<String>) -> Self {
90        Self::Unsatisfied {
91            detail: detail.into(),
92        }
93    }
94
95    fn inapplicable(detail: impl Into<String>) -> Self {
96        Self::Inapplicable {
97            detail: detail.into(),
98        }
99    }
100
101    fn unknown(detail: impl Into<String>) -> Self {
102        Self::Unknown {
103            detail: detail.into(),
104        }
105    }
106}
107
108/// One read-only forge API answer.
109enum Api {
110    /// The call succeeded and parsed.
111    Ok(Value),
112    /// The forge answered 404: the thing is not there.
113    Missing,
114    /// The call failed for another reason, with the CLI's own words.
115    Failed(String),
116}
117
118/// Observe one step's desired state.
119///
120/// # Errors
121///
122/// Propagates executor failures; a forge answer that merely disagrees is a
123/// [`StepState`], not an error.
124pub fn observe(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
125    if step == "package-check" {
126        return package_check(ctx, run);
127    }
128    if step == "branch-reminder" {
129        return Ok(branch_reminder_state(ctx));
130    }
131    if step == "forge-version" {
132        return forge_version(ctx, run);
133    }
134    match ctx.forge {
135        Some(Forge::Github) => github(ctx, step, run),
136        Some(Forge::Gitlab) => gitlab(ctx, step, run),
137        // A forge step at a target with no adapter proves nothing and
138        // reads nothing: the applicability gate states why, and this is
139        // the observation saying the same.
140        None => Ok(StepState::inapplicable(
141            "the profile names no forge this release drives",
142        )),
143    }
144}
145
146/// What Python's check cannot answer. PEP 517 lets a project choose its
147/// build backend, and an sdist and a wheel can carry different files, so
148/// one `python3 -m build` run supplies no listing contract across both
149/// outputs.
150const PYTHON_LIMITATION: &str = "sdist and wheel policy inclusion is unproved: PEP 517 leaves the file set to the build backend and the two outputs can differ; inspect both before publishing";
151
152/// What Bash's check cannot answer. Its binding builds the tarball with
153/// `git archive`, where an `export-ignore` attribute drops a tracked file.
154const BASH_LIMITATION: &str = "the make dist tarball is not inspected: git archive honours export-ignore, so SECURITY.md inclusion is unproved; inspect the generated tarball before publishing";
155
156/// §0: the technology's own no-credential packaging check; the one step that
157/// reads its command from the binding rather than from a forge tree.
158///
159/// Publishability is the whole of the check for every binding. Policy reach
160/// is asserted only where the binding has a deterministic listing command
161/// the step can run with no credentials, which today is a sole Cargo
162/// package rooted at the target; every other shape reports its successful
163/// packaging result with the unproved inclusion named.
164fn package_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
165    let (program, args): (&str, &[&str]) = match ctx.tech {
166        Some("rust") => ("cargo", &["publish", "--dry-run", "--allow-dirty"]),
167        Some("python") => ("python3", &["-m", "build"]),
168        Some("bash") => {
169            return Ok(StepState::ok_with_limitation(
170                "no registry for this technology; there is nothing to package",
171                BASH_LIMITATION,
172            ));
173        }
174        Some(other) => {
175            return Ok(StepState::unknown(format!(
176                "no packaging check is defined for {other}"
177            )));
178        }
179        None => {
180            return Ok(StepState::unknown(
181                "no version file names a technology; see rk binding --list",
182            ));
183        }
184    };
185    let outcome = run(&cargo_exec(ctx, program, args))?;
186    if !outcome.success() {
187        return Ok(StepState::not(format!(
188            "the packaging check failed: {}",
189            last_line(&outcome.stderr)
190        )));
191    }
192    let built = "the package builds and passes the registry's dry run";
193    Ok(match ctx.tech {
194        Some("rust") => policy_in_the_crate(ctx, run, built)?,
195        _ => StepState::ok_with_limitation(built, PYTHON_LIMITATION),
196    })
197}
198
199/// One no-credential Cargo invocation against the target.
200fn cargo_exec(ctx: &Ctx, program: &str, args: &[&str]) -> Exec {
201    Exec {
202        program: program.into(),
203        args: args.iter().map(Into::into).collect(),
204        env: ctx.child_env("package-check"),
205        cwd: ctx.target.as_std_path().to_path_buf(),
206        stdin: None,
207    }
208}
209
210/// Whether the published crate carries the root policy and none of
211/// release-kit's own files, for the one shape Cargo answers unambiguously.
212///
213/// [`cargo_package::probe`] lists files only for a sole selected default
214/// member whose manifest is the target's own `Cargo.toml`; a virtual
215/// workspace, several default members, and a sole nested member each keep
216/// the successful publishability result and name the limitation instead of
217/// claiming a reach they cannot prove.
218///
219/// release-plz attributes a commit to the crate when the commit changes a
220/// file the crate ships, so a packaged file every landing rewrites makes
221/// every upgrade a release with no code change. The fault names each such
222/// path and the `exclude` entry that removes it, and edits nothing.
223///
224/// SATISFIES forge-setup:a-package-check-states-policy-reach
225fn policy_in_the_crate(ctx: &Ctx, run: &mut Runner, built: &str) -> Result<StepState, RkError> {
226    let root_manifest = ctx.target.as_std_path().join("Cargo.toml");
227    let probe = cargo_package::probe(&root_manifest, &[], |args| {
228        run(&cargo_exec(ctx, "cargo", args)).map(|outcome| Answer {
229            success: outcome.success(),
230            stdout: outcome.stdout,
231            stderr: outcome.stderr,
232        })
233    })?;
234    let listing = match probe {
235        Probe::Listed(listing) => listing,
236        Probe::OtherShape => {
237            return Ok(StepState::ok_with_limitation(
238                built,
239                format!(
240                    "{POLICY_DESTINATION} inclusion and the absence of release-kit's own files are unproved: the package check lists files only for a single default package rooted at the target, and this workspace selects a different shape; inspect the published archive before releasing"
241                ),
242            ));
243        }
244        Probe::MetadataFailed(stderr) => {
245            return Ok(StepState::unknown(format!(
246                "{built}, and the listing check could not run: cargo metadata failed: {}",
247                last_line(&stderr)
248            )));
249        }
250        Probe::ListingFailed(stderr) => {
251            return Ok(StepState::unknown(format!(
252                "{built}, and the listing check could not run: cargo package --list failed: {}",
253                last_line(&stderr)
254            )));
255        }
256    };
257    let forbidden = cargo_package::release_kit_paths(ctx.landed_destinations());
258    let shipped = listing.shipped(&forbidden);
259    Ok(boundary_state(
260        built,
261        listing.carries(POLICY_DESTINATION),
262        &shipped,
263    ))
264}
265
266/// The verdict over one listing: the policy must ship, and no file
267/// release-kit lands or owns may. Both faults share one detail, because a
268/// step reports one line.
269fn boundary_state(built: &str, carried: bool, shipped: &[&str]) -> StepState {
270    const WHY: &str = "and release-plz attributes every commit touching one of them to the package, so a landing that rewrites one asks for a release with no code change";
271    let policy_fix = format!(
272        "add /{POLICY_DESTINATION} to [package].include, remove the [package].exclude entry matching it, or stop ignoring the file"
273    );
274    let own = || {
275        let entries = cargo_package::exclude_entries(shipped)
276            .iter()
277            .map(|entry| format!("\"{entry}\""))
278            .collect::<Vec<_>>()
279            .join(", ");
280        (
281            shipped.join(", "),
282            format!("add {entries} to [package].exclude, or drop them from [package].include"),
283        )
284    };
285    match (carried, shipped.is_empty()) {
286        (true, true) => StepState::ok(format!(
287            "{built}, and the published package carries {POLICY_DESTINATION} and no file release-kit lands or owns"
288        )),
289        (false, true) => StepState::not(format!(
290            "{built}, but the published package omits {POLICY_DESTINATION}: {policy_fix}"
291        )),
292        (true, false) => {
293            let (list, fix) = own();
294            StepState::not(format!(
295                "{built}, but the published package ships files release-kit lands or owns ({list}), {WHY}: {fix}"
296            ))
297        }
298        (false, false) => {
299            let (list, fix) = own();
300            StepState::not(format!(
301                "{built}, but the published package omits {POLICY_DESTINATION} and ships files release-kit lands or owns ({list}), {WHY}: {policy_fix}; and {fix}"
302            ))
303        }
304    }
305}
306
307/// §1: the post-merge reminder hook, judged from the target's own files;
308/// the one step whose observation asks no forge and spawns no CLI.
309fn branch_reminder_state(ctx: &Ctx) -> StepState {
310    use crate::setup::branch_reminder::{HookState, observe_hook};
311    match observe_hook(&ctx.target) {
312        HookState::Installed => {
313            StepState::ok("the post-merge hook carries the release-kit reminder")
314        }
315        HookState::Absent => StepState::not("no post-merge hook is installed"),
316        HookState::Foreign => {
317            StepState::not("a post-merge hook exists without the release-kit marker")
318        }
319        HookState::Drifted => StepState::not("the reminder hook drifted from this binary's body"),
320        HookState::Unreadable(detail) => StepState::unknown(detail),
321    }
322}
323
324/// The GitLab version this convention needs, as major and minor.
325///
326/// `trigger: strategy: mirror` arrived in GitLab 18.2, and the merge-request
327/// pipeline's `project-jobs` bridge rests on it: below the floor the child
328/// pipeline's status never reaches the parent, so a failing project job
329/// merges.
330pub const GITLAB_VERSION_FLOOR: (u64, u64) = (18, 2);
331
332/// The two suffixes that name an edition rather than a pre-release. Every
333/// other suffix is a pre-release, and the step fails closed on one.
334const GITLAB_EDITIONS: [&str; 2] = ["ee", "ce"];
335
336/// The refusal an instance below the floor reads: the reading, the reason,
337/// and the fix.
338fn version_refusal(found: &str, prerelease: Option<&str>) -> String {
339    let (major, minor) = GITLAB_VERSION_FLOOR;
340    let mut said = vec![format!(
341        "this GitLab instance reports {found}; the convention needs {major}.{minor} or newer"
342    )];
343    if let Some(suffix) = prerelease {
344        said.push(format!(
345            "the -{suffix} suffix is a pre-release, and nothing proves the feature shipped in it, so this step fails closed"
346        ));
347    }
348    said.push(format!(
349        "the merge-request pipeline triggers a child pipeline with `strategy: mirror`, which GitLab added in {major}.{minor}"
350    ));
351    said.push(
352        "below it the child's status never reaches the parent pipeline, so a failing project job merges".to_owned(),
353    );
354    said.push(format!(
355        "upgrade the instance to {major}.{minor} or newer, or host the project on gitlab.com"
356    ));
357    said.join("; ")
358}
359
360/// §3: the forge's own version against the convention's floor.
361///
362/// GitHub is a rolling service and is answered without a call. GitLab is one
363/// read-only `GET /version`, and every failure to read is `Unknown`, which
364/// blocks the `protect-trunk` prerequisite exactly as `Unsatisfied` does.
365fn forge_version(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
366    if ctx.forge == Some(Forge::Github) {
367        return Ok(StepState::ok(
368            "github.com is a rolling service and declares no version floor",
369        ));
370    }
371    let body = match api_get(ctx, run, "version")? {
372        Api::Ok(body) => body,
373        Api::Missing => {
374            return Ok(StepState::unknown(
375                "this instance answers no GET /version; the floor cannot be read. Check that glab is authenticated against it: glab auth login",
376            ));
377        }
378        Api::Failed(err) => {
379            return Ok(StepState::unknown(format!(
380                "the version could not be read: {err}. Check that glab is authenticated against this instance: glab auth login"
381            )));
382        }
383    };
384    let Some(found) = body["version"].as_str() else {
385        return Ok(StepState::unknown(
386            "the forge answer carries no version field; the floor cannot be read. Check that glab is authenticated against this instance: glab auth login",
387        ));
388    };
389    let (number, suffix) = found
390        .split_once('-')
391        .map_or((found, None), |(n, s)| (n, Some(s)));
392    let mut parts = number.split('.');
393    let parsed = parts
394        .next()
395        .and_then(|major| major.parse::<u64>().ok())
396        .zip(parts.next().and_then(|minor| minor.parse::<u64>().ok()));
397    let Some(pair) = parsed else {
398        return Ok(StepState::unknown(format!(
399            "the forge reports the version as '{found}', which names no major and minor pair; the floor cannot be read"
400        )));
401    };
402    if let Some(suffix) = suffix.filter(|s| !GITLAB_EDITIONS.contains(s)) {
403        return Ok(StepState::not(version_refusal(found, Some(suffix))));
404    }
405    if pair < GITLAB_VERSION_FLOOR {
406        return Ok(StepState::not(version_refusal(found, None)));
407    }
408    let (major, minor) = GITLAB_VERSION_FLOOR;
409    Ok(StepState::ok(format!(
410        "this instance reports {found}, at or above the {major}.{minor} floor"
411    )))
412}
413
414/// The destructive step's own guard: whether deleting a candidate branch
415/// can lose work.
416///
417/// `Satisfied` means every candidate is already gone or is an ancestor of
418/// the trunk; `Unsatisfied` means the deletion must refuse.
419///
420/// # Errors
421///
422/// Propagates executor failures.
423pub fn single_trunk_guard(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
424    let trunk = ctx.trunk();
425    for candidate in ctx.retired_branches() {
426        let candidate = candidate.as_str();
427        if candidate == trunk {
428            continue;
429        }
430        let state = match ctx.forge {
431            Some(Forge::Github) => github_candidate_guard(ctx, run, candidate)?,
432            Some(Forge::Gitlab) => gitlab_candidate_guard(ctx, run, candidate)?,
433            // A destructive step fails closed, and an absent adapter is
434            // one more thing the guard cannot establish.
435            None => StepState::unknown("the profile names no forge this release drives"),
436        };
437        if !state.satisfied() {
438            return Ok(state);
439        }
440    }
441    Ok(StepState::ok(
442        "every candidate branch is absent, or an ancestor of the trunk",
443    ))
444}
445
446/// One candidate branch's ancestry, on GitHub.
447fn github_candidate_guard(
448    ctx: &Ctx,
449    run: &mut Runner,
450    candidate: &str,
451) -> Result<StepState, RkError> {
452    let trunk = ctx.trunk();
453    match api_get(
454        ctx,
455        run,
456        &format!("repos/{}/git/ref/heads/{candidate}", ctx.repo),
457    )? {
458        Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
459        Api::Failed(err) => return Ok(StepState::unknown(err)),
460        Api::Ok(_) => {}
461    }
462    match api_get(
463        ctx,
464        run,
465        &format!("repos/{}/compare/{candidate}...{trunk}", ctx.repo),
466    )? {
467        Api::Ok(body) => {
468            let status = body["status"].as_str().unwrap_or("");
469            Ok(if matches!(status, "ahead" | "identical") {
470                StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
471            } else {
472                StepState::not(format!(
473                    "{candidate} is not an ancestor of {trunk} ({status}); deleting it would lose work"
474                ))
475            })
476        }
477        Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
478        Api::Failed(err) => Ok(StepState::unknown(err)),
479    }
480}
481
482/// One candidate branch's ancestry, on GitLab.
483fn gitlab_candidate_guard(
484    ctx: &Ctx,
485    run: &mut Runner,
486    candidate: &str,
487) -> Result<StepState, RkError> {
488    let trunk = ctx.trunk();
489    let project = ctx.repo.replace('/', "%2F");
490    match api_get(
491        ctx,
492        run,
493        &format!("projects/{project}/repository/branches/{candidate}"),
494    )? {
495        Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
496        Api::Failed(err) => return Ok(StepState::unknown(err)),
497        Api::Ok(_) => {}
498    }
499    match api_get(
500        ctx,
501        run,
502        &format!("projects/{project}/repository/compare?from={trunk}&to={candidate}"),
503    )? {
504        Api::Ok(body) => {
505            let ahead = body["commits"]
506                .as_array()
507                .is_some_and(|list| !list.is_empty());
508            Ok(if ahead {
509                StepState::not(format!(
510                    "{candidate} carries commits {trunk} does not; deleting it would lose work"
511                ))
512            } else {
513                StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
514            })
515        }
516        Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
517        Api::Failed(err) => Ok(StepState::unknown(err)),
518    }
519}
520
521/// One captured, read-only forge API call.
522fn api_get(ctx: &Ctx, run: &mut Runner, path: &str) -> Result<Api, RkError> {
523    let exec = Exec {
524        program: ctx.cli.clone().into_os_string(),
525        args: vec!["api".into(), path.into()],
526        env: ctx.child_env("observe"),
527        cwd: ctx.target.as_std_path().to_path_buf(),
528        stdin: None,
529    };
530    let outcome = run(&exec)?;
531    if outcome.success() {
532        return Ok(
533            serde_json::from_slice::<Value>(&outcome.stdout).map_or_else(
534                |_| Api::Failed("the forge answer did not parse as JSON".into()),
535                Api::Ok,
536            ),
537        );
538    }
539    let stderr = String::from_utf8_lossy(&outcome.stderr).into_owned();
540    if stderr.contains("404") {
541        Ok(Api::Missing)
542    } else {
543        Ok(Api::Failed(last_line(&outcome.stderr)))
544    }
545}
546
547/// The last non-empty line of a byte stream, for one-line detail fields.
548fn last_line(bytes: &[u8]) -> String {
549    String::from_utf8_lossy(bytes)
550        .lines()
551        .rev()
552        .find(|line| !line.trim().is_empty())
553        .unwrap_or("no output")
554        .to_owned()
555}
556
557#[allow(
558    clippy::too_many_lines,
559    reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
560)]
561fn github(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
562    let trunk = ctx.trunk();
563    let repo = &ctx.repo;
564    match step {
565        "private-vulnerability-reporting" => {
566            let visibility_path = format!("repos/{repo}");
567            match api_get(ctx, run, &visibility_path)? {
568                Api::Ok(body) => match body["private"].as_bool() {
569                    Some(true) => {
570                        return Ok(StepState::inapplicable(
571                            "private vulnerability reporting is available for public repositories",
572                        ));
573                    }
574                    Some(false) => {}
575                    None => {
576                        return Ok(StepState::unknown(format!(
577                            "{visibility_path}: repository visibility is unreadable"
578                        )));
579                    }
580                },
581                Api::Missing => {
582                    return Ok(StepState::unknown(format!(
583                        "{visibility_path}: repository visibility is unreadable (404)"
584                    )));
585                }
586                Api::Failed(err) => {
587                    return Ok(StepState::unknown(format!("{visibility_path}: {err}")));
588                }
589            }
590            let path = format!("repos/{repo}/private-vulnerability-reporting");
591            Ok(match api_get(ctx, run, &path)? {
592                Api::Ok(body) => match body["enabled"].as_bool() {
593                    Some(true) => StepState::ok("private vulnerability reporting is enabled"),
594                    Some(false) => StepState::not("private vulnerability reporting is disabled"),
595                    None => StepState::unknown(format!("{path}: enabled is unreadable")),
596                },
597                Api::Missing => {
598                    StepState::unknown(format!("{path}: reporting state is unreadable (404)"))
599                }
600                Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
601            })
602        }
603
604        "default-branch" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
605            Api::Ok(body) => {
606                let found = body["default_branch"].as_str().unwrap_or("");
607                if found == trunk {
608                    StepState::ok(format!("{trunk} is the default branch"))
609                } else {
610                    StepState::not(format!("the default branch is {found}"))
611                }
612            }
613            Api::Missing => StepState::not(format!("the forge does not know {repo}")),
614            Api::Failed(err) => StepState::unknown(err),
615        }),
616        "single-trunk" => {
617            for candidate in ctx.retired_branches() {
618                let candidate = candidate.as_str();
619                if candidate == trunk {
620                    continue;
621                }
622                match api_get(ctx, run, &format!("repos/{repo}/git/ref/heads/{candidate}"))? {
623                    Api::Missing => {}
624                    Api::Ok(_) => {
625                        return Ok(StepState::not(format!("a {candidate} branch still exists")));
626                    }
627                    Api::Failed(err) => return Ok(StepState::unknown(err)),
628                }
629            }
630            Ok(StepState::ok(
631                "no long-lived branch besides the trunk remains",
632            ))
633        }
634        "merge-cleanup" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
635            Api::Ok(body) => {
636                if body["delete_branch_on_merge"].as_bool().unwrap_or(false) {
637                    StepState::ok("a merged branch is deleted by the forge")
638                } else {
639                    StepState::not("a merged branch outlives its merge")
640                }
641            }
642            Api::Missing => StepState::not(format!("the forge does not know {repo}")),
643            Api::Failed(err) => StepState::unknown(err),
644        }),
645        "auto-merge" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
646            Api::Ok(body) => {
647                if body["allow_auto_merge"].as_bool().unwrap_or(false) {
648                    StepState::ok("a request may merge itself once its checks pass")
649                } else {
650                    StepState::not("a request cannot merge itself; the auto-merge switch is off")
651                }
652            }
653            Api::Missing => StepState::not(format!("the forge does not know {repo}")),
654            Api::Failed(err) => StepState::unknown(err),
655        }),
656        "ci-permissions" => Ok(
657            match api_get(
658                ctx,
659                run,
660                &format!("repos/{repo}/actions/permissions/workflow"),
661            )? {
662                Api::Ok(body) => {
663                    let write = body["default_workflow_permissions"] == "write";
664                    let approve = body["can_approve_pull_request_reviews"] == true;
665                    if write && approve {
666                        StepState::ok("CI may write and open requests")
667                    } else {
668                        StepState::not(format!(
669                            "workflow permissions are {} with request approval {}",
670                            body["default_workflow_permissions"],
671                            body["can_approve_pull_request_reviews"]
672                        ))
673                    }
674                }
675                Api::Missing => StepState::not("no workflow permissions are readable"),
676                Api::Failed(err) => StepState::unknown(err),
677            },
678        ),
679        "bot-secrets" => Ok(
680            match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
681                Api::Ok(body) => {
682                    let names: Vec<&str> = body["secrets"]
683                        .as_array()
684                        .map(|list| {
685                            list.iter()
686                                .filter_map(|secret| secret["name"].as_str())
687                                .collect()
688                        })
689                        .unwrap_or_default();
690                    let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
691                    if wanted.iter().all(|name| names.contains(name)) {
692                        StepState::ok("both bot secrets are stored")
693                    } else if names.is_empty() {
694                        StepState::not("no bot secrets are stored")
695                    } else {
696                        StepState::not(format!("stored secrets: {}", names.join(", ")))
697                    }
698                }
699                Api::Missing => StepState::not("no secrets are readable"),
700                Api::Failed(err) => StepState::unknown(err),
701            },
702        ),
703        "protect-trunk" => github_trunk_ruleset(ctx, run),
704        "protect-tags" => github_ruleset(
705            ctx,
706            run,
707            ctx.tag_ruleset(),
708            "tag",
709            "refs/tags/v*",
710            &["deletion", "update"],
711        ),
712        "protect-release-lines" => {
713            match github_ruleset_body(ctx, run, ctx.lines_ruleset())? {
714                RulesetLookup::Absent => {
715                    return Ok(StepState::inapplicable(
716                        "release/* is unprotected; optional — applied only where older lines exist",
717                    ));
718                }
719                RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
720                RulesetLookup::Found(_) => {}
721            }
722            github_ruleset(
723                ctx,
724                run,
725                ctx.lines_ruleset(),
726                "branch",
727                "refs/heads/release/*",
728                &["deletion", "non_fast_forward"],
729            )
730        }
731        "protections-check" => {
732            // Confirmed drift and unreadable answers stay apart: a proven
733            // mismatch is drift even beside an outage, and an outage with
734            // nothing proven wrong stays unknown, never drift.
735            let mut failures = Vec::new();
736            let mut unknowns = Vec::new();
737            // Every satisfied step's limitation survives the aggregate.
738            let mut limitations: Vec<String> = Vec::new();
739            for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
740                match github(ctx, owned, run)? {
741                    StepState::Satisfied {
742                        limitation: found, ..
743                    } => limitations.extend(found),
744                    StepState::Inapplicable { .. } => {}
745                    StepState::Unsatisfied { detail } => {
746                        failures.push(format!("{owned}: {detail}"));
747                    }
748                    StepState::Unknown { detail } => {
749                        unknowns.push(format!("{owned}: {detail}"));
750                    }
751                }
752            }
753            match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
754                Api::Ok(body) => {
755                    let owned = [
756                        ctx.trunk_ruleset().to_owned(),
757                        ctx.safety_ruleset().to_owned(),
758                        ctx.tag_ruleset().to_owned(),
759                        ctx.lines_ruleset().to_owned(),
760                    ];
761                    for ruleset in body.as_array().into_iter().flatten() {
762                        let name = ruleset["name"].as_str().unwrap_or("");
763                        if !owned.iter().any(|expected| expected == name) {
764                            failures.push(format!("a ruleset no step owns: {name}"));
765                        }
766                    }
767                }
768                Api::Missing | Api::Failed(_) => {
769                    unknowns.push("the ruleset inventory is not readable".to_owned());
770                }
771            }
772            Ok(if !failures.is_empty() {
773                StepState::not(failures.join("; "))
774            } else if !unknowns.is_empty() {
775                StepState::unknown(unknowns.join("; "))
776            } else {
777                StepState::Satisfied {
778                    detail: "exactly the owned protections, with those rules".into(),
779                    limitation: if limitations.is_empty() {
780                        None
781                    } else {
782                        Some(limitations.join("; "))
783                    },
784                }
785            })
786        }
787        _ => Ok(StepState::unknown(format!("no observation for {step}"))),
788    }
789}
790
791/// The installation, observed as the App itself.
792///
793/// The forge serves `repos/{owner}/{repo}/installation` to an App JWT and
794/// to nothing a user can hold. The caller mints `jwt` — once per run, with
795/// the token and the key bytes already registered as redaction needles —
796/// which is why this lives outside the name dispatch above: an observation
797/// entered without that token has no honest answer.
798#[must_use]
799pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
800    match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
801        AppApi::Ok(body) => {
802            let id = body["id"].as_i64().unwrap_or_default();
803            let held = &body["permissions"];
804            let short: Vec<String> = minimum_grant(ctx)
805                .into_iter()
806                .filter(|(key, level)| held[key] != *level)
807                .map(|(key, level)| format!("{key}: {level}"))
808                .collect();
809            if short.is_empty() {
810                StepState::ok(format!("installation {id} covers {}", ctx.repo))
811            } else {
812                // An installation predating a widened grant reads
813                // unsatisfied until its owner approves the new permission
814                // in the App's installation settings; no token this run
815                // can mint grants it.
816                StepState::not(format!(
817                    "installation {id} covers {} and does not hold [{}]; approve the App's updated permissions on the installation's own settings page",
818                    ctx.repo,
819                    short.join(", ")
820                ))
821            }
822        }
823        AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
824        AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
825    }
826}
827
828/// The release App's minimum grant for this target, as the installation
829/// reports it.
830///
831/// Contents and pull requests carry the release itself: the tag, the bump
832/// branch, and the request. A rendered release gate reads a check run
833/// beside them, and that rendering is the one shape that needs the third
834/// permission, so a target that renders no gate is not asked for it.
835fn minimum_grant(ctx: &Ctx) -> Vec<(&'static str, &'static str)> {
836    let mut grant = vec![("contents", "write"), ("pull_requests", "write")];
837    if ctx.integration() == crate::landing::Integration::Local
838        && ctx.profile.release.style == Some(crate::landing::Style::Trunk)
839    {
840        grant.push(("checks", "read"));
841    }
842    grant
843}
844
845/// A plain ruleset: active, and carrying exactly the expected rule types —
846/// not one fewer, and not one more, because an extra rule here is a rule the
847/// setup cannot reproduce or explain and can block the very push the method
848/// depends on.
849fn github_ruleset(
850    ctx: &Ctx,
851    run: &mut Runner,
852    name: &str,
853    target: &str,
854    include: &str,
855    rules: &[&str],
856) -> Result<StepState, RkError> {
857    let detail = match github_ruleset_body(ctx, run, name)? {
858        RulesetLookup::Found(detail) => detail,
859        RulesetLookup::Absent => {
860            return Ok(StepState::not(format!("no ruleset named {name}")));
861        }
862        RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
863    };
864    if detail["enforcement"] != "active" {
865        return Ok(StepState::not(format!("{name} is not active")));
866    }
867    // The name proves nothing: the ruleset must cover exactly the declared
868    // refs, or the protection it reports exists somewhere else.
869    if detail["target"] != target {
870        return Ok(StepState::not(format!(
871            "{name} does not target {target} refs"
872        )));
873    }
874    if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
875        return Ok(StepState::not(format!(
876            "{name} does not cover {include} alone"
877        )));
878    }
879    if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
880        return Ok(StepState::not(format!(
881            "{name} excludes refs from its own coverage"
882        )));
883    }
884    let mut held: Vec<&str> = detail["rules"]
885        .as_array()
886        .map(|list| {
887            list.iter()
888                .filter_map(|rule| rule["type"].as_str())
889                .collect()
890        })
891        .unwrap_or_default();
892    held.sort_unstable();
893    let mut expected: Vec<&str> = rules.to_vec();
894    expected.sort_unstable();
895    if held == expected {
896        Ok(StepState::ok(format!(
897            "{name} is active with exactly its rules"
898        )))
899    } else {
900        Ok(StepState::not(format!(
901            "{name} carries the rules [{}] where the setup owns [{}]",
902            held.join(", "),
903            expected.join(", ")
904        )))
905    }
906}
907
908// The trunk ruleset is checked for the shape a release merge needs.
909// The rule kinds the setup writes and can reproduce come from
910// `protection.owned_trunk_rules`, floored to contain all four. The set
911// also drives the missing-rule fault, so a kind this convention refuses
912// must stay out of it: adding one would demand that rule on every target.
913// The floor is what stops a target dropping one it needs.
914
915/// A fault line for every rule on the trunk that the setup does not own.
916///
917/// The merge queue gets its own text, because this convention refuses one
918/// deliberately and the operator needs the consequence and the remedy. Every
919/// other unowned kind reads generically: an unowned rule is one the setup
920/// cannot reproduce or explain, and it can block the very merge the method
921/// depends on.
922fn unowned_rule_faults(rules: &[Value], owned: &[String]) -> Vec<String> {
923    rules
924        .iter()
925        .filter_map(|rule| rule["type"].as_str())
926        .filter(|kind| !owned.iter().any(|name| name == kind))
927        .map(|kind| {
928            if kind == "merge_queue" {
929                MERGE_QUEUE_FAULT.to_owned()
930            } else {
931                format!("an unowned rule is present: {kind}")
932            }
933        })
934        .collect()
935}
936
937fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
938    let trunk = ctx.trunk();
939    let name = ctx.trunk_ruleset().to_owned();
940    let detail = match github_ruleset_body(ctx, run, &name)? {
941        RulesetLookup::Found(detail) => detail,
942        RulesetLookup::Absent => {
943            return Ok(StepState::not(format!("no ruleset named {name}")));
944        }
945        RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
946    };
947    let rules = detail["rules"].as_array().cloned().unwrap_or_default();
948    let mut faults = Vec::new();
949    if detail["enforcement"] != "active" {
950        faults.push(format!("{name} is not active"));
951    }
952    // The name proves nothing: a ruleset applies only where its conditions
953    // say, so a right-named ruleset covering another ref would otherwise
954    // read as a protected trunk.
955    if detail["target"] != "branch" {
956        faults.push(format!("{name} does not target branches"));
957    }
958    let expected_ref = serde_json::json!([format!("refs/heads/{trunk}")]);
959    if detail["conditions"]["ref_name"]["include"] != expected_ref {
960        faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
961    }
962    // A matching exclusion negates the include, so the owned shape is an
963    // exclusion list that is exactly empty.
964    if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
965        faults.push(format!("{name} excludes refs from its own coverage"));
966    }
967    let expected_bypass = super::context::github_bypass_actors(&ctx.protection().bypass_actors);
968    if detail["bypass_actors"] != expected_bypass {
969        faults.push("the bypass actors do not match the recorded authority".to_owned());
970    }
971    faults.extend(trunk_rule_faults(ctx, &rules, &name));
972    if let Some(request) = rules.iter().find(|rule| rule["type"] == "pull_request")
973        && request["parameters"]["allowed_merge_methods"]
974            != serde_json::json!(ctx.protection().allowed_merge_methods)
975    {
976        faults.push("the merge method is not exactly a squash merge".to_owned());
977    }
978    if let Some(checks) = rules
979        .iter()
980        .find(|rule| rule["type"] == "required_status_checks")
981    {
982        if checks["parameters"]["strict_required_status_checks_policy"]
983            != ctx.protection().strict_required_status_checks
984        {
985            faults.push(STALE_MERGE_FAULT.to_owned());
986        }
987        let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
988            .as_array()
989            .map(|list| {
990                list.iter()
991                    .filter_map(|check| check["context"].as_str())
992                    .collect()
993            })
994            .unwrap_or_default();
995        // Where the expected check is known, the context set must be exactly
996        // it plus the title check: an extra stale context does not fail a
997        // merge, it hangs one, and a missing title check lets an
998        // unconventional squash title land on the trunk.
999        if contexts.is_empty() {
1000            faults.push("no status check is required".to_owned());
1001        } else if let Some(expected) = &ctx.required_check {
1002            let mut held = contexts.clone();
1003            held.sort_unstable();
1004            let title_check = ctx.title_check();
1005            let mut owned_contexts = [expected.as_str(), title_check];
1006            owned_contexts.sort_unstable();
1007            if held != owned_contexts {
1008                faults.push(format!(
1009                    "the required checks are [{}] where the setup owns [{}]",
1010                    contexts.join(", "),
1011                    owned_contexts.join(", ")
1012                ));
1013            }
1014        } else if !contexts.contains(&ctx.title_check()) {
1015            faults.push(format!("the {} check is not required", ctx.title_check()));
1016        }
1017    }
1018    match squash_merge_sources(ctx, run)? {
1019        MergeSources::Owned => {}
1020        MergeSources::Faults(proven) => faults.extend(proven),
1021        // Proven drift wins over an outage: an unreadable settings read
1022        // downgrades the answer to unknown only when nothing above it was
1023        // proven wrong.
1024        MergeSources::Unreadable(err) => {
1025            if faults.is_empty() {
1026                return Ok(StepState::unknown(err));
1027            }
1028        }
1029    }
1030    match github_safety_ruleset(ctx, run)? {
1031        SafetyRuleset::Owned => {}
1032        SafetyRuleset::Faults(proven) => faults.extend(proven),
1033        SafetyRuleset::Unreadable(err) => {
1034            if faults.is_empty() {
1035                return Ok(StepState::unknown(err));
1036            }
1037        }
1038    }
1039    if let Some(shape) = gate_faults(ctx) {
1040        faults.push(shape);
1041    }
1042    if !faults.is_empty() {
1043        return Ok(StepState::not(faults.join("; ")));
1044    }
1045    Ok(StepState::ok(format!(
1046        "{name} holds the release-merge shape beside {}",
1047        ctx.safety_ruleset()
1048    )))
1049}
1050
1051/// Which rules the trunk ruleset must carry, and which it must not.
1052///
1053/// Each ruleset carries the half of the owned rules its bypass fits, so the
1054/// trunk ruleset holds what a recorded actor may be excused from. A safety
1055/// rule here is the shape from before the split: it reads as protection
1056/// while inheriting this ruleset's bypass, so it gets its own words rather
1057/// than the generic unowned-rule fault.
1058fn trunk_rule_faults(ctx: &Ctx, rules: &[Value], name: &str) -> Vec<String> {
1059    let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
1060    let mut faults = Vec::new();
1061    let mut accounted: Vec<String> = ctx
1062        .protection()
1063        .owned_trunk_rules
1064        .iter()
1065        .filter(|rule| crate::config::REQUEST_RULES.contains(&rule.as_str()))
1066        .cloned()
1067        .collect();
1068    for required in &accounted {
1069        if !has(required) {
1070            faults.push(format!("the {required} rule is missing"));
1071        }
1072    }
1073    for stray in crate::config::SAFETY_RULES {
1074        if has(stray) {
1075            faults.push(format!(
1076                "the {stray} rule sits in {name}, where a bypass actor excuses it"
1077            ));
1078        }
1079        accounted.push(stray.to_owned());
1080    }
1081    faults.extend(unowned_rule_faults(rules, &accounted));
1082    faults
1083}
1084
1085/// What the safety ruleset's own read answered.
1086enum SafetyRuleset {
1087    Owned,
1088    Faults(Vec<String>),
1089    Unreadable(String),
1090}
1091
1092/// The ruleset no actor is excused from.
1093///
1094/// A bypass actor recorded here would hand whoever it names the deletion
1095/// and the force-push along with the trunk push, which is the one thing the
1096/// split exists to prevent. The rules it carries are the safety half of
1097/// `protection.owned_trunk_rules`, so one key still answers what the setup
1098/// owns on the trunk.
1099fn github_safety_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<SafetyRuleset, RkError> {
1100    let trunk = ctx.trunk();
1101    let name = ctx.safety_ruleset().to_owned();
1102    let detail = match github_ruleset_body(ctx, run, &name)? {
1103        RulesetLookup::Found(detail) => detail,
1104        RulesetLookup::Absent => {
1105            return Ok(SafetyRuleset::Faults(vec![format!(
1106                "no ruleset named {name} holds the trunk against deletion and force-push"
1107            )]));
1108        }
1109        RulesetLookup::Unreadable(err) => return Ok(SafetyRuleset::Unreadable(err)),
1110    };
1111    let mut faults = Vec::new();
1112    if detail["enforcement"] != "active" {
1113        faults.push(format!("{name} is not active"));
1114    }
1115    if detail["target"] != "branch" {
1116        faults.push(format!("{name} does not target branches"));
1117    }
1118    if detail["conditions"]["ref_name"]["include"]
1119        != serde_json::json!([format!("refs/heads/{trunk}")])
1120    {
1121        faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
1122    }
1123    if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
1124        faults.push(format!("{name} excludes refs from its own coverage"));
1125    }
1126    if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
1127        faults.push(format!(
1128            "{name} names a bypass actor, so deletion and force-push hold against nobody"
1129        ));
1130    }
1131    let rules = detail["rules"].as_array().cloned().unwrap_or_default();
1132    let safety_rules: Vec<String> = ctx
1133        .protection()
1134        .owned_trunk_rules
1135        .iter()
1136        .filter(|rule| crate::config::SAFETY_RULES.contains(&rule.as_str()))
1137        .cloned()
1138        .collect();
1139    for required in &safety_rules {
1140        if !rules.iter().any(|rule| rule["type"] == required.as_str()) {
1141            faults.push(format!("the {required} rule is missing from {name}"));
1142        }
1143    }
1144    faults.extend(unowned_rule_faults(&rules, &safety_rules));
1145    if faults.is_empty() {
1146        Ok(SafetyRuleset::Owned)
1147    } else {
1148        Ok(SafetyRuleset::Faults(faults))
1149    }
1150}
1151
1152/// The ways the named gate is shaped so that it cannot report a blocking
1153/// answer. A required check that never reports is a broken trunk
1154/// protection, not a weaker guarantee, so each of these is a fault rather
1155/// than a limitation. Read only where the check is named: without the flag
1156/// the observation knows no gate.
1157///
1158/// It judges the gate alone. Which other jobs a project means to block a
1159/// merge is intent, no file states it, and `forges/github.md` carries that
1160/// as a convention instead.
1161fn gate_faults(ctx: &Ctx) -> Option<String> {
1162    let check = ctx.required_check.as_deref()?;
1163    let shape = workflow_jobs::faults(
1164        &workflow_jobs::read_gate(&ctx.target, check, ctx.trunk()),
1165        check,
1166        ctx.trunk(),
1167    );
1168    // Under local integration the release gate waits on a workflow
1169    // completing and then judges this check. A trigger cannot name a check
1170    // and a required context cannot name a workflow, so nothing but this
1171    // reader proves the two answers describe one file.
1172    let waking = (ctx.integration() == crate::landing::Integration::Local)
1173        .then_some(ctx.required_workflow.as_deref())
1174        .flatten()
1175        .and_then(|workflow| {
1176            workflow_jobs::waking_workflow_fault(&ctx.target, workflow, check, ctx.trunk())
1177        });
1178    match (shape, waking) {
1179        (None, None) => None,
1180        (Some(one), None) | (None, Some(one)) => Some(one),
1181        (Some(shape), Some(waking)) => Some(format!("{shape}; {waking}")),
1182    }
1183}
1184
1185/// What the repository's squash message settings hold.
1186enum MergeSources {
1187    /// The request's title and body, as the setup owns.
1188    Owned,
1189    /// Proven other values, one fault line each.
1190    Faults(Vec<String>),
1191    /// The settings could not be read.
1192    Unreadable(String),
1193}
1194
1195/// The squash message sources, repository settings beside the ruleset:
1196/// with the title source unset, a one-commit request offers that commit's
1197/// own subject as the trunk's message, which the bot then reads for the
1198/// version; with the message source on another value, the trunk's body is
1199/// not the request's description the content gates judged. One GET
1200/// answers for both, each faulted by name.
1201fn squash_merge_sources(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
1202    Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
1203        Api::Ok(body) => {
1204            let mut faults = Vec::new();
1205            let owned_title = ctx.protection().github.squash_title_source.as_str();
1206            let owned_body = ctx.protection().github.squash_body_source.as_str();
1207            if body["squash_merge_commit_title"] != owned_title {
1208                faults.push(format!(
1209                    "the squash title source is {} where the setup owns {owned_title}",
1210                    body["squash_merge_commit_title"]
1211                ));
1212            }
1213            if body["squash_merge_commit_message"] != owned_body {
1214                faults.push(format!(
1215                    "the squash message source is {} where the setup owns {owned_body}",
1216                    body["squash_merge_commit_message"]
1217                ));
1218            }
1219            if faults.is_empty() {
1220                MergeSources::Owned
1221            } else {
1222                MergeSources::Faults(faults)
1223            }
1224        }
1225        Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
1226        Api::Failed(err) => MergeSources::Unreadable(err),
1227    })
1228}
1229
1230/// One ruleset lookup by name: found, provably absent, or unreadable —
1231/// an unreadable inventory must never read as an absent ruleset.
1232enum RulesetLookup {
1233    /// The ruleset exists; its detail body.
1234    Found(Value),
1235    /// The inventory was read successfully and no ruleset carries the
1236    /// name.
1237    Absent,
1238    /// The inventory or the detail could not be read.
1239    Unreadable(String),
1240}
1241
1242/// A ruleset's detail body by name.
1243fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
1244    // A 404 on the collection is an unreachable inventory — a missing
1245    // repository or an unauthorized read — never an empty one: an empty
1246    // inventory answers 200 with an empty list.
1247    let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
1248        Api::Ok(body) => body,
1249        Api::Missing => {
1250            return Ok(RulesetLookup::Unreadable(
1251                "the ruleset inventory is not readable".into(),
1252            ));
1253        }
1254        Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
1255    };
1256    let id = list
1257        .as_array()
1258        .into_iter()
1259        .flatten()
1260        .find(|ruleset| ruleset["name"] == name)
1261        .and_then(|ruleset| ruleset["id"].as_i64());
1262    let Some(id) = id else {
1263        return Ok(RulesetLookup::Absent);
1264    };
1265    match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
1266        Api::Ok(body) => Ok(RulesetLookup::Found(body)),
1267        // A listed id that answers 404 is not proof of absence either — the
1268        // forge also answers 404 for an unauthorized read — so a rerun
1269        // decides, rather than a false drift.
1270        Api::Missing => Ok(RulesetLookup::Unreadable(format!(
1271            "the {name} detail is not readable"
1272        ))),
1273        Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
1274    }
1275}
1276
1277/// The GitLab limitation the `auto-merge` step reports: the forge has no
1278/// project-level switch, so the observation reads the pipeline requirement
1279/// the trunk protection asserts.
1280const 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";
1281
1282/// The GitLab limitation `protect-tags` and `protections-check` report.
1283const GITLAB_TAG_LIMITATION: &str =
1284    "an Owner or Maintainer can still delete a protected tag through the UI or API";
1285
1286/// The fault a merge queue on the trunk reads as: what is enabled, what it
1287/// costs, and how to undo it. This convention refuses a queue rather than
1288/// owning one, so the operator needs the consequence rather than a rule
1289/// type's bare name.
1290const 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";
1291
1292/// The freshness defect is independent of an absent required check.
1293const 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";
1294
1295/// The GitLab limitation `protect-trunk` and `protections-check` report:
1296/// the title gate rides the request's own pipeline on this forge.
1297const 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";
1298
1299#[allow(
1300    clippy::too_many_lines,
1301    reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
1302)]
1303fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
1304    let trunk = ctx.trunk();
1305    let project = ctx.repo.replace('/', "%2F");
1306    match step {
1307        "private-vulnerability-reporting" => {
1308            let path = format!("projects/{project}");
1309            Ok(match api_get(ctx, run, &path)? {
1310                Api::Ok(body) => {
1311                    let access = body["issues_access_level"].as_str();
1312                    if !matches!(access, Some("enabled" | "private" | "disabled")) {
1313                        StepState::unknown("issue intake access is unreadable")
1314                    } else if body
1315                        .get("issues_enabled")
1316                        .is_some_and(|flag| !flag.is_boolean())
1317                    {
1318                        StepState::unknown("legacy issue intake flag is unreadable")
1319                    } else if body["issues_enabled"] == false || access == Some("disabled") {
1320                        StepState::not("issue intake is disabled; see setup guide step 3g")
1321                    } else if access == Some("private") {
1322                        StepState::not("issue intake is restricted; see setup guide step 3g")
1323                    } else {
1324                        StepState::ok_with_limitation(
1325                            "issue intake is enabled",
1326                            GITLAB_PRIVATE_REPORTING_LIMITATION,
1327                        )
1328                    }
1329                }
1330                Api::Missing => {
1331                    StepState::unknown(format!("{path}: issue intake is unreadable (404)"))
1332                }
1333                Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
1334            })
1335        }
1336
1337        "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1338            Api::Ok(body) => {
1339                let found = body["default_branch"].as_str().unwrap_or("");
1340                if found == trunk {
1341                    StepState::ok(format!("{trunk} is the default branch"))
1342                } else {
1343                    StepState::not(format!("the default branch is {found}"))
1344                }
1345            }
1346            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1347            Api::Failed(err) => StepState::unknown(err),
1348        }),
1349        "single-trunk" => {
1350            for candidate in ctx.retired_branches() {
1351                let candidate = candidate.as_str();
1352                if candidate == trunk {
1353                    continue;
1354                }
1355                match api_get(
1356                    ctx,
1357                    run,
1358                    &format!("projects/{project}/repository/branches/{candidate}"),
1359                )? {
1360                    Api::Missing => {}
1361                    Api::Ok(_) => {
1362                        return Ok(StepState::not(format!("a {candidate} branch still exists")));
1363                    }
1364                    Api::Failed(err) => return Ok(StepState::unknown(err)),
1365                }
1366            }
1367            Ok(StepState::ok(
1368                "no long-lived branch besides the trunk remains",
1369            ))
1370        }
1371        "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1372            Api::Ok(body) => {
1373                if body["remove_source_branch_after_merge"]
1374                    .as_bool()
1375                    .unwrap_or(false)
1376                {
1377                    StepState::ok("a merged branch is deleted by the forge")
1378                } else {
1379                    StepState::not("a merged branch outlives its merge")
1380                }
1381            }
1382            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1383            Api::Failed(err) => StepState::unknown(err),
1384        }),
1385        "auto-merge" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1386            Api::Ok(body) => {
1387                if body["only_allow_merge_if_pipeline_succeeds"]
1388                    .as_bool()
1389                    .unwrap_or(false)
1390                {
1391                    StepState::ok_with_limitation(
1392                        "a request may merge itself once its pipeline passes",
1393                        GITLAB_AUTO_MERGE_LIMITATION,
1394                    )
1395                } else {
1396                    StepState::not(
1397                        "the pipeline requirement auto-merge rides on is off; protect-trunk asserts it",
1398                    )
1399                }
1400            }
1401            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1402            Api::Failed(err) => StepState::unknown(err),
1403        }),
1404        "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
1405            Api::Ok(body) => {
1406                if body["jobs_enabled"] == true {
1407                    StepState::ok("pipelines are enabled")
1408                } else {
1409                    StepState::not("pipelines are disabled")
1410                }
1411            }
1412            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
1413            Api::Failed(err) => StepState::unknown(err),
1414        }),
1415        "install-bot" => {
1416            // The listing paginates, exactly as the script's does: an
1417            // active token past the first page must not read as absent, or
1418            // verification would contradict the apply it verifies. Absence
1419            // is only reported once a short page proves the listing was
1420            // exhausted; a bound reached on a full page is an unknown.
1421            let mut active = false;
1422            let mut exhausted = false;
1423            for page in 1..=10u32 {
1424                let path = format!(
1425                    "projects/{project}/access_tokens?state=active&per_page=100&page={page}"
1426                );
1427                let list = match api_get(ctx, run, &path)? {
1428                    Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1429                    Api::Missing => Vec::new(),
1430                    Api::Failed(err) => return Ok(StepState::unknown(err)),
1431                };
1432                active = active
1433                    || list.iter().any(|token| {
1434                        token["name"] == "release-bot"
1435                            && token["revoked"] == false
1436                            && token["active"] != false
1437                    });
1438                if list.len() < 100 {
1439                    exhausted = true;
1440                }
1441                if active || exhausted {
1442                    break;
1443                }
1444            }
1445            if !active {
1446                return Ok(if exhausted {
1447                    StepState::not("no active release-bot token exists")
1448                } else {
1449                    StepState::unknown(
1450                        "the token listing did not exhaust within ten pages; nothing was decided",
1451                    )
1452                });
1453            }
1454            // A token whose stored variable has gone missing is a stranded
1455            // identity — its value is unrecoverable — so the step is only
1456            // satisfied when both halves hold, and a rerun rotates.
1457            Ok(
1458                match api_get(
1459                    ctx,
1460                    run,
1461                    &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1462                )? {
1463                    Api::Ok(_) => StepState::ok(
1464                        "an active release-bot token exists and its variable is stored",
1465                    ),
1466                    Api::Missing => StepState::not(
1467                        "an active release-bot token exists with no stored variable; a rerun revokes and replaces it",
1468                    ),
1469                    Api::Failed(err) => StepState::unknown(err),
1470                },
1471            )
1472        }
1473        "bot-secrets" => Ok(
1474            match api_get(
1475                ctx,
1476                run,
1477                &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1478            )? {
1479                Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
1480                Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
1481                Api::Failed(err) => StepState::unknown(err),
1482            },
1483        ),
1484        "protect-trunk" => {
1485            let protection = match api_get(
1486                ctx,
1487                run,
1488                &format!("projects/{project}/protected_branches/{trunk}"),
1489            )? {
1490                Api::Ok(body) => body,
1491                Api::Missing => {
1492                    return Ok(StepState::not(format!("{trunk} is not protected")));
1493                }
1494                Api::Failed(err) => return Ok(StepState::unknown(err)),
1495            };
1496            // Exactly one push grant, and it is the no-access entry: the
1497            // forge honors the most permissive grant, so a second entry
1498            // beside access level 0 is a branch that still takes a push.
1499            let grants = protection["push_access_levels"]
1500                .as_array()
1501                .cloned()
1502                .unwrap_or_default();
1503            let policy = ctx.protection();
1504            let no_push =
1505                grants.len() == 1 && grants[0]["access_level"] == policy.gitlab.push_access_level;
1506            // The merge grant is owned exactly too: a merge level of 0 keeps
1507            // every release request unmergeable while the push shape reads
1508            // clean, so both halves are checked.
1509            let merges = protection["merge_access_levels"]
1510                .as_array()
1511                .cloned()
1512                .unwrap_or_default();
1513            let can_merge =
1514                merges.len() == 1 && merges[0]["access_level"] == policy.gitlab.merge_access_level;
1515            let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
1516                Api::Ok(body) => body,
1517                Api::Missing | Api::Failed(_) => Value::Null,
1518            };
1519            let mut faults = Vec::new();
1520            if !no_push {
1521                faults.push(format!(
1522                    "{trunk} still takes a direct push: the forge honors the most permissive of {} push grants",
1523                    grants.len()
1524                ));
1525            }
1526            if !can_merge {
1527                faults.push(format!(
1528                    "{trunk} merge grants are not exactly the one owned maintainer level"
1529                ));
1530            }
1531            if protection["allow_force_push"] != false {
1532                faults.push(format!("{trunk} allows force pushes"));
1533            }
1534            if settings["only_allow_merge_if_pipeline_succeeds"] != true {
1535                faults.push("the pipeline requirement is off".to_owned());
1536            }
1537            if settings["merge_method"] != policy.gitlab.merge_method.as_str() {
1538                faults.push("the merge method is not fast-forward".to_owned());
1539            }
1540            if settings["squash_option"] != policy.gitlab.squash_option.as_str() {
1541                faults.push("merge requests do not always squash".to_owned());
1542            }
1543            if settings["squash_commit_template"] != policy.gitlab.squash_commit_template.as_str() {
1544                faults.push("the squash template is not the merge request's title".to_owned());
1545            }
1546            Ok(if faults.is_empty() {
1547                StepState::ok_with_limitation(
1548                    format!("{trunk} holds the release-merge shape"),
1549                    GITLAB_TITLE_LIMITATION,
1550                )
1551            } else {
1552                StepState::not(faults.join("; "))
1553            })
1554        }
1555        "protect-tags" => Ok(
1556            match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
1557                Api::Ok(_) => {
1558                    StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
1559                }
1560                Api::Missing => StepState::not("v* is not protected"),
1561                Api::Failed(err) => StepState::unknown(err),
1562            },
1563        ),
1564        "protect-release-lines" => Ok(
1565            match api_get(
1566                ctx,
1567                run,
1568                &format!("projects/{project}/protected_branches/release%2F%2A"),
1569            )? {
1570                Api::Ok(body) => {
1571                    let level_ok = |levels: &Value| {
1572                        levels
1573                            .as_array()
1574                            .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
1575                    };
1576                    if body["allow_force_push"] != false {
1577                        StepState::not("release/* allows force pushes")
1578                    } else if !level_ok(&body["push_access_levels"])
1579                        || !level_ok(&body["merge_access_levels"])
1580                    {
1581                        // A push level of 0 blocks the documented
1582                        // cherry-pick-by-push path while force-push reads
1583                        // clean, so the grant shape is owned exactly.
1584                        StepState::not(
1585                            "release/* grants are not exactly the owned maintainer levels",
1586                        )
1587                    } else {
1588                        StepState::ok("release/* refuses force pushes and deletion by git clients")
1589                    }
1590                }
1591                Api::Missing => StepState::inapplicable(
1592                    "release/* is unprotected; optional — applied only where older lines exist",
1593                ),
1594                Api::Failed(err) => StepState::unknown(err),
1595            },
1596        ),
1597        "protections-check" => {
1598            // Same separation as the sibling forge: proven drift wins,
1599            // an outage with nothing proven wrong stays unknown.
1600            let mut failures = Vec::new();
1601            let mut unknowns = Vec::new();
1602            // Every satisfied step's limitation survives the aggregate: a
1603            // first limitation must not shadow a second.
1604            let mut limitations: Vec<String> = Vec::new();
1605            for owned in ["protect-trunk", "protect-tags", "protect-release-lines"] {
1606                match gitlab(ctx, owned, run)? {
1607                    StepState::Satisfied {
1608                        limitation: found, ..
1609                    } => limitations.extend(found),
1610                    StepState::Inapplicable { .. } => {}
1611                    StepState::Unsatisfied { detail } => {
1612                        failures.push(format!("{owned}: {detail}"));
1613                    }
1614                    StepState::Unknown { detail } => {
1615                        unknowns.push(format!("{owned}: {detail}"));
1616                    }
1617                }
1618            }
1619            Ok(if !failures.is_empty() {
1620                StepState::not(failures.join("; "))
1621            } else if !unknowns.is_empty() {
1622                StepState::unknown(unknowns.join("; "))
1623            } else {
1624                StepState::Satisfied {
1625                    detail: "the protections hold, as far as this forge enforces them".into(),
1626                    limitation: if limitations.is_empty() {
1627                        None
1628                    } else {
1629                        Some(limitations.join("; "))
1630                    },
1631                }
1632            })
1633        }
1634        _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1635    }
1636}