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