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