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