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::proof::{self, Standing};
32use crate::setup::report::{Report, Row, Stance, stance};
33use crate::setup::secrets;
34use crate::setup::steps::{Mutates, STEPS, StepSpec, spec};
35
36/// Dispatch the setup surface.
37///
38/// # Errors
39///
40/// Every failure classified through the matrix, each carrying its `reason`.
41pub fn run(args: &SetupArgs) -> Result<(), RkError> {
42    // Every action that reads a target reads it with git; printing a step
43    // script and listing the steps read nothing.
44    let reads_nothing = matches!(args.action, Some(SetupAction::Script { .. }))
45        || (args.action.is_none() && args.list);
46    if !reads_nothing {
47        crate::requirements::require("rk setup", &[crate::requirements::Tool::Git])?;
48    }
49    match &args.action {
50        Some(SetupAction::Script { name, forge }) => script(name, forge.as_deref()),
51        Some(SetupAction::Check {
52            target,
53            repo,
54            forge,
55            required_check,
56            json,
57        }) => check(
58            Output::new(*json),
59            observing(
60                target,
61                repo.as_deref(),
62                forge.as_deref(),
63                required_check.as_deref(),
64            )?,
65        ),
66        Some(SetupAction::Checkpoint {
67            target,
68            repo,
69            forge,
70            required_check,
71            json,
72        }) => checkpoint(
73            Output::new(*json),
74            observing(
75                target,
76                repo.as_deref(),
77                forge.as_deref(),
78                required_check.as_deref(),
79            )?,
80        ),
81        // No forge CLI is resolved and no run opens: the status reads a
82        // committed file and this target's own configuration alone.
83        Some(SetupAction::Status {
84            target,
85            repo,
86            forge,
87            required_check,
88            json,
89        }) => status(
90            Output::new(*json),
91            &Ctx::resolve(
92                target,
93                repo.as_deref(),
94                forge.as_deref(),
95                required_check.as_deref(),
96            )?,
97        ),
98        Some(action @ SetupAction::Step { .. }) => step(action),
99        None if args.list => list(args.forge.as_deref()),
100        None => {
101            let target = args.target.clone().ok_or_else(|| {
102                RkError::Usage("name a --target, or pass --list to see the steps".into())
103            })?;
104            let all: Vec<&StepSpec> = STEPS.iter().collect();
105            let mut ctx = Ctx::resolve(
106                &target,
107                args.repo.as_deref(),
108                args.forge.as_deref(),
109                args.required_check.as_deref(),
110            )?;
111            reject_check_flag_on_gitlab(&ctx)?;
112            if args.apply {
113                require_check_for(&ctx, &all)?;
114                // The full run skips its optional steps, so the callers
115                // are what the run will actually reach.
116                let acted: Vec<&StepSpec> = all
117                    .iter()
118                    .copied()
119                    .filter(|step| !skipped_by_a_full_run(&ctx, step, all.len()))
120                    .collect();
121                ctx.require_cli(&acted)?;
122                execute(Output::new(args.json), ctx, &all, "setup")
123            } else {
124                let acted: Vec<&StepSpec> = all
125                    .iter()
126                    .copied()
127                    .filter(|step| !skipped_by_a_full_run(&ctx, step, all.len()))
128                    .collect();
129                ctx.require_cli(&acted)?;
130                preview(Output::new(args.json), &ctx, &all)
131            }
132        }
133    }
134}
135
136/// `rk setup step`: preview one step, or run it under `--apply`.
137fn step(action: &SetupAction) -> Result<(), RkError> {
138    let SetupAction::Step {
139        name,
140        target,
141        repo,
142        forge,
143        required_check,
144        apply,
145        json,
146    } = action
147    else {
148        unreachable!("the dispatch routes only a step here");
149    };
150    let selected = spec(name).ok_or_else(|| {
151        RkError::Usage(format!("unknown step '{name}'; rk setup --list names them"))
152    })?;
153    let mut ctx = Ctx::resolve(
154        target,
155        repo.as_deref(),
156        forge.as_deref(),
157        required_check.as_deref(),
158    )?;
159    reject_check_flag_on_gitlab(&ctx)?;
160    if *apply {
161        refuse_an_excluded_step(&ctx, selected)?;
162        require_check_for(&ctx, &[selected])?;
163        // Every argument refusal has been given, so what remains
164        // is the call itself and its one prerequisite.
165        ctx.require_cli(&[selected])?;
166        execute(Output::new(*json), ctx, &[selected], "setup step")
167    } else {
168        // A preview writes nothing, but it names the command it
169        // would run, so a forge CLI it could never find is worth
170        // saying now rather than at the apply.
171        ctx.require_cli(&[selected])?;
172        preview(Output::new(*json), &ctx, &[selected])
173    }
174}
175
176/// The context of a run that observes every step: a check and a
177/// checkpoint. Each asks the forge for every step the target runs, so it
178/// needs the forge CLI for each one that asks a question.
179fn observing(
180    target: &camino::Utf8PathBuf,
181    repo: Option<&str>,
182    forge: Option<&str>,
183    required_check: Option<&str>,
184) -> Result<Ctx, RkError> {
185    let mut ctx = Ctx::resolve(target, repo, forge, required_check)?;
186    reject_check_flag_on_gitlab(&ctx)?;
187    let all: Vec<&StepSpec> = STEPS.iter().collect();
188    ctx.require_cli(&all)?;
189    Ok(ctx)
190}
191
192/// Whether a full run skips this step at this target.
193///
194/// `optional` is the setup declaration's claim that a step is not universal; the
195/// target's own configuration answers whether it wants this one. Today
196/// `protect-release-lines` is the single such step, and a project that
197/// sets `setup.release_lines` gets it run rather than skipped and
198/// remembered.
199fn skipped_by_a_full_run(ctx: &Ctx, step: &StepSpec, selected: usize) -> bool {
200    if !step.optional || selected <= 1 {
201        return false;
202    }
203    !(step.name == "protect-release-lines" && ctx.release_lines())
204}
205
206/// A step the target declared it does not run is refused by name rather
207/// than applied. The committed file is the one statement of which steps
208/// this target runs, so a run that installed what the file excludes would
209/// leave the forge and the declaration disagreeing with nobody to notice;
210/// changing the model is one edit, and that edit is the auditable act.
211fn refuse_an_excluded_step(ctx: &Ctx, step: &StepSpec) -> Result<(), RkError> {
212    match stance(ctx, step) {
213        Stance::Applies => Ok(()),
214        Stance::Excluded(reason) | Stance::Redundant { reason, .. } => {
215            Err(RkError::Usage(format!(
216                "{} is excluded by {}: {reason}; remove it from setup.excluded_steps to run it",
217                step.name,
218                crate::config::CONFIG_PATH
219            )))
220        }
221        // A step the target configuration does not select is refused
222        // rather than applied: the value that decided it is the answer,
223        // and changing the configuration is the auditable act.
224        Stance::NotApplicable(reason) => Err(RkError::refusal(
225            Diagnostic::new(
226                Reason::PrerequisiteUnmet,
227                format!("{} does not apply to this target: {reason}", step.name),
228            )
229            .expected("a target configuration that selects this step")
230            .action("rk profile --target . reports what this target resolves to")
231            .target_state("unchanged")
232            .step(step.name),
233        )),
234    }
235}
236
237/// On GitLab `--required-check` is a usage error, per the forge document:
238/// the forge requires the whole pipeline and names no individual check, and
239/// a flag silently discarded would read as configured while nothing uses it.
240fn reject_check_flag_on_gitlab(ctx: &Ctx) -> Result<(), RkError> {
241    if ctx.forge == Some(Forge::Gitlab) && ctx.required_check.is_some() {
242        return Err(RkError::Usage(
243            "--required-check is refused on gitlab: the forge requires the whole pipeline and names no individual check".into(),
244        ));
245    }
246    Ok(())
247}
248
249/// On GitHub the trunk protection needs the check name before any step
250/// runs: a wrong or missing one does not fail, it hangs the merge button,
251/// so a full apply refuses up front rather than writing eight steps and
252/// stopping. A target that excludes the protection is asked for nothing,
253/// because the value would answer a step this run never reaches, or one
254/// whose effective policy carries no required-check rule. Both integration
255/// modes normally carry that rule, and this prerequisite stays about the
256/// ruleset step.
257fn require_check_for(ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
258    let needs = ctx.forge == Some(Forge::Github)
259        && ctx.required_check.is_none()
260        && ctx
261            .protection()
262            .owned_trunk_rules
263            .iter()
264            .any(|rule| rule == "required_status_checks")
265        && steps
266            .iter()
267            .any(|step| step.name == "protect-trunk" && stance(ctx, step).acts());
268    if needs {
269        return Err(RkError::refusal(
270            Diagnostic::new(
271                Reason::PrerequisiteUnmet,
272                "protect-trunk refuses until the required check is named, and nothing was written",
273            )
274            .expected("the name of the CI check the release merge must pass")
275            .action(format!(
276                "set setup.required_check in {}, or pass --required-check <name>; gh api repos/{}/commits/HEAD/check-runs lists the project's check names",
277                crate::config::CONFIG_PATH,
278                ctx.repo
279            ))
280            .step("protect-trunk"),
281        ));
282    }
283    Ok(())
284}
285
286/// `rk setup --list`: the ordered steps, what each proves, and which needs
287/// input on which forge — visible before a first apply rather than
288/// discovered by one.
289fn list(forge: Option<&str>) -> Result<(), RkError> {
290    let forge = forge
291        .map(|name| {
292            Forge::parse(name).ok_or_else(|| {
293                RkError::Usage(format!(
294                    "unknown forge '{name}'; the forges are: github, gitlab"
295                ))
296            })
297        })
298        .transpose()?;
299    let out = Output::human();
300    for (idx, step) in STEPS.iter().enumerate() {
301        let mut line = format!(
302            "{:2}. {} [{}] proves: {}",
303            idx + 1,
304            step.name,
305            step.chapter,
306            step.proves
307        );
308        if step.name == "protect-trunk" && forge != Some(Forge::Gitlab) {
309            line.push_str(" (needs --required-check on github)");
310        }
311        if step.destructive {
312            line.push_str(" (destructive)");
313        }
314        if step.optional {
315            line.push_str(" (optional; a full apply skips it)");
316        }
317        out.result_line(line);
318    }
319    out.next(&[
320        "rk setup --target . previews every step".to_owned(),
321        "rk setup script <name> prints one embedded script".to_owned(),
322    ]);
323    Ok(())
324}
325
326/// `rk setup script <name>`: the audit escape hatch, printed byte-identical
327/// to the embedded file.
328fn script(name: &str, forge: Option<&str>) -> Result<(), RkError> {
329    if name == "package-check" {
330        return Err(RkError::Usage(
331            "package-check reads its command from the technology binding and has no script".into(),
332        ));
333    }
334    if name == "branch-reminder" {
335        return Err(RkError::Usage(
336            "branch-reminder writes an embedded hook body and has no script; rk setup step branch-reminder previews the write".into(),
337        ));
338    }
339    if name == "forge-version" {
340        return Err(RkError::Usage(
341            "forge-version reads the forge's own version and has no script; rk setup step forge-version previews the read".into(),
342        ));
343    }
344    let forge = match forge {
345        Some(value) => Forge::parse(value).ok_or_else(|| {
346            RkError::Usage(format!(
347                "unknown forge '{value}'; the forges are: github, gitlab"
348            ))
349        })?,
350        None => Forge::Github,
351    };
352    let path = format!("{}/{name}", forge.as_str());
353    let file = embedded::SETUP.get_file(&path).ok_or(RkError::NotFound {
354        kind: "setup step",
355        name: name.to_owned(),
356    })?;
357    Output::human().result_raw(&String::from_utf8_lossy(file.contents()));
358    Ok(())
359}
360
361/// The shared run state: the boundary, the resolved context, the journal,
362/// and the event stream.
363struct Engine {
364    out: Output,
365    ctx: Ctx,
366    journal: Option<Journal>,
367    secrets: Vec<Zeroizing<Vec<u8>>>,
368    /// The run's one read of the named key file; see [`key_file_for`].
369    key: Option<secrets::KeyFile>,
370    /// The run's App JWT, minted at most once; see [`app_jwt_for`].
371    app_jwt: Option<String>,
372    /// Whether an unreachable key file is an observation boundary rather
373    /// than a refusal: true for a read-only check alone.
374    key_boundary: bool,
375    /// Why the named key file was unreachable, once a check found it so.
376    key_unavailable: Option<String>,
377    seq: u64,
378    command: &'static str,
379    run_id: String,
380    /// The run's snapshot directory, opened by the first step that saves
381    /// a before-state.
382    snapshot: Option<crate::snapshot::Snapshot>,
383    /// Whether the run names one step, as `rk setup step` does: a request
384    /// for that step's action, which a full run does not make.
385    named: bool,
386}
387
388impl Engine {
389    /// Open the run: journal first, before any remote mutation. An apply
390    /// that cannot create its journal refuses; observability-only modes
391    /// warn and continue, because refusing them over observability is
392    /// self-defeating.
393    fn open(
394        out: Output,
395        ctx: Ctx,
396        command: &'static str,
397        journal_required: bool,
398    ) -> Result<Self, RkError> {
399        // A stale key export is refused wherever a run opens, so every
400        // mode catches it and not the one step that would have used it.
401        secrets::refuse_legacy_key()?;
402        let journal = match Journal::create(
403            command,
404            ctx.target.as_str(),
405            ctx.forge.map_or("none", Forge::as_str),
406            &ctx.repo,
407        ) {
408            Ok(journal) => Some(journal),
409            Err(source) if journal_required => {
410                return Err(RkError::refusal(
411                    Diagnostic::new(
412                        Reason::JournalUnavailable,
413                        format!("the run journal cannot be created: {source}"),
414                    )
415                    .expected("a writable state root for the journal")
416                    .target_state("nothing was run and nothing changed"),
417                ));
418            }
419            Err(source) => {
420                out.warn(format!("no run journal for this run: {source}"));
421                None
422            }
423        };
424        let run_id = journal
425            .as_ref()
426            .map_or_else(|| "unjournaled".to_owned(), |j| j.run_id().to_owned());
427        let mut engine = Self {
428            out,
429            ctx,
430            journal,
431            secrets: Ctx::secret_values(),
432            key: None,
433            app_jwt: None,
434            key_boundary: false,
435            key_unavailable: None,
436            seq: 0,
437            command,
438            run_id,
439            snapshot: None,
440            named: false,
441        };
442        let opening = Event::opening(
443            engine.next_seq(),
444            crate::applog::now_utc(),
445            engine.run_id.clone(),
446            engine.command,
447        );
448        engine.emit(&opening);
449        if engine.ctx.self_hosted_gitlab() {
450            engine.out.warn(
451                "this remote is a self-hosted GitLab: registry trusted publishing covers GitLab.com only, so the OIDC invariant cannot be satisfied here",
452            );
453        }
454        Ok(engine)
455    }
456
457    const fn next_seq(&mut self) -> u64 {
458        let seq = self.seq;
459        self.seq += 1;
460        seq
461    }
462
463    fn event(&mut self, kind: EventKind, step: Option<&str>) -> Event {
464        let mut event = Event::opening(
465            self.next_seq(),
466            crate::applog::now_utc(),
467            self.run_id.clone(),
468            self.command,
469        );
470        event.kind = kind;
471        event.step = step.map(str::to_owned);
472        event
473    }
474
475    fn emit(&mut self, event: &Event) {
476        self.out.event(event);
477        if let Some(journal) = &mut self.journal
478            && let Ok(line) = serde_json::to_string(event)
479        {
480            journal.event_line(&line);
481        }
482    }
483
484    /// Run one external command: echo it, stream and redact its output,
485    /// journal everything, surface its exit.
486    fn exec(&mut self, exec: &Exec, passthrough: bool) -> Result<Outcome, RkError> {
487        let echo = exec.echo();
488        self.out.frame(&echo);
489        if let Some(journal) = &mut self.journal {
490            journal.transcript(echo.as_bytes());
491            journal.transcript(b"\n");
492        }
493        let secrets = std::mem::take(&mut self.secrets);
494        let step_name: Option<String> = None;
495        let mut chunks: Vec<(ChildStream, Vec<u8>)> = Vec::new();
496        let spawned = process::run(exec, |stream, chunk| {
497            chunks.push((stream, process::redact(chunk, &secrets)));
498        });
499        self.secrets = secrets;
500        for (stream, chunk) in chunks {
501            if passthrough {
502                self.out.child_passthrough(stream, &chunk);
503            }
504            let event = self.event(EventKind::ChildOutput, step_name.as_deref());
505            let event = event.child_output(stream, &chunk);
506            self.emit(&event);
507            if let Some(journal) = &mut self.journal {
508                journal.transcript(&chunk);
509            }
510        }
511        spawned.map_err(|source| {
512            RkError::refusal(
513                Diagnostic::new(
514                    Reason::SubprocessSpawn,
515                    format!("{} did not spawn: {source}", exec.program.to_string_lossy()),
516                )
517                .expected("a POSIX sh and the forge CLI on PATH")
518                .run(self.run_path()),
519            )
520        })
521    }
522
523    fn run_path(&self) -> String {
524        self.journal.as_ref().map_or_else(
525            || "no journal was written".to_owned(),
526            |j| j.dir.display().to_string(),
527        )
528    }
529
530    fn finish(&mut self, exit_code: i32, reason: Option<&str>) {
531        let mut event = self.event(EventKind::RunFinished, None);
532        event.exit_code = Some(exit_code);
533        event.status = Some(if exit_code == 0 {
534            "ok".into()
535        } else {
536            "failed".into()
537        });
538        self.emit(&event);
539        if let Some(journal) = &mut self.journal {
540            journal.finish(exit_code, reason);
541        }
542    }
543}
544
545/// Attach the failure to its run journal and close the run.
546fn fail(engine: &mut Engine, error: RkError) -> RkError {
547    let error = match error {
548        RkError::Refusal(mut diagnostic) => {
549            diagnostic.run.get_or_insert_with(|| engine.run_path());
550            RkError::Refusal(diagnostic)
551        }
552        RkError::Subprocess(mut diagnostic) => {
553            diagnostic.run.get_or_insert_with(|| engine.run_path());
554            RkError::Subprocess(diagnostic)
555        }
556        RkError::CheckFailed(mut diagnostic) => {
557            diagnostic.run.get_or_insert_with(|| engine.run_path());
558            RkError::CheckFailed(diagnostic)
559        }
560        other => other,
561    };
562    engine.finish(i32::from(error.exit_code()), Some(error.reason().as_str()));
563    error
564}
565
566/// Preview: walk the ordered steps and print, for each, the step name, what
567/// it proves, and the resolved invocation — without materializing anything
568/// and without invoking any external command. What preview shows is what
569/// apply would run, because both read the same step table and environment
570/// construction.
571fn preview(out: Output, ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
572    let mut engine = Engine::open(out, clone_ctx(ctx), "setup preview", false)?;
573    out.result_line(format!(
574        "DRY RUN: rk setup would run these steps against {} on {}; re-run with --apply",
575        engine.ctx.repo,
576        engine.ctx.forge.map_or("no forge", Forge::as_str)
577    ));
578    for (idx, step) in steps.iter().enumerate() {
579        out.result_line(format!(
580            "step {}/{} {} — proves {}",
581            idx + 1,
582            steps.len(),
583            step.name,
584            step.proves_for(ctx)
585        ));
586        let stance = stance(&engine.ctx, step);
587        if !stance.acts() {
588            out.result_line(format!("  {}", stance.framed()));
589            let mut event = engine.event(EventKind::StepFinished, Some(step.name));
590            event.status = Some(stance.word().into());
591            event.detail = Some(stance.detail());
592            engine.emit(&event);
593            continue;
594        }
595        // Preview is the rehearsal of apply, so a credential apply could
596        // not use is a preview failure: the operator learns it here rather
597        // than one flag later, and before an invocation is claimed.
598        if step.name == "bot-secrets" && engine.ctx.forge == Some(Forge::Github) {
599            secrets::resolve_key_file(&engine.ctx.target)?;
600        }
601        out.result_line(format!("  {}", render_invocation(&engine.ctx, step)));
602        // The name is asked for only where a rule consumes it. It feeds the
603        // required-status-checks rule alone, and local integration owns no
604        // such rule, because no forge can require a check before the push
605        // that starts it. Asking there would name a flag whose value reaches
606        // nothing the run installs.
607        if step.name == "protect-trunk"
608            && engine.ctx.forge == Some(Forge::Github)
609            && engine.ctx.required_check.is_none()
610            && engine
611                .ctx
612                .protection()
613                .owned_trunk_rules
614                .iter()
615                .any(|rule| rule == "required_status_checks")
616        {
617            out.result_line("  needs: --required-check <name> before apply");
618        }
619        if skipped_by_a_full_run(&engine.ctx, step, steps.len()) {
620            out.result_line(format!(
621                "  optional: a full apply skips it; set setup.release_lines, or rk setup step {} --apply runs it",
622                step.name
623            ));
624        }
625        let excluded = excluded_prerequisites(&engine.ctx, step);
626        for line in &excluded {
627            out.result_line(format!("  {line}"));
628        }
629        let mut event = engine.event(EventKind::StepFinished, Some(step.name));
630        event.status = Some("previewed".into());
631        event.detail = prerequisite_detail(&excluded);
632        engine.emit(&event);
633    }
634    let next = next_for_apply(&engine.ctx, steps);
635    out.next(&[
636        next,
637        "rk setup check --target . proves what is already true".to_owned(),
638    ]);
639    engine.finish(0, None);
640    Ok(())
641}
642
643/// The one line preview prints per step: the exact spawn shape, with every
644/// non-secret variable resolved.
645fn render_invocation(ctx: &Ctx, step: &StepSpec) -> String {
646    match step.name {
647        "branch-reminder" => {
648            "would write: the post-merge reminder hook at $(git rev-parse --git-path hooks)/post-merge".to_owned()
649        }
650        "package-check" => match ctx.tech {
651            Some("rust") if ctx.reporting_policy() => "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, asserting it carries SECURITY.md and none of release-kit's own files; another workspace shape reports both as unproved".to_owned(),
652            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, asserting it ships none of release-kit's own files; another workspace shape reports that as unproved".to_owned(),
653            Some("python") if ctx.reporting_policy() => "would run: uv build, then list the sdist and the wheel in dist/; sdist and wheel SECURITY.md inclusion stays unproved".to_owned(),
654            Some("python") => "would run: uv build, then list the sdist and the wheel in dist/".to_owned(),
655            Some("bash") => ctx
656                .profile
657                .release
658                .build
659                .as_ref()
660                .and_then(|build| build.verify.as_deref())
661                .map_or_else(
662                    || "would judge nothing: profile.release.build.verify names no command, and the package is not listed".to_owned(),
663                    |command| format!(
664                        "would judge the verify command {command}, from profile.release.build.verify: a make command needs a makefile and runs make -n; the package is not listed"
665                    ),
666                ),
667            _ => "needs: a version file naming the technology".to_owned(),
668        },
669        "forge-version" => {
670            let (major, minor) = observe::GITLAB_VERSION_FLOOR;
671            match ctx.forge {
672                Some(Forge::Github) => {
673                    "nothing to read: github.com is a rolling service and declares no version floor"
674                        .to_owned()
675                }
676                Some(Forge::Gitlab) => format!(
677                    "would read: GET /version, and compare it against the {major}.{minor} floor; nothing is written"
678                ),
679                None => "nothing to read: the profile names no forge".to_owned(),
680            }
681        }
682        name => {
683            let check = ctx
684                .required_check
685                .as_ref()
686                .filter(|_| ctx.forge == Some(Forge::Github) && name == "protect-trunk")
687                .map(|value| format!(" RK_REQUIRED_CHECK={value}"))
688                .unwrap_or_default();
689            // The ruleset a protection step installs is the one
690            // per-target fact the operator most needs to see before an
691            // apply, because a renamed ruleset leaves the old one standing.
692            let ruleset = match name {
693                "protect-trunk" => format!(
694                    " RK_TRUNK_RULESET={} RK_SAFETY_RULESET={} RK_TITLE_CHECK={}",
695                    ctx.trunk_ruleset(),
696                    ctx.safety_ruleset(),
697                    ctx.title_check()
698                ),
699                "protect-tags" => format!(" RK_TAG_RULESET={}", ctx.tag_ruleset()),
700                "protect-release-lines" => format!(" RK_LINES_RULESET={}", ctx.lines_ruleset()),
701                _ => String::new(),
702            };
703            format!(
704                "would run: sh <embedded setup/{}/{name}> with RK_REPO={} RK_TRUNK_BRANCH={}{ruleset}{check}",
705                ctx.forge.map_or("<no forge>", Forge::as_str),
706                ctx.repo,
707                ctx.trunk()
708            )
709        }
710    }
711}
712
713fn next_for_apply(ctx: &Ctx, steps: &[&StepSpec]) -> String {
714    let check = ctx
715        .required_check
716        .as_ref()
717        .map(|value| format!(" --required-check {value}"))
718        .unwrap_or_default();
719    if steps.len() == 1 {
720        format!(
721            "rk setup step {} --target {} --apply{check}",
722            steps[0].name, ctx.target
723        )
724    } else {
725        format!("rk setup --target {} --apply{check}", ctx.target)
726    }
727}
728
729/// A `Ctx` copy for engine ownership; the context is plain data.
730fn clone_ctx(ctx: &Ctx) -> Ctx {
731    ctx.clone()
732}
733
734/// Apply: run the selected steps in order, each through the full lifecycle.
735fn execute(
736    out: Output,
737    ctx: Ctx,
738    steps: &[&StepSpec],
739    command: &'static str,
740) -> Result<(), RkError> {
741    guard_sh()?;
742    let mut engine = Engine::open(out, ctx, command, true)?;
743    engine.named = steps.len() == 1;
744    let mut done: Vec<(String, String)> = Vec::new();
745    for (idx, step) in steps.iter().enumerate() {
746        // A declared exclusion states the skip and runs nothing. A single
747        // step named on the command line never arrives here: that form
748        // refuses before the run opens.
749        let stance = stance(&engine.ctx, step);
750        if !stance.acts() {
751            engine.out.frame(format!(
752                "step {}/{} {} — {}",
753                idx + 1,
754                steps.len(),
755                step.name,
756                stance.framed()
757            ));
758            let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
759            finished.status = Some(stance.word().into());
760            finished.detail = Some(stance.detail());
761            engine.emit(&finished);
762            done.push((step.name.to_owned(), stance.word().to_owned()));
763            continue;
764        }
765        // An optional step applies only by name: a full run states the skip
766        // rather than acting on a condition the operator never asserted.
767        if skipped_by_a_full_run(&engine.ctx, step, steps.len()) {
768            engine.out.frame(format!(
769                "step {}/{} {} — skipped (optional; set setup.release_lines, or rk setup step {} --apply runs it)",
770                idx + 1,
771                steps.len(),
772                step.name,
773                step.name
774            ));
775            let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
776            finished.status = Some("skipped".into());
777            engine.emit(&finished);
778            done.push((step.name.to_owned(), "skipped".to_owned()));
779            continue;
780        }
781        engine.out.frame(format!(
782            "step {}/{} {} — {}",
783            idx + 1,
784            steps.len(),
785            step.name,
786            step.proves_for(&engine.ctx)
787        ));
788        let excluded = excluded_prerequisites(&engine.ctx, step);
789        for line in &excluded {
790            engine.out.frame(format!("  {line}"));
791        }
792        let mut started = engine.event(EventKind::StepStarted, Some(step.name));
793        started.status = Some("running".into());
794        started.detail = prerequisite_detail(&excluded);
795        engine.emit(&started);
796        let clock = Instant::now();
797        let status = match apply_step(&mut engine, step) {
798            Ok(status) => status,
799            Err(error) => {
800                let error = attach_progress(error, &done, step, steps);
801                let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
802                finished.status = Some("failed".into());
803                finished.reason = Some(error.reason());
804                finished.duration_ms = Some(elapsed_ms(clock));
805                engine.emit(&finished);
806                return Err(fail(&mut engine, error));
807            }
808        };
809        engine.out.frame(format!(
810            "{} {}: {}",
811            if matches!(status, Done::Skipped(_)) {
812                "skipped"
813            } else {
814                "ok"
815            },
816            step.name,
817            status.line()
818        ));
819        let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
820        finished.status = Some(status.wire().into());
821        finished.detail = Some(status.line());
822        finished.exit_code = Some(0);
823        finished.duration_ms = Some(elapsed_ms(clock));
824        engine.emit(&finished);
825        done.push((step.name.to_owned(), status.wire().to_owned()));
826    }
827    engine.out.result_line(format!(
828        "setup: {} completed against {}",
829        step_count(done.len()),
830        engine.ctx.repo
831    ));
832    for (name, status) in &done {
833        engine.out.result_line(format!("  {status} {name}"));
834    }
835    engine.out.next(&[
836        format!("rk setup check --target {}", engine.ctx.target),
837        "rk guide setup orders what no command performs".to_owned(),
838    ]);
839    engine.finish(0, None);
840    Ok(())
841}
842
843/// A step count rendered with the noun that agrees with it, so no summary
844/// line can regrow a dangling plural.
845fn step_count(count: usize) -> String {
846    format!("{count} {}", if count == 1 { "step" } else { "steps" })
847}
848
849fn elapsed_ms(clock: Instant) -> u64 {
850    u64::try_from(clock.elapsed().as_millis()).unwrap_or(u64::MAX)
851}
852
853/// What one applied step reported.
854enum Done {
855    /// The desired state already held; nothing ran.
856    Satisfied(String),
857    /// The target is ineligible for this step.
858    Skipped(String),
859    /// The script ran and the postcondition was read back.
860    Changed(String, Option<String>),
861    /// A read-only step ran and passed.
862    Passed(String),
863}
864
865impl Done {
866    const fn wire(&self) -> &'static str {
867        match self {
868            Self::Satisfied(_) => "satisfied",
869            Self::Skipped(_) => "skipped",
870            Self::Changed(..) => "applied",
871            Self::Passed(_) => "passed",
872        }
873    }
874
875    fn line(&self) -> String {
876        match self {
877            Self::Satisfied(detail) | Self::Passed(detail) | Self::Skipped(detail) => {
878                detail.clone()
879            }
880            Self::Changed(detail, limitation) => limitation.as_ref().map_or_else(
881                || detail.clone(),
882                |limit| format!("{detail} (limitation: {limit})"),
883            ),
884        }
885    }
886}
887
888/// What the prerequisite gate does with one prerequisite at this target.
889///
890/// SATISFIES forge-setup:the-setup-refuses-a-forge-below-the-floor
891#[derive(Debug, PartialEq, Eq)]
892enum PrereqGate {
893    /// The prerequisite is not part of this target's setup: the gate
894    /// passes and says nothing.
895    Silent,
896    /// The target excluded the prerequisite, with its stated reason: the
897    /// gate passes and the dependent step reports the exclusion.
898    Excluded(String),
899    /// The prerequisite applies: observe it, and refuse on anything but
900    /// satisfied.
901    Observe,
902}
903
904/// The gate's answer for one prerequisite's stance, read from the
905/// committed declaration and never from the forge.
906fn prereq_gate(stance: Stance) -> PrereqGate {
907    match stance {
908        Stance::Applies => PrereqGate::Observe,
909        Stance::NotApplicable(_) => PrereqGate::Silent,
910        Stance::Excluded(reason) | Stance::Redundant { reason, .. } => PrereqGate::Excluded(reason),
911    }
912}
913
914/// The lines a dependent step carries for each prerequisite the target
915/// excluded, in the order the step names them.
916fn excluded_prerequisites(ctx: &Ctx, step: &StepSpec) -> Vec<String> {
917    step.prereqs
918        .iter()
919        .filter_map(|prereq| {
920            let held = spec(prereq)?;
921            match prereq_gate(stance(ctx, held)) {
922                PrereqGate::Excluded(reason) => {
923                    Some(format!("prerequisite {prereq} excluded: {reason}"))
924                }
925                PrereqGate::Silent | PrereqGate::Observe => None,
926            }
927        })
928        .collect()
929}
930
931/// The event detail that carries a step's excluded prerequisites under
932/// `--json`, or none where the step has none.
933fn prerequisite_detail(lines: &[String]) -> Option<String> {
934    (!lines.is_empty()).then(|| lines.join("; "))
935}
936
937/// One step, full lifecycle.
938#[allow(
939    clippy::too_many_lines,
940    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"
941)]
942fn apply_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
943    // Prerequisites are observed, not remembered: the forge is the
944    // authority on whether an earlier step's state holds. A prerequisite
945    // the target excluded or does not run is not observed: the committed
946    // declaration is the answer, and the run reports it beside the step.
947    for prereq in step.prereqs {
948        let gate = spec(prereq).map_or(PrereqGate::Observe, |held| {
949            prereq_gate(stance(&engine.ctx, held))
950        });
951        if gate != PrereqGate::Observe {
952            continue;
953        }
954        let state = observe_with(engine, prereq)?;
955        if !state.satisfied() {
956            return Err(RkError::refusal(
957                Diagnostic::new(
958                    Reason::PrerequisiteUnmet,
959                    format!(
960                        "{} requires {prereq} first: {}",
961                        step.name,
962                        state_detail(&state)
963                    ),
964                )
965                .expected(format!("{prereq} satisfied before {}", step.name))
966                .action(format!(
967                    "rk setup step {prereq} --target {} --apply",
968                    engine.ctx.target
969                ))
970                .step(step.name),
971            ));
972        }
973    }
974    match step.name {
975        "package-check" => {
976            if engine.ctx.tech.is_none() {
977                return Err(RkError::Usage(
978                    "no version file names a technology; rk binding --list names the bindings"
979                        .into(),
980                ));
981            }
982            let state = observe_with(engine, "package-check")?;
983            match state {
984                StepState::Satisfied { detail, .. } => Ok(Done::Passed(detail)),
985                StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
986                    Err(RkError::subprocess(
987                        Diagnostic::new(
988                            Reason::SubprocessFailed,
989                            format!("package-check failed: {detail}"),
990                        )
991                        .expected(step.proves_for(&engine.ctx).to_owned())
992                        .step(step.name),
993                    ))
994                }
995                StepState::Unknown { detail } => Err(RkError::subprocess(
996                    Diagnostic::new(
997                        Reason::SubprocessFailed,
998                        format!("package-check could not run: {detail}"),
999                    )
1000                    .step(step.name),
1001                )),
1002            }
1003        }
1004        // The step mutates nothing, so apply and check read the same answer
1005        // and apply writes nothing at all. An unreadable version is a
1006        // refusal, never a pass: the floor exists to stop a protection the
1007        // forge cannot honor, and a floor nobody could read proves neither
1008        // way.
1009        "forge-version" => match observe_with(engine, "forge-version")? {
1010            StepState::Satisfied { detail, .. } => Ok(Done::Satisfied(detail)),
1011            StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1012                Err(RkError::refusal(
1013                    Diagnostic::new(Reason::PrerequisiteUnmet, detail)
1014                        .expected(step.proves_for(&engine.ctx).to_owned())
1015                        .action(if engine.ctx.on_gitlab_com() {
1016                            "rerun after the next gitlab.com deployment"
1017                        } else {
1018                            "upgrade the instance, or host the project on gitlab.com"
1019                        })
1020                        .target_state("unchanged")
1021                        .step(step.name),
1022                ))
1023            }
1024            StepState::Unknown { detail } => Err(RkError::refusal(
1025                Diagnostic::new(Reason::ForgeTemporary, detail)
1026                    .expected("a readable forge version")
1027                    .action("glab auth login, then rerun")
1028                    .target_state("unchanged")
1029                    .step(step.name),
1030            )),
1031        },
1032        "branch-reminder" => {
1033            use crate::setup::branch_reminder::{HookState, hook_body, hook_path, observe_hook};
1034            match observe_hook(&engine.ctx.target) {
1035                HookState::Installed => Ok(Done::Satisfied(
1036                    "the post-merge reminder hook is installed".into(),
1037                )),
1038                HookState::Foreign => Err(RkError::refusal(
1039                    Diagnostic::new(
1040                        Reason::StateDrift,
1041                        "a foreign post-merge hook exists; the reminder is never written over it",
1042                    )
1043                    .expected("no post-merge hook, or one carrying the release-kit marker")
1044                    .action(
1045                        "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`",
1046                    )
1047                    .target_state("unchanged")
1048                    .step(step.name),
1049                )),
1050                HookState::Unreadable(detail) => Err(RkError::refusal(
1051                    Diagnostic::new(
1052                        Reason::StateDrift,
1053                        format!("the post-merge hook cannot be read: {detail}"),
1054                    )
1055                    .target_state("unchanged")
1056                    .step(step.name),
1057                )),
1058                HookState::Absent | HookState::Drifted => {
1059                    let path = hook_path(&engine.ctx.target).map_err(|detail| {
1060                        RkError::refusal(
1061                            Diagnostic::new(
1062                                Reason::PrerequisiteUnmet,
1063                                format!("the hooks directory cannot be resolved: {detail}"),
1064                            )
1065                            .expected("a git repository whose hooks directory git can name")
1066                            .step(step.name),
1067                        )
1068                    })?;
1069                    crate::atomic::write(&path, hook_body())?;
1070                    #[cfg(unix)]
1071                    {
1072                        use std::os::unix::fs::PermissionsExt as _;
1073                        std::fs::set_permissions(
1074                            &path,
1075                            std::fs::Permissions::from_mode(0o755),
1076                        )?;
1077                    }
1078                    Ok(Done::Changed(
1079                        "wrote the post-merge reminder hook".into(),
1080                        None,
1081                    ))
1082                }
1083            }
1084        }
1085        "single-trunk" => {
1086            let guard = {
1087                let ctx = clone_ctx(&engine.ctx);
1088                let mut runner = |exec: &Exec| engine.exec(exec, false);
1089                observe::single_trunk_guard(&ctx, &mut runner)?
1090            };
1091            // A destructive step fails closed: an ancestry the guard cannot
1092            // establish is treated exactly like one it refuted.
1093            match &guard {
1094                StepState::Satisfied { .. } => {}
1095                StepState::Unsatisfied { detail }
1096                | StepState::Inapplicable { detail }
1097                | StepState::Unknown { detail } => {
1098                    return Err(RkError::refusal(
1099                        Diagnostic::new(
1100                            Reason::DestructiveRefusal,
1101                            format!("single-trunk refuses: {detail}"),
1102                        )
1103                        .expected(
1104                            "proof that every candidate branch is absent, or an ancestor of the trunk",
1105                        )
1106                        .step(step.name),
1107                    ));
1108                }
1109            }
1110            run_forge_step(engine, step)
1111        }
1112        "bot-secrets" => {
1113            // Validate before observing: a wrong path or a wrong mode is
1114            // the operator's answer either way, and the refusal costs no
1115            // forge call. Only GitHub reads a key file; GitLab's credential
1116            // is a token, and its step must not fail over a variable it
1117            // never consumes.
1118            let adapter = engine.ctx.adapter()?;
1119            let key = match adapter {
1120                // The run's one read: an install-bot observation earlier in
1121                // this run already holds the bytes, and this step stores
1122                // those very bytes rather than reopening the path.
1123                Forge::Github => key_file_for(engine)?.map(|key| key.bytes.clone()),
1124                Forge::Gitlab => None,
1125            };
1126            let provided = match adapter {
1127                // Both halves of an App identity, or neither: a run holding
1128                // only one of them would store half a credential.
1129                Forge::Github => secrets::value_of("RK_BOT_APP_ID").is_some() && key.is_some(),
1130                Forge::Gitlab => secrets::value_of("RK_BOT_TOKEN").is_some(),
1131            };
1132            let state = observe_with(engine, step.name)?;
1133            // On gitlab.com a Free group's bot is a service account, and
1134            // the script mints a fresh token on it where the stored value
1135            // is missing, or wherever the run names this step, which is the
1136            // rotation request; a full run leaves a stored token alone. The
1137            // script reads the tier itself, and refuses on the token path,
1138            // whose value only install-bot can create.
1139            let mints = adapter == Forge::Gitlab && engine.ctx.on_gitlab_com();
1140            let rotates = if mints && engine.named && state.satisfied() && !provided {
1141                let ctx = clone_ctx(&engine.ctx);
1142                let mut runner = |exec: &Exec| engine.exec(exec, false);
1143                match observe::gitlab_bot_is_a_service_account(&ctx, &mut runner)? {
1144                    Ok(service_account) => service_account,
1145                    // A rotation asked for by name whose form is unknown is
1146                    // neither done nor satisfied: it stops before any write.
1147                    Err(detail) => {
1148                        return Err(RkError::refusal(
1149                            Diagnostic::new(
1150                                Reason::ObservationIncomplete,
1151                                format!("bot-secrets cannot tell which bot identity to rotate: {detail}"),
1152                            )
1153                            .expected("the namespace's tier, read from gitlab.com")
1154                            .action("check that glab is authenticated against gitlab.com and reads the namespace, then rerun rk setup step bot-secrets --apply".to_owned())
1155                            .step(step.name),
1156                        ));
1157                    }
1158                }
1159            } else {
1160                false
1161            };
1162            let runs = provided || rotates || (mints && !state.satisfied());
1163            if !runs {
1164                if state.satisfied() {
1165                    return Ok(Done::Satisfied(state_detail(&state)));
1166                }
1167                let wanted = match adapter {
1168                    Forge::Github => {
1169                        "export RK_BOT_APP_ID and RK_BOT_PRIVATE_KEY_FILE, the second naming the .pem; rk forge github carries the walkthrough"
1170                    }
1171                    Forge::Gitlab => {
1172                        "rk setup step install-bot --apply stores the token, or export RK_BOT_TOKEN to rotate one"
1173                    }
1174                };
1175                return Err(RkError::refusal(
1176                    Diagnostic::new(
1177                        Reason::PrerequisiteUnmet,
1178                        "bot-secrets has no credentials to store",
1179                    )
1180                    .expected("the bot credentials in the environment, the key as a path")
1181                    .action(wanted.to_owned())
1182                    .step(step.name),
1183                ));
1184            }
1185            if let Some(journal) = &mut engine.journal {
1186                for name in SECRET_VARS {
1187                    if secrets::value_of(name).is_some() {
1188                        journal.record_secret(name, true, "environment");
1189                    }
1190                }
1191                if key.is_some() {
1192                    journal.record_secret(secrets::PRIVATE_KEY_FILE, true, "file");
1193                }
1194            }
1195            // The bytes rk validated are the bytes the child receives and
1196            // the bytes the redactor holds: one read, one value, so nothing
1197            // can be substituted between the check and the forge.
1198            let stdin = key;
1199            run_forge_step_with(engine, step, stdin, Vec::new())
1200        }
1201        "protections-check" => {
1202            let (outcome, _) = run_script(engine, step)?;
1203            if !outcome.success() {
1204                return Err(classify_failure(engine, step, &outcome));
1205            }
1206            // The script is the operator-auditable mirror; the observation
1207            // is the authoritative shape check, so the step passes only
1208            // when both agree.
1209            match observe_with(engine, step.name)? {
1210                StepState::Satisfied { detail, limitation } => {
1211                    Ok(Done::Passed(limitation.map_or_else(
1212                        || detail.clone(),
1213                        |limit| format!("{detail} (limitation: {limit})"),
1214                    )))
1215                }
1216                StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1217                    Err(RkError::refusal(
1218                        Diagnostic::new(
1219                            Reason::StateDrift,
1220                            format!("protections-check passed its script and the observation disagrees: {detail}"),
1221                        )
1222                        .expected(step.proves_for(&engine.ctx).to_owned())
1223                        .step(step.name),
1224                    ))
1225                }
1226                // An unreadable readback is a retryable outage, not drift,
1227                // exactly as the postcondition lifecycle classifies it.
1228                StepState::Unknown { detail } => Err(RkError::refusal(
1229                    Diagnostic::new(
1230                        Reason::ForgeTemporary,
1231                        format!(
1232                            "protections-check passed its script and the readback could not confirm it: {detail}"
1233                        ),
1234                    )
1235                    .expected(step.proves_for(&engine.ctx).to_owned())
1236                    .action("check authentication and connectivity, then rerun")
1237                    .step(step.name),
1238                )),
1239            }
1240        }
1241        // GitHub's grant is the one write the forge offers a command, and
1242        // it takes a user credential; everything else here — the pre- and
1243        // post-observation, and the installation id the script is handed —
1244        // happens as the App itself. GitLab's install-bot needs none of
1245        // this and takes the generic lifecycle below.
1246        "install-bot" if engine.ctx.forge == Some(Forge::Github) => {
1247            match observe_with(engine, step.name)? {
1248                StepState::Satisfied { detail, .. } => {
1249                    return Ok(Done::Satisfied(detail));
1250                }
1251                StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
1252                StepState::Unknown { detail } => {
1253                    return Err(RkError::refusal(
1254                        Diagnostic::new(
1255                            Reason::ForgeTemporary,
1256                            format!("{} cannot observe the current state: {detail}", step.name),
1257                        )
1258                        .expected("a readable forge answer before anything mutates")
1259                        .action("check the App credentials and connectivity, then rerun")
1260                        .step(step.name),
1261                    ));
1262                }
1263            }
1264            let installation = github_installation_id(engine, step)?;
1265            run_forge_step_with(
1266                engine,
1267                step,
1268                None,
1269                vec![("RK_BOT_INSTALLATION".into(), installation.into())],
1270            )
1271        }
1272        _ => {
1273            if step.mutates == Mutates::Forge {
1274                // The lifecycle applies only on a state it has read: an
1275                // observation that cannot decide fails closed here exactly
1276                // as it does after the write, so no mutation ever rides on
1277                // an unreadable forge answer.
1278                match observe_with(engine, step.name)? {
1279                    StepState::Satisfied { detail, limitation } => {
1280                        let detail = if step.name == "private-vulnerability-reporting" {
1281                            limitation.map_or_else(
1282                                || detail.clone(),
1283                                |limit| format!("{detail} (limitation: {limit})"),
1284                            )
1285                        } else {
1286                            detail
1287                        };
1288                        return Ok(Done::Satisfied(detail));
1289                    }
1290                    StepState::Inapplicable { detail }
1291                        if step.name == "private-vulnerability-reporting" =>
1292                    {
1293                        return Ok(Done::Skipped(detail));
1294                    }
1295                    StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
1296                    StepState::Unknown { detail } => {
1297                        return Err(RkError::refusal(
1298                            Diagnostic::new(
1299                                Reason::ForgeTemporary,
1300                                format!("{} cannot observe the current state: {detail}", step.name),
1301                            )
1302                            .expected("a readable forge answer before anything mutates")
1303                            .action("check authentication and connectivity, then rerun")
1304                            .step(step.name),
1305                        ));
1306                    }
1307                }
1308            }
1309            run_forge_step(engine, step)
1310        }
1311    }
1312}
1313
1314/// The id of the App's installation on this repository's owner, read as
1315/// the App itself. The grant needs it, and no user credential can read
1316/// it: the observation that just ran answered 404, so the repository
1317/// endpoint that names the id directly has nothing to say yet. The
1318/// account-level installation is a direct read for either account kind —
1319/// the user endpoint answers for a person, the organization endpoint for
1320/// an organization — so nothing here lists or paginates.
1321fn github_installation_id(engine: &mut Engine, step: &StepSpec) -> Result<String, RkError> {
1322    let refuse = |message: String, action: &str| {
1323        RkError::refusal(
1324            Diagnostic::new(Reason::PrerequisiteUnmet, message)
1325                .expected("the App installed on the repository's owner")
1326                .action(action.to_owned())
1327                .step(step.name),
1328        )
1329    };
1330    let jwt = match app_jwt_for(engine)? {
1331        Ok(jwt) => jwt,
1332        Err(detail) => {
1333            return Err(refuse(
1334                format!("install-bot has no App token: {detail}"),
1335                app_jwt::REMEDIATION,
1336            ));
1337        }
1338    };
1339    let owner = engine
1340        .ctx
1341        .repo
1342        .split('/')
1343        .next()
1344        .unwrap_or_default()
1345        .to_owned();
1346    let ctx = clone_ctx(&engine.ctx);
1347    for path in [
1348        format!("users/{owner}/installation"),
1349        format!("orgs/{owner}/installation"),
1350    ] {
1351        match app_jwt::api_get(&ctx, &jwt, &path) {
1352            AppApi::Ok(body) => {
1353                return body["id"].as_i64().map(|id| id.to_string()).ok_or_else(|| {
1354                    refuse(
1355                        format!("the forge answered {path} without an installation id"),
1356                        "check RK_BOT_APP_ID and the key file name the same App",
1357                    )
1358                });
1359            }
1360            AppApi::Missing => {}
1361            AppApi::Refused(detail) => {
1362                return Err(refuse(
1363                    detail,
1364                    "check RK_BOT_APP_ID and the key file name the same App",
1365                ));
1366            }
1367            AppApi::Failed(detail) => {
1368                return Err(RkError::refusal(
1369                    Diagnostic::new(
1370                        Reason::ForgeTemporary,
1371                        format!("install-bot cannot read the App's installation: {detail}"),
1372                    )
1373                    .action("check connectivity, then rerun")
1374                    .step(step.name),
1375                ));
1376            }
1377        }
1378    }
1379    Err(refuse(
1380        format!("the App has no installation on {owner}"),
1381        "install the App on the account first; the setup guide's step 5 walks it",
1382    ))
1383}
1384
1385/// Materialize, spawn, classify, and verify one forge-mutating step.
1386fn run_forge_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
1387    run_forge_step_with(engine, step, None, Vec::new())
1388}
1389
1390/// The same, with bytes written to the step's standard input and values
1391/// `rk` derived added to its environment.
1392fn run_forge_step_with(
1393    engine: &mut Engine,
1394    step: &StepSpec,
1395    stdin: Option<Zeroizing<Vec<u8>>>,
1396    extra_env: Vec<(OsString, OsString)>,
1397) -> Result<Done, RkError> {
1398    if crate::setup::before_state::destructive(step.name) {
1399        save_before_state(engine, step)?;
1400    }
1401    let (outcome, _) = run_script_with(engine, step, stdin, extra_env)?;
1402    if !outcome.success() {
1403        return Err(classify_failure(engine, step, &outcome));
1404    }
1405    let state = observe_with(engine, step.name)?;
1406    match state {
1407        StepState::Satisfied { detail, limitation } => Ok(Done::Changed(detail, limitation)),
1408        StepState::Inapplicable { detail } if step.name == "private-vulnerability-reporting" => {
1409            Ok(Done::Skipped(detail))
1410        }
1411        StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1412            Err(RkError::refusal(
1413                Diagnostic::new(
1414                    Reason::StateDrift,
1415                    format!(
1416                        "{} ran and its postcondition does not hold: {detail}",
1417                        step.name
1418                    ),
1419                )
1420                .expected(step.proves_for(&engine.ctx).to_owned())
1421                .step(step.name),
1422            ))
1423        }
1424        // The lifecycle ends with a proven postcondition; a readback that
1425        // cannot run leaves the step unproven, and an unproven apply is a
1426        // failure a retry can cure, never a success.
1427        StepState::Unknown { detail } => Err(RkError::refusal(
1428            Diagnostic::new(
1429                Reason::ForgeTemporary,
1430                format!(
1431                    "{} ran and the readback could not confirm it: {detail}",
1432                    step.name
1433                ),
1434            )
1435            .expected(step.proves_for(&engine.ctx).to_owned())
1436            .action(format!(
1437                "rk setup step {} --target {} --apply re-asserts and re-proves it",
1438                step.name, engine.ctx.target
1439            ))
1440            .step(step.name),
1441        )),
1442    }
1443}
1444
1445/// Save what `step` is about to change, before its script runs, and name
1446/// the path in the human report and the event stream.
1447///
1448/// SATISFIES forge-setup:a-step-is-idempotent
1449fn save_before_state(engine: &mut Engine, step: &StepSpec) -> Result<(), RkError> {
1450    // During a migration the archive took the before-state of every
1451    // destructive step, on a host that could read the forge. The step runs
1452    // only where the registry holds that snapshot, and the archive's copy
1453    // stays the record of what the forge held before the migration.
1454    if let Some(held) = migration_snapshot(&engine.ctx.target, step.name)? {
1455        engine.out.result_line(format!(
1456            "the migration holds the before-state of {} at {held}",
1457            step.name
1458        ));
1459        let mut event = engine.event(EventKind::Snapshot, Some(step.name));
1460        event.detail = Some(held);
1461        engine.emit(&event);
1462        return Ok(());
1463    }
1464    let snapshot = if let Some(snapshot) = &engine.snapshot {
1465        snapshot.clone()
1466    } else {
1467        let canonical = std::fs::canonicalize(engine.ctx.target.as_std_path())?;
1468        let snapshot = crate::snapshot::Snapshot::begin(&canonical)?;
1469        engine.snapshot = Some(snapshot.clone());
1470        snapshot
1471    };
1472    let ctx = clone_ctx(&engine.ctx);
1473    let mut runner = |exec: &Exec| engine.exec(exec, false);
1474    let path = crate::setup::before_state::save(&ctx, step.name, &snapshot, &mut runner)?;
1475    let path = path.display().to_string();
1476    engine
1477        .out
1478        .result_line(format!("saved the before-state of {} to {path}", step.name));
1479    let mut event = engine.event(EventKind::Snapshot, Some(step.name));
1480    event.detail = Some(path);
1481    engine.emit(&event);
1482    Ok(())
1483}
1484
1485/// The registry id of a destructive step's forge snapshot.
1486#[must_use]
1487pub fn forge_snapshot_id(step: &str) -> String {
1488    format!("forge-{step}")
1489}
1490
1491/// Where an open migration holds the before-state of `step`, or `None`
1492/// where no migration is open.
1493///
1494/// # Errors
1495///
1496/// A `destructive-refusal` while a migration is open and its registry
1497/// holds no intact snapshot of the step, and a refusal where the workspace is not
1498/// on this host.
1499fn migration_snapshot(target: &camino::Utf8Path, step: &str) -> Result<Option<String>, RkError> {
1500    let Some(id) = crate::migrate::open_id(target)? else {
1501        return Ok(None);
1502    };
1503    let Some(workspace) = crate::migrate::open_workspace(target)? else {
1504        return Err(crate::migrate::none_open());
1505    };
1506    if !workspace.join(crate::migrate::REGISTRY_FILE).is_file() {
1507        return Err(crate::migrate::workspace_missing(&id, &workspace));
1508    }
1509    let registry = crate::migrate::registry::load(&workspace)?;
1510    let wanted = forge_snapshot_id(step);
1511    // A snapshot whose file is gone or altered restores nothing, so it
1512    // licenses nothing either; one that cannot be read stops the step.
1513    let found = registry
1514        .snapshots
1515        .iter()
1516        .find(|snapshot| snapshot.id == wanted);
1517    let intact = match found {
1518        Some(snapshot) => {
1519            crate::migrate::registry::integrity(&workspace, snapshot)?
1520                == crate::migrate::registry::Integrity::Intact
1521        }
1522        None => false,
1523    };
1524    let Some(snapshot) = found.filter(|_| intact) else {
1525        return Err(RkError::refusal(
1526            Diagnostic::new(
1527                Reason::DestructiveRefusal,
1528                format!(
1529                    "{step} changes forge state, and the migration {id} holds no intact snapshot of it (migration:a-destructive-step-waits-for-its-snapshot)"
1530                ),
1531            )
1532            .expected("the step's before-state in the migration registry")
1533            .action("rk migrate archive --apply --target . takes the missing forge snapshots on a host that holds the forge credentials")
1534            .target_state("unchanged")
1535            .step(step),
1536        ));
1537    };
1538    Ok(Some(workspace.join(&snapshot.file).to_string()))
1539}
1540
1541/// One destructive step's forge snapshot, as the archive took it.
1542#[derive(Debug, Clone, PartialEq, Eq)]
1543pub enum ForgeSnapshot {
1544    /// The before-state is saved at this path.
1545    Taken(PathBuf),
1546    /// The before-state could not be read from where this ran.
1547    Unknown(String),
1548}
1549
1550/// Take the before-state of every destructive step this target runs into
1551/// `snapshot`, for a migration's archive.
1552///
1553/// A step whose state this host cannot read reads unknown and stays
1554/// refused until a host that can takes it; `skip` names the steps already
1555/// held.
1556///
1557/// # Errors
1558///
1559/// A target configuration that does not resolve.
1560pub fn migration_snapshots(
1561    target: &camino::Utf8PathBuf,
1562    snapshot: &crate::snapshot::Snapshot,
1563    skip: &[String],
1564) -> Result<Vec<(&'static str, ForgeSnapshot)>, RkError> {
1565    let mut ctx = Ctx::resolve(target, None, None, None)?;
1566    let steps: Vec<&StepSpec> = STEPS
1567        .iter()
1568        .filter(|step| {
1569            crate::setup::before_state::destructive(step.name)
1570                && stance(&ctx, step).acts()
1571                && !skip.contains(&forge_snapshot_id(step.name))
1572        })
1573        .collect();
1574    if steps.is_empty() {
1575        return Ok(Vec::new());
1576    }
1577    if let Err(error) = ctx.require_cli(&steps) {
1578        return Ok(steps
1579            .iter()
1580            .map(|step| (step.name, ForgeSnapshot::Unknown(error.to_string())))
1581            .collect());
1582    }
1583    // The archive's own report is the result; the engine's framing goes
1584    // to stderr and its events nowhere, so a JSON report stays one object.
1585    let mut engine = Engine::open(Output::new(false), ctx, "migrate", false)?;
1586    let mut out = Vec::new();
1587    for step in steps {
1588        let ctx = clone_ctx(&engine.ctx);
1589        let mut runner = |exec: &Exec| engine.exec(exec, false);
1590        let taken = match crate::setup::before_state::save(&ctx, step.name, snapshot, &mut runner) {
1591            Ok(path) => ForgeSnapshot::Taken(path),
1592            Err(error) => ForgeSnapshot::Unknown(error.to_string()),
1593        };
1594        out.push((step.name, taken));
1595    }
1596    Ok(out)
1597}
1598
1599/// The destructive steps this target runs, offline: the steps whose
1600/// before-state a migration must hold.
1601///
1602/// # Errors
1603///
1604/// A target configuration that does not resolve.
1605pub fn destructive_steps(target: &camino::Utf8PathBuf) -> Result<Vec<&'static str>, RkError> {
1606    let ctx = Ctx::resolve(target, None, None, None)?;
1607    Ok(STEPS
1608        .iter()
1609        .filter(|step| {
1610            crate::setup::before_state::destructive(step.name) && stance(&ctx, step).acts()
1611        })
1612        .map(|step| step.name)
1613        .collect())
1614}
1615
1616/// Where the committed setup proof stands against this target, offline:
1617/// `absent`, `compatible`, `stale`, or `invalid`.
1618///
1619/// # Errors
1620///
1621/// A target configuration that does not resolve.
1622pub fn proof_word(target: &camino::Utf8PathBuf) -> Result<&'static str, RkError> {
1623    let ctx = Ctx::resolve(target, None, None, None)?;
1624    Ok(proof::judge(&ctx.target, &proof::subject(&ctx)).word())
1625}
1626
1627/// Observe one step through the engine's executor.
1628///
1629/// `install-bot` on GitHub is the one observation that authenticates as
1630/// the App itself, so it routes through [`app_jwt_for`] here — where the
1631/// engine can mint once and register the redaction needles — rather than
1632/// through the credential-free name dispatch in [`observe::observe`].
1633fn observe_with(engine: &mut Engine, step: &str) -> Result<StepState, RkError> {
1634    if step == "install-bot" && engine.ctx.forge == Some(Forge::Github) {
1635        let jwt = match app_jwt_for(engine)? {
1636            Ok(jwt) => jwt,
1637            Err(detail) => return Ok(StepState::Unknown { detail }),
1638        };
1639        return Ok(observe::github_install_bot(&engine.ctx, &jwt));
1640    }
1641    let ctx = clone_ctx(&engine.ctx);
1642    let mut runner = |exec: &Exec| engine.exec(exec, false);
1643    observe::observe(&ctx, step, &mut runner)
1644}
1645
1646/// The run's validated key file, read exactly once per run: the first
1647/// consumer resolves it and every later one reuses the same bytes, so the
1648/// file that authenticated the App is the file `bot-secrets` stores, and
1649/// no replacement between steps can split the two. The bytes become a
1650/// redaction needle the moment they are read.
1651fn key_file_for(engine: &mut Engine) -> Result<Option<&secrets::KeyFile>, RkError> {
1652    if engine.key.is_none() && engine.key_unavailable.is_none() {
1653        match secrets::look_up_key_file(&engine.ctx.target)? {
1654            secrets::KeyLookup::Absent => {}
1655            secrets::KeyLookup::Found(key) => {
1656                engine.secrets.push(key.bytes.clone());
1657                engine.key = Some(key);
1658            }
1659            // A check reads the forge and changes nothing, so a key this
1660            // runtime cannot reach leaves the one step that needs it
1661            // undecided and lets every other step run. A run that would
1662            // mutate, or record a proof, still needs the key and refuses.
1663            secrets::KeyLookup::Unavailable(unavailable) if engine.key_boundary => {
1664                engine.key_unavailable = Some(unavailable.message);
1665            }
1666            secrets::KeyLookup::Unavailable(unavailable) => return Err(unavailable.refusal()),
1667        }
1668    }
1669    Ok(engine.key.as_ref())
1670}
1671
1672/// The run's App JWT, minted at most once: the key comes from the run's
1673/// one read, and the minted token and its signature segment become
1674/// redaction needles before anything else spawns. Every install-bot
1675/// observation and the grant's installation-id discovery reuse the one
1676/// token, whose nine-minute life covers a run's contiguous step easily.
1677///
1678/// The inner value is `Err` with a one-line detail where no token can
1679/// exist — absent exports, or a signer that failed — which an observation
1680/// reports as `unknown` and an apply turns into a refusal.
1681fn app_jwt_for(engine: &mut Engine) -> Result<Result<String, String>, RkError> {
1682    if let Some(jwt) = &engine.app_jwt {
1683        return Ok(Ok(jwt.clone()));
1684    }
1685    let app_id = app_jwt::app_id(engine.ctx.bot_app_id())?;
1686    let key_bytes = key_file_for(engine)?.map(|key| key.bytes.clone());
1687    if let Some(unreachable) = &engine.key_unavailable {
1688        return Ok(Err(format!(
1689            "the installation is readable only to the App itself, and this runtime cannot reach the key {} names: {unreachable}; a host that holds the key re-verifies it",
1690            secrets::PRIVATE_KEY_FILE
1691        )));
1692    }
1693    let (Some(app_id), Some(key_bytes)) = (app_id, key_bytes) else {
1694        return Ok(Err(format!(
1695            "the installation is readable only to the App itself; {}",
1696            app_jwt::REMEDIATION
1697        )));
1698    };
1699    let credentials = app_jwt::AppCredentials { app_id, key_bytes };
1700    let ctx = clone_ctx(&engine.ctx);
1701    Ok(match app_jwt::mint(&ctx, &credentials) {
1702        Ok(jwt) => {
1703            engine
1704                .secrets
1705                .push(Zeroizing::new(jwt.clone().into_bytes()));
1706            if let Some(signature) = jwt.rsplit('.').next() {
1707                engine
1708                    .secrets
1709                    .push(Zeroizing::new(signature.as_bytes().to_vec()));
1710            }
1711            engine.app_jwt = Some(jwt.clone());
1712            Ok(jwt)
1713        }
1714        Err(detail) => Err(detail),
1715    })
1716}
1717
1718fn state_detail(state: &StepState) -> String {
1719    state.detail().to_owned()
1720}
1721
1722/// Materialize the step's script into the run's private directory, prove
1723/// the written bytes by digest, and spawn it through the interpreter.
1724fn run_script(engine: &mut Engine, step: &StepSpec) -> Result<(Outcome, PathBuf), RkError> {
1725    run_script_with(engine, step, None, Vec::new())
1726}
1727
1728/// The same, with bytes written to the script's standard input.
1729///
1730/// A credential travels this way and no other: `rk` reads it, validates it,
1731/// and hands the child the bytes it validated, so nothing between the check
1732/// and the forge can substitute a different file.
1733fn run_script_with(
1734    engine: &mut Engine,
1735    step: &StepSpec,
1736    stdin: Option<Zeroizing<Vec<u8>>>,
1737    extra_env: Vec<(OsString, OsString)>,
1738) -> Result<(Outcome, PathBuf), RkError> {
1739    let forge = engine.ctx.adapter()?;
1740    let rel = format!("{}/{}", forge.as_str(), step.name);
1741    let bytes = embedded::SETUP
1742        .get_file(&rel)
1743        .map(include_dir::File::contents)
1744        .ok_or_else(|| RkError::Other(anyhow::anyhow!("no embedded script at setup/{rel}")))?;
1745    let journal = engine
1746        .journal
1747        .as_mut()
1748        .ok_or_else(|| RkError::Other(anyhow::anyhow!("an apply always has a journal")))?;
1749    let dir = journal.scripts_dir().join(forge.as_str());
1750    fs::create_dir_all(&dir)?;
1751    restrict(&dir, 0o700);
1752    let path = dir.join(step.name);
1753    fs::write(&path, bytes)?;
1754    restrict(&path, 0o600);
1755    let written = fs::read(&path)?;
1756    let digest = Digest::of(&written);
1757    if digest != Digest::of(bytes) {
1758        return Err(RkError::Other(anyhow::anyhow!(
1759            "the materialized script at {} differs from the embedded bytes",
1760            path.display()
1761        )));
1762    }
1763    journal.record_script(format!("scripts/{rel}"), digest.to_string());
1764    let mut env = engine.ctx.child_env(step.name);
1765    env.extend(extra_env);
1766    let exec = Exec {
1767        program: crate::probes::sh_bin(),
1768        args: vec![path.clone().into_os_string()],
1769        env,
1770        cwd: engine.ctx.target.as_std_path().to_path_buf(),
1771        stdin,
1772    };
1773    let outcome = engine.exec(&exec, true)?;
1774    Ok((outcome, path))
1775}
1776
1777/// Honest classification: `gh` documents exit 4 as authentication required;
1778/// beyond that only an HTTP status in the response says more, and a step
1779/// that fails for a reason nothing establishes stays `subprocess-failed`
1780/// with its own stderr surfaced verbatim.
1781fn classify_failure(engine: &Engine, step: &StepSpec, outcome: &Outcome) -> RkError {
1782    let stderr = String::from_utf8_lossy(&outcome.stderr);
1783    // A signalled child usually writes nothing before it dies, and
1784    // "no output" would blame the forge for a kill that came from
1785    // outside it. The adapter already resolved 128+N; say which.
1786    let last = if outcome.exit_code >= 128 {
1787        format!("killed by signal {}", outcome.exit_code - 128)
1788    } else {
1789        stderr
1790            .lines()
1791            .rev()
1792            .find(|line| !line.trim().is_empty())
1793            .unwrap_or("no output")
1794            .to_owned()
1795    };
1796    let reason = if (engine.ctx.forge == Some(Forge::Github) && outcome.exit_code == 4)
1797        || stderr.contains("HTTP 401")
1798    {
1799        Reason::ForgeAuthentication
1800    } else if stderr.contains("HTTP 403") {
1801        Reason::ForgePermission
1802    } else if stderr.contains("HTTP 429") || stderr.contains("rate limit") {
1803        Reason::ForgeRateLimit
1804    } else {
1805        Reason::SubprocessFailed
1806    };
1807    let diagnostic = Diagnostic::new(reason, format!("the forge refused '{}': {last}", step.name))
1808        .expected(step.proves_for(&engine.ctx).to_owned())
1809        .action(format!(
1810            "rk setup step {} --target {} --apply",
1811            step.name, engine.ctx.target
1812        ))
1813        .step(step.name);
1814    let diagnostic = match reason {
1815        Reason::ForgePermission => diagnostic.expected(format!(
1816            "repository administration write on {} for the authenticated account",
1817            engine.ctx.repo
1818        )),
1819        _ => diagnostic,
1820    };
1821    match reason {
1822        Reason::SubprocessFailed => RkError::subprocess(diagnostic),
1823        _ => RkError::refusal(diagnostic),
1824    }
1825}
1826
1827/// Fold the run's progress into the failure, so the diagnostic answers
1828/// what state the target is in.
1829fn attach_progress(
1830    error: RkError,
1831    done: &[(String, String)],
1832    failed: &StepSpec,
1833    steps: &[&StepSpec],
1834) -> RkError {
1835    let remaining = steps.len().saturating_sub(done.len() + 1);
1836    let state = format!(
1837        "{} completed; {} failed; {remaining} not attempted",
1838        step_count(done.len()),
1839        failed.name
1840    );
1841    match error {
1842        RkError::Refusal(mut diagnostic) => {
1843            diagnostic.target_state.get_or_insert(state);
1844            RkError::Refusal(diagnostic)
1845        }
1846        RkError::Subprocess(mut diagnostic) => {
1847            diagnostic.target_state.get_or_insert(state);
1848            RkError::Subprocess(diagnostic)
1849        }
1850        other => other,
1851    }
1852}
1853
1854/// Observe every step once, in table order, and render each row as it
1855/// lands, so a person reads the forge calls beside the step that made
1856/// them. The rows it returns are the only source of every later verdict.
1857///
1858/// SATISFIES setup-proof:one-report-owns-every-classification
1859fn observe_all(engine: &mut Engine) -> Result<Report, RkError> {
1860    let mut report = Report::default();
1861    for step in &STEPS {
1862        let clock = Instant::now();
1863        // A step the target declared it does not run is stated and judged
1864        // by nothing: no forge call, no verdict, and no weight in the exit
1865        // code. The reason travels with it, so a reader can tell a chosen
1866        // subset from an incomplete setup.
1867        let stance = stance(&engine.ctx, step);
1868        let row = if stance.acts() {
1869            let state = observe_with(engine, step.name)?;
1870            Row::observed(step, stance, &state)
1871        } else {
1872            Row::stated(step, stance)
1873        };
1874        engine.out.result_line(row.line());
1875        let (status, detail) = row.event_fields();
1876        let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
1877        finished.status = Some(status);
1878        finished.detail = Some(detail);
1879        finished.duration_ms = Some(elapsed_ms(clock));
1880        engine.emit(&finished);
1881        report.rows.push(row);
1882    }
1883    if report.judged() < STEPS.len() {
1884        engine.out.result_line(format!(
1885            "{} judged; the rest do not apply to this target or {} excludes them",
1886            step_count(report.judged()),
1887            crate::config::CONFIG_PATH
1888        ));
1889    }
1890    Ok(report)
1891}
1892
1893/// `rk setup check`: observe and verify every step, report per step, and
1894/// judge at the end. The mutating half is unreachable from this path: it
1895/// calls only the observe functions.
1896fn check(out: Output, ctx: Ctx) -> Result<(), RkError> {
1897    let mut engine = Engine::open(out, ctx, "setup check", false)?;
1898    engine.key_boundary = true;
1899    let report = observe_all(&mut engine)?;
1900    // The milestones are information the publish task resumes from; none
1901    // weighs on the verdict below.
1902    for milestone in crate::setup::milestones::observe(&engine.ctx) {
1903        engine.out.result_line(milestone.line());
1904        let mut event = engine.event(EventKind::Milestone, Some(milestone.id));
1905        event.status = Some(milestone.status.to_owned());
1906        event.detail = Some(milestone.detail);
1907        engine.emit(&event);
1908    }
1909    if !report.checkpointable() {
1910        let error = verdict(&engine.ctx, &report);
1911        return Err(fail(&mut engine, error));
1912    }
1913    engine
1914        .out
1915        .next(&["rk guide release orders the first release".to_owned()]);
1916    engine.finish(0, None);
1917    Ok(())
1918}
1919
1920/// The check's failure, by what it found. An unsatisfied step is drift,
1921/// and applying the setup is its remedy. An unknown step is an
1922/// observation this runtime could not make, which proves nothing wrong,
1923/// so it prescribes no apply: it names where the observation can be
1924/// repeated, and what the committed proof says meanwhile. A proof never
1925/// masks a step found wrong now.
1926///
1927/// SATISFIES setup-proof:an-unavailable-credential-is-an-observation-boundary
1928/// SATISFIES setup-proof:current-evidence-overrides-the-proof
1929fn verdict(ctx: &Ctx, report: &Report) -> RkError {
1930    let unsatisfied = report.unsatisfied();
1931    let unknown = report.unknown();
1932    let standing = proof::judge(&ctx.target, &proof::subject(ctx));
1933    let proven = match &standing {
1934        Standing::Compatible(proof) => Some(proof),
1935        _ => None,
1936    };
1937    if unsatisfied == 0 {
1938        let state = proven.map_or_else(
1939            || {
1940                format!(
1941                    "no setup defect was inferred; no compatible {} is committed, so the current state is unverified",
1942                    proof::PROOF_PATH
1943                )
1944            },
1945            |proof| {
1946                format!(
1947                    "no setup defect was inferred; the committed setup proof from {} by rk {} still matches this target's setup contract",
1948                    proof.verified_at, proof.rk_version
1949                )
1950            },
1951        );
1952        return RkError::check_failed(
1953            Diagnostic::new(
1954                Reason::ObservationIncomplete,
1955                format!(
1956                    "{} could not be verified from this runtime, and none is unsatisfied",
1957                    step_count(unknown)
1958                ),
1959            )
1960            .expected("every applicable step readable from where the check runs")
1961            .action("rerun rk setup check where each unknown step can be observed; its line names what it needs")
1962            .target_state(state),
1963        );
1964    }
1965    let mut message = format!(
1966        "{} {} not satisfied",
1967        step_count(unsatisfied),
1968        if unsatisfied == 1 { "is" } else { "are" }
1969    );
1970    let mut action = format!(
1971        "rk setup --target {} --apply re-asserts the unsatisfied ones",
1972        ctx.target
1973    );
1974    if unknown > 0 {
1975        use std::fmt::Write as _;
1976        let _ = write!(
1977            message,
1978            ", and {unknown} could not be verified from this runtime"
1979        );
1980        action.push_str("; the unknown ones need a runtime that can observe what each line names");
1981    }
1982    let mut diagnostic = Diagnostic::new(Reason::StateDrift, message)
1983        .expected("every step's proof column to hold")
1984        .action(action);
1985    if let Some(proof) = proven {
1986        diagnostic = diagnostic.target_state(format!(
1987            "this observation supersedes the committed setup proof from {} by rk {}",
1988            proof.verified_at, proof.rk_version
1989        ));
1990    }
1991    RkError::check_failed(diagnostic)
1992}
1993
1994/// `rk setup checkpoint`: observe every step as `check` does, and commit
1995/// the result only where the observation is complete. An incomplete one
1996/// writes nothing, so an earlier proof stays byte for byte.
1997///
1998/// SATISFIES setup-proof:a-checkpoint-records-only-a-complete-observation
1999fn checkpoint(out: Output, ctx: Ctx) -> Result<(), RkError> {
2000    let mut engine = Engine::open(out, ctx, "setup checkpoint", false)?;
2001    let report = observe_all(&mut engine)?;
2002    let Some(proof) = proof::of(&engine.ctx, &report, crate::applog::now_utc()) else {
2003        let error = RkError::check_failed(
2004            Diagnostic::new(
2005                Reason::StateDrift,
2006                format!(
2007                    "the observation is incomplete: {} not satisfied and {} could not be verified",
2008                    step_count(report.unsatisfied()),
2009                    report.unknown()
2010                ),
2011            )
2012            .expected("every applicable step observed and holding")
2013            .action(format!(
2014                "rk setup check --target {} names each gap; checkpoint again once none remains",
2015                engine.ctx.target
2016            ))
2017            .target_state(format!("{} was not written", proof::PROOF_PATH)),
2018        );
2019        return Err(fail(&mut engine, error));
2020    };
2021    if let Err(source) = proof::write(&engine.ctx.target, &proof) {
2022        let error = RkError::refusal(
2023            Diagnostic::new(
2024                Reason::Io,
2025                format!("{} cannot be written: {source}", proof::PROOF_PATH),
2026            )
2027            .expected("a writable .release-kit directory in the target")
2028            .target_state("the earlier proof, where one exists, is unchanged"),
2029        );
2030        return Err(fail(&mut engine, error));
2031    }
2032    engine.out.result_line(format!(
2033        "wrote {}: {} proven at {} by rk {}",
2034        proof::PROOF_PATH,
2035        step_count(proof.steps.len()),
2036        proof.verified_at,
2037        proof.rk_version
2038    ));
2039    engine.out.next(&[
2040        format!("commit {} with the setup it proves", proof::PROOF_PATH),
2041        format!(
2042            "rk setup status --target {} reads it offline",
2043            engine.ctx.target
2044        ),
2045    ]);
2046    engine.finish(0, None);
2047    Ok(())
2048}
2049
2050/// `rk setup status`: where the committed proof stands against this
2051/// target's contract now. It reads a file and resolves configuration,
2052/// and nothing else: no forge, no key, no journal.
2053///
2054/// SATISFIES setup-proof:the-status-reads-the-proof-offline
2055fn status(out: Output, ctx: &Ctx) -> Result<(), RkError> {
2056    let standing = proof::judge(&ctx.target, &proof::subject(ctx));
2057    out.emit(&proof::StatusDocument::of(&standing))?;
2058    match &standing {
2059        Standing::Absent => out.result_line(format!(
2060            "setup proof: absent — {} does not exist, so no complete observation is on record",
2061            proof::PROOF_PATH
2062        )),
2063        Standing::Compatible(proof) => out.result_line(format!(
2064            "setup proof: compatible — proven {} by rk {}, and this target's setup contract has not changed since",
2065            proof.verified_at, proof.rk_version
2066        )),
2067        Standing::Stale { proof, differences } => out.result_line(format!(
2068            "setup proof: stale — proven {} by rk {}, and the setup contract changed since: {}",
2069            proof.verified_at,
2070            proof.rk_version,
2071            differences.join(", ")
2072        )),
2073        Standing::Invalid { detail, .. } => {
2074            out.result_line(format!("setup proof: invalid — {detail}"));
2075        }
2076    }
2077    if let Standing::Compatible(proof) | Standing::Stale { proof, .. } = &standing {
2078        for step in &proof.steps {
2079            let limitation = step
2080                .limitation
2081                .as_ref()
2082                .map_or_else(String::new, |limit| format!(" (limitation: {limit})"));
2083            out.result_line(format!("  {} {}{limitation}", step.state, step.name));
2084        }
2085    }
2086    let next = match standing {
2087        Standing::Compatible(_) => format!(
2088            "rk setup check --target {} observes the forge now, where this runtime can",
2089            ctx.target
2090        ),
2091        _ => format!(
2092            "rk setup checkpoint --target {}, from a host that can observe every step, records a proof",
2093            ctx.target
2094        ),
2095    };
2096    out.next(&[next]);
2097    Ok(())
2098}
2099
2100/// Restrict a materialized path's mode: data, not an executable — nothing
2101/// ever executes a script directly, so no mode is load-bearing.
2102fn restrict(path: &std::path::Path, mode: u32) {
2103    #[cfg(unix)]
2104    {
2105        use std::os::unix::fs::PermissionsExt as _;
2106        let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
2107    }
2108    #[cfg(not(unix))]
2109    let _ = (path, mode);
2110}
2111
2112/// A POSIX shell must spawn before anything else does; every step runs
2113/// through it.
2114fn guard_sh() -> Result<(), RkError> {
2115    let ok = std::process::Command::new(crate::probes::sh_bin())
2116        .args(["-c", "exit 0"])
2117        .status()
2118        .is_ok_and(|status| status.success());
2119    if ok {
2120        Ok(())
2121    } else {
2122        Err(RkError::refusal(
2123            Diagnostic::new(Reason::PrerequisiteUnmet, "no POSIX sh runs on this host")
2124                .expected("a working sh on PATH; every step spawns through it")
2125                .action("install a POSIX shell, then rerun")
2126                .target_state("nothing was run and nothing changed"),
2127        ))
2128    }
2129}
2130
2131#[cfg(test)]
2132mod tests {
2133    /// Every summary line reports its count through one helper, so none of
2134    /// them can regrow a dangling plural.
2135    #[test]
2136    fn a_step_count_carries_a_noun_that_agrees_with_it() {
2137        assert_eq!(super::step_count(0), "0 steps");
2138        assert_eq!(super::step_count(1), "1 step");
2139        assert_eq!(super::step_count(2), "2 steps");
2140    }
2141
2142    /// SATISFIES forge-setup:the-setup-refuses-a-forge-below-the-floor
2143    /// A prerequisite the target does not run never blocks its dependent,
2144    /// and only an exclusion carries a line: the two states stay distinct.
2145    /// No step pair today makes a prerequisite inapplicable where its
2146    /// dependent applies, so the gate's answer is held here, at the pure
2147    /// mapping both the preview and the apply read.
2148    #[test]
2149    fn an_inapplicable_prerequisite_never_blocks_a_gate() {
2150        use super::{PrereqGate, Stance, prereq_gate};
2151        assert_eq!(
2152            prereq_gate(Stance::NotApplicable("the profile names no forge".into())),
2153            PrereqGate::Silent
2154        );
2155        assert_eq!(
2156            prereq_gate(Stance::Excluded("read by hand".into())),
2157            PrereqGate::Excluded("read by hand".into())
2158        );
2159        assert_eq!(
2160            prereq_gate(Stance::Redundant {
2161                reason: "read by hand".into(),
2162                inapplicable: "the profile names no forge".into(),
2163            }),
2164            PrereqGate::Excluded("read by hand".into())
2165        );
2166        assert_eq!(prereq_gate(Stance::Applies), PrereqGate::Observe);
2167    }
2168}