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