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