Skip to main content

release_kit/commands/
setup.rs

1//! `rk setup`: execute the repository-side setup against the detected forge.
2//!
3//! Preview is the default and is an offline rendering — it materializes
4//! nothing and invokes no external command. Apply runs each step as the
5//! observe-compare-apply-verify lifecycle: observe the current state,
6//! report and skip when satisfied, otherwise materialize the embedded
7//! script into the run's private journal directory, verify its digest,
8//! spawn it as `sh <path>`, and read the state back. `check` calls the same
9//! observe functions with the mutating half unreachable from its code path.
10
11use std::ffi::OsString;
12use std::fs;
13use std::path::PathBuf;
14use std::time::Instant;
15
16use zeroize::Zeroizing;
17
18use crate::cli::setup::{SetupAction, SetupArgs};
19use crate::detect::Forge;
20use crate::diagnostic::{Diagnostic, Reason};
21use crate::digest::Digest;
22use crate::embedded;
23use crate::error::RkError;
24use crate::events::{ChildStream, Event, EventKind};
25use crate::output::Output;
26use crate::setup::app_jwt::{self, AppApi};
27use crate::setup::context::{Ctx, SECRET_VARS};
28use crate::setup::journal::Journal;
29use crate::setup::observe::{self, StepState};
30use crate::setup::process::{self, Exec, Outcome};
31use crate::setup::secrets;
32use crate::setup::steps::{Mutates, STEPS, StepSpec, spec};
33
34/// Dispatch the setup surface.
35///
36/// # Errors
37///
38/// Every failure classified through the matrix, each carrying its `reason`.
39pub fn run(args: &SetupArgs) -> Result<(), RkError> {
40    match &args.action {
41        Some(SetupAction::Script { name, forge }) => script(name, forge.as_deref()),
42        Some(SetupAction::Check {
43            target,
44            repo,
45            forge,
46            required_check,
47            json,
48        }) => {
49            let ctx = Ctx::resolve(
50                target,
51                repo.as_deref(),
52                forge.as_deref(),
53                required_check.as_deref(),
54            )?;
55            reject_check_flag_on_gitlab(&ctx)?;
56            check(Output::new(*json), ctx)
57        }
58        Some(SetupAction::Step {
59            name,
60            target,
61            repo,
62            forge,
63            required_check,
64            apply,
65            json,
66        }) => {
67            let selected = spec(name).ok_or_else(|| {
68                RkError::Usage(format!("unknown step '{name}'; rk setup --list names them"))
69            })?;
70            let ctx = Ctx::resolve(
71                target,
72                repo.as_deref(),
73                forge.as_deref(),
74                required_check.as_deref(),
75            )?;
76            reject_check_flag_on_gitlab(&ctx)?;
77            if *apply {
78                require_check_for(&ctx, &[selected])?;
79                execute(Output::new(*json), ctx, &[selected], "setup step")
80            } else {
81                preview(Output::new(*json), &ctx, &[selected])
82            }
83        }
84        None if args.list => list(args.forge.as_deref()),
85        None => {
86            let target = args.target.clone().ok_or_else(|| {
87                RkError::Usage("name a --target, or pass --list to see the steps".into())
88            })?;
89            let ctx = Ctx::resolve(
90                &target,
91                args.repo.as_deref(),
92                args.forge.as_deref(),
93                args.required_check.as_deref(),
94            )?;
95            reject_check_flag_on_gitlab(&ctx)?;
96            let all: Vec<&StepSpec> = STEPS.iter().collect();
97            if args.apply {
98                require_check_for(&ctx, &all)?;
99                execute(Output::new(args.json), ctx, &all, "setup")
100            } else {
101                preview(Output::new(args.json), &ctx, &all)
102            }
103        }
104    }
105}
106
107/// Whether a full run skips this step at this target.
108///
109/// `optional` is the payload's claim that a step is not universal; the
110/// target's own configuration answers whether it wants this one. Today
111/// `protect-release-lines` is the single such step, and a project that
112/// sets `setup.release_lines` gets it run rather than skipped and
113/// remembered.
114fn skipped_by_a_full_run(ctx: &Ctx, step: &StepSpec, selected: usize) -> bool {
115    if !step.optional || selected <= 1 {
116        return false;
117    }
118    !(step.name == "protect-release-lines" && ctx.release_lines())
119}
120
121/// On GitLab `--required-check` is a usage error, per the forge document:
122/// the forge requires the whole pipeline and names no individual check, and
123/// a flag silently discarded would read as configured while nothing uses it.
124fn reject_check_flag_on_gitlab(ctx: &Ctx) -> Result<(), RkError> {
125    if ctx.forge == Forge::Gitlab && ctx.required_check.is_some() {
126        return Err(RkError::Usage(
127            "--required-check is refused on gitlab: the forge requires the whole pipeline and names no individual check".into(),
128        ));
129    }
130    Ok(())
131}
132
133/// On GitHub the trunk protection needs the check name before any step
134/// runs: a wrong or missing one does not fail, it hangs the merge button,
135/// so a full apply refuses up front rather than writing eight steps and
136/// stopping.
137fn require_check_for(ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
138    let needs = ctx.forge == Forge::Github
139        && ctx.required_check.is_none()
140        && steps.iter().any(|step| step.name == "protect-trunk");
141    if needs {
142        return Err(RkError::refusal(
143            Diagnostic::new(
144                Reason::PrerequisiteUnmet,
145                "protect-trunk refuses until the required check is named, and nothing was written",
146            )
147            .expected("the name of the CI check the release merge must pass")
148            .action(format!(
149                "set setup.required_check in {}, or pass --required-check <name>; gh api repos/{}/commits/HEAD/check-runs lists the project's check names",
150                crate::config::CONFIG_PATH,
151                ctx.repo
152            ))
153            .step("protect-trunk"),
154        ));
155    }
156    Ok(())
157}
158
159/// `rk setup --list`: the ordered steps, what each proves, and which needs
160/// input on which forge — visible before a first apply rather than
161/// discovered by one.
162fn list(forge: Option<&str>) -> Result<(), RkError> {
163    let forge = forge
164        .map(|name| {
165            Forge::parse(name).ok_or_else(|| {
166                RkError::Usage(format!(
167                    "unknown forge '{name}'; the forges are: github, gitlab"
168                ))
169            })
170        })
171        .transpose()?;
172    let out = Output::human();
173    for (idx, step) in STEPS.iter().enumerate() {
174        let mut line = format!(
175            "{:2}. {} [{}] proves: {}",
176            idx + 1,
177            step.name,
178            step.chapter,
179            step.proves
180        );
181        if step.name == "protect-trunk" && forge != Some(Forge::Gitlab) {
182            line.push_str(" (needs --required-check on github)");
183        }
184        if step.destructive {
185            line.push_str(" (destructive)");
186        }
187        if step.optional {
188            line.push_str(" (optional; a full apply skips it)");
189        }
190        out.result_line(line);
191    }
192    out.next(&[
193        "rk setup --target . previews every step".to_owned(),
194        "rk setup script <name> prints one embedded script".to_owned(),
195    ]);
196    Ok(())
197}
198
199/// `rk setup script <name>`: the audit escape hatch, printed byte-identical
200/// to the embedded file.
201fn script(name: &str, forge: Option<&str>) -> Result<(), RkError> {
202    if name == "package-check" {
203        return Err(RkError::Usage(
204            "package-check reads its command from the technology binding and has no script".into(),
205        ));
206    }
207    if name == "branch-reminder" {
208        return Err(RkError::Usage(
209            "branch-reminder writes an embedded hook body and has no script; rk setup step branch-reminder previews the write".into(),
210        ));
211    }
212    if name == "forge-version" {
213        return Err(RkError::Usage(
214            "forge-version reads the forge's own version and has no script; rk setup step forge-version previews the read".into(),
215        ));
216    }
217    let forge = match forge {
218        Some(value) => Forge::parse(value).ok_or_else(|| {
219            RkError::Usage(format!(
220                "unknown forge '{value}'; the forges are: github, gitlab"
221            ))
222        })?,
223        None => Forge::Github,
224    };
225    let path = format!("{}/{name}", forge.as_str());
226    let file = embedded::SETUP.get_file(&path).ok_or(RkError::NotFound {
227        kind: "setup step",
228        name: name.to_owned(),
229    })?;
230    Output::human().result_raw(&String::from_utf8_lossy(file.contents()));
231    Ok(())
232}
233
234/// The shared run state: the boundary, the resolved context, the journal,
235/// and the event stream.
236struct Engine {
237    out: Output,
238    ctx: Ctx,
239    journal: Option<Journal>,
240    secrets: Vec<Zeroizing<Vec<u8>>>,
241    /// The run's one read of the named key file; see [`key_file_for`].
242    key: Option<secrets::KeyFile>,
243    /// The run's App JWT, minted at most once; see [`app_jwt_for`].
244    app_jwt: Option<String>,
245    seq: u64,
246    command: &'static str,
247    run_id: String,
248}
249
250impl Engine {
251    /// Open the run: journal first, before any remote mutation. An apply
252    /// that cannot create its journal refuses; observability-only modes
253    /// warn and continue, because refusing them over observability is
254    /// self-defeating.
255    fn open(
256        out: Output,
257        ctx: Ctx,
258        command: &'static str,
259        journal_required: bool,
260    ) -> Result<Self, RkError> {
261        // A stale key export is refused wherever a run opens, so every
262        // mode catches it and not the one step that would have used it.
263        secrets::refuse_legacy_key()?;
264        let journal =
265            match Journal::create(command, ctx.target.as_str(), ctx.forge.as_str(), &ctx.repo) {
266                Ok(journal) => Some(journal),
267                Err(source) if journal_required => {
268                    return Err(RkError::refusal(
269                        Diagnostic::new(
270                            Reason::JournalUnavailable,
271                            format!("the run journal cannot be created: {source}"),
272                        )
273                        .expected("a writable state root for the journal")
274                        .target_state("nothing was run and nothing changed"),
275                    ));
276                }
277                Err(source) => {
278                    out.warn(format!("no run journal for this run: {source}"));
279                    None
280                }
281            };
282        let run_id = journal
283            .as_ref()
284            .map_or_else(|| "unjournaled".to_owned(), |j| j.run_id().to_owned());
285        let mut engine = Self {
286            out,
287            ctx,
288            journal,
289            secrets: Ctx::secret_values(),
290            key: None,
291            app_jwt: None,
292            seq: 0,
293            command,
294            run_id,
295        };
296        let opening = Event::opening(
297            engine.next_seq(),
298            crate::applog::now_utc(),
299            engine.run_id.clone(),
300            engine.command,
301        );
302        engine.emit(&opening);
303        if engine.ctx.self_hosted_gitlab() {
304            engine.out.warn(
305                "this remote is a self-hosted GitLab: registry trusted publishing covers GitLab.com only, so the OIDC invariant cannot be satisfied here",
306            );
307        }
308        Ok(engine)
309    }
310
311    const fn next_seq(&mut self) -> u64 {
312        let seq = self.seq;
313        self.seq += 1;
314        seq
315    }
316
317    fn event(&mut self, kind: EventKind, step: Option<&str>) -> Event {
318        let mut event = Event::opening(
319            self.next_seq(),
320            crate::applog::now_utc(),
321            self.run_id.clone(),
322            self.command,
323        );
324        event.kind = kind;
325        event.step = step.map(str::to_owned);
326        event
327    }
328
329    fn emit(&mut self, event: &Event) {
330        self.out.event(event);
331        if let Some(journal) = &mut self.journal {
332            if let Ok(line) = serde_json::to_string(event) {
333                journal.event_line(&line);
334            }
335        }
336    }
337
338    /// Run one external command: echo it, stream and redact its output,
339    /// journal everything, surface its exit.
340    fn exec(&mut self, exec: &Exec, passthrough: bool) -> Result<Outcome, RkError> {
341        let echo = exec.echo();
342        self.out.frame(&echo);
343        if let Some(journal) = &mut self.journal {
344            journal.transcript(echo.as_bytes());
345            journal.transcript(b"\n");
346        }
347        let secrets = std::mem::take(&mut self.secrets);
348        let step_name: Option<String> = None;
349        let mut chunks: Vec<(ChildStream, Vec<u8>)> = Vec::new();
350        let spawned = process::run(exec, |stream, chunk| {
351            chunks.push((stream, process::redact(chunk, &secrets)));
352        });
353        self.secrets = secrets;
354        for (stream, chunk) in chunks {
355            if passthrough {
356                self.out.child_passthrough(stream, &chunk);
357            }
358            let event = self.event(EventKind::ChildOutput, step_name.as_deref());
359            let event = event.child_output(stream, &chunk);
360            self.emit(&event);
361            if let Some(journal) = &mut self.journal {
362                journal.transcript(&chunk);
363            }
364        }
365        spawned.map_err(|source| {
366            RkError::refusal(
367                Diagnostic::new(
368                    Reason::SubprocessSpawn,
369                    format!("{} did not spawn: {source}", exec.program.to_string_lossy()),
370                )
371                .expected("a POSIX sh and the forge CLI on PATH")
372                .run(self.run_path()),
373            )
374        })
375    }
376
377    fn run_path(&self) -> String {
378        self.journal.as_ref().map_or_else(
379            || "no journal was written".to_owned(),
380            |j| j.dir.display().to_string(),
381        )
382    }
383
384    fn finish(&mut self, exit_code: i32, reason: Option<&str>) {
385        let mut event = self.event(EventKind::RunFinished, None);
386        event.exit_code = Some(exit_code);
387        event.status = Some(if exit_code == 0 {
388            "ok".into()
389        } else {
390            "failed".into()
391        });
392        self.emit(&event);
393        if let Some(journal) = &mut self.journal {
394            journal.finish(exit_code, reason);
395        }
396    }
397}
398
399/// Attach the failure to its run journal and close the run.
400fn fail(engine: &mut Engine, error: RkError) -> RkError {
401    let error = match error {
402        RkError::Refusal(mut diagnostic) => {
403            diagnostic.run.get_or_insert_with(|| engine.run_path());
404            RkError::Refusal(diagnostic)
405        }
406        RkError::Subprocess(mut diagnostic) => {
407            diagnostic.run.get_or_insert_with(|| engine.run_path());
408            RkError::Subprocess(diagnostic)
409        }
410        RkError::CheckFailed(mut diagnostic) => {
411            diagnostic.run.get_or_insert_with(|| engine.run_path());
412            RkError::CheckFailed(diagnostic)
413        }
414        other => other,
415    };
416    engine.finish(i32::from(error.exit_code()), Some(error.reason().as_str()));
417    error
418}
419
420/// Preview: walk the ordered steps and print, for each, the step name, what
421/// it proves, and the resolved invocation — without materializing anything
422/// and without invoking any external command. What preview shows is what
423/// apply would run, because both read the same step table and environment
424/// construction.
425fn preview(out: Output, ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
426    let mut engine = Engine::open(out, clone_ctx(ctx), "setup preview", false)?;
427    out.result_line(format!(
428        "DRY RUN: rk setup would run these steps against {} on {}; re-run with --apply",
429        engine.ctx.repo,
430        engine.ctx.forge.as_str()
431    ));
432    for (idx, step) in steps.iter().enumerate() {
433        out.result_line(format!(
434            "step {}/{} {} — proves {}",
435            idx + 1,
436            steps.len(),
437            step.name,
438            step.proves
439        ));
440        // Preview is the rehearsal of apply, so a credential apply could
441        // not use is a preview failure: the operator learns it here rather
442        // than one flag later, and before an invocation is claimed.
443        if step.name == "bot-secrets" && engine.ctx.forge == Forge::Github {
444            secrets::resolve_key_file(&engine.ctx.target)?;
445        }
446        out.result_line(format!("  {}", render_invocation(&engine.ctx, step)));
447        if step.name == "protect-trunk"
448            && engine.ctx.forge == Forge::Github
449            && engine.ctx.required_check.is_none()
450        {
451            out.result_line("  needs: --required-check <name> before apply");
452        }
453        if skipped_by_a_full_run(&engine.ctx, step, steps.len()) {
454            out.result_line(format!(
455                "  optional: a full apply skips it; set setup.release_lines, or rk setup step {} --apply runs it",
456                step.name
457            ));
458        }
459        let mut event = engine.event(EventKind::StepFinished, Some(step.name));
460        event.status = Some("previewed".into());
461        engine.emit(&event);
462    }
463    let next = next_for_apply(&engine.ctx, steps);
464    out.next(&[
465        next,
466        "rk setup check --target . proves what is already true".to_owned(),
467    ]);
468    engine.finish(0, None);
469    Ok(())
470}
471
472/// The one line preview prints per step: the exact spawn shape, with every
473/// non-secret variable resolved.
474fn render_invocation(ctx: &Ctx, step: &StepSpec) -> String {
475    match step.name {
476        "branch-reminder" => {
477            "would write: the post-merge reminder hook at $(git rev-parse --git-path hooks)/post-merge".to_owned()
478        }
479        "package-check" => match ctx.tech {
480            Some("rust") => "would run: cargo publish --dry-run --allow-dirty".to_owned(),
481            Some("python") => "would run: python3 -m build".to_owned(),
482            Some("bash") => "nothing to run: no registry for this technology".to_owned(),
483            _ => "needs: a version file naming the technology".to_owned(),
484        },
485        "forge-version" => {
486            let (major, minor) = observe::GITLAB_VERSION_FLOOR;
487            match ctx.forge {
488                Forge::Github => {
489                    "nothing to read: github.com is a rolling service and declares no version floor"
490                        .to_owned()
491                }
492                Forge::Gitlab => format!(
493                    "would read: GET /version, and compare it against the {major}.{minor} floor; nothing is written"
494                ),
495            }
496        }
497        name => {
498            let check = ctx
499                .required_check
500                .as_ref()
501                .filter(|_| ctx.forge == Forge::Github && name == "protect-trunk")
502                .map(|value| format!(" RK_REQUIRED_CHECK={value}"))
503                .unwrap_or_default();
504            // The ruleset a protection step installs is the one
505            // per-target fact the operator most needs to see before an
506            // apply, because a renamed ruleset leaves the old one standing.
507            let ruleset = match name {
508                "protect-trunk" => format!(" RK_TRUNK_RULESET={} RK_TITLE_CHECK={}", ctx.trunk_ruleset(), ctx.title_check()),
509                "protect-tags" => format!(" RK_TAG_RULESET={}", ctx.tag_ruleset()),
510                "protect-release-lines" => format!(" RK_LINES_RULESET={}", ctx.lines_ruleset()),
511                _ => String::new(),
512            };
513            format!(
514                "would run: sh <embedded setup/{}/{name}> with RK_REPO={} RK_TRUNK_BRANCH={}{ruleset}{check}",
515                ctx.forge.as_str(),
516                ctx.repo,
517                ctx.trunk()
518            )
519        }
520    }
521}
522
523fn next_for_apply(ctx: &Ctx, steps: &[&StepSpec]) -> String {
524    let check = ctx
525        .required_check
526        .as_ref()
527        .map(|value| format!(" --required-check {value}"))
528        .unwrap_or_default();
529    if steps.len() == 1 {
530        format!(
531            "rk setup step {} --target {} --apply{check}",
532            steps[0].name, ctx.target
533        )
534    } else {
535        format!("rk setup --target {} --apply{check}", ctx.target)
536    }
537}
538
539/// A `Ctx` copy for engine ownership; the context is plain data.
540fn clone_ctx(ctx: &Ctx) -> Ctx {
541    ctx.clone()
542}
543
544/// Apply: run the selected steps in order, each through the full lifecycle.
545fn execute(
546    out: Output,
547    ctx: Ctx,
548    steps: &[&StepSpec],
549    command: &'static str,
550) -> Result<(), RkError> {
551    guard_sh()?;
552    let mut engine = Engine::open(out, ctx, command, true)?;
553    let mut done: Vec<(String, String)> = Vec::new();
554    for (idx, step) in steps.iter().enumerate() {
555        // An optional step applies only by name: a full run states the skip
556        // rather than acting on a condition the operator never asserted.
557        if skipped_by_a_full_run(&engine.ctx, step, steps.len()) {
558            engine.out.frame(format!(
559                "step {}/{} {} — skipped (optional; set setup.release_lines, or rk setup step {} --apply runs it)",
560                idx + 1,
561                steps.len(),
562                step.name,
563                step.name
564            ));
565            let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
566            finished.status = Some("skipped".into());
567            engine.emit(&finished);
568            done.push((step.name.to_owned(), "skipped".to_owned()));
569            continue;
570        }
571        engine.out.frame(format!(
572            "step {}/{} {} — {}",
573            idx + 1,
574            steps.len(),
575            step.name,
576            step.proves
577        ));
578        let mut started = engine.event(EventKind::StepStarted, Some(step.name));
579        started.status = Some("running".into());
580        engine.emit(&started);
581        let clock = Instant::now();
582        let status = match apply_step(&mut engine, step) {
583            Ok(status) => status,
584            Err(error) => {
585                let error = attach_progress(error, &done, step, steps);
586                let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
587                finished.status = Some("failed".into());
588                finished.reason = Some(error.reason());
589                finished.duration_ms = Some(elapsed_ms(clock));
590                engine.emit(&finished);
591                return Err(fail(&mut engine, error));
592            }
593        };
594        engine.out.frame(format!(
595            "{} {}: {}",
596            if matches!(status, Done::Skipped(_)) {
597                "skipped"
598            } else {
599                "ok"
600            },
601            step.name,
602            status.line()
603        ));
604        let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
605        finished.status = Some(status.wire().into());
606        finished.exit_code = Some(0);
607        finished.duration_ms = Some(elapsed_ms(clock));
608        engine.emit(&finished);
609        done.push((step.name.to_owned(), status.wire().to_owned()));
610    }
611    engine.out.result_line(format!(
612        "setup: {} completed against {}",
613        step_count(done.len()),
614        engine.ctx.repo
615    ));
616    for (name, status) in &done {
617        engine.out.result_line(format!("  {status} {name}"));
618    }
619    engine.out.next(&[
620        format!("rk setup check --target {}", engine.ctx.target),
621        "rk guide setup orders what no command performs".to_owned(),
622    ]);
623    engine.finish(0, None);
624    Ok(())
625}
626
627/// A step count rendered with the noun that agrees with it, so no summary
628/// line can regrow a dangling plural.
629fn step_count(count: usize) -> String {
630    format!("{count} {}", if count == 1 { "step" } else { "steps" })
631}
632
633fn elapsed_ms(clock: Instant) -> u64 {
634    u64::try_from(clock.elapsed().as_millis()).unwrap_or(u64::MAX)
635}
636
637/// What one applied step reported.
638enum Done {
639    /// The desired state already held; nothing ran.
640    Satisfied(String),
641    /// The target is ineligible for this step.
642    Skipped(String),
643    /// The script ran and the postcondition was read back.
644    Changed(String, Option<String>),
645    /// A read-only step ran and passed.
646    Passed(String),
647}
648
649impl Done {
650    const fn wire(&self) -> &'static str {
651        match self {
652            Self::Satisfied(_) => "satisfied",
653            Self::Skipped(_) => "skipped",
654            Self::Changed(..) => "applied",
655            Self::Passed(_) => "passed",
656        }
657    }
658
659    fn line(&self) -> String {
660        match self {
661            Self::Satisfied(detail) | Self::Passed(detail) | Self::Skipped(detail) => {
662                detail.clone()
663            }
664            Self::Changed(detail, limitation) => limitation.as_ref().map_or_else(
665                || detail.clone(),
666                |limit| format!("{detail} (limitation: {limit})"),
667            ),
668        }
669    }
670}
671
672/// One step, full lifecycle.
673#[allow(clippy::too_many_lines)]
674fn apply_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
675    // Prerequisites are observed, not remembered: the forge is the
676    // authority on whether an earlier step's state holds.
677    for prereq in step.prereqs {
678        let state = observe_with(engine, prereq)?;
679        if !state.satisfied() {
680            return Err(RkError::refusal(
681                Diagnostic::new(
682                    Reason::PrerequisiteUnmet,
683                    format!(
684                        "{} requires {prereq} first: {}",
685                        step.name,
686                        state_detail(&state)
687                    ),
688                )
689                .expected(format!("{prereq} satisfied before {}", step.name))
690                .action(format!(
691                    "rk setup step {prereq} --target {} --apply",
692                    engine.ctx.target
693                ))
694                .step(step.name),
695            ));
696        }
697    }
698    match step.name {
699        "package-check" => {
700            if engine.ctx.tech.is_none() {
701                return Err(RkError::Usage(
702                    "no version file names a technology; rk binding --list names the bindings"
703                        .into(),
704                ));
705            }
706            let state = observe_with(engine, "package-check")?;
707            match state {
708                StepState::Satisfied { detail, .. } => Ok(Done::Passed(detail)),
709                StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
710                    Err(RkError::subprocess(
711                        Diagnostic::new(
712                            Reason::SubprocessFailed,
713                            format!("package-check failed: {detail}"),
714                        )
715                        .expected(step.proves.to_owned())
716                        .step(step.name),
717                    ))
718                }
719                StepState::Unknown { detail } => Err(RkError::subprocess(
720                    Diagnostic::new(
721                        Reason::SubprocessFailed,
722                        format!("package-check could not run: {detail}"),
723                    )
724                    .step(step.name),
725                )),
726            }
727        }
728        // The step mutates nothing, so apply and check read the same answer
729        // and apply writes nothing at all. An unreadable version is a
730        // refusal, never a pass: the floor exists to stop a protection the
731        // forge cannot honor, and a floor nobody could read proves neither
732        // way.
733        "forge-version" => match observe_with(engine, "forge-version")? {
734            StepState::Satisfied { detail, .. } => Ok(Done::Satisfied(detail)),
735            StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
736                Err(RkError::refusal(
737                    Diagnostic::new(Reason::PrerequisiteUnmet, detail)
738                        .expected(step.proves.to_owned())
739                        .action("upgrade the instance, or host the project on gitlab.com")
740                        .target_state("unchanged")
741                        .step(step.name),
742                ))
743            }
744            StepState::Unknown { detail } => Err(RkError::refusal(
745                Diagnostic::new(Reason::ForgeTemporary, detail)
746                    .expected("a readable forge version")
747                    .action("glab auth login, then rerun")
748                    .target_state("unchanged")
749                    .step(step.name),
750            )),
751        },
752        "branch-reminder" => {
753            use crate::setup::branch_reminder::{HookState, hook_body, hook_path, observe_hook};
754            match observe_hook(&engine.ctx.target) {
755                HookState::Installed => Ok(Done::Satisfied(
756                    "the post-merge reminder hook is installed".into(),
757                )),
758                HookState::Foreign => Err(RkError::refusal(
759                    Diagnostic::new(
760                        Reason::StateDrift,
761                        "a foreign post-merge hook exists; the reminder is never written over it",
762                    )
763                    .expected("no post-merge hook, or one carrying the release-kit marker")
764                    .action(
765                        "merge by hand: guard each call behind its own capability probe inside the existing hook — `rk branches prune --help >/dev/null 2>&1` before `rk branches prune --quiet || :`, and the same pair for `rk worktree prune`",
766                    )
767                    .target_state("unchanged")
768                    .step(step.name),
769                )),
770                HookState::Unreadable(detail) => Err(RkError::refusal(
771                    Diagnostic::new(
772                        Reason::StateDrift,
773                        format!("the post-merge hook cannot be read: {detail}"),
774                    )
775                    .target_state("unchanged")
776                    .step(step.name),
777                )),
778                HookState::Absent | HookState::Drifted => {
779                    let path = hook_path(&engine.ctx.target).map_err(|detail| {
780                        RkError::refusal(
781                            Diagnostic::new(
782                                Reason::PrerequisiteUnmet,
783                                format!("the hooks directory cannot be resolved: {detail}"),
784                            )
785                            .expected("a git repository whose hooks directory git can name")
786                            .step(step.name),
787                        )
788                    })?;
789                    crate::atomic::write(&path, hook_body())?;
790                    #[cfg(unix)]
791                    {
792                        use std::os::unix::fs::PermissionsExt as _;
793                        std::fs::set_permissions(
794                            &path,
795                            std::fs::Permissions::from_mode(0o755),
796                        )?;
797                    }
798                    Ok(Done::Changed(
799                        "wrote the post-merge reminder hook".into(),
800                        None,
801                    ))
802                }
803            }
804        }
805        "single-trunk" => {
806            let guard = {
807                let ctx = clone_ctx(&engine.ctx);
808                let mut runner = |exec: &Exec| engine.exec(exec, false);
809                observe::single_trunk_guard(&ctx, &mut runner)?
810            };
811            // A destructive step fails closed: an ancestry the guard cannot
812            // establish is treated exactly like one it refuted.
813            match &guard {
814                StepState::Satisfied { .. } => {}
815                StepState::Unsatisfied { detail }
816                | StepState::Inapplicable { detail }
817                | StepState::Unknown { detail } => {
818                    return Err(RkError::refusal(
819                        Diagnostic::new(
820                            Reason::DestructiveRefusal,
821                            format!("single-trunk refuses: {detail}"),
822                        )
823                        .expected(
824                            "proof that every candidate branch is absent, or an ancestor of the trunk",
825                        )
826                        .step(step.name),
827                    ));
828                }
829            }
830            run_forge_step(engine, step)
831        }
832        "bot-secrets" => {
833            // Validate before observing: a wrong path or a wrong mode is
834            // the operator's answer either way, and the refusal costs no
835            // forge call. Only GitHub reads a key file; GitLab's credential
836            // is a token, and its step must not fail over a variable it
837            // never consumes.
838            let key = match engine.ctx.forge {
839                // The run's one read: an install-bot observation earlier in
840                // this run already holds the bytes, and this step stores
841                // those very bytes rather than reopening the path.
842                Forge::Github => key_file_for(engine)?.map(|key| key.bytes.clone()),
843                Forge::Gitlab => None,
844            };
845            let provided = match engine.ctx.forge {
846                // Both halves of an App identity, or neither: a run holding
847                // only one of them would store half a credential.
848                Forge::Github => secrets::value_of("RK_BOT_APP_ID").is_some() && key.is_some(),
849                Forge::Gitlab => secrets::value_of("RK_BOT_TOKEN").is_some(),
850            };
851            let state = observe_with(engine, step.name)?;
852            if !provided {
853                if state.satisfied() {
854                    return Ok(Done::Satisfied(state_detail(&state)));
855                }
856                let wanted = match engine.ctx.forge {
857                    Forge::Github => {
858                        "export RK_BOT_APP_ID and RK_BOT_PRIVATE_KEY_FILE, the second naming the .pem; rk forge github carries the walkthrough"
859                    }
860                    Forge::Gitlab => {
861                        "rk setup step install-bot --apply stores the token, or export RK_BOT_TOKEN to rotate one"
862                    }
863                };
864                return Err(RkError::refusal(
865                    Diagnostic::new(
866                        Reason::PrerequisiteUnmet,
867                        "bot-secrets has no credentials to store",
868                    )
869                    .expected("the bot credentials in the environment, the key as a path")
870                    .action(wanted.to_owned())
871                    .step(step.name),
872                ));
873            }
874            if let Some(journal) = &mut engine.journal {
875                for name in SECRET_VARS {
876                    if secrets::value_of(name).is_some() {
877                        journal.record_secret(name, true, "environment");
878                    }
879                }
880                if key.is_some() {
881                    journal.record_secret(secrets::PRIVATE_KEY_FILE, true, "file");
882                }
883            }
884            // The bytes rk validated are the bytes the child receives and
885            // the bytes the redactor holds: one read, one value, so nothing
886            // can be substituted between the check and the forge.
887            let stdin = key;
888            run_forge_step_with(engine, step, stdin, Vec::new())
889        }
890        "protections-check" => {
891            let (outcome, _) = run_script(engine, step)?;
892            if !outcome.success() {
893                return Err(classify_failure(engine, step, &outcome));
894            }
895            // The script is the operator-auditable mirror; the observation
896            // is the authoritative shape check, so the step passes only
897            // when both agree.
898            match observe_with(engine, step.name)? {
899                StepState::Satisfied { detail, limitation } => {
900                    Ok(Done::Passed(limitation.map_or_else(
901                        || detail.clone(),
902                        |limit| format!("{detail} (limitation: {limit})"),
903                    )))
904                }
905                StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
906                    Err(RkError::refusal(
907                        Diagnostic::new(
908                            Reason::StateDrift,
909                            format!("protections-check passed its script and the observation disagrees: {detail}"),
910                        )
911                        .expected(step.proves.to_owned())
912                        .step(step.name),
913                    ))
914                }
915                // An unreadable readback is a retryable outage, not drift,
916                // exactly as the postcondition lifecycle classifies it.
917                StepState::Unknown { detail } => Err(RkError::refusal(
918                    Diagnostic::new(
919                        Reason::ForgeTemporary,
920                        format!(
921                            "protections-check passed its script and the readback could not confirm it: {detail}"
922                        ),
923                    )
924                    .expected(step.proves.to_owned())
925                    .action("check authentication and connectivity, then rerun")
926                    .step(step.name),
927                )),
928            }
929        }
930        // GitHub's grant is the one write the forge offers a command, and
931        // it takes a user credential; everything else here — the pre- and
932        // post-observation, and the installation id the script is handed —
933        // happens as the App itself. GitLab's install-bot needs none of
934        // this and takes the generic lifecycle below.
935        "install-bot" if engine.ctx.forge == Forge::Github => {
936            match observe_with(engine, step.name)? {
937                StepState::Satisfied { detail, .. } => {
938                    return Ok(Done::Satisfied(detail));
939                }
940                StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
941                StepState::Unknown { detail } => {
942                    return Err(RkError::refusal(
943                        Diagnostic::new(
944                            Reason::ForgeTemporary,
945                            format!("{} cannot observe the current state: {detail}", step.name),
946                        )
947                        .expected("a readable forge answer before anything mutates")
948                        .action("check the App credentials and connectivity, then rerun")
949                        .step(step.name),
950                    ));
951                }
952            }
953            let installation = github_installation_id(engine, step)?;
954            run_forge_step_with(
955                engine,
956                step,
957                None,
958                vec![("RK_BOT_INSTALLATION".into(), installation.into())],
959            )
960        }
961        _ => {
962            if step.mutates == Mutates::Forge {
963                // The lifecycle applies only on a state it has read: an
964                // observation that cannot decide fails closed here exactly
965                // as it does after the write, so no mutation ever rides on
966                // an unreadable forge answer.
967                match observe_with(engine, step.name)? {
968                    StepState::Satisfied { detail, limitation } => {
969                        let detail = if step.name == "private-vulnerability-reporting" {
970                            limitation.map_or_else(
971                                || detail.clone(),
972                                |limit| format!("{detail} (limitation: {limit})"),
973                            )
974                        } else {
975                            detail
976                        };
977                        return Ok(Done::Satisfied(detail));
978                    }
979                    StepState::Inapplicable { detail }
980                        if step.name == "private-vulnerability-reporting" =>
981                    {
982                        return Ok(Done::Skipped(detail));
983                    }
984                    StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
985                    StepState::Unknown { detail } => {
986                        return Err(RkError::refusal(
987                            Diagnostic::new(
988                                Reason::ForgeTemporary,
989                                format!("{} cannot observe the current state: {detail}", step.name),
990                            )
991                            .expected("a readable forge answer before anything mutates")
992                            .action("check authentication and connectivity, then rerun")
993                            .step(step.name),
994                        ));
995                    }
996                }
997            }
998            run_forge_step(engine, step)
999        }
1000    }
1001}
1002
1003/// The id of the App's installation on this repository's owner, read as
1004/// the App itself. The grant needs it, and no user credential can read
1005/// it: the observation that just ran answered 404, so the repository
1006/// endpoint that names the id directly has nothing to say yet. The
1007/// account-level installation is a direct read for either account kind —
1008/// the user endpoint answers for a person, the organization endpoint for
1009/// an organization — so nothing here lists or paginates.
1010fn github_installation_id(engine: &mut Engine, step: &StepSpec) -> Result<String, RkError> {
1011    let refuse = |message: String, action: &str| {
1012        RkError::refusal(
1013            Diagnostic::new(Reason::PrerequisiteUnmet, message)
1014                .expected("the App installed on the repository's owner")
1015                .action(action.to_owned())
1016                .step(step.name),
1017        )
1018    };
1019    let jwt = match app_jwt_for(engine)? {
1020        Ok(jwt) => jwt,
1021        Err(detail) => {
1022            return Err(refuse(
1023                format!("install-bot has no App token: {detail}"),
1024                app_jwt::REMEDIATION,
1025            ));
1026        }
1027    };
1028    let owner = engine
1029        .ctx
1030        .repo
1031        .split('/')
1032        .next()
1033        .unwrap_or_default()
1034        .to_owned();
1035    let ctx = clone_ctx(&engine.ctx);
1036    for path in [
1037        format!("users/{owner}/installation"),
1038        format!("orgs/{owner}/installation"),
1039    ] {
1040        match app_jwt::api_get(&ctx, &jwt, &path) {
1041            AppApi::Ok(body) => {
1042                return body["id"].as_i64().map(|id| id.to_string()).ok_or_else(|| {
1043                    refuse(
1044                        format!("the forge answered {path} without an installation id"),
1045                        "check RK_BOT_APP_ID and the key file name the same App",
1046                    )
1047                });
1048            }
1049            AppApi::Missing => {}
1050            AppApi::Refused(detail) => {
1051                return Err(refuse(
1052                    detail,
1053                    "check RK_BOT_APP_ID and the key file name the same App",
1054                ));
1055            }
1056            AppApi::Failed(detail) => {
1057                return Err(RkError::refusal(
1058                    Diagnostic::new(
1059                        Reason::ForgeTemporary,
1060                        format!("install-bot cannot read the App's installation: {detail}"),
1061                    )
1062                    .action("check connectivity, then rerun")
1063                    .step(step.name),
1064                ));
1065            }
1066        }
1067    }
1068    Err(refuse(
1069        format!("the App has no installation on {owner}"),
1070        "install the App on the account first; the setup guide's step 5 walks it",
1071    ))
1072}
1073
1074/// Materialize, spawn, classify, and verify one forge-mutating step.
1075fn run_forge_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
1076    run_forge_step_with(engine, step, None, Vec::new())
1077}
1078
1079/// The same, with bytes written to the step's standard input and values
1080/// `rk` derived added to its environment.
1081fn run_forge_step_with(
1082    engine: &mut Engine,
1083    step: &StepSpec,
1084    stdin: Option<Zeroizing<Vec<u8>>>,
1085    extra_env: Vec<(OsString, OsString)>,
1086) -> Result<Done, RkError> {
1087    let (outcome, _) = run_script_with(engine, step, stdin, extra_env)?;
1088    if !outcome.success() {
1089        return Err(classify_failure(engine, step, &outcome));
1090    }
1091    let state = observe_with(engine, step.name)?;
1092    match state {
1093        StepState::Satisfied { detail, limitation } => Ok(Done::Changed(detail, limitation)),
1094        StepState::Inapplicable { detail } if step.name == "private-vulnerability-reporting" => {
1095            Ok(Done::Skipped(detail))
1096        }
1097        StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1098            Err(RkError::refusal(
1099                Diagnostic::new(
1100                    Reason::StateDrift,
1101                    format!(
1102                        "{} ran and its postcondition does not hold: {detail}",
1103                        step.name
1104                    ),
1105                )
1106                .expected(step.proves.to_owned())
1107                .step(step.name),
1108            ))
1109        }
1110        // The lifecycle ends with a proven postcondition; a readback that
1111        // cannot run leaves the step unproven, and an unproven apply is a
1112        // failure a retry can cure, never a success.
1113        StepState::Unknown { detail } => Err(RkError::refusal(
1114            Diagnostic::new(
1115                Reason::ForgeTemporary,
1116                format!(
1117                    "{} ran and the readback could not confirm it: {detail}",
1118                    step.name
1119                ),
1120            )
1121            .expected(step.proves.to_owned())
1122            .action(format!(
1123                "rk setup step {} --target {} --apply re-asserts and re-proves it",
1124                step.name, engine.ctx.target
1125            ))
1126            .step(step.name),
1127        )),
1128    }
1129}
1130
1131/// Observe one step through the engine's executor.
1132///
1133/// `install-bot` on GitHub is the one observation that authenticates as
1134/// the App itself, so it routes through [`app_jwt_for`] here — where the
1135/// engine can mint once and register the redaction needles — rather than
1136/// through the credential-free name dispatch in [`observe::observe`].
1137fn observe_with(engine: &mut Engine, step: &str) -> Result<StepState, RkError> {
1138    if step == "install-bot" && engine.ctx.forge == Forge::Github {
1139        let jwt = match app_jwt_for(engine)? {
1140            Ok(jwt) => jwt,
1141            Err(detail) => return Ok(StepState::Unknown { detail }),
1142        };
1143        return Ok(observe::github_install_bot(&engine.ctx, &jwt));
1144    }
1145    let ctx = clone_ctx(&engine.ctx);
1146    let mut runner = |exec: &Exec| engine.exec(exec, false);
1147    observe::observe(&ctx, step, &mut runner)
1148}
1149
1150/// The run's validated key file, read exactly once per run: the first
1151/// consumer resolves it and every later one reuses the same bytes, so the
1152/// file that authenticated the App is the file `bot-secrets` stores, and
1153/// no replacement between steps can split the two. The bytes become a
1154/// redaction needle the moment they are read.
1155fn key_file_for(engine: &mut Engine) -> Result<Option<&secrets::KeyFile>, RkError> {
1156    if engine.key.is_none() {
1157        engine.key = secrets::resolve_key_file(&engine.ctx.target)?;
1158        if let Some(key) = &engine.key {
1159            engine.secrets.push(key.bytes.clone());
1160        }
1161    }
1162    Ok(engine.key.as_ref())
1163}
1164
1165/// The run's App JWT, minted at most once: the key comes from the run's
1166/// one read, and the minted token and its signature segment become
1167/// redaction needles before anything else spawns. Every install-bot
1168/// observation and the grant's installation-id discovery reuse the one
1169/// token, whose nine-minute life covers a run's contiguous step easily.
1170///
1171/// The inner value is `Err` with a one-line detail where no token can
1172/// exist — absent exports, or a signer that failed — which an observation
1173/// reports as `unknown` and an apply turns into a refusal.
1174fn app_jwt_for(engine: &mut Engine) -> Result<Result<String, String>, RkError> {
1175    if let Some(jwt) = &engine.app_jwt {
1176        return Ok(Ok(jwt.clone()));
1177    }
1178    let app_id = app_jwt::app_id(engine.ctx.bot_app_id())?;
1179    let key_bytes = key_file_for(engine)?.map(|key| key.bytes.clone());
1180    let (Some(app_id), Some(key_bytes)) = (app_id, key_bytes) else {
1181        return Ok(Err(format!(
1182            "the installation is readable only to the App itself; {}",
1183            app_jwt::REMEDIATION
1184        )));
1185    };
1186    let credentials = app_jwt::AppCredentials { app_id, key_bytes };
1187    let ctx = clone_ctx(&engine.ctx);
1188    Ok(match app_jwt::mint(&ctx, &credentials) {
1189        Ok(jwt) => {
1190            engine
1191                .secrets
1192                .push(Zeroizing::new(jwt.clone().into_bytes()));
1193            if let Some(signature) = jwt.rsplit('.').next() {
1194                engine
1195                    .secrets
1196                    .push(Zeroizing::new(signature.as_bytes().to_vec()));
1197            }
1198            engine.app_jwt = Some(jwt.clone());
1199            Ok(jwt)
1200        }
1201        Err(detail) => Err(detail),
1202    })
1203}
1204
1205fn state_detail(state: &StepState) -> String {
1206    match state {
1207        StepState::Satisfied { detail, .. }
1208        | StepState::Unsatisfied { detail }
1209        | StepState::Inapplicable { detail }
1210        | StepState::Unknown { detail } => detail.clone(),
1211    }
1212}
1213
1214/// Materialize the step's script into the run's private directory, prove
1215/// the written bytes by digest, and spawn it through the interpreter.
1216fn run_script(engine: &mut Engine, step: &StepSpec) -> Result<(Outcome, PathBuf), RkError> {
1217    run_script_with(engine, step, None, Vec::new())
1218}
1219
1220/// The same, with bytes written to the script's standard input.
1221///
1222/// A credential travels this way and no other: `rk` reads it, validates it,
1223/// and hands the child the bytes it validated, so nothing between the check
1224/// and the forge can substitute a different file.
1225fn run_script_with(
1226    engine: &mut Engine,
1227    step: &StepSpec,
1228    stdin: Option<Zeroizing<Vec<u8>>>,
1229    extra_env: Vec<(OsString, OsString)>,
1230) -> Result<(Outcome, PathBuf), RkError> {
1231    let rel = format!("{}/{}", engine.ctx.forge.as_str(), step.name);
1232    let bytes = embedded::SETUP
1233        .get_file(&rel)
1234        .map(include_dir::File::contents)
1235        .ok_or_else(|| RkError::Other(anyhow::anyhow!("no embedded script at setup/{rel}")))?;
1236    let journal = engine
1237        .journal
1238        .as_mut()
1239        .ok_or_else(|| RkError::Other(anyhow::anyhow!("an apply always has a journal")))?;
1240    let dir = journal.scripts_dir().join(engine.ctx.forge.as_str());
1241    fs::create_dir_all(&dir)?;
1242    restrict(&dir, 0o700);
1243    let path = dir.join(step.name);
1244    fs::write(&path, bytes)?;
1245    restrict(&path, 0o600);
1246    let written = fs::read(&path)?;
1247    let digest = Digest::of(&written);
1248    if digest != Digest::of(bytes) {
1249        return Err(RkError::Other(anyhow::anyhow!(
1250            "the materialized script at {} differs from the embedded bytes",
1251            path.display()
1252        )));
1253    }
1254    journal.record_script(format!("scripts/{rel}"), digest.to_string());
1255    let mut env = engine.ctx.child_env(step.name);
1256    env.extend(extra_env);
1257    let exec = Exec {
1258        program: crate::probes::sh_bin(),
1259        args: vec![path.clone().into_os_string()],
1260        env,
1261        cwd: engine.ctx.target.as_std_path().to_path_buf(),
1262        stdin,
1263    };
1264    let outcome = engine.exec(&exec, true)?;
1265    Ok((outcome, path))
1266}
1267
1268/// Honest classification: `gh` documents exit 4 as authentication required;
1269/// beyond that only an HTTP status in the response says more, and a step
1270/// that fails for a reason nothing establishes stays `subprocess-failed`
1271/// with its own stderr surfaced verbatim.
1272fn classify_failure(engine: &Engine, step: &StepSpec, outcome: &Outcome) -> RkError {
1273    let stderr = String::from_utf8_lossy(&outcome.stderr);
1274    // A signalled child usually writes nothing before it dies, and
1275    // "no output" would blame the forge for a kill that came from
1276    // outside it. The adapter already resolved 128+N; say which.
1277    let last = if outcome.exit_code >= 128 {
1278        format!("killed by signal {}", outcome.exit_code - 128)
1279    } else {
1280        stderr
1281            .lines()
1282            .rev()
1283            .find(|line| !line.trim().is_empty())
1284            .unwrap_or("no output")
1285            .to_owned()
1286    };
1287    let reason = if (engine.ctx.forge == Forge::Github && outcome.exit_code == 4)
1288        || stderr.contains("HTTP 401")
1289    {
1290        Reason::ForgeAuthentication
1291    } else if stderr.contains("HTTP 403") {
1292        Reason::ForgePermission
1293    } else if stderr.contains("HTTP 429") || stderr.contains("rate limit") {
1294        Reason::ForgeRateLimit
1295    } else {
1296        Reason::SubprocessFailed
1297    };
1298    let diagnostic = Diagnostic::new(reason, format!("the forge refused '{}': {last}", step.name))
1299        .expected(step.proves.to_owned())
1300        .action(format!(
1301            "rk setup step {} --target {} --apply",
1302            step.name, engine.ctx.target
1303        ))
1304        .step(step.name);
1305    let diagnostic = match reason {
1306        Reason::ForgePermission => diagnostic.expected(format!(
1307            "repository administration write on {} for the authenticated account",
1308            engine.ctx.repo
1309        )),
1310        _ => diagnostic,
1311    };
1312    match reason {
1313        Reason::SubprocessFailed => RkError::subprocess(diagnostic),
1314        _ => RkError::refusal(diagnostic),
1315    }
1316}
1317
1318/// Fold the run's progress into the failure, so the diagnostic answers
1319/// what state the target is in.
1320fn attach_progress(
1321    error: RkError,
1322    done: &[(String, String)],
1323    failed: &StepSpec,
1324    steps: &[&StepSpec],
1325) -> RkError {
1326    let remaining = steps.len().saturating_sub(done.len() + 1);
1327    let state = format!(
1328        "{} completed; {} failed; {remaining} not attempted",
1329        step_count(done.len()),
1330        failed.name
1331    );
1332    match error {
1333        RkError::Refusal(mut diagnostic) => {
1334            diagnostic.target_state.get_or_insert(state);
1335            RkError::Refusal(diagnostic)
1336        }
1337        RkError::Subprocess(mut diagnostic) => {
1338            diagnostic.target_state.get_or_insert(state);
1339            RkError::Subprocess(diagnostic)
1340        }
1341        other => other,
1342    }
1343}
1344
1345/// `rk setup check`: observe and verify every step, report per step, and
1346/// judge at the end. The mutating half is unreachable from this path: it
1347/// calls only the observe functions.
1348fn check(out: Output, ctx: Ctx) -> Result<(), RkError> {
1349    let mut engine = Engine::open(out, ctx, "setup check", false)?;
1350    let mut unsatisfied = 0usize;
1351    let mut unverifiable = 0usize;
1352    for step in &STEPS {
1353        let clock = Instant::now();
1354        let state = observe_with(&mut engine, step.name)?;
1355        let (label, wire) = match &state {
1356            StepState::Satisfied { .. } => ("ok", "satisfied"),
1357            // An optional step whose condition does not hold is stated, not
1358            // judged: nothing is wrong and nothing was skipped silently.
1359            StepState::Inapplicable { .. } => ("skipped", "skipped"),
1360            StepState::Unsatisfied { .. } => {
1361                unsatisfied += 1;
1362                ("unsatisfied", "unsatisfied")
1363            }
1364            // A step the check cannot verify has not passed: an unreadable
1365            // forge answer must never read as a clean setup.
1366            StepState::Unknown { .. } => {
1367                unverifiable += 1;
1368                ("unknown", "unknown")
1369            }
1370        };
1371        let mut line = format!("{label} {} — {}", step.name, state_detail(&state));
1372        if let StepState::Satisfied {
1373            limitation: Some(limit),
1374            ..
1375        } = &state
1376        {
1377            use std::fmt::Write as _;
1378            let _ = write!(line, " (limitation: {limit})");
1379        }
1380        engine.out.result_line(line);
1381        let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
1382        finished.status = Some(wire.into());
1383        finished.duration_ms = Some(elapsed_ms(clock));
1384        engine.emit(&finished);
1385    }
1386    if unsatisfied > 0 || unverifiable > 0 {
1387        let error = RkError::check_failed(
1388            Diagnostic::new(
1389                Reason::StateDrift,
1390                format!(
1391                    "{} {} not satisfied and {unverifiable} could not be verified",
1392                    step_count(unsatisfied),
1393                    if unsatisfied == 1 { "is" } else { "are" }
1394                ),
1395            )
1396            .expected("every step's proof column to hold and to be readable")
1397            .action(format!(
1398                "rk setup --target {} --apply re-asserts them",
1399                engine.ctx.target
1400            )),
1401        );
1402        return Err(fail(&mut engine, error));
1403    }
1404    engine
1405        .out
1406        .next(&["rk guide release orders the first release".to_owned()]);
1407    engine.finish(0, None);
1408    Ok(())
1409}
1410
1411/// Restrict a materialized path's mode: data, not an executable — nothing
1412/// ever executes a script directly, so no mode is load-bearing.
1413fn restrict(path: &std::path::Path, mode: u32) {
1414    #[cfg(unix)]
1415    {
1416        use std::os::unix::fs::PermissionsExt as _;
1417        let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
1418    }
1419    #[cfg(not(unix))]
1420    let _ = (path, mode);
1421}
1422
1423/// A POSIX shell must spawn before anything else does; every step runs
1424/// through it.
1425fn guard_sh() -> Result<(), RkError> {
1426    let ok = std::process::Command::new(crate::probes::sh_bin())
1427        .args(["-c", "exit 0"])
1428        .status()
1429        .is_ok_and(|status| status.success());
1430    if ok {
1431        Ok(())
1432    } else {
1433        Err(RkError::refusal(
1434            Diagnostic::new(Reason::PrerequisiteUnmet, "no POSIX sh runs on this host")
1435                .expected("a working sh on PATH; every step spawns through it")
1436                .action("install a POSIX shell, then rerun")
1437                .target_state("nothing was run and nothing changed"),
1438        ))
1439    }
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444    /// Every summary line reports its count through one helper, so none of
1445    /// them can regrow a dangling plural.
1446    #[test]
1447    fn a_step_count_carries_a_noun_that_agrees_with_it() {
1448        assert_eq!(super::step_count(0), "0 steps");
1449        assert_eq!(super::step_count(1), "1 step");
1450        assert_eq!(super::step_count(2), "2 steps");
1451    }
1452}