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