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