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