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