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