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 forge gate's context is the job in `pr-title.yml` that holds
31// the squash title to the commit convention under `squash`, or the job in
32// `pr-commits.yml` that holds every commit of the request to it under
33// `rebase`. The target names the title check in `protection.title_check`,
34// read through `Ctx::title_check`, which answers the commit gate's job
35// under `rebase`; the landed job keeps its own name as a source constant,
36// so a target that renames the key without renaming the job breaks its
37// own trunk protection and this observer reports it.
38
39const 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";
40
41/// What one observation found.
42#[derive(Debug)]
43pub enum StepState {
44    /// The desired state holds; a limitation names what the forge enforces
45    /// less strongly than the step's proof claims.
46    Satisfied {
47        /// What was found, one line.
48        detail: String,
49        /// The weaker guarantee, by name, where the forge enforces less.
50        limitation: Option<String>,
51    },
52    /// The desired state does not hold.
53    Unsatisfied {
54        /// What was found instead.
55        detail: String,
56    },
57    /// Eligibility or an optional step's condition does not hold: nothing
58    /// is proven, and `check` reports it as skipped.
59    Inapplicable {
60        /// Why the step does not apply here.
61        detail: String,
62    },
63    /// The observation could not decide.
64    Unknown {
65        /// Why not.
66        detail: String,
67    },
68}
69
70impl StepState {
71    /// What was found, one line.
72    #[must_use]
73    pub fn detail(&self) -> &str {
74        match self {
75            Self::Satisfied { detail, .. }
76            | Self::Unsatisfied { detail }
77            | Self::Inapplicable { detail }
78            | Self::Unknown { detail } => detail,
79        }
80    }
81
82    /// Whether the desired state holds.
83    #[must_use]
84    pub const fn satisfied(&self) -> bool {
85        matches!(self, Self::Satisfied { .. })
86    }
87
88    fn ok(detail: impl Into<String>) -> Self {
89        Self::Satisfied {
90            detail: detail.into(),
91            limitation: None,
92        }
93    }
94
95    fn ok_with_limitation(detail: impl Into<String>, limitation: impl Into<String>) -> Self {
96        Self::Satisfied {
97            detail: detail.into(),
98            limitation: Some(limitation.into()),
99        }
100    }
101
102    fn not(detail: impl Into<String>) -> Self {
103        Self::Unsatisfied {
104            detail: detail.into(),
105        }
106    }
107
108    fn inapplicable(detail: impl Into<String>) -> Self {
109        Self::Inapplicable {
110            detail: detail.into(),
111        }
112    }
113
114    fn unknown(detail: impl Into<String>) -> Self {
115        Self::Unknown {
116            detail: detail.into(),
117        }
118    }
119}
120
121/// One read-only forge API answer.
122pub(crate) enum Api {
123    /// The call succeeded and parsed.
124    Ok(Value),
125    /// The forge answered 404: the thing is not there.
126    Missing,
127    /// The call failed for another reason, with the CLI's own words.
128    Failed(String),
129}
130
131/// Observe one step's desired state.
132///
133/// # Errors
134///
135/// Propagates executor failures; a forge answer that merely disagrees is a
136/// [`StepState`], not an error.
137pub fn observe(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
138    if step == "package-check" {
139        return package_check(ctx, run);
140    }
141    if step == "branch-reminder" {
142        return Ok(branch_reminder_state(ctx));
143    }
144    if step == "forge-version" {
145        return forge_version(ctx, run);
146    }
147    match ctx.forge {
148        Some(Forge::Github) => github(ctx, step, run),
149        Some(Forge::Gitlab) => gitlab(ctx, step, run),
150        // A forge step at a target with no adapter proves nothing and
151        // reads nothing: the applicability gate states why, and this is
152        // the observation saying the same.
153        None => Ok(StepState::inapplicable(
154            "the profile names no forge this release drives",
155        )),
156    }
157}
158
159/// What Python's check cannot answer. PEP 517 lets a project choose its
160/// build backend, and an sdist and a wheel can carry different files, so
161/// one `uv build` run supplies no listing contract across both outputs.
162const 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";
163
164/// What no setup step proves about a bash release's package: `rk release
165/// build` packs it from `HEAD` at the release, and only `--list` reads it
166/// before then.
167const BASH_LIMITATION: &str = "the package is not listed here: its contents, SECURITY.md inclusion among them, are unproved; run rk release build --list before the first release, and name SECURITY.md in profile.release.build.include to ship it";
168
169/// Whether a bash release's verify command can run before the first
170/// release: a make command needs a makefile in the target, and a make
171/// target that resolves where make is on this host; another command needs
172/// its first word to resolve where this host can tell. A command with
173/// shell syntax reads ok with its resolution unproved, because only a
174/// plain command passes its words to the program unchanged. With no verify
175/// command the step has nothing to resolve. The package itself is never
176/// listed here, so the limitation names `rk release build --list` every
177/// time.
178///
179/// SATISFIES forge-setup:a-package-check-states-policy-reach
180fn bash_build_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
181    const KEY: &str = "profile.release.build.verify";
182    let Some(command) = ctx
183        .profile
184        .release
185        .build
186        .as_ref()
187        .and_then(|build| build.verify.as_deref())
188    else {
189        return Ok(StepState::ok_with_limitation(
190            format!(
191                "rk release build packs the declared tracked files of HEAD, and {KEY} names no command that proves the package builds"
192            ),
193            BASH_LIMITATION.to_owned(),
194        ));
195    };
196    let limitation = BASH_LIMITATION.to_owned();
197    // The build runs the command through sh -c. Only a command whose words
198    // the shell passes on unchanged can be judged word by word here.
199    if !plain_command(command) {
200        return Ok(StepState::ok_with_limitation(
201            format!(
202                "{command} carries shell syntax, so its resolution is not proved here. The build runs it through sh -c in the unpacked package"
203            ),
204            limitation,
205        ));
206    }
207    let mut words = command.split_whitespace();
208    let program = words.next().unwrap_or_default();
209    let env = ctx.child_env("package-check");
210    // A file this host would run: present, and executable by someone.
211    let runnable = |path: &std::path::Path| {
212        use std::os::unix::fs::PermissionsExt as _;
213        std::fs::metadata(path)
214            .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
215    };
216    let on_path = |name: &str| {
217        env.iter()
218            .find(|(key, _)| key == "PATH")
219            .map(|(_, value)| value.clone())
220            .or_else(|| std::env::var_os("PATH"))
221            .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| runnable(&dir.join(name))))
222    };
223    if program == "make" {
224        let makefile = ["GNUmakefile", "makefile", "Makefile"]
225            .iter()
226            .any(|name| ctx.target.join(name).is_file());
227        if !makefile {
228            return Ok(StepState::not(format!(
229                "the verify command is {command}, from {KEY}, and the target holds no Makefile, GNUmakefile, or makefile; ship one in the package, or set {KEY} to the command that proves it"
230            )));
231        }
232        if !on_path("make") {
233            return Ok(StepState::ok_with_limitation(
234                format!(
235                    "{command} has its makefile; make is not on this host, so the target was not resolved"
236                ),
237                limitation,
238            ));
239        }
240        let mut args: Vec<&str> = vec!["-n"];
241        args.extend(words);
242        let outcome = run(&cargo_exec(ctx, "make", &args))?;
243        if !outcome.success() {
244            return Ok(StepState::not(format!(
245                "make -n does not resolve the verify command {command}: {}; give the makefile that target, or correct {KEY}",
246                last_line(&outcome.stderr)
247            )));
248        }
249        return Ok(StepState::ok_with_limitation(
250            format!("{command} resolves in the target's makefile"),
251            limitation,
252        ));
253    }
254    // A path is the project's own file, which this host can judge: one that
255    // is absent or not executable cannot run in any image.
256    if program.contains('/') {
257        if !runnable(ctx.target.join(program).as_std_path()) {
258            return Ok(StepState::not(format!(
259                "the verify command is {command}, from {KEY}, and {program} is not an executable file in the target; make it one, or correct {KEY}"
260            )));
261        }
262        return Ok(StepState::ok_with_limitation(
263            format!(
264                "{command} is the project's own verify command, and {program} is executable here; it runs in the unpacked package"
265            ),
266            limitation,
267        ));
268    }
269    let resolves = on_path(program);
270    Ok(StepState::ok_with_limitation(
271        if resolves {
272            format!(
273                "{command} is the project's own verify command, and {program} resolves here; it runs in the unpacked package"
274            )
275        } else {
276            format!(
277                "{command} is the project's own verify command, and {program} does not resolve on this host, where profile.release.build.packages need not be installed; it runs in the unpacked package"
278            )
279        },
280        limitation,
281    ))
282}
283
284/// Whether `sh -c` runs `command` as one program with its words as they
285/// stand: no quoting, escape, operator, redirection, substitution, glob,
286/// comment, or leading assignment.
287fn plain_command(command: &str) -> bool {
288    const SHELL: &[char] = &[
289        '\u{27}', '\u{22}', '\u{60}', '\\', '$', ';', '&', '|', '<', '>', '(', ')', '{', '}', '*',
290        '?', '[', ']', '~', '#', '!', '\n',
291    ];
292    !command.contains(SHELL)
293        && command
294            .split_whitespace()
295            .next()
296            .is_some_and(|program| !program.contains('='))
297}
298
299/// ยง0: the technology's own no-credential packaging check; the one step that
300/// reads its command from the binding rather than from a forge tree.
301///
302/// Publishability is the whole of the check for every binding. Policy reach
303/// is asserted only where the target requests the reporting policy and the
304/// binding has a deterministic listing command the step can run with no
305/// credentials, which today is a sole Cargo package rooted at the target;
306/// every other shape reports its successful packaging result with the
307/// unproved inclusion named. A target that opted out of the policy owes no
308/// policy in its artifact, so nothing about one is judged or named there.
309fn package_check(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
310    // The stance already reports the step as not applicable; this keeps
311    // any caller that observes it anyway from running a packaging command.
312    if let Some(reason) = super::steps::no_registry(ctx) {
313        return Ok(StepState::ok(reason));
314    }
315    let (program, args): (&str, &[&str]) = match ctx.tech {
316        Some("rust") => ("cargo", &["publish", "--dry-run", "--allow-dirty"]),
317        Some("python") => return python_build_check(ctx, run, ctx.reporting_policy()),
318        Some("bash") => return bash_build_check(ctx, run),
319        Some(other) => {
320            return Ok(StepState::unknown(format!(
321                "no packaging check is defined for {other}"
322            )));
323        }
324        None => {
325            return Ok(StepState::unknown(
326                "no version file names a technology; see rk binding --list",
327            ));
328        }
329    };
330    let outcome = run(&cargo_exec(ctx, program, args))?;
331    if !outcome.success() {
332        return Ok(StepState::not(format!(
333            "the packaging check failed: {}",
334            last_line(&outcome.stderr)
335        )));
336    }
337    let built = "the package builds and passes the registry's dry run";
338    policy_in_the_crate(ctx, run, built)
339}
340
341/// Whether the uv program resolves to an executable file the way the spawn
342/// resolves it from `cwd`, the directory the child runs in: a path with a
343/// directory part, or a bare name on the child's PATH, else this
344/// process's. A relative path or PATH entry resolves against `cwd`.
345fn uv_runs(
346    program: &std::ffi::OsStr,
347    env: &[(std::ffi::OsString, std::ffi::OsString)],
348    cwd: &std::path::Path,
349) -> bool {
350    use std::os::unix::fs::PermissionsExt as _;
351    let runnable = |path: &std::path::Path| {
352        std::fs::metadata(path)
353            .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
354    };
355    let path = std::path::Path::new(program);
356    if path.components().count() > 1 {
357        return runnable(&cwd.join(path));
358    }
359    env.iter()
360        .find(|(key, _)| key == "PATH")
361        .map(|(_, value)| value.clone())
362        .or_else(|| std::env::var_os("PATH"))
363        .is_some_and(|paths| {
364            std::env::split_paths(&paths).any(|dir| runnable(&cwd.join(dir).join(path)))
365        })
366}
367
368/// The python check: `uv build`, the build the release runs, then the sdist
369/// and the wheel it left in `dist/`, named. Their contents stay unproved
370/// where the target requests the reporting policy.
371fn python_build_check(ctx: &Ctx, run: &mut Runner, policy: bool) -> Result<StepState, RkError> {
372    let program = crate::probes::uv_bin();
373    let env = ctx.child_env("package-check");
374    if !uv_runs(&program, &env, ctx.target.as_std_path()) {
375        // A host with no uv cannot build the package. That is a limit of
376        // this observation, not a defect of the target.
377        return Ok(StepState::Unknown {
378            detail: format!(
379                "uv ({}) is not runnable on this host, so the package build was not checked; install uv, or point RK_UV_BIN at it",
380                program.to_string_lossy()
381            ),
382        });
383    }
384    let exec = Exec {
385        program,
386        args: vec!["build".into()],
387        env,
388        cwd: ctx.target.as_std_path().to_path_buf(),
389        stdin: None,
390    };
391    let outcome = run(&exec)?;
392    if !outcome.success() {
393        return Ok(StepState::not(format!(
394            "the packaging check failed: {}",
395            last_line(&outcome.stderr)
396        )));
397    }
398    let listed = |suffix: &str| -> Vec<String> {
399        let mut names: Vec<String> = std::fs::read_dir(ctx.target.join("dist").as_std_path())
400            .into_iter()
401            .flatten()
402            .flatten()
403            .map(|entry| entry.file_name().to_string_lossy().into_owned())
404            .filter(|name| name.ends_with(suffix))
405            .collect();
406        names.sort();
407        names
408    };
409    let (sdists, wheels) = (listed(".tar.gz"), listed(".whl"));
410    if sdists.is_empty() || wheels.is_empty() {
411        return Ok(StepState::not(
412            "uv build left no sdist and wheel pair in dist/; the release uploads both",
413        ));
414    }
415    let built = format!(
416        "uv build wrote the sdist {} and the wheel {}",
417        sdists.join(", "),
418        wheels.join(", ")
419    );
420    Ok(if policy {
421        StepState::ok_with_limitation(built, PYTHON_LIMITATION)
422    } else {
423        StepState::ok(built)
424    })
425}
426
427/// One no-credential Cargo invocation against the target.
428fn cargo_exec(ctx: &Ctx, program: &str, args: &[&str]) -> Exec {
429    Exec {
430        program: program.into(),
431        args: args.iter().map(Into::into).collect(),
432        env: ctx.child_env("package-check"),
433        cwd: ctx.target.as_std_path().to_path_buf(),
434        stdin: None,
435    }
436}
437
438/// Whether the published crate carries the root policy and none of
439/// release-kit's own files, for the one shape Cargo answers unambiguously.
440///
441/// [`cargo_package::probe`] lists files only for a sole selected default
442/// member whose manifest is the target's own `Cargo.toml`; a virtual
443/// workspace, several default members, and a sole nested member each keep
444/// the successful publishability result and name the limitation instead of
445/// claiming a reach they cannot prove.
446///
447/// release-plz attributes a commit to the crate when the commit changes a
448/// file the crate ships, so a packaged file every landing rewrites makes
449/// every upgrade a release with no code change. The fault names each such
450/// path and the `exclude` entry that removes it, and edits nothing.
451///
452/// SATISFIES forge-setup:a-package-check-states-policy-reach
453fn policy_in_the_crate(ctx: &Ctx, run: &mut Runner, built: &str) -> Result<StepState, RkError> {
454    let policy = ctx.reporting_policy();
455    let root_manifest = ctx.target.as_std_path().join("Cargo.toml");
456    let probe = cargo_package::probe(&root_manifest, &[], |args| {
457        run(&cargo_exec(ctx, "cargo", args)).map(|outcome| Answer {
458            success: outcome.success(),
459            stdout: outcome.stdout,
460            stderr: outcome.stderr,
461        })
462    })?;
463    let listing = match probe {
464        Probe::Listed(listing) => listing,
465        Probe::OtherShape => {
466            let unproved = if policy {
467                format!(
468                    "{POLICY_DESTINATION} inclusion and the absence of release-kit's own files are"
469                )
470            } else {
471                "the absence of release-kit's own files is".to_owned()
472            };
473            return Ok(StepState::ok_with_limitation(
474                built,
475                format!(
476                    "{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"
477                ),
478            ));
479        }
480        Probe::MetadataFailed(stderr) => {
481            return Ok(StepState::unknown(format!(
482                "{built}, and the listing check could not run: cargo metadata failed: {}",
483                last_line(&stderr)
484            )));
485        }
486        Probe::ListingFailed(stderr) => {
487            return Ok(StepState::unknown(format!(
488                "{built}, and the listing check could not run: cargo package --list failed: {}",
489                last_line(&stderr)
490            )));
491        }
492    };
493    let forbidden = cargo_package::release_kit_paths(ctx.landed_destinations());
494    let shipped = listing.shipped(&forbidden);
495    Ok(boundary_state(
496        built,
497        policy,
498        !policy || listing.carries(POLICY_DESTINATION),
499        &shipped,
500    ))
501}
502
503/// The verdict over one listing: the policy must ship where the target
504/// requests it, and no file release-kit lands or owns may. Both faults
505/// share one detail, because a step reports one line.
506fn boundary_state(built: &str, policy: bool, carried: bool, shipped: &[&str]) -> StepState {
507    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";
508    let policy_fix = format!(
509        "add /{POLICY_DESTINATION} to [package].include, remove the [package].exclude entry matching it, or stop ignoring the file"
510    );
511    let own = || {
512        let entries = cargo_package::exclude_entries(shipped)
513            .iter()
514            .map(|entry| format!("\"{entry}\""))
515            .collect::<Vec<_>>()
516            .join(", ");
517        (
518            shipped.join(", "),
519            format!("add {entries} to [package].exclude, or drop them from [package].include"),
520        )
521    };
522    match (carried, shipped.is_empty()) {
523        (true, true) if policy => StepState::ok(format!(
524            "{built}, and the published package carries {POLICY_DESTINATION} and no file release-kit lands or owns"
525        )),
526        (true, true) => StepState::ok(format!(
527            "{built}, and the published package ships no file release-kit lands or owns"
528        )),
529        (false, true) => StepState::not(format!(
530            "{built}, but the published package omits {POLICY_DESTINATION}: {policy_fix}"
531        )),
532        (true, false) => {
533            let (list, fix) = own();
534            StepState::not(format!(
535                "{built}, but the published package ships files release-kit lands or owns ({list}), {WHY}: {fix}"
536            ))
537        }
538        (false, false) => {
539            let (list, fix) = own();
540            StepState::not(format!(
541                "{built}, but the published package omits {POLICY_DESTINATION} and ships files release-kit lands or owns ({list}), {WHY}: {policy_fix}; and {fix}"
542            ))
543        }
544    }
545}
546
547/// ยง1: the post-merge reminder hook, judged from the target's own files;
548/// the one step whose observation asks no forge and spawns no CLI.
549fn branch_reminder_state(ctx: &Ctx) -> StepState {
550    use crate::setup::branch_reminder::{HookState, observe_hook};
551    match observe_hook(&ctx.target) {
552        HookState::Installed => {
553            StepState::ok("the post-merge hook carries the release-kit reminder")
554        }
555        HookState::Absent => StepState::not("no post-merge hook is installed"),
556        HookState::Foreign => {
557            StepState::not("a post-merge hook exists without the release-kit marker")
558        }
559        HookState::Drifted => StepState::not("the reminder hook drifted from this binary's body"),
560        HookState::Unreadable(detail) => StepState::unknown(detail),
561    }
562}
563
564/// The GitLab version this convention needs, as major and minor.
565///
566/// `trigger: strategy: mirror` arrived in GitLab 18.2, and the merge-request
567/// pipeline's `project-jobs` bridge rests on it: below the floor the child
568/// pipeline's status never reaches the parent, so a failing project job
569/// merges.
570pub const GITLAB_VERSION_FLOOR: (u64, u64) = (18, 2);
571
572/// The two suffixes that name an edition rather than a pre-release. Every
573/// other suffix is a pre-release, and the step fails closed on one, except
574/// on gitlab.com, where the suffix names the deployment's own continuous
575/// build.
576const GITLAB_EDITIONS: [&str; 2] = ["ee", "ce"];
577
578/// The refusal an instance below the floor reads: the reading, the reason,
579/// and the fix. On gitlab.com the fix never names gitlab.com, the state the
580/// target is already in.
581fn version_refusal(found: &str, prerelease: Option<&str>, on_gitlab_com: bool) -> String {
582    let (major, minor) = GITLAB_VERSION_FLOOR;
583    let mut said = vec![format!(
584        "this GitLab instance reports {found}; the convention needs {major}.{minor} or newer"
585    )];
586    if let Some(suffix) = prerelease {
587        said.push(format!(
588            "the -{suffix} suffix is a pre-release, and nothing proves the feature shipped in it, so this step fails closed"
589        ));
590    }
591    said.push(format!(
592        "the merge-request pipeline triggers a child pipeline with `strategy: mirror`, which GitLab added in {major}.{minor}"
593    ));
594    said.push(
595        "below it the child's status never reaches the parent pipeline, so a failing project job merges".to_owned(),
596    );
597    if on_gitlab_com {
598        said.push(format!(
599            "gitlab.com runs below {major}.{minor} only until its next deployment; rerun the check after it"
600        ));
601    } else {
602        said.push(format!(
603            "upgrade the instance to {major}.{minor} or newer, or host the project on gitlab.com"
604        ));
605    }
606    said.join("; ")
607}
608
609/// ยง3: the forge's own version against the convention's floor.
610///
611/// GitHub is a rolling service and is answered without a call. GitLab is one
612/// read-only `GET /version`, and every failure to read is `Unknown`, which
613/// blocks the `protect-trunk` prerequisite exactly as `Unsatisfied` does.
614fn forge_version(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
615    if ctx.forge == Some(Forge::Github) {
616        return Ok(StepState::ok(
617            "github.com is a rolling service and declares no version floor",
618        ));
619    }
620    let body = match api_get(ctx, run, "version")? {
621        Api::Ok(body) => body,
622        Api::Missing => {
623            return Ok(StepState::unknown(
624                "this instance answers no GET /version; the floor cannot be read. Check that glab is authenticated against it: glab auth login",
625            ));
626        }
627        Api::Failed(err) => {
628            return Ok(StepState::unknown(format!(
629                "the version could not be read: {err}. Check that glab is authenticated against this instance: glab auth login"
630            )));
631        }
632    };
633    let Some(found) = body["version"].as_str() else {
634        return Ok(StepState::unknown(
635            "the forge answer carries no version field; the floor cannot be read. Check that glab is authenticated against this instance: glab auth login",
636        ));
637    };
638    let (number, suffix) = found
639        .split_once('-')
640        .map_or((found, None), |(n, s)| (n, Some(s)));
641    let mut parts = number.split('.');
642    let parsed = parts
643        .next()
644        .and_then(|major| major.parse::<u64>().ok())
645        .zip(parts.next().and_then(|minor| minor.parse::<u64>().ok()));
646    let Some(pair) = parsed else {
647        return Ok(StepState::unknown(format!(
648            "the forge reports the version as '{found}', which names no major and minor pair; the floor cannot be read"
649        )));
650    };
651    // gitlab.com answers every deployment with a `-pre` suffix, so there
652    // the suffix is the SaaS build and the number alone decides. Only a
653    // detected gitlab.com loosens the rule: a self-managed or unknown host
654    // still fails closed on every suffix that is not an edition.
655    let on_gitlab_com = ctx.on_gitlab_com();
656    let prerelease = suffix.filter(|s| !GITLAB_EDITIONS.contains(s));
657    if let Some(suffix) = prerelease.filter(|_| !on_gitlab_com) {
658        return Ok(StepState::not(version_refusal(found, Some(suffix), false)));
659    }
660    if pair < GITLAB_VERSION_FLOOR {
661        return Ok(StepState::not(version_refusal(found, None, on_gitlab_com)));
662    }
663    let (major, minor) = GITLAB_VERSION_FLOOR;
664    Ok(StepState::ok(prerelease.map_or_else(
665        || format!("this instance reports {found}, at or above the {major}.{minor} floor"),
666        |suffix| {
667            format!(
668                "gitlab.com reports {found}, at or above the {major}.{minor} floor; the -{suffix} suffix is the SaaS deployment's own"
669            )
670        },
671    )))
672}
673
674/// The destructive step's own guard: whether deleting a candidate branch
675/// can lose work.
676///
677/// `Satisfied` means every candidate is already gone or is an ancestor of
678/// the trunk; `Unsatisfied` means the deletion must refuse.
679///
680/// # Errors
681///
682/// Propagates executor failures.
683pub fn single_trunk_guard(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
684    let trunk = ctx.trunk();
685    for candidate in ctx.retired_branches() {
686        let candidate = candidate.as_str();
687        if candidate == trunk {
688            continue;
689        }
690        let state = match ctx.forge {
691            Some(Forge::Github) => github_candidate_guard(ctx, run, candidate)?,
692            Some(Forge::Gitlab) => gitlab_candidate_guard(ctx, run, candidate)?,
693            // A destructive step fails closed, and an absent adapter is
694            // one more thing the guard cannot establish.
695            None => StepState::unknown("the profile names no forge this release drives"),
696        };
697        if !state.satisfied() {
698            return Ok(state);
699        }
700    }
701    Ok(StepState::ok(
702        "every candidate branch is absent, or an ancestor of the trunk",
703    ))
704}
705
706/// One candidate branch's ancestry, on GitHub.
707fn github_candidate_guard(
708    ctx: &Ctx,
709    run: &mut Runner,
710    candidate: &str,
711) -> Result<StepState, RkError> {
712    let trunk = ctx.trunk();
713    match api_get(
714        ctx,
715        run,
716        &format!("repos/{}/git/ref/heads/{candidate}", ctx.repo),
717    )? {
718        Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
719        Api::Failed(err) => return Ok(StepState::unknown(err)),
720        Api::Ok(_) => {}
721    }
722    match api_get(
723        ctx,
724        run,
725        &format!("repos/{}/compare/{candidate}...{trunk}", ctx.repo),
726    )? {
727        Api::Ok(body) => {
728            let status = body["status"].as_str().unwrap_or("");
729            Ok(if matches!(status, "ahead" | "identical") {
730                StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
731            } else {
732                StepState::not(format!(
733                    "{candidate} is not an ancestor of {trunk} ({status}); deleting it would lose work"
734                ))
735            })
736        }
737        Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
738        Api::Failed(err) => Ok(StepState::unknown(err)),
739    }
740}
741
742/// One candidate branch's ancestry, on GitLab.
743fn gitlab_candidate_guard(
744    ctx: &Ctx,
745    run: &mut Runner,
746    candidate: &str,
747) -> Result<StepState, RkError> {
748    let trunk = ctx.trunk();
749    let project = ctx.repo.replace('/', "%2F");
750    match api_get(
751        ctx,
752        run,
753        &format!("projects/{project}/repository/branches/{candidate}"),
754    )? {
755        Api::Missing => return Ok(StepState::ok(format!("{candidate} is already gone"))),
756        Api::Failed(err) => return Ok(StepState::unknown(err)),
757        Api::Ok(_) => {}
758    }
759    match api_get(
760        ctx,
761        run,
762        &format!("projects/{project}/repository/compare?from={trunk}&to={candidate}"),
763    )? {
764        Api::Ok(body) => {
765            let ahead = body["commits"]
766                .as_array()
767                .is_some_and(|list| !list.is_empty());
768            Ok(if ahead {
769                StepState::not(format!(
770                    "{candidate} carries commits {trunk} does not; deleting it would lose work"
771                ))
772            } else {
773                StepState::ok(format!("{candidate} is an ancestor of {trunk}"))
774            })
775        }
776        Api::Missing => Ok(StepState::unknown("the comparison is not readable")),
777        Api::Failed(err) => Ok(StepState::unknown(err)),
778    }
779}
780
781/// One captured, read-only forge API call.
782pub(crate) fn api_get(ctx: &Ctx, run: &mut Runner, path: &str) -> Result<Api, RkError> {
783    let exec = Exec {
784        program: ctx.cli.clone().into_os_string(),
785        args: vec!["api".into(), path.into()],
786        env: ctx.child_env("observe"),
787        cwd: ctx.target.as_std_path().to_path_buf(),
788        stdin: None,
789    };
790    let outcome = run(&exec)?;
791    if outcome.success() {
792        return Ok(
793            serde_json::from_slice::<Value>(&outcome.stdout).map_or_else(
794                |_| Api::Failed("the forge answer did not parse as JSON".into()),
795                Api::Ok,
796            ),
797        );
798    }
799    let stderr = String::from_utf8_lossy(&outcome.stderr).into_owned();
800    if stderr.contains("404") {
801        Ok(Api::Missing)
802    } else {
803        Ok(Api::Failed(last_line(&outcome.stderr)))
804    }
805}
806
807/// The last non-empty line of a byte stream, for one-line detail fields.
808fn last_line(bytes: &[u8]) -> String {
809    String::from_utf8_lossy(bytes)
810        .lines()
811        .rev()
812        .find(|line| !line.trim().is_empty())
813        .unwrap_or("no output")
814        .to_owned()
815}
816
817#[allow(
818    clippy::too_many_lines,
819    reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
820)]
821fn github(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
822    let trunk = ctx.trunk();
823    let repo = &ctx.repo;
824    match step {
825        "private-vulnerability-reporting" => {
826            let visibility_path = format!("repos/{repo}");
827            match api_get(ctx, run, &visibility_path)? {
828                Api::Ok(body) => match body["private"].as_bool() {
829                    Some(true) => {
830                        return Ok(StepState::inapplicable(
831                            "private vulnerability reporting is available for public repositories",
832                        ));
833                    }
834                    Some(false) => {}
835                    None => {
836                        return Ok(StepState::unknown(format!(
837                            "{visibility_path}: repository visibility is unreadable"
838                        )));
839                    }
840                },
841                Api::Missing => {
842                    return Ok(StepState::unknown(format!(
843                        "{visibility_path}: repository visibility is unreadable (404)"
844                    )));
845                }
846                Api::Failed(err) => {
847                    return Ok(StepState::unknown(format!("{visibility_path}: {err}")));
848                }
849            }
850            let path = format!("repos/{repo}/private-vulnerability-reporting");
851            Ok(match api_get(ctx, run, &path)? {
852                Api::Ok(body) => match body["enabled"].as_bool() {
853                    Some(true) => StepState::ok("private vulnerability reporting is enabled"),
854                    Some(false) => StepState::not("private vulnerability reporting is disabled"),
855                    None => StepState::unknown(format!("{path}: enabled is unreadable")),
856                },
857                Api::Missing => {
858                    StepState::unknown(format!("{path}: reporting state is unreadable (404)"))
859                }
860                Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
861            })
862        }
863
864        "default-branch" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
865            Api::Ok(body) => {
866                let found = body["default_branch"].as_str().unwrap_or("");
867                if found == trunk {
868                    StepState::ok(format!("{trunk} is the default branch"))
869                } else {
870                    StepState::not(format!("the default branch is {found}"))
871                }
872            }
873            Api::Missing => StepState::not(format!("the forge does not know {repo}")),
874            Api::Failed(err) => StepState::unknown(err),
875        }),
876        "single-trunk" => {
877            for candidate in ctx.retired_branches() {
878                let candidate = candidate.as_str();
879                if candidate == trunk {
880                    continue;
881                }
882                match api_get(ctx, run, &format!("repos/{repo}/git/ref/heads/{candidate}"))? {
883                    Api::Missing => {}
884                    Api::Ok(_) => {
885                        return Ok(StepState::not(format!("a {candidate} branch still exists")));
886                    }
887                    Api::Failed(err) => return Ok(StepState::unknown(err)),
888                }
889            }
890            Ok(StepState::ok(
891                "no long-lived branch besides the trunk remains",
892            ))
893        }
894        "merge-cleanup" => Ok(match api_get(ctx, run, &format!("repos/{repo}"))? {
895            Api::Ok(body) => {
896                if body["delete_branch_on_merge"].as_bool().unwrap_or(false) {
897                    StepState::ok("a merged branch is deleted by the forge")
898                } else {
899                    StepState::not("a merged branch outlives its merge")
900                }
901            }
902            Api::Missing => StepState::not(format!("the forge does not know {repo}")),
903            Api::Failed(err) => StepState::unknown(err),
904        }),
905        "merge-on-order" => {
906            // Every page is read: an armed request past the first page
907            // still faults, and a listing that does not exhaust decides
908            // nothing.
909            let requests = match open_requests(ctx, run, &format!("repos/{repo}/pulls?state=open"))?
910            {
911                Ok(requests) => requests,
912                Err(state) => return Ok(state),
913            };
914            if let Some(fault) = armed_fault(ctx, &requests, "head", "ref") {
915                return Ok(StepState::not(fault));
916            }
917            // The switch serves the project's own requests too, so it is
918            // reported and never judged: no landed job arms anything.
919            let switch = match api_get(ctx, run, &format!("repos/{repo}"))? {
920                Api::Ok(body) => body["allow_auto_merge"]
921                    .as_bool()
922                    .map_or("unread", |on| if on { "on" } else { "off" }),
923                Api::Missing => {
924                    return Ok(StepState::not(format!("the forge does not know {repo}")));
925                }
926                Api::Failed(_) => "unread",
927            };
928            Ok(StepState::ok(format!(
929                "no release request stands armed to merge itself; the repository's allow_auto_merge switch is {switch}, and no landed job uses it"
930            )))
931        }
932        "ci-permissions" => Ok(
933            match api_get(
934                ctx,
935                run,
936                &format!("repos/{repo}/actions/permissions/workflow"),
937            )? {
938                Api::Ok(body) => {
939                    let write = body["default_workflow_permissions"] == "write";
940                    let approve = body["can_approve_pull_request_reviews"] == true;
941                    if write && approve {
942                        StepState::ok("CI may write and open requests")
943                    } else {
944                        StepState::not(format!(
945                            "workflow permissions are {} with request approval {}",
946                            body["default_workflow_permissions"],
947                            body["can_approve_pull_request_reviews"]
948                        ))
949                    }
950                }
951                Api::Missing => StepState::not("no workflow permissions are readable"),
952                Api::Failed(err) => StepState::unknown(err),
953            },
954        ),
955        "bot-secrets" => Ok(
956            match api_get(ctx, run, &format!("repos/{repo}/actions/secrets"))? {
957                Api::Ok(body) => {
958                    let names: Vec<&str> = body["secrets"]
959                        .as_array()
960                        .map(|list| {
961                            list.iter()
962                                .filter_map(|secret| secret["name"].as_str())
963                                .collect()
964                        })
965                        .unwrap_or_default();
966                    let wanted = ["RELEASE_BOT_APP_ID", "RELEASE_BOT_APP_PRIVATE_KEY"];
967                    if wanted.iter().all(|name| names.contains(name)) {
968                        StepState::ok("both bot secrets are stored")
969                    } else if names.is_empty() {
970                        StepState::not("no bot secrets are stored")
971                    } else {
972                        StepState::not(format!("stored secrets: {}", names.join(", ")))
973                    }
974                }
975                Api::Missing => StepState::not("no secrets are readable"),
976                Api::Failed(err) => StepState::unknown(err),
977            },
978        ),
979        "protect-trunk" => github_trunk_ruleset(ctx, run),
980        "protect-tags" => github_ruleset(
981            ctx,
982            run,
983            ctx.tag_ruleset(),
984            "tag",
985            "refs/tags/v*",
986            &["deletion", "update"],
987        ),
988        "protect-release-lines" => {
989            match github_ruleset_body(ctx, run, ctx.lines_ruleset())? {
990                RulesetLookup::Absent => {
991                    return Ok(StepState::inapplicable(
992                        "release/* is unprotected; optional โ€” applied only where older lines exist",
993                    ));
994                }
995                RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
996                RulesetLookup::Found(_) => {}
997            }
998            github_ruleset(
999                ctx,
1000                run,
1001                ctx.lines_ruleset(),
1002                "branch",
1003                "refs/heads/release/*",
1004                &["deletion", "non_fast_forward"],
1005            )
1006        }
1007        "protections-check" => {
1008            // Confirmed drift and unreadable answers stay apart: a proven
1009            // mismatch is drift even beside an outage, and an outage with
1010            // nothing proven wrong stays unknown, never drift.
1011            let mut failures = Vec::new();
1012            let mut unknowns = Vec::new();
1013            // Every satisfied step's limitation survives the aggregate.
1014            let mut limitations: Vec<String> = Vec::new();
1015            for owned in owned_protections(ctx) {
1016                match github(ctx, owned, run)? {
1017                    StepState::Satisfied {
1018                        limitation: found, ..
1019                    } => limitations.extend(found),
1020                    StepState::Inapplicable { .. } => {}
1021                    StepState::Unsatisfied { detail } => {
1022                        failures.push(format!("{owned}: {detail}"));
1023                    }
1024                    StepState::Unknown { detail } => {
1025                        unknowns.push(format!("{owned}: {detail}"));
1026                    }
1027                }
1028            }
1029            match api_get(ctx, run, &format!("repos/{repo}/rulesets"))? {
1030                Api::Ok(body) => {
1031                    let owned = [
1032                        ctx.trunk_ruleset().to_owned(),
1033                        ctx.safety_ruleset().to_owned(),
1034                        ctx.tag_ruleset().to_owned(),
1035                        ctx.lines_ruleset().to_owned(),
1036                    ];
1037                    for ruleset in body.as_array().into_iter().flatten() {
1038                        let name = ruleset["name"].as_str().unwrap_or("");
1039                        if !owned.iter().any(|expected| expected == name) {
1040                            failures.push(format!("a ruleset no step owns: {name}"));
1041                        }
1042                    }
1043                }
1044                Api::Missing | Api::Failed(_) => {
1045                    unknowns.push("the ruleset inventory is not readable".to_owned());
1046                }
1047            }
1048            Ok(if !failures.is_empty() {
1049                StepState::not(failures.join("; "))
1050            } else if !unknowns.is_empty() {
1051                StepState::unknown(unknowns.join("; "))
1052            } else {
1053                StepState::Satisfied {
1054                    detail: "exactly the owned protections, with those rules".into(),
1055                    limitation: if limitations.is_empty() {
1056                        None
1057                    } else {
1058                        Some(limitations.join("; "))
1059                    },
1060                }
1061            })
1062        }
1063        _ => Ok(StepState::unknown(format!("no observation for {step}"))),
1064    }
1065}
1066
1067/// The protection steps `protections-check` aggregates for one target: the
1068/// trunk, the release tags wherever the release mode is not `none`, and
1069/// the optional release lines.
1070fn owned_protections(ctx: &Ctx) -> Vec<&'static str> {
1071    let mut owned = vec!["protect-trunk"];
1072    if ctx.tag_protection() {
1073        owned.push("protect-tags");
1074    }
1075    owned.push("protect-release-lines");
1076    owned
1077}
1078
1079/// The installation, observed as the App itself.
1080///
1081/// The forge serves `repos/{owner}/{repo}/installation` to an App JWT and
1082/// to nothing a user can hold. The caller mints `jwt` โ€” once per run, with
1083/// the token and the key bytes already registered as redaction needles โ€”
1084/// which is why this lives outside the name dispatch above: an observation
1085/// entered without that token has no honest answer.
1086#[must_use]
1087pub fn github_install_bot(ctx: &Ctx, jwt: &str) -> StepState {
1088    match app_jwt::api_get(ctx, jwt, &format!("repos/{}/installation", ctx.repo)) {
1089        AppApi::Ok(body) => {
1090            let id = body["id"].as_i64().unwrap_or_default();
1091            let held = &body["permissions"];
1092            let short: Vec<String> = minimum_grant(ctx)
1093                .into_iter()
1094                .filter(|(key, level)| held[key] != *level)
1095                .map(|(key, level)| format!("{key}: {level}"))
1096                .collect();
1097            if short.is_empty() {
1098                StepState::ok(format!("installation {id} covers {}", ctx.repo))
1099            } else {
1100                // An installation predating a widened grant reads
1101                // unsatisfied until its owner approves the new permission
1102                // in the App's installation settings; no token this run
1103                // can mint grants it.
1104                StepState::not(format!(
1105                    "installation {id} covers {} and does not hold [{}]; approve the App's updated permissions on the installation's own settings page",
1106                    ctx.repo,
1107                    short.join(", ")
1108                ))
1109            }
1110        }
1111        AppApi::Missing => StepState::not(format!("the App is not installed on {}", ctx.repo)),
1112        AppApi::Refused(detail) | AppApi::Failed(detail) => StepState::unknown(detail),
1113    }
1114}
1115
1116/// The release App's minimum grant for this target, as the installation
1117/// reports it.
1118///
1119/// Contents and pull requests carry the release itself: the tag, the bump
1120/// branch, and the request. The merge job that `rk release merge`
1121/// dispatches reads the required check run beside them, and every
1122/// automatic release lands that job, so an automatic release needs the
1123/// third permission and no other target is asked for it.
1124fn minimum_grant(ctx: &Ctx) -> Vec<(&'static str, &'static str)> {
1125    let mut grant = vec![("contents", "write"), ("pull_requests", "write")];
1126    if ctx.profile.release.mode == crate::profile::ReleaseMode::Automatic {
1127        grant.push(("checks", "read"));
1128    }
1129    grant
1130}
1131
1132/// A plain ruleset: active, and carrying exactly the expected rule types โ€”
1133/// not one fewer, and not one more, because an extra rule here is a rule the
1134/// setup cannot reproduce or explain and can block the very push the method
1135/// depends on.
1136fn github_ruleset(
1137    ctx: &Ctx,
1138    run: &mut Runner,
1139    name: &str,
1140    target: &str,
1141    include: &str,
1142    rules: &[&str],
1143) -> Result<StepState, RkError> {
1144    let detail = match github_ruleset_body(ctx, run, name)? {
1145        RulesetLookup::Found(detail) => detail,
1146        RulesetLookup::Absent => {
1147            return Ok(StepState::not(format!("no ruleset named {name}")));
1148        }
1149        RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
1150    };
1151    if detail["enforcement"] != "active" {
1152        return Ok(StepState::not(format!("{name} is not active")));
1153    }
1154    // The name proves nothing: the ruleset must cover exactly the declared
1155    // refs, or the protection it reports exists somewhere else.
1156    if detail["target"] != target {
1157        return Ok(StepState::not(format!(
1158            "{name} does not target {target} refs"
1159        )));
1160    }
1161    if detail["conditions"]["ref_name"]["include"] != serde_json::json!([include]) {
1162        return Ok(StepState::not(format!(
1163            "{name} does not cover {include} alone"
1164        )));
1165    }
1166    if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
1167        return Ok(StepState::not(format!(
1168            "{name} excludes refs from its own coverage"
1169        )));
1170    }
1171    let mut held: Vec<&str> = detail["rules"]
1172        .as_array()
1173        .map(|list| {
1174            list.iter()
1175                .filter_map(|rule| rule["type"].as_str())
1176                .collect()
1177        })
1178        .unwrap_or_default();
1179    held.sort_unstable();
1180    let mut expected: Vec<&str> = rules.to_vec();
1181    expected.sort_unstable();
1182    if held == expected {
1183        Ok(StepState::ok(format!(
1184            "{name} is active with exactly its rules"
1185        )))
1186    } else {
1187        Ok(StepState::not(format!(
1188            "{name} carries the rules [{}] where the setup owns [{}]",
1189            held.join(", "),
1190            expected.join(", ")
1191        )))
1192    }
1193}
1194
1195// The trunk ruleset is checked for the shape a release merge needs.
1196// The rule kinds the setup writes and can reproduce come from
1197// `protection.owned_trunk_rules`, floored to contain all four. The set
1198// also drives the missing-rule fault, so a kind this convention refuses
1199// must stay out of it: adding one would demand that rule on every target.
1200// The floor is what stops a target dropping one it needs.
1201
1202/// A fault line for every rule on the trunk that the setup does not own.
1203///
1204/// The merge queue gets its own text, because this convention refuses one
1205/// deliberately and the operator needs the consequence and the remedy. Every
1206/// other unowned kind reads generically: an unowned rule is one the setup
1207/// cannot reproduce or explain, and it can block the very merge the method
1208/// depends on.
1209fn unowned_rule_faults(rules: &[Value], owned: &[String]) -> Vec<String> {
1210    rules
1211        .iter()
1212        .filter_map(|rule| rule["type"].as_str())
1213        .filter(|kind| !owned.iter().any(|name| name == kind))
1214        .map(|kind| {
1215            if kind == "merge_queue" {
1216                MERGE_QUEUE_FAULT.to_owned()
1217            } else {
1218                format!("an unowned rule is present: {kind}")
1219            }
1220        })
1221        .collect()
1222}
1223
1224fn github_trunk_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<StepState, RkError> {
1225    let trunk = ctx.trunk();
1226    let name = ctx.trunk_ruleset().to_owned();
1227    let detail = match github_ruleset_body(ctx, run, &name)? {
1228        RulesetLookup::Found(detail) => detail,
1229        RulesetLookup::Absent => {
1230            return Ok(StepState::not(format!("no ruleset named {name}")));
1231        }
1232        RulesetLookup::Unreadable(err) => return Ok(StepState::unknown(err)),
1233    };
1234    let rules = detail["rules"].as_array().cloned().unwrap_or_default();
1235    let mut faults = Vec::new();
1236    if detail["enforcement"] != "active" {
1237        faults.push(format!("{name} is not active"));
1238    }
1239    // The name proves nothing: a ruleset applies only where its conditions
1240    // say, so a right-named ruleset covering another ref would otherwise
1241    // read as a protected trunk.
1242    if detail["target"] != "branch" {
1243        faults.push(format!("{name} does not target branches"));
1244    }
1245    let expected_ref = serde_json::json!([format!("refs/heads/{trunk}")]);
1246    if detail["conditions"]["ref_name"]["include"] != expected_ref {
1247        faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
1248    }
1249    // A matching exclusion negates the include, so the owned shape is an
1250    // exclusion list that is exactly empty.
1251    if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
1252        faults.push(format!("{name} excludes refs from its own coverage"));
1253    }
1254    let expected_bypass = super::context::github_bypass_actors(&ctx.protection().bypass_actors);
1255    if detail["bypass_actors"] != expected_bypass {
1256        faults.push("the bypass actors do not match the recorded authority".to_owned());
1257    }
1258    faults.extend(trunk_rule_faults(ctx, &rules, &name));
1259    faults.extend(merge_method_fault(ctx, &rules));
1260    if let Some(checks) = rules
1261        .iter()
1262        .find(|rule| rule["type"] == "required_status_checks")
1263    {
1264        if checks["parameters"]["strict_required_status_checks_policy"]
1265            != ctx.protection().strict_required_status_checks
1266        {
1267            faults.push(STALE_MERGE_FAULT.to_owned());
1268        }
1269        let contexts: Vec<&str> = checks["parameters"]["required_status_checks"]
1270            .as_array()
1271            .map(|list| {
1272                list.iter()
1273                    .filter_map(|check| check["context"].as_str())
1274                    .collect()
1275            })
1276            .unwrap_or_default();
1277        // Where the expected check is known, the context set must be exactly
1278        // it plus the title check: an extra stale context does not fail a
1279        // merge, it hangs one, and a missing title check lets an
1280        // unconventional squash title land on the trunk.
1281        if contexts.is_empty() {
1282            faults.push("no status check is required".to_owned());
1283        } else if let Some(expected) = &ctx.required_check {
1284            let mut held = contexts.clone();
1285            held.sort_unstable();
1286            let title_check = ctx.title_check();
1287            let mut owned_contexts = [expected.as_str(), title_check];
1288            owned_contexts.sort_unstable();
1289            if held != owned_contexts {
1290                faults.push(format!(
1291                    "the required checks are [{}] where the setup owns [{}]",
1292                    contexts.join(", "),
1293                    owned_contexts.join(", ")
1294                ));
1295            }
1296        } else if !contexts.contains(&ctx.title_check()) {
1297            faults.push(format!("the {} check is not required", ctx.title_check()));
1298        }
1299    }
1300    match repository_merge_settings(ctx, run)? {
1301        MergeSources::Owned => {}
1302        MergeSources::Faults(proven) => faults.extend(proven),
1303        // Proven drift wins over an outage: an unreadable settings read
1304        // downgrades the answer to unknown only when nothing above it was
1305        // proven wrong.
1306        MergeSources::Unreadable(err) => {
1307            if faults.is_empty() {
1308                return Ok(StepState::unknown(err));
1309            }
1310        }
1311    }
1312    match github_safety_ruleset(ctx, run)? {
1313        SafetyRuleset::Owned => {}
1314        SafetyRuleset::Faults(proven) => faults.extend(proven),
1315        SafetyRuleset::Unreadable(err) => {
1316            if faults.is_empty() {
1317                return Ok(StepState::unknown(err));
1318            }
1319        }
1320    }
1321    if let Some(shape) = gate_faults(ctx) {
1322        faults.push(shape);
1323    }
1324    if !faults.is_empty() {
1325        return Ok(StepState::not(faults.join("; ")));
1326    }
1327    Ok(StepState::ok(format!(
1328        "{name} holds the release-merge shape beside {}",
1329        ctx.safety_ruleset()
1330    )))
1331}
1332
1333/// The fault of a request rule allowing any merge method but the one the
1334/// recorded merge form names.
1335fn merge_method_fault(ctx: &Ctx, rules: &[Value]) -> Option<String> {
1336    let request = rules.iter().find(|rule| rule["type"] == "pull_request")?;
1337    (request["parameters"]["allowed_merge_methods"]
1338        != serde_json::json!(ctx.protection().allowed_merge_methods))
1339    .then(|| {
1340        format!(
1341            "the merge method is not exactly a {} merge",
1342            ctx.merge_form().as_str()
1343        )
1344    })
1345}
1346
1347/// Which rules the trunk ruleset must carry, and which it must not.
1348///
1349/// Each ruleset carries the half of the owned rules its bypass fits, so the
1350/// trunk ruleset holds what a recorded actor may be excused from. A safety
1351/// rule here is the shape from before the split: it reads as protection
1352/// while inheriting this ruleset's bypass, so it gets its own words rather
1353/// than the generic unowned-rule fault.
1354fn trunk_rule_faults(ctx: &Ctx, rules: &[Value], name: &str) -> Vec<String> {
1355    let has = |kind: &str| rules.iter().any(|rule| rule["type"] == kind);
1356    let mut faults = Vec::new();
1357    let mut accounted: Vec<String> = ctx
1358        .protection()
1359        .owned_trunk_rules
1360        .iter()
1361        .filter(|rule| crate::config::REQUEST_RULES.contains(&rule.as_str()))
1362        .cloned()
1363        .collect();
1364    for required in &accounted {
1365        if !has(required) {
1366            faults.push(format!("the {required} rule is missing"));
1367        }
1368    }
1369    for stray in crate::config::SAFETY_RULES {
1370        if has(stray) {
1371            faults.push(format!(
1372                "the {stray} rule sits in {name}, where a bypass actor excuses it"
1373            ));
1374        }
1375        accounted.push(stray.to_owned());
1376    }
1377    faults.extend(unowned_rule_faults(rules, &accounted));
1378    faults
1379}
1380
1381/// What the safety ruleset's own read answered.
1382enum SafetyRuleset {
1383    Owned,
1384    Faults(Vec<String>),
1385    Unreadable(String),
1386}
1387
1388/// The ruleset no actor is excused from.
1389///
1390/// A bypass actor recorded here would hand whoever it names the deletion
1391/// and the force-push along with the trunk push, which is the one thing the
1392/// split exists to prevent. The rules it carries are the safety half of
1393/// `protection.owned_trunk_rules`, so one key still answers what the setup
1394/// owns on the trunk.
1395fn github_safety_ruleset(ctx: &Ctx, run: &mut Runner) -> Result<SafetyRuleset, RkError> {
1396    let trunk = ctx.trunk();
1397    let name = ctx.safety_ruleset().to_owned();
1398    let detail = match github_ruleset_body(ctx, run, &name)? {
1399        RulesetLookup::Found(detail) => detail,
1400        RulesetLookup::Absent => {
1401            return Ok(SafetyRuleset::Faults(vec![format!(
1402                "no ruleset named {name} holds the trunk against deletion and force-push"
1403            )]));
1404        }
1405        RulesetLookup::Unreadable(err) => return Ok(SafetyRuleset::Unreadable(err)),
1406    };
1407    let mut faults = Vec::new();
1408    if detail["enforcement"] != "active" {
1409        faults.push(format!("{name} is not active"));
1410    }
1411    if detail["target"] != "branch" {
1412        faults.push(format!("{name} does not target branches"));
1413    }
1414    if detail["conditions"]["ref_name"]["include"]
1415        != serde_json::json!([format!("refs/heads/{trunk}")])
1416    {
1417        faults.push(format!("{name} does not cover refs/heads/{trunk} alone"));
1418    }
1419    if detail["conditions"]["ref_name"]["exclude"] != serde_json::json!([]) {
1420        faults.push(format!("{name} excludes refs from its own coverage"));
1421    }
1422    if !detail["bypass_actors"].as_array().is_none_or(Vec::is_empty) {
1423        faults.push(format!(
1424            "{name} names a bypass actor, so deletion and force-push hold against nobody"
1425        ));
1426    }
1427    let rules = detail["rules"].as_array().cloned().unwrap_or_default();
1428    let safety_rules: Vec<String> = ctx
1429        .protection()
1430        .owned_trunk_rules
1431        .iter()
1432        .filter(|rule| crate::config::SAFETY_RULES.contains(&rule.as_str()))
1433        .cloned()
1434        .collect();
1435    for required in &safety_rules {
1436        if !rules.iter().any(|rule| rule["type"] == required.as_str()) {
1437            faults.push(format!("the {required} rule is missing from {name}"));
1438        }
1439    }
1440    faults.extend(unowned_rule_faults(&rules, &safety_rules));
1441    if faults.is_empty() {
1442        Ok(SafetyRuleset::Owned)
1443    } else {
1444        Ok(SafetyRuleset::Faults(faults))
1445    }
1446}
1447
1448/// The ways the named gate is shaped so that it cannot report a blocking
1449/// answer. A required check that never reports is a broken trunk
1450/// protection, not a weaker guarantee, so each of these is a fault rather
1451/// than a limitation. Read only where the check is named: without the flag
1452/// the observation knows no gate.
1453///
1454/// It judges the gate alone. Which other jobs a project means to block a
1455/// merge is intent, no file states it, and `forges/github.md` carries that
1456/// as a convention instead.
1457fn gate_faults(ctx: &Ctx) -> Option<String> {
1458    if !ctx.forge_gate() {
1459        return None;
1460    }
1461    let check = ctx.required_check.as_deref()?;
1462    workflow_jobs::faults(
1463        &workflow_jobs::read_gate(&ctx.target, check, ctx.trunk()),
1464        check,
1465        ctx.trunk(),
1466    )
1467}
1468
1469/// What the repository's merge settings hold.
1470enum MergeSources {
1471    /// The settings the recorded merge form needs, as the setup owns.
1472    Owned,
1473    /// Proven other values, one fault line each.
1474    Faults(Vec<String>),
1475    /// The settings could not be read.
1476    Unreadable(String),
1477}
1478
1479/// The repository's own merge settings, beside the ruleset, as the
1480/// recorded merge form needs them. One GET answers for each form.
1481///
1482/// Under `squash` they are the squash message sources: with the title
1483/// source unset, a one-commit request offers that commit's own subject as
1484/// the trunk's message, which the bot then reads for the version; with the
1485/// message source on another value, the trunk's body is not the request's
1486/// description the content gates judged. Each is faulted by name.
1487///
1488/// Under `rebase` it is the repository switch `allow_rebase_merge`. The
1489/// ruleset allows the rebase merge alone, and a repository that disallows
1490/// it offers no merge method at all, so a request can never merge. A
1491/// switch that is missing or not a boolean is a fault rather than a pass,
1492/// and no squash source is read, because no rebase merge reads one.
1493fn repository_merge_settings(ctx: &Ctx, run: &mut Runner) -> Result<MergeSources, RkError> {
1494    let squash = ctx.merge_form() == crate::landing::MergeForm::Squash;
1495    Ok(match api_get(ctx, run, &format!("repos/{}", ctx.repo))? {
1496        Api::Ok(body) => {
1497            let mut faults = Vec::new();
1498            if squash {
1499                let owned_title = ctx.protection().github.squash_title_source.as_str();
1500                let owned_body = ctx.protection().github.squash_body_source.as_str();
1501                if body["squash_merge_commit_title"] != owned_title {
1502                    faults.push(format!(
1503                        "the squash title source is {} where the setup owns {owned_title}",
1504                        body["squash_merge_commit_title"]
1505                    ));
1506                }
1507                if body["squash_merge_commit_message"] != owned_body {
1508                    faults.push(format!(
1509                        "the squash message source is {} where the setup owns {owned_body}",
1510                        body["squash_merge_commit_message"]
1511                    ));
1512                }
1513            } else if let Some(fault) = rebase_switch_fault(body["allow_rebase_merge"].as_bool()) {
1514                faults.push(fault);
1515            }
1516            if faults.is_empty() {
1517                MergeSources::Owned
1518            } else {
1519                MergeSources::Faults(faults)
1520            }
1521        }
1522        Api::Missing => MergeSources::Faults(vec![format!("the forge does not know {}", ctx.repo)]),
1523        Api::Failed(err) => MergeSources::Unreadable(err),
1524    })
1525}
1526
1527/// The fault of the repository's `allow_rebase_merge` switch under the
1528/// `rebase` merge form, or `None` where it is on.
1529fn rebase_switch_fault(allowed: Option<bool>) -> Option<String> {
1530    match allowed {
1531        Some(true) => None,
1532        Some(false) => Some(
1533            "the repository disallows rebase merges (allow_rebase_merge is false), so the ruleset's one merge method is refused and no request can merge"
1534                .to_owned(),
1535        ),
1536        None => Some("the repository's allow_rebase_merge switch is missing or unreadable".to_owned()),
1537    }
1538}
1539
1540/// One ruleset lookup by name: found, provably absent, or unreadable โ€”
1541/// an unreadable inventory must never read as an absent ruleset.
1542enum RulesetLookup {
1543    /// The ruleset exists; its detail body.
1544    Found(Value),
1545    /// The inventory was read successfully and no ruleset carries the
1546    /// name.
1547    Absent,
1548    /// The inventory or the detail could not be read.
1549    Unreadable(String),
1550}
1551
1552/// A ruleset's detail body by name.
1553fn github_ruleset_body(ctx: &Ctx, run: &mut Runner, name: &str) -> Result<RulesetLookup, RkError> {
1554    // A 404 on the collection is an unreachable inventory โ€” a missing
1555    // repository or an unauthorized read โ€” never an empty one: an empty
1556    // inventory answers 200 with an empty list.
1557    let list = match api_get(ctx, run, &format!("repos/{}/rulesets", ctx.repo))? {
1558        Api::Ok(body) => body,
1559        Api::Missing => {
1560            return Ok(RulesetLookup::Unreadable(
1561                "the ruleset inventory is not readable".into(),
1562            ));
1563        }
1564        Api::Failed(err) => return Ok(RulesetLookup::Unreadable(err)),
1565    };
1566    let id = list
1567        .as_array()
1568        .into_iter()
1569        .flatten()
1570        .find(|ruleset| ruleset["name"] == name)
1571        .and_then(|ruleset| ruleset["id"].as_i64());
1572    let Some(id) = id else {
1573        return Ok(RulesetLookup::Absent);
1574    };
1575    match api_get(ctx, run, &format!("repos/{}/rulesets/{id}", ctx.repo))? {
1576        Api::Ok(body) => Ok(RulesetLookup::Found(body)),
1577        // A listed id that answers 404 is not proof of absence either โ€” the
1578        // forge also answers 404 for an unauthorized read โ€” so a rerun
1579        // decides, rather than a false drift.
1580        Api::Missing => Ok(RulesetLookup::Unreadable(format!(
1581            "the {name} detail is not readable"
1582        ))),
1583        Api::Failed(err) => Ok(RulesetLookup::Unreadable(err)),
1584    }
1585}
1586
1587/// The fault for each open release request that stands armed to merge
1588/// itself, with the call that disarms it, or `None` where none does.
1589///
1590/// A request is the release driver's own where its head branch carries
1591/// the driver's prefix. `head` and `field` name where the request states
1592/// its head branch: `head.ref` on GitHub, `source_branch` on GitLab.
1593///
1594/// SATISFIES forge-setup:an-armed-release-request-faults
1595fn armed_fault(ctx: &Ctx, requests: &Value, head: &str, field: &str) -> Option<String> {
1596    let prefix = crate::projection::release_driver_shape(ctx.driver())?;
1597    let github = ctx.forge == Some(Forge::Github);
1598    let armed: Vec<String> = requests
1599        .as_array()?
1600        .iter()
1601        .filter(|request| {
1602            let branch = if field.is_empty() {
1603                &request[head]
1604            } else {
1605                &request[head][field]
1606            };
1607            branch.as_str().is_some_and(|name| name.starts_with(prefix))
1608        })
1609        .filter(|request| {
1610            if github {
1611                !request["auto_merge"].is_null()
1612            } else {
1613                request["merge_when_pipeline_succeeds"] == true
1614                    || request["auto_merge_enabled"] == true
1615            }
1616        })
1617        .filter_map(|request| {
1618            let number = request[if github { "number" } else { "iid" }].as_u64()?;
1619            let forge = ctx.forge?;
1620            Some(format!(
1621                "{} disarms with {}",
1622                crate::release_state::request_name(forge, number),
1623                crate::release_state::disarm_command(forge, &ctx.repo, number)
1624            ))
1625        })
1626        .collect();
1627    (!armed.is_empty()).then(|| {
1628        format!(
1629            "an open release request stands armed to merge itself, and a release waits for the operator's order: {}; rk setup step merge-on-order --apply disarms every one",
1630            armed.join("; ")
1631        )
1632    })
1633}
1634
1635/// The GitLab limitation `protect-tags` and `protections-check` report.
1636const GITLAB_TAG_LIMITATION: &str =
1637    "an Owner or Maintainer can still delete a protected tag through the UI or API";
1638
1639/// The fault a merge queue on the trunk reads as: what is enabled, what it
1640/// costs, and how to undo it. This convention refuses a queue rather than
1641/// owning one, so the operator needs the consequence rather than a rule
1642/// type's bare name.
1643const 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";
1644
1645/// The freshness defect is independent of an absent required check.
1646const STALE_MERGE_FAULT: &str = "the trunk permits a merge from a branch that does not carry the trunk's tip; a 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";
1647
1648/// The GitLab limitation `protect-trunk` and `protections-check` report:
1649/// the title gate rides the request's own pipeline on this forge.
1650const 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";
1651
1652/// The fault of an observed pipeline requirement against the forge gate's
1653/// answer, or `None` where the two agree.
1654///
1655/// SATISFIES target-config:the-effective-protection-follows-the-profile
1656fn pipeline_fault(forge_gate: bool, observed: Option<bool>) -> Option<String> {
1657    match (forge_gate, observed) {
1658        (true, Some(true)) | (false, Some(false)) => None,
1659        (true, Some(false)) => Some("the pipeline requirement is off".to_owned()),
1660        (false, Some(true)) => Some(
1661            "the pipeline requirement is on, and no forge gate lands to run a pipeline, so no merge request can merge"
1662                .to_owned(),
1663        ),
1664        (_, None) => Some("the pipeline requirement is missing or unreadable".to_owned()),
1665    }
1666}
1667
1668/// The same limitation under the `rebase` form: the commit gate rides the
1669/// request's own pipeline too, and every push starts one, so only the
1670/// first half holds.
1671const GITLAB_COMMIT_LIMITATION: &str =
1672    "the commit gate stops accident, not authority: a merge request runs its own CI configuration";
1673
1674/// The bot identity form a GitLab run takes: a project access token, or a
1675/// service account of the top-level group whose id this carries.
1676enum GitlabIdentity {
1677    Token,
1678    ServiceAccount(u64),
1679}
1680
1681/// The identity form the forge and the tier allow today, read the way the
1682/// `install-bot` script reads it: gitlab.com reads the namespace's plan and
1683/// kind, and every other instance takes a token with any license. `Err`
1684/// carries the state that ends the observation.
1685fn gitlab_identity_form(
1686    ctx: &Ctx,
1687    run: &mut Runner,
1688) -> Result<Result<GitlabIdentity, StepState>, RkError> {
1689    if !ctx.on_gitlab_com() {
1690        return Ok(Ok(GitlabIdentity::Token));
1691    }
1692    let root = ctx.repo.split('/').next().unwrap_or_default();
1693    let namespace = match api_get(ctx, run, &format!("namespaces/{root}"))? {
1694        Api::Ok(body) => body,
1695        Api::Missing => {
1696            return Ok(Err(StepState::not(format!(
1697                "the forge does not know the namespace {root}"
1698            ))));
1699        }
1700        Api::Failed(err) => return Ok(Err(StepState::unknown(err))),
1701    };
1702    let plan = namespace["plan"].as_str().unwrap_or_default();
1703    let kind = namespace["kind"].as_str().unwrap_or_default();
1704    let paid = ["premium", "ultimate", "silver", "gold"]
1705        .iter()
1706        .any(|tier| plan.starts_with(tier))
1707        || plan == "opensource";
1708    Ok(match (plan, kind) {
1709        _ if paid => Ok(GitlabIdentity::Token),
1710        ("free", "group") => namespace["id"].as_u64().map_or_else(
1711            || {
1712                Err(StepState::unknown(format!(
1713                    "the namespace {root} answers no id"
1714                )))
1715            },
1716            |id| Ok(GitlabIdentity::ServiceAccount(id)),
1717        ),
1718        ("free", "user") => Err(StepState::not(format!(
1719            "the personal namespace {root} has no bot identity form on GitLab.com Free; move the project under a group, or use a Premium or Ultimate tier"
1720        ))),
1721        _ => Err(StepState::unknown(format!(
1722            "the namespace {root} reads plan {plan:?} and kind {kind:?}, which name no identity form"
1723        ))),
1724    })
1725}
1726
1727/// Whether the bot identity a GitLab run takes today is a service account,
1728/// or why the form could not be read.
1729///
1730/// A failed reading is never taken for the token form: a caller that
1731/// forces an action on a service account must stop on it.
1732///
1733/// # Errors
1734/// A runner failure.
1735pub fn gitlab_bot_is_a_service_account(
1736    ctx: &Ctx,
1737    run: &mut Runner,
1738) -> Result<Result<bool, String>, RkError> {
1739    Ok(match gitlab_identity_form(ctx, run)? {
1740        Ok(form) => Ok(matches!(form, GitlabIdentity::ServiceAccount(_))),
1741        Err(state) => Err(state.detail().to_owned()),
1742    })
1743}
1744
1745/// Whether the bot's variable is stored, as the last half of either form.
1746fn gitlab_bot_variable(
1747    ctx: &Ctx,
1748    run: &mut Runner,
1749    project: &str,
1750    holds: &str,
1751) -> Result<StepState, RkError> {
1752    Ok(
1753        match api_get(
1754            ctx,
1755            run,
1756            &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
1757        )? {
1758            Api::Ok(_) => StepState::ok(format!("{holds} and its variable is stored")),
1759            Api::Missing => StepState::not(format!(
1760                "{holds} with no stored variable; a rerun replaces its token"
1761            )),
1762            Api::Failed(err) => StepState::unknown(err),
1763        },
1764    )
1765}
1766
1767/// One paginated listing, read until a short page proves it exhausted, or
1768/// until `found` holds. `None` is a bound reached on a full page, which
1769/// decides nothing.
1770fn gitlab_listing(
1771    ctx: &Ctx,
1772    run: &mut Runner,
1773    path: &str,
1774    found: impl Fn(&serde_json::Value) -> bool,
1775) -> Result<Result<Option<serde_json::Value>, StepState>, RkError> {
1776    for page in 1..=10u32 {
1777        let list = match api_get(ctx, run, &format!("{path}&per_page=100&page={page}"))? {
1778            Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1779            Api::Missing => Vec::new(),
1780            Api::Failed(err) => return Ok(Err(StepState::unknown(err))),
1781        };
1782        if let Some(hit) = list.iter().find(|entry| found(entry)) {
1783            return Ok(Ok(Some(hit.clone())));
1784        }
1785        if list.len() < 100 {
1786            return Ok(Ok(None));
1787        }
1788    }
1789    Ok(Err(StepState::unknown(
1790        "the listing did not exhaust within ten pages; nothing was decided",
1791    )))
1792}
1793
1794/// Every entry of one paginated listing, read until a short page proves
1795/// it exhausted. A bound reached on a full page decides nothing.
1796fn gitlab_all(
1797    ctx: &Ctx,
1798    run: &mut Runner,
1799    path: &str,
1800) -> Result<Result<Vec<serde_json::Value>, StepState>, RkError> {
1801    let mut all = Vec::new();
1802    for page in 1..=10u32 {
1803        let list = match api_get(ctx, run, &format!("{path}&per_page=100&page={page}"))? {
1804            Api::Ok(body) => body.as_array().cloned().unwrap_or_default(),
1805            Api::Missing => Vec::new(),
1806            Api::Failed(err) => return Ok(Err(StepState::unknown(err))),
1807        };
1808        let short = list.len() < 100;
1809        all.extend(list);
1810        if short {
1811            return Ok(Ok(all));
1812        }
1813    }
1814    Ok(Err(StepState::unknown(
1815        "the listing did not exhaust within ten pages; nothing was decided",
1816    )))
1817}
1818
1819/// Every open request, read page by page until a parsed page shorter
1820/// than the page size proves the listing exhausted. A missing listing, a
1821/// body that is no array, a failed read, or a bound reached on a full
1822/// page decides nothing, so no armed request hides behind an unread page.
1823fn open_requests(
1824    ctx: &Ctx,
1825    run: &mut Runner,
1826    path: &str,
1827) -> Result<Result<Value, StepState>, RkError> {
1828    let mut all = Vec::new();
1829    for page in 1..=10u32 {
1830        let list = match api_get(ctx, run, &format!("{path}&per_page=100&page={page}"))? {
1831            Api::Ok(Value::Array(list)) => list,
1832            Api::Ok(_) => {
1833                return Ok(Err(StepState::unknown(
1834                    "the open requests did not read as a list; nothing was decided",
1835                )));
1836            }
1837            Api::Missing => {
1838                return Ok(Err(StepState::unknown(
1839                    "the forge refused the listing of open requests; nothing was decided",
1840                )));
1841            }
1842            Api::Failed(err) => return Ok(Err(StepState::unknown(err))),
1843        };
1844        let short = list.len() < 100;
1845        all.extend(list);
1846        if short {
1847            return Ok(Ok(Value::Array(all)));
1848        }
1849    }
1850    Ok(Err(StepState::unknown(
1851        "the listing did not exhaust within ten pages; nothing was decided",
1852    )))
1853}
1854
1855/// The description every token `install-bot` mints carries, so the live
1856/// bot is found by what marks it rather than by a default's spelling.
1857const BOT_MARKER: &str = "release-kit bot identity";
1858
1859/// The state a configured name that is not the live bot's takes: a rename
1860/// would strand the identity that serves the project.
1861fn gitlab_rename(live: &str, name: &str) -> StepState {
1862    StepState::not(format!(
1863        "the release-kit bot {live} serves this project, and setup.bot.name asks for {name}; a rename strands the first identity, so revoke it by hand or set the name back"
1864    ))
1865}
1866
1867/// The state of a service account that already serves this project under
1868/// another name than the configured one, `None` where there is none: an account of the group that is a member
1869/// here and either holds a marked token or carries the default username
1870/// from before the marker existed. An unrelated member is neither.
1871fn gitlab_live_account(
1872    ctx: &Ctx,
1873    run: &mut Runner,
1874    project: &str,
1875    group: u64,
1876    default: &str,
1877    name: &str,
1878) -> Result<Option<StepState>, RkError> {
1879    let accounts = match gitlab_all(
1880        ctx,
1881        run,
1882        &format!("groups/{group}/service_accounts?order_by=id"),
1883    )? {
1884        Ok(accounts) => accounts,
1885        Err(state) => return Ok(Some(state)),
1886    };
1887    let members = match gitlab_all(
1888        ctx,
1889        run,
1890        &format!("projects/{project}/members/all?order_by=id"),
1891    )? {
1892        Ok(members) => members,
1893        Err(state) => return Ok(Some(state)),
1894    };
1895    for account in &accounts {
1896        let (Some(id), Some(user)) = (account["id"].as_u64(), account["username"].as_str()) else {
1897            continue;
1898        };
1899        if user == name
1900            || !members
1901                .iter()
1902                .any(|member| member["id"].as_u64() == Some(id))
1903        {
1904            continue;
1905        }
1906        if user == default {
1907            return Ok(Some(gitlab_rename(user, name)));
1908        }
1909        let path =
1910            format!("groups/{group}/service_accounts/{id}/personal_access_tokens?state=active");
1911        let tokens = match gitlab_all(ctx, run, &path)? {
1912            Ok(tokens) => tokens,
1913            Err(state) => return Ok(Some(state)),
1914        };
1915        if tokens
1916            .iter()
1917            .any(|token| token["description"] == BOT_MARKER && token["active"] != false)
1918        {
1919            return Ok(Some(gitlab_rename(user, name)));
1920        }
1921    }
1922    Ok(None)
1923}
1924
1925/// The `install-bot` observation: the form the tier selects, then that
1926/// form's identity under the configured name, then the stored variable.
1927/// The listings paginate exactly as the script's do, so an identity past
1928/// the first page does not read as absent.
1929fn gitlab_install_bot(ctx: &Ctx, run: &mut Runner, project: &str) -> Result<StepState, RkError> {
1930    let form = match gitlab_identity_form(ctx, run)? {
1931        Ok(form) => form,
1932        Err(state) => return Ok(state),
1933    };
1934    match form {
1935        GitlabIdentity::Token => {
1936            let name = ctx.bot_name().unwrap_or("release-bot");
1937            let path = format!("projects/{project}/access_tokens?state=active");
1938            let tokens = match gitlab_all(ctx, run, &path)? {
1939                Ok(tokens) => tokens,
1940                Err(state) => return Ok(state),
1941            };
1942            let active: Vec<_> = tokens
1943                .iter()
1944                .filter(|token| token["revoked"] == false && token["active"] != false)
1945                .collect();
1946            // The live bot under another name is a rename whether or not a
1947            // token under the requested name exists.
1948            let live = active.iter().find(|token| {
1949                token["name"] != name
1950                    && (token["description"] == BOT_MARKER || token["name"] == "release-bot")
1951            });
1952            if let Some(live) = live.and_then(|token| token["name"].as_str()) {
1953                return Ok(gitlab_rename(live, name));
1954            }
1955            if !active.iter().any(|token| token["name"] == name) {
1956                return Ok(StepState::not(format!("no active {name} token exists")));
1957            }
1958            gitlab_bot_variable(ctx, run, project, &format!("an active {name} token exists"))
1959        }
1960        GitlabIdentity::ServiceAccount(group) => {
1961            let project_id = match api_get(ctx, run, &format!("projects/{project}"))? {
1962                Api::Ok(body) => body["id"].as_u64(),
1963                Api::Missing => {
1964                    return Ok(StepState::not(format!(
1965                        "the forge does not know {}",
1966                        ctx.repo
1967                    )));
1968                }
1969                Api::Failed(err) => return Ok(StepState::unknown(err)),
1970            };
1971            let Some(project_id) = project_id else {
1972                return Ok(StepState::unknown("the project answers no id"));
1973            };
1974            let name = ctx
1975                .bot_name()
1976                .map_or_else(|| format!("release-bot-{project_id}"), str::to_owned);
1977            let path = format!("groups/{group}/service_accounts?order_by=id");
1978            let account = match gitlab_listing(ctx, run, &path, |account| {
1979                account["username"] == name.as_str()
1980            })? {
1981                Ok(account) => account,
1982                Err(state) => return Ok(state),
1983            };
1984            // The live bot under another name is a rename whether or not an
1985            // account under the requested name exists.
1986            let default = format!("release-bot-{project_id}");
1987            if let Some(state) = gitlab_live_account(ctx, run, project, group, &default, &name)? {
1988                return Ok(state);
1989            }
1990            let Some(id) = account.and_then(|account| account["id"].as_u64()) else {
1991                return Ok(StepState::not(format!(
1992                    "no service account named {name} exists"
1993                )));
1994            };
1995            let level = match api_get(ctx, run, &format!("projects/{project}/members/all/{id}"))? {
1996                Api::Ok(body) => body["access_level"].as_u64().unwrap_or(0),
1997                Api::Missing => {
1998                    return Ok(StepState::not(format!(
1999                        "the service account {name} is not a member of this project"
2000                    )));
2001                }
2002                Api::Failed(err) => return Ok(StepState::unknown(err)),
2003            };
2004            if level < 40 {
2005                return Ok(StepState::not(format!(
2006                    "the service account {name} is a member of this project below Maintainer"
2007                )));
2008            }
2009            gitlab_bot_variable(
2010                ctx,
2011                run,
2012                project,
2013                &format!("a service account named {name} is a Maintainer of this project"),
2014            )
2015        }
2016    }
2017}
2018
2019#[allow(
2020    clippy::too_many_lines,
2021    reason = "one arm per setup step, so the match is what makes an unobserved step a compile error"
2022)]
2023fn gitlab(ctx: &Ctx, step: &str, run: &mut Runner) -> Result<StepState, RkError> {
2024    let trunk = ctx.trunk();
2025    let project = ctx.repo.replace('/', "%2F");
2026    match step {
2027        "private-vulnerability-reporting" => {
2028            let path = format!("projects/{project}");
2029            Ok(match api_get(ctx, run, &path)? {
2030                Api::Ok(body) => {
2031                    let access = body["issues_access_level"].as_str();
2032                    if !matches!(access, Some("enabled" | "private" | "disabled")) {
2033                        StepState::unknown("issue intake access is unreadable")
2034                    } else if body
2035                        .get("issues_enabled")
2036                        .is_some_and(|flag| !flag.is_boolean())
2037                    {
2038                        StepState::unknown("legacy issue intake flag is unreadable")
2039                    } else if body["issues_enabled"] == false || access == Some("disabled") {
2040                        StepState::not("issue intake is disabled; see setup guide step 3g")
2041                    } else if access == Some("private") {
2042                        StepState::not("issue intake is restricted; see setup guide step 3g")
2043                    } else {
2044                        StepState::ok_with_limitation(
2045                            "issue intake is enabled",
2046                            GITLAB_PRIVATE_REPORTING_LIMITATION,
2047                        )
2048                    }
2049                }
2050                Api::Missing => {
2051                    StepState::unknown(format!("{path}: issue intake is unreadable (404)"))
2052                }
2053                Api::Failed(err) => StepState::unknown(format!("{path}: {err}")),
2054            })
2055        }
2056
2057        "default-branch" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
2058            Api::Ok(body) => {
2059                let found = body["default_branch"].as_str().unwrap_or("");
2060                if found == trunk {
2061                    StepState::ok(format!("{trunk} is the default branch"))
2062                } else {
2063                    StepState::not(format!("the default branch is {found}"))
2064                }
2065            }
2066            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
2067            Api::Failed(err) => StepState::unknown(err),
2068        }),
2069        "single-trunk" => {
2070            for candidate in ctx.retired_branches() {
2071                let candidate = candidate.as_str();
2072                if candidate == trunk {
2073                    continue;
2074                }
2075                match api_get(
2076                    ctx,
2077                    run,
2078                    &format!("projects/{project}/repository/branches/{candidate}"),
2079                )? {
2080                    Api::Missing => {}
2081                    Api::Ok(_) => {
2082                        return Ok(StepState::not(format!("a {candidate} branch still exists")));
2083                    }
2084                    Api::Failed(err) => return Ok(StepState::unknown(err)),
2085                }
2086            }
2087            Ok(StepState::ok(
2088                "no long-lived branch besides the trunk remains",
2089            ))
2090        }
2091        "merge-cleanup" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
2092            Api::Ok(body) => {
2093                if body["remove_source_branch_after_merge"]
2094                    .as_bool()
2095                    .unwrap_or(false)
2096                {
2097                    StepState::ok("a merged branch is deleted by the forge")
2098                } else {
2099                    StepState::not("a merged branch outlives its merge")
2100                }
2101            }
2102            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
2103            Api::Failed(err) => StepState::unknown(err),
2104        }),
2105        "merge-on-order" => {
2106            match api_get(ctx, run, &format!("projects/{project}"))? {
2107                Api::Ok(body) => {
2108                    if !body["only_allow_merge_if_pipeline_succeeds"]
2109                        .as_bool()
2110                        .unwrap_or(false)
2111                    {
2112                        return Ok(StepState::not(
2113                            "the pipeline requirement that holds a person's merge behind a passing pipeline is off; rk setup step merge-on-order --apply asserts it",
2114                        ));
2115                    }
2116                }
2117                Api::Missing => {
2118                    return Ok(StepState::not(format!(
2119                        "the forge does not know {}",
2120                        ctx.repo
2121                    )));
2122                }
2123                Api::Failed(err) => return Ok(StepState::unknown(err)),
2124            }
2125            let path = format!("projects/{project}/merge_requests?state=opened");
2126            Ok(match open_requests(ctx, run, &path)? {
2127                Ok(requests) => armed_fault(ctx, &requests, "source_branch", "")
2128                    .map_or_else(
2129                        || {
2130                            StepState::ok(
2131                                "no release request stands armed to merge itself, and a merge waits for a passing pipeline",
2132                            )
2133                        },
2134                        StepState::not,
2135                    ),
2136                Err(state) => state,
2137            })
2138        }
2139        "ci-permissions" => Ok(match api_get(ctx, run, &format!("projects/{project}"))? {
2140            Api::Ok(body) => {
2141                if body["jobs_enabled"] == true {
2142                    StepState::ok("pipelines are enabled")
2143                } else {
2144                    StepState::not("pipelines are disabled")
2145                }
2146            }
2147            Api::Missing => StepState::not(format!("the forge does not know {}", ctx.repo)),
2148            Api::Failed(err) => StepState::unknown(err),
2149        }),
2150        "install-bot" => gitlab_install_bot(ctx, run, &project),
2151        "bot-secrets" => Ok(
2152            match api_get(
2153                ctx,
2154                run,
2155                &format!("projects/{project}/variables/RELEASE_BOT_TOKEN"),
2156            )? {
2157                Api::Ok(_) => StepState::ok("RELEASE_BOT_TOKEN is stored"),
2158                Api::Missing => StepState::not("RELEASE_BOT_TOKEN is not stored"),
2159                Api::Failed(err) => StepState::unknown(err),
2160            },
2161        ),
2162        "protect-trunk" => {
2163            let protection = match api_get(
2164                ctx,
2165                run,
2166                &format!("projects/{project}/protected_branches/{trunk}"),
2167            )? {
2168                Api::Ok(body) => body,
2169                Api::Missing => {
2170                    return Ok(StepState::not(format!("{trunk} is not protected")));
2171                }
2172                Api::Failed(err) => return Ok(StepState::unknown(err)),
2173            };
2174            // Exactly one push grant, and it is the no-access entry: the
2175            // forge honors the most permissive grant, so a second entry
2176            // beside access level 0 is a branch that still takes a push.
2177            let grants = protection["push_access_levels"]
2178                .as_array()
2179                .cloned()
2180                .unwrap_or_default();
2181            let policy = ctx.protection();
2182            let no_push =
2183                grants.len() == 1 && grants[0]["access_level"] == policy.gitlab.push_access_level;
2184            // The merge grant is owned exactly too: a merge level of 0 keeps
2185            // every release request unmergeable while the push shape reads
2186            // clean, so both halves are checked.
2187            let merges = protection["merge_access_levels"]
2188                .as_array()
2189                .cloned()
2190                .unwrap_or_default();
2191            let can_merge =
2192                merges.len() == 1 && merges[0]["access_level"] == policy.gitlab.merge_access_level;
2193            let settings = match api_get(ctx, run, &format!("projects/{project}"))? {
2194                Api::Ok(body) => body,
2195                Api::Missing | Api::Failed(_) => Value::Null,
2196            };
2197            let mut faults = Vec::new();
2198            if !no_push {
2199                faults.push(format!(
2200                    "{trunk} still takes a direct push: the forge honors the most permissive of {} push grants",
2201                    grants.len()
2202                ));
2203            }
2204            if !can_merge {
2205                faults.push(format!(
2206                    "{trunk} merge grants are not exactly the one owned maintainer level"
2207                ));
2208            }
2209            if protection["allow_force_push"] != false {
2210                faults.push(format!("{trunk} allows force pushes"));
2211            }
2212            // The pipeline is required exactly where a forge gate lands to
2213            // run in it. With none, no pipeline runs, so a requirement left
2214            // on would hold every merge request; it is judged in both
2215            // directions, and a value that cannot be read is no answer.
2216            if let Some(fault) = pipeline_fault(
2217                ctx.forge_gate(),
2218                settings["only_allow_merge_if_pipeline_succeeds"].as_bool(),
2219            ) {
2220                faults.push(fault);
2221            }
2222            if settings["merge_method"] != policy.gitlab.merge_method.as_str() {
2223                faults.push("the merge method is not fast-forward".to_owned());
2224            }
2225            let squash = ctx.merge_form() == crate::landing::MergeForm::Squash;
2226            if settings["squash_option"] != policy.gitlab.squash_option.as_str() {
2227                faults.push(if squash {
2228                    "merge requests do not always squash".to_owned()
2229                } else {
2230                    "merge requests may squash, so a request's commits would not keep their own messages".to_owned()
2231                });
2232            }
2233            // The template is the squash commit's message source, so it is
2234            // asserted only where a merge squashes.
2235            if squash
2236                && settings["squash_commit_template"]
2237                    != policy.gitlab.squash_commit_template.as_str()
2238            {
2239                faults.push("the squash template is not the merge request's title".to_owned());
2240            }
2241            Ok(if faults.is_empty() {
2242                let detail = format!("{trunk} holds the release-merge shape");
2243                if ctx.forge_gate() {
2244                    StepState::ok_with_limitation(
2245                        detail,
2246                        if squash {
2247                            GITLAB_TITLE_LIMITATION
2248                        } else {
2249                            GITLAB_COMMIT_LIMITATION
2250                        },
2251                    )
2252                } else {
2253                    // No gate lands, so no gate's limitation applies.
2254                    StepState::ok(detail)
2255                }
2256            } else {
2257                StepState::not(faults.join("; "))
2258            })
2259        }
2260        "protect-tags" => Ok(
2261            match api_get(ctx, run, &format!("projects/{project}/protected_tags/v%2A"))? {
2262                Api::Ok(_) => {
2263                    StepState::ok_with_limitation("v* is protected", GITLAB_TAG_LIMITATION)
2264                }
2265                Api::Missing => StepState::not("v* is not protected"),
2266                Api::Failed(err) => StepState::unknown(err),
2267            },
2268        ),
2269        "protect-release-lines" => Ok(
2270            match api_get(
2271                ctx,
2272                run,
2273                &format!("projects/{project}/protected_branches/release%2F%2A"),
2274            )? {
2275                Api::Ok(body) => {
2276                    let level_ok = |levels: &Value| {
2277                        levels
2278                            .as_array()
2279                            .is_some_and(|list| list.len() == 1 && list[0]["access_level"] == 40)
2280                    };
2281                    if body["allow_force_push"] != false {
2282                        StepState::not("release/* allows force pushes")
2283                    } else if !level_ok(&body["push_access_levels"])
2284                        || !level_ok(&body["merge_access_levels"])
2285                    {
2286                        // A push level of 0 blocks the documented
2287                        // cherry-pick-by-push path while force-push reads
2288                        // clean, so the grant shape is owned exactly.
2289                        StepState::not(
2290                            "release/* grants are not exactly the owned maintainer levels",
2291                        )
2292                    } else {
2293                        StepState::ok("release/* refuses force pushes and deletion by git clients")
2294                    }
2295                }
2296                Api::Missing => StepState::inapplicable(
2297                    "release/* is unprotected; optional โ€” applied only where older lines exist",
2298                ),
2299                Api::Failed(err) => StepState::unknown(err),
2300            },
2301        ),
2302        "protections-check" => {
2303            // Same separation as the sibling forge: proven drift wins,
2304            // an outage with nothing proven wrong stays unknown.
2305            let mut failures = Vec::new();
2306            let mut unknowns = Vec::new();
2307            // Every satisfied step's limitation survives the aggregate: a
2308            // first limitation must not shadow a second.
2309            let mut limitations: Vec<String> = Vec::new();
2310            for owned in owned_protections(ctx) {
2311                match gitlab(ctx, owned, run)? {
2312                    StepState::Satisfied {
2313                        limitation: found, ..
2314                    } => limitations.extend(found),
2315                    StepState::Inapplicable { .. } => {}
2316                    StepState::Unsatisfied { detail } => {
2317                        failures.push(format!("{owned}: {detail}"));
2318                    }
2319                    StepState::Unknown { detail } => {
2320                        unknowns.push(format!("{owned}: {detail}"));
2321                    }
2322                }
2323            }
2324            Ok(if !failures.is_empty() {
2325                StepState::not(failures.join("; "))
2326            } else if !unknowns.is_empty() {
2327                StepState::unknown(unknowns.join("; "))
2328            } else {
2329                StepState::Satisfied {
2330                    detail: "the protections hold, as far as this forge enforces them".into(),
2331                    limitation: if limitations.is_empty() {
2332                        None
2333                    } else {
2334                        Some(limitations.join("; "))
2335                    },
2336                }
2337            })
2338        }
2339        _ => Ok(StepState::unknown(format!("no observation for {step}"))),
2340    }
2341}
2342
2343#[cfg(test)]
2344mod tests {
2345    /// The repository's rebase switch passes only where it reads on.
2346    #[test]
2347    fn merge_form_rebase_switch_is_judged() {
2348        assert_eq!(super::rebase_switch_fault(Some(true)), None);
2349        assert!(
2350            super::rebase_switch_fault(Some(false))
2351                .is_some_and(|fault| fault.contains("allow_rebase_merge is false"))
2352        );
2353        assert!(
2354            super::rebase_switch_fault(None)
2355                .is_some_and(|fault| fault.contains("missing or unreadable"))
2356        );
2357    }
2358
2359    /// The pipeline requirement is judged in both directions, and a value
2360    /// that cannot be read is never ready.
2361    ///
2362    /// SATISFIES target-config:the-effective-protection-follows-the-profile
2363    #[test]
2364    fn release_less_pipeline_requirement_is_judged_both_ways() {
2365        assert_eq!(super::pipeline_fault(true, Some(true)), None);
2366        assert_eq!(super::pipeline_fault(false, Some(false)), None);
2367        for (gate, observed) in [
2368            (true, Some(false)),
2369            (false, Some(true)),
2370            (true, None),
2371            (false, None),
2372        ] {
2373            assert!(
2374                super::pipeline_fault(gate, observed).is_some(),
2375                "gate {gate}, observed {observed:?}"
2376            );
2377        }
2378    }
2379}