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}
379
380impl Engine {
381    /// Open the run: journal first, before any remote mutation. An apply
382    /// that cannot create its journal refuses; observability-only modes
383    /// warn and continue, because refusing them over observability is
384    /// self-defeating.
385    fn open(
386        out: Output,
387        ctx: Ctx,
388        command: &'static str,
389        journal_required: bool,
390    ) -> Result<Self, RkError> {
391        // A stale key export is refused wherever a run opens, so every
392        // mode catches it and not the one step that would have used it.
393        secrets::refuse_legacy_key()?;
394        let journal = match Journal::create(
395            command,
396            ctx.target.as_str(),
397            ctx.forge.map_or("none", Forge::as_str),
398            &ctx.repo,
399        ) {
400            Ok(journal) => Some(journal),
401            Err(source) if journal_required => {
402                return Err(RkError::refusal(
403                    Diagnostic::new(
404                        Reason::JournalUnavailable,
405                        format!("the run journal cannot be created: {source}"),
406                    )
407                    .expected("a writable state root for the journal")
408                    .target_state("nothing was run and nothing changed"),
409                ));
410            }
411            Err(source) => {
412                out.warn(format!("no run journal for this run: {source}"));
413                None
414            }
415        };
416        let run_id = journal
417            .as_ref()
418            .map_or_else(|| "unjournaled".to_owned(), |j| j.run_id().to_owned());
419        let mut engine = Self {
420            out,
421            ctx,
422            journal,
423            secrets: Ctx::secret_values(),
424            key: None,
425            app_jwt: None,
426            key_boundary: false,
427            key_unavailable: None,
428            seq: 0,
429            command,
430            run_id,
431        };
432        let opening = Event::opening(
433            engine.next_seq(),
434            crate::applog::now_utc(),
435            engine.run_id.clone(),
436            engine.command,
437        );
438        engine.emit(&opening);
439        if engine.ctx.self_hosted_gitlab() {
440            engine.out.warn(
441                "this remote is a self-hosted GitLab: registry trusted publishing covers GitLab.com only, so the OIDC invariant cannot be satisfied here",
442            );
443        }
444        Ok(engine)
445    }
446
447    const fn next_seq(&mut self) -> u64 {
448        let seq = self.seq;
449        self.seq += 1;
450        seq
451    }
452
453    fn event(&mut self, kind: EventKind, step: Option<&str>) -> Event {
454        let mut event = Event::opening(
455            self.next_seq(),
456            crate::applog::now_utc(),
457            self.run_id.clone(),
458            self.command,
459        );
460        event.kind = kind;
461        event.step = step.map(str::to_owned);
462        event
463    }
464
465    fn emit(&mut self, event: &Event) {
466        self.out.event(event);
467        if let Some(journal) = &mut self.journal
468            && let Ok(line) = serde_json::to_string(event)
469        {
470            journal.event_line(&line);
471        }
472    }
473
474    /// Run one external command: echo it, stream and redact its output,
475    /// journal everything, surface its exit.
476    fn exec(&mut self, exec: &Exec, passthrough: bool) -> Result<Outcome, RkError> {
477        let echo = exec.echo();
478        self.out.frame(&echo);
479        if let Some(journal) = &mut self.journal {
480            journal.transcript(echo.as_bytes());
481            journal.transcript(b"\n");
482        }
483        let secrets = std::mem::take(&mut self.secrets);
484        let step_name: Option<String> = None;
485        let mut chunks: Vec<(ChildStream, Vec<u8>)> = Vec::new();
486        let spawned = process::run(exec, |stream, chunk| {
487            chunks.push((stream, process::redact(chunk, &secrets)));
488        });
489        self.secrets = secrets;
490        for (stream, chunk) in chunks {
491            if passthrough {
492                self.out.child_passthrough(stream, &chunk);
493            }
494            let event = self.event(EventKind::ChildOutput, step_name.as_deref());
495            let event = event.child_output(stream, &chunk);
496            self.emit(&event);
497            if let Some(journal) = &mut self.journal {
498                journal.transcript(&chunk);
499            }
500        }
501        spawned.map_err(|source| {
502            RkError::refusal(
503                Diagnostic::new(
504                    Reason::SubprocessSpawn,
505                    format!("{} did not spawn: {source}", exec.program.to_string_lossy()),
506                )
507                .expected("a POSIX sh and the forge CLI on PATH")
508                .run(self.run_path()),
509            )
510        })
511    }
512
513    fn run_path(&self) -> String {
514        self.journal.as_ref().map_or_else(
515            || "no journal was written".to_owned(),
516            |j| j.dir.display().to_string(),
517        )
518    }
519
520    fn finish(&mut self, exit_code: i32, reason: Option<&str>) {
521        let mut event = self.event(EventKind::RunFinished, None);
522        event.exit_code = Some(exit_code);
523        event.status = Some(if exit_code == 0 {
524            "ok".into()
525        } else {
526            "failed".into()
527        });
528        self.emit(&event);
529        if let Some(journal) = &mut self.journal {
530            journal.finish(exit_code, reason);
531        }
532    }
533}
534
535/// Attach the failure to its run journal and close the run.
536fn fail(engine: &mut Engine, error: RkError) -> RkError {
537    let error = match error {
538        RkError::Refusal(mut diagnostic) => {
539            diagnostic.run.get_or_insert_with(|| engine.run_path());
540            RkError::Refusal(diagnostic)
541        }
542        RkError::Subprocess(mut diagnostic) => {
543            diagnostic.run.get_or_insert_with(|| engine.run_path());
544            RkError::Subprocess(diagnostic)
545        }
546        RkError::CheckFailed(mut diagnostic) => {
547            diagnostic.run.get_or_insert_with(|| engine.run_path());
548            RkError::CheckFailed(diagnostic)
549        }
550        other => other,
551    };
552    engine.finish(i32::from(error.exit_code()), Some(error.reason().as_str()));
553    error
554}
555
556/// Preview: walk the ordered steps and print, for each, the step name, what
557/// it proves, and the resolved invocation — without materializing anything
558/// and without invoking any external command. What preview shows is what
559/// apply would run, because both read the same step table and environment
560/// construction.
561fn preview(out: Output, ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
562    let mut engine = Engine::open(out, clone_ctx(ctx), "setup preview", false)?;
563    out.result_line(format!(
564        "DRY RUN: rk setup would run these steps against {} on {}; re-run with --apply",
565        engine.ctx.repo,
566        engine.ctx.forge.map_or("no forge", Forge::as_str)
567    ));
568    for (idx, step) in steps.iter().enumerate() {
569        out.result_line(format!(
570            "step {}/{} {} — proves {}",
571            idx + 1,
572            steps.len(),
573            step.name,
574            step.proves
575        ));
576        let stance = stance(&engine.ctx, step);
577        if !stance.acts() {
578            out.result_line(format!("  {}", stance.framed()));
579            let mut event = engine.event(EventKind::StepFinished, Some(step.name));
580            event.status = Some(stance.word().into());
581            event.detail = Some(stance.detail());
582            engine.emit(&event);
583            continue;
584        }
585        // Preview is the rehearsal of apply, so a credential apply could
586        // not use is a preview failure: the operator learns it here rather
587        // than one flag later, and before an invocation is claimed.
588        if step.name == "bot-secrets" && engine.ctx.forge == Some(Forge::Github) {
589            secrets::resolve_key_file(&engine.ctx.target)?;
590        }
591        out.result_line(format!("  {}", render_invocation(&engine.ctx, step)));
592        // The name is asked for only where a rule consumes it. It feeds the
593        // required-status-checks rule alone, and local integration owns no
594        // such rule, because no forge can require a check before the push
595        // that starts it. Asking there would name a flag whose value reaches
596        // nothing the run installs.
597        if step.name == "protect-trunk"
598            && engine.ctx.forge == Some(Forge::Github)
599            && engine.ctx.required_check.is_none()
600            && engine
601                .ctx
602                .protection()
603                .owned_trunk_rules
604                .iter()
605                .any(|rule| rule == "required_status_checks")
606        {
607            out.result_line("  needs: --required-check <name> before apply");
608        }
609        if skipped_by_a_full_run(&engine.ctx, step, steps.len()) {
610            out.result_line(format!(
611                "  optional: a full apply skips it; set setup.release_lines, or rk setup step {} --apply runs it",
612                step.name
613            ));
614        }
615        let mut event = engine.event(EventKind::StepFinished, Some(step.name));
616        event.status = Some("previewed".into());
617        engine.emit(&event);
618    }
619    let next = next_for_apply(&engine.ctx, steps);
620    out.next(&[
621        next,
622        "rk setup check --target . proves what is already true".to_owned(),
623    ]);
624    engine.finish(0, None);
625    Ok(())
626}
627
628/// The one line preview prints per step: the exact spawn shape, with every
629/// non-secret variable resolved.
630fn render_invocation(ctx: &Ctx, step: &StepSpec) -> String {
631    match step.name {
632        "branch-reminder" => {
633            "would write: the post-merge reminder hook at $(git rev-parse --git-path hooks)/post-merge".to_owned()
634        }
635        "package-check" => match ctx.tech {
636            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(),
637            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(),
638            Some("python") if ctx.reporting_policy() => "would run: python3 -m build; sdist and wheel SECURITY.md inclusion stays unproved".to_owned(),
639            Some("python") => "would run: python3 -m build".to_owned(),
640            Some("bash") if ctx.reporting_policy() => "nothing to run: no registry for this technology; the make dist tarball is not inspected".to_owned(),
641            Some("bash") => "nothing to run: no registry for this technology".to_owned(),
642            _ => "needs: a version file naming the technology".to_owned(),
643        },
644        "forge-version" => {
645            let (major, minor) = observe::GITLAB_VERSION_FLOOR;
646            match ctx.forge {
647                Some(Forge::Github) => {
648                    "nothing to read: github.com is a rolling service and declares no version floor"
649                        .to_owned()
650                }
651                Some(Forge::Gitlab) => format!(
652                    "would read: GET /version, and compare it against the {major}.{minor} floor; nothing is written"
653                ),
654                None => "nothing to read: the profile names no forge".to_owned(),
655            }
656        }
657        name => {
658            let check = ctx
659                .required_check
660                .as_ref()
661                .filter(|_| ctx.forge == Some(Forge::Github) && name == "protect-trunk")
662                .map(|value| format!(" RK_REQUIRED_CHECK={value}"))
663                .unwrap_or_default();
664            // The ruleset a protection step installs is the one
665            // per-target fact the operator most needs to see before an
666            // apply, because a renamed ruleset leaves the old one standing.
667            let ruleset = match name {
668                "protect-trunk" => format!(
669                    " RK_TRUNK_RULESET={} RK_SAFETY_RULESET={} RK_TITLE_CHECK={}",
670                    ctx.trunk_ruleset(),
671                    ctx.safety_ruleset(),
672                    ctx.title_check()
673                ),
674                "protect-tags" => format!(" RK_TAG_RULESET={}", ctx.tag_ruleset()),
675                "protect-release-lines" => format!(" RK_LINES_RULESET={}", ctx.lines_ruleset()),
676                _ => String::new(),
677            };
678            format!(
679                "would run: sh <embedded setup/{}/{name}> with RK_REPO={} RK_TRUNK_BRANCH={}{ruleset}{check}",
680                ctx.forge.map_or("<no forge>", Forge::as_str),
681                ctx.repo,
682                ctx.trunk()
683            )
684        }
685    }
686}
687
688fn next_for_apply(ctx: &Ctx, steps: &[&StepSpec]) -> String {
689    let check = ctx
690        .required_check
691        .as_ref()
692        .map(|value| format!(" --required-check {value}"))
693        .unwrap_or_default();
694    if steps.len() == 1 {
695        format!(
696            "rk setup step {} --target {} --apply{check}",
697            steps[0].name, ctx.target
698        )
699    } else {
700        format!("rk setup --target {} --apply{check}", ctx.target)
701    }
702}
703
704/// A `Ctx` copy for engine ownership; the context is plain data.
705fn clone_ctx(ctx: &Ctx) -> Ctx {
706    ctx.clone()
707}
708
709/// Apply: run the selected steps in order, each through the full lifecycle.
710fn execute(
711    out: Output,
712    ctx: Ctx,
713    steps: &[&StepSpec],
714    command: &'static str,
715) -> Result<(), RkError> {
716    guard_sh()?;
717    let mut engine = Engine::open(out, ctx, command, true)?;
718    let mut done: Vec<(String, String)> = Vec::new();
719    for (idx, step) in steps.iter().enumerate() {
720        // A declared exclusion states the skip and runs nothing. A single
721        // step named on the command line never arrives here: that form
722        // refuses before the run opens.
723        let stance = stance(&engine.ctx, step);
724        if !stance.acts() {
725            engine.out.frame(format!(
726                "step {}/{} {} — {}",
727                idx + 1,
728                steps.len(),
729                step.name,
730                stance.framed()
731            ));
732            let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
733            finished.status = Some(stance.word().into());
734            finished.detail = Some(stance.detail());
735            engine.emit(&finished);
736            done.push((step.name.to_owned(), stance.word().to_owned()));
737            continue;
738        }
739        // An optional step applies only by name: a full run states the skip
740        // rather than acting on a condition the operator never asserted.
741        if skipped_by_a_full_run(&engine.ctx, step, steps.len()) {
742            engine.out.frame(format!(
743                "step {}/{} {} — skipped (optional; set setup.release_lines, or rk setup step {} --apply runs it)",
744                idx + 1,
745                steps.len(),
746                step.name,
747                step.name
748            ));
749            let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
750            finished.status = Some("skipped".into());
751            engine.emit(&finished);
752            done.push((step.name.to_owned(), "skipped".to_owned()));
753            continue;
754        }
755        engine.out.frame(format!(
756            "step {}/{} {} — {}",
757            idx + 1,
758            steps.len(),
759            step.name,
760            step.proves
761        ));
762        let mut started = engine.event(EventKind::StepStarted, Some(step.name));
763        started.status = Some("running".into());
764        engine.emit(&started);
765        let clock = Instant::now();
766        let status = match apply_step(&mut engine, step) {
767            Ok(status) => status,
768            Err(error) => {
769                let error = attach_progress(error, &done, step, steps);
770                let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
771                finished.status = Some("failed".into());
772                finished.reason = Some(error.reason());
773                finished.duration_ms = Some(elapsed_ms(clock));
774                engine.emit(&finished);
775                return Err(fail(&mut engine, error));
776            }
777        };
778        engine.out.frame(format!(
779            "{} {}: {}",
780            if matches!(status, Done::Skipped(_)) {
781                "skipped"
782            } else {
783                "ok"
784            },
785            step.name,
786            status.line()
787        ));
788        let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
789        finished.status = Some(status.wire().into());
790        finished.detail = Some(status.line());
791        finished.exit_code = Some(0);
792        finished.duration_ms = Some(elapsed_ms(clock));
793        engine.emit(&finished);
794        done.push((step.name.to_owned(), status.wire().to_owned()));
795    }
796    engine.out.result_line(format!(
797        "setup: {} completed against {}",
798        step_count(done.len()),
799        engine.ctx.repo
800    ));
801    for (name, status) in &done {
802        engine.out.result_line(format!("  {status} {name}"));
803    }
804    engine.out.next(&[
805        format!("rk setup check --target {}", engine.ctx.target),
806        "rk guide setup orders what no command performs".to_owned(),
807    ]);
808    engine.finish(0, None);
809    Ok(())
810}
811
812/// A step count rendered with the noun that agrees with it, so no summary
813/// line can regrow a dangling plural.
814fn step_count(count: usize) -> String {
815    format!("{count} {}", if count == 1 { "step" } else { "steps" })
816}
817
818fn elapsed_ms(clock: Instant) -> u64 {
819    u64::try_from(clock.elapsed().as_millis()).unwrap_or(u64::MAX)
820}
821
822/// What one applied step reported.
823enum Done {
824    /// The desired state already held; nothing ran.
825    Satisfied(String),
826    /// The target is ineligible for this step.
827    Skipped(String),
828    /// The script ran and the postcondition was read back.
829    Changed(String, Option<String>),
830    /// A read-only step ran and passed.
831    Passed(String),
832}
833
834impl Done {
835    const fn wire(&self) -> &'static str {
836        match self {
837            Self::Satisfied(_) => "satisfied",
838            Self::Skipped(_) => "skipped",
839            Self::Changed(..) => "applied",
840            Self::Passed(_) => "passed",
841        }
842    }
843
844    fn line(&self) -> String {
845        match self {
846            Self::Satisfied(detail) | Self::Passed(detail) | Self::Skipped(detail) => {
847                detail.clone()
848            }
849            Self::Changed(detail, limitation) => limitation.as_ref().map_or_else(
850                || detail.clone(),
851                |limit| format!("{detail} (limitation: {limit})"),
852            ),
853        }
854    }
855}
856
857/// One step, full lifecycle.
858#[allow(
859    clippy::too_many_lines,
860    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"
861)]
862fn apply_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
863    // Prerequisites are observed, not remembered: the forge is the
864    // authority on whether an earlier step's state holds.
865    for prereq in step.prereqs {
866        let state = observe_with(engine, prereq)?;
867        if !state.satisfied() {
868            return Err(RkError::refusal(
869                Diagnostic::new(
870                    Reason::PrerequisiteUnmet,
871                    format!(
872                        "{} requires {prereq} first: {}",
873                        step.name,
874                        state_detail(&state)
875                    ),
876                )
877                .expected(format!("{prereq} satisfied before {}", step.name))
878                .action(format!(
879                    "rk setup step {prereq} --target {} --apply",
880                    engine.ctx.target
881                ))
882                .step(step.name),
883            ));
884        }
885    }
886    match step.name {
887        "package-check" => {
888            if engine.ctx.tech.is_none() {
889                return Err(RkError::Usage(
890                    "no version file names a technology; rk binding --list names the bindings"
891                        .into(),
892                ));
893            }
894            let state = observe_with(engine, "package-check")?;
895            match state {
896                StepState::Satisfied { detail, .. } => Ok(Done::Passed(detail)),
897                StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
898                    Err(RkError::subprocess(
899                        Diagnostic::new(
900                            Reason::SubprocessFailed,
901                            format!("package-check failed: {detail}"),
902                        )
903                        .expected(step.proves.to_owned())
904                        .step(step.name),
905                    ))
906                }
907                StepState::Unknown { detail } => Err(RkError::subprocess(
908                    Diagnostic::new(
909                        Reason::SubprocessFailed,
910                        format!("package-check could not run: {detail}"),
911                    )
912                    .step(step.name),
913                )),
914            }
915        }
916        // The step mutates nothing, so apply and check read the same answer
917        // and apply writes nothing at all. An unreadable version is a
918        // refusal, never a pass: the floor exists to stop a protection the
919        // forge cannot honor, and a floor nobody could read proves neither
920        // way.
921        "forge-version" => match observe_with(engine, "forge-version")? {
922            StepState::Satisfied { detail, .. } => Ok(Done::Satisfied(detail)),
923            StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
924                Err(RkError::refusal(
925                    Diagnostic::new(Reason::PrerequisiteUnmet, detail)
926                        .expected(step.proves.to_owned())
927                        .action("upgrade the instance, or host the project on gitlab.com")
928                        .target_state("unchanged")
929                        .step(step.name),
930                ))
931            }
932            StepState::Unknown { detail } => Err(RkError::refusal(
933                Diagnostic::new(Reason::ForgeTemporary, detail)
934                    .expected("a readable forge version")
935                    .action("glab auth login, then rerun")
936                    .target_state("unchanged")
937                    .step(step.name),
938            )),
939        },
940        "branch-reminder" => {
941            use crate::setup::branch_reminder::{HookState, hook_body, hook_path, observe_hook};
942            match observe_hook(&engine.ctx.target) {
943                HookState::Installed => Ok(Done::Satisfied(
944                    "the post-merge reminder hook is installed".into(),
945                )),
946                HookState::Foreign => Err(RkError::refusal(
947                    Diagnostic::new(
948                        Reason::StateDrift,
949                        "a foreign post-merge hook exists; the reminder is never written over it",
950                    )
951                    .expected("no post-merge hook, or one carrying the release-kit marker")
952                    .action(
953                        "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`",
954                    )
955                    .target_state("unchanged")
956                    .step(step.name),
957                )),
958                HookState::Unreadable(detail) => Err(RkError::refusal(
959                    Diagnostic::new(
960                        Reason::StateDrift,
961                        format!("the post-merge hook cannot be read: {detail}"),
962                    )
963                    .target_state("unchanged")
964                    .step(step.name),
965                )),
966                HookState::Absent | HookState::Drifted => {
967                    let path = hook_path(&engine.ctx.target).map_err(|detail| {
968                        RkError::refusal(
969                            Diagnostic::new(
970                                Reason::PrerequisiteUnmet,
971                                format!("the hooks directory cannot be resolved: {detail}"),
972                            )
973                            .expected("a git repository whose hooks directory git can name")
974                            .step(step.name),
975                        )
976                    })?;
977                    crate::atomic::write(&path, hook_body())?;
978                    #[cfg(unix)]
979                    {
980                        use std::os::unix::fs::PermissionsExt as _;
981                        std::fs::set_permissions(
982                            &path,
983                            std::fs::Permissions::from_mode(0o755),
984                        )?;
985                    }
986                    Ok(Done::Changed(
987                        "wrote the post-merge reminder hook".into(),
988                        None,
989                    ))
990                }
991            }
992        }
993        "single-trunk" => {
994            let guard = {
995                let ctx = clone_ctx(&engine.ctx);
996                let mut runner = |exec: &Exec| engine.exec(exec, false);
997                observe::single_trunk_guard(&ctx, &mut runner)?
998            };
999            // A destructive step fails closed: an ancestry the guard cannot
1000            // establish is treated exactly like one it refuted.
1001            match &guard {
1002                StepState::Satisfied { .. } => {}
1003                StepState::Unsatisfied { detail }
1004                | StepState::Inapplicable { detail }
1005                | StepState::Unknown { detail } => {
1006                    return Err(RkError::refusal(
1007                        Diagnostic::new(
1008                            Reason::DestructiveRefusal,
1009                            format!("single-trunk refuses: {detail}"),
1010                        )
1011                        .expected(
1012                            "proof that every candidate branch is absent, or an ancestor of the trunk",
1013                        )
1014                        .step(step.name),
1015                    ));
1016                }
1017            }
1018            run_forge_step(engine, step)
1019        }
1020        "bot-secrets" => {
1021            // Validate before observing: a wrong path or a wrong mode is
1022            // the operator's answer either way, and the refusal costs no
1023            // forge call. Only GitHub reads a key file; GitLab's credential
1024            // is a token, and its step must not fail over a variable it
1025            // never consumes.
1026            let adapter = engine.ctx.adapter()?;
1027            let key = match adapter {
1028                // The run's one read: an install-bot observation earlier in
1029                // this run already holds the bytes, and this step stores
1030                // those very bytes rather than reopening the path.
1031                Forge::Github => key_file_for(engine)?.map(|key| key.bytes.clone()),
1032                Forge::Gitlab => None,
1033            };
1034            let provided = match adapter {
1035                // Both halves of an App identity, or neither: a run holding
1036                // only one of them would store half a credential.
1037                Forge::Github => secrets::value_of("RK_BOT_APP_ID").is_some() && key.is_some(),
1038                Forge::Gitlab => secrets::value_of("RK_BOT_TOKEN").is_some(),
1039            };
1040            let state = observe_with(engine, step.name)?;
1041            if !provided {
1042                if state.satisfied() {
1043                    return Ok(Done::Satisfied(state_detail(&state)));
1044                }
1045                let wanted = match adapter {
1046                    Forge::Github => {
1047                        "export RK_BOT_APP_ID and RK_BOT_PRIVATE_KEY_FILE, the second naming the .pem; rk forge github carries the walkthrough"
1048                    }
1049                    Forge::Gitlab => {
1050                        "rk setup step install-bot --apply stores the token, or export RK_BOT_TOKEN to rotate one"
1051                    }
1052                };
1053                return Err(RkError::refusal(
1054                    Diagnostic::new(
1055                        Reason::PrerequisiteUnmet,
1056                        "bot-secrets has no credentials to store",
1057                    )
1058                    .expected("the bot credentials in the environment, the key as a path")
1059                    .action(wanted.to_owned())
1060                    .step(step.name),
1061                ));
1062            }
1063            if let Some(journal) = &mut engine.journal {
1064                for name in SECRET_VARS {
1065                    if secrets::value_of(name).is_some() {
1066                        journal.record_secret(name, true, "environment");
1067                    }
1068                }
1069                if key.is_some() {
1070                    journal.record_secret(secrets::PRIVATE_KEY_FILE, true, "file");
1071                }
1072            }
1073            // The bytes rk validated are the bytes the child receives and
1074            // the bytes the redactor holds: one read, one value, so nothing
1075            // can be substituted between the check and the forge.
1076            let stdin = key;
1077            run_forge_step_with(engine, step, stdin, Vec::new())
1078        }
1079        "protections-check" => {
1080            let (outcome, _) = run_script(engine, step)?;
1081            if !outcome.success() {
1082                return Err(classify_failure(engine, step, &outcome));
1083            }
1084            // The script is the operator-auditable mirror; the observation
1085            // is the authoritative shape check, so the step passes only
1086            // when both agree.
1087            match observe_with(engine, step.name)? {
1088                StepState::Satisfied { detail, limitation } => {
1089                    Ok(Done::Passed(limitation.map_or_else(
1090                        || detail.clone(),
1091                        |limit| format!("{detail} (limitation: {limit})"),
1092                    )))
1093                }
1094                StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1095                    Err(RkError::refusal(
1096                        Diagnostic::new(
1097                            Reason::StateDrift,
1098                            format!("protections-check passed its script and the observation disagrees: {detail}"),
1099                        )
1100                        .expected(step.proves.to_owned())
1101                        .step(step.name),
1102                    ))
1103                }
1104                // An unreadable readback is a retryable outage, not drift,
1105                // exactly as the postcondition lifecycle classifies it.
1106                StepState::Unknown { detail } => Err(RkError::refusal(
1107                    Diagnostic::new(
1108                        Reason::ForgeTemporary,
1109                        format!(
1110                            "protections-check passed its script and the readback could not confirm it: {detail}"
1111                        ),
1112                    )
1113                    .expected(step.proves.to_owned())
1114                    .action("check authentication and connectivity, then rerun")
1115                    .step(step.name),
1116                )),
1117            }
1118        }
1119        // GitHub's grant is the one write the forge offers a command, and
1120        // it takes a user credential; everything else here — the pre- and
1121        // post-observation, and the installation id the script is handed —
1122        // happens as the App itself. GitLab's install-bot needs none of
1123        // this and takes the generic lifecycle below.
1124        "install-bot" if engine.ctx.forge == Some(Forge::Github) => {
1125            match observe_with(engine, step.name)? {
1126                StepState::Satisfied { detail, .. } => {
1127                    return Ok(Done::Satisfied(detail));
1128                }
1129                StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
1130                StepState::Unknown { detail } => {
1131                    return Err(RkError::refusal(
1132                        Diagnostic::new(
1133                            Reason::ForgeTemporary,
1134                            format!("{} cannot observe the current state: {detail}", step.name),
1135                        )
1136                        .expected("a readable forge answer before anything mutates")
1137                        .action("check the App credentials and connectivity, then rerun")
1138                        .step(step.name),
1139                    ));
1140                }
1141            }
1142            let installation = github_installation_id(engine, step)?;
1143            run_forge_step_with(
1144                engine,
1145                step,
1146                None,
1147                vec![("RK_BOT_INSTALLATION".into(), installation.into())],
1148            )
1149        }
1150        _ => {
1151            if step.mutates == Mutates::Forge {
1152                // The lifecycle applies only on a state it has read: an
1153                // observation that cannot decide fails closed here exactly
1154                // as it does after the write, so no mutation ever rides on
1155                // an unreadable forge answer.
1156                match observe_with(engine, step.name)? {
1157                    StepState::Satisfied { detail, limitation } => {
1158                        let detail = if step.name == "private-vulnerability-reporting" {
1159                            limitation.map_or_else(
1160                                || detail.clone(),
1161                                |limit| format!("{detail} (limitation: {limit})"),
1162                            )
1163                        } else {
1164                            detail
1165                        };
1166                        return Ok(Done::Satisfied(detail));
1167                    }
1168                    StepState::Inapplicable { detail }
1169                        if step.name == "private-vulnerability-reporting" =>
1170                    {
1171                        return Ok(Done::Skipped(detail));
1172                    }
1173                    StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
1174                    StepState::Unknown { detail } => {
1175                        return Err(RkError::refusal(
1176                            Diagnostic::new(
1177                                Reason::ForgeTemporary,
1178                                format!("{} cannot observe the current state: {detail}", step.name),
1179                            )
1180                            .expected("a readable forge answer before anything mutates")
1181                            .action("check authentication and connectivity, then rerun")
1182                            .step(step.name),
1183                        ));
1184                    }
1185                }
1186            }
1187            run_forge_step(engine, step)
1188        }
1189    }
1190}
1191
1192/// The id of the App's installation on this repository's owner, read as
1193/// the App itself. The grant needs it, and no user credential can read
1194/// it: the observation that just ran answered 404, so the repository
1195/// endpoint that names the id directly has nothing to say yet. The
1196/// account-level installation is a direct read for either account kind —
1197/// the user endpoint answers for a person, the organization endpoint for
1198/// an organization — so nothing here lists or paginates.
1199fn github_installation_id(engine: &mut Engine, step: &StepSpec) -> Result<String, RkError> {
1200    let refuse = |message: String, action: &str| {
1201        RkError::refusal(
1202            Diagnostic::new(Reason::PrerequisiteUnmet, message)
1203                .expected("the App installed on the repository's owner")
1204                .action(action.to_owned())
1205                .step(step.name),
1206        )
1207    };
1208    let jwt = match app_jwt_for(engine)? {
1209        Ok(jwt) => jwt,
1210        Err(detail) => {
1211            return Err(refuse(
1212                format!("install-bot has no App token: {detail}"),
1213                app_jwt::REMEDIATION,
1214            ));
1215        }
1216    };
1217    let owner = engine
1218        .ctx
1219        .repo
1220        .split('/')
1221        .next()
1222        .unwrap_or_default()
1223        .to_owned();
1224    let ctx = clone_ctx(&engine.ctx);
1225    for path in [
1226        format!("users/{owner}/installation"),
1227        format!("orgs/{owner}/installation"),
1228    ] {
1229        match app_jwt::api_get(&ctx, &jwt, &path) {
1230            AppApi::Ok(body) => {
1231                return body["id"].as_i64().map(|id| id.to_string()).ok_or_else(|| {
1232                    refuse(
1233                        format!("the forge answered {path} without an installation id"),
1234                        "check RK_BOT_APP_ID and the key file name the same App",
1235                    )
1236                });
1237            }
1238            AppApi::Missing => {}
1239            AppApi::Refused(detail) => {
1240                return Err(refuse(
1241                    detail,
1242                    "check RK_BOT_APP_ID and the key file name the same App",
1243                ));
1244            }
1245            AppApi::Failed(detail) => {
1246                return Err(RkError::refusal(
1247                    Diagnostic::new(
1248                        Reason::ForgeTemporary,
1249                        format!("install-bot cannot read the App's installation: {detail}"),
1250                    )
1251                    .action("check connectivity, then rerun")
1252                    .step(step.name),
1253                ));
1254            }
1255        }
1256    }
1257    Err(refuse(
1258        format!("the App has no installation on {owner}"),
1259        "install the App on the account first; the setup guide's step 5 walks it",
1260    ))
1261}
1262
1263/// Materialize, spawn, classify, and verify one forge-mutating step.
1264fn run_forge_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
1265    run_forge_step_with(engine, step, None, Vec::new())
1266}
1267
1268/// The same, with bytes written to the step's standard input and values
1269/// `rk` derived added to its environment.
1270fn run_forge_step_with(
1271    engine: &mut Engine,
1272    step: &StepSpec,
1273    stdin: Option<Zeroizing<Vec<u8>>>,
1274    extra_env: Vec<(OsString, OsString)>,
1275) -> Result<Done, RkError> {
1276    let (outcome, _) = run_script_with(engine, step, stdin, extra_env)?;
1277    if !outcome.success() {
1278        return Err(classify_failure(engine, step, &outcome));
1279    }
1280    let state = observe_with(engine, step.name)?;
1281    match state {
1282        StepState::Satisfied { detail, limitation } => Ok(Done::Changed(detail, limitation)),
1283        StepState::Inapplicable { detail } if step.name == "private-vulnerability-reporting" => {
1284            Ok(Done::Skipped(detail))
1285        }
1286        StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1287            Err(RkError::refusal(
1288                Diagnostic::new(
1289                    Reason::StateDrift,
1290                    format!(
1291                        "{} ran and its postcondition does not hold: {detail}",
1292                        step.name
1293                    ),
1294                )
1295                .expected(step.proves.to_owned())
1296                .step(step.name),
1297            ))
1298        }
1299        // The lifecycle ends with a proven postcondition; a readback that
1300        // cannot run leaves the step unproven, and an unproven apply is a
1301        // failure a retry can cure, never a success.
1302        StepState::Unknown { detail } => Err(RkError::refusal(
1303            Diagnostic::new(
1304                Reason::ForgeTemporary,
1305                format!(
1306                    "{} ran and the readback could not confirm it: {detail}",
1307                    step.name
1308                ),
1309            )
1310            .expected(step.proves.to_owned())
1311            .action(format!(
1312                "rk setup step {} --target {} --apply re-asserts and re-proves it",
1313                step.name, engine.ctx.target
1314            ))
1315            .step(step.name),
1316        )),
1317    }
1318}
1319
1320/// Observe one step through the engine's executor.
1321///
1322/// `install-bot` on GitHub is the one observation that authenticates as
1323/// the App itself, so it routes through [`app_jwt_for`] here — where the
1324/// engine can mint once and register the redaction needles — rather than
1325/// through the credential-free name dispatch in [`observe::observe`].
1326fn observe_with(engine: &mut Engine, step: &str) -> Result<StepState, RkError> {
1327    if step == "install-bot" && engine.ctx.forge == Some(Forge::Github) {
1328        let jwt = match app_jwt_for(engine)? {
1329            Ok(jwt) => jwt,
1330            Err(detail) => return Ok(StepState::Unknown { detail }),
1331        };
1332        return Ok(observe::github_install_bot(&engine.ctx, &jwt));
1333    }
1334    let ctx = clone_ctx(&engine.ctx);
1335    let mut runner = |exec: &Exec| engine.exec(exec, false);
1336    observe::observe(&ctx, step, &mut runner)
1337}
1338
1339/// The run's validated key file, read exactly once per run: the first
1340/// consumer resolves it and every later one reuses the same bytes, so the
1341/// file that authenticated the App is the file `bot-secrets` stores, and
1342/// no replacement between steps can split the two. The bytes become a
1343/// redaction needle the moment they are read.
1344fn key_file_for(engine: &mut Engine) -> Result<Option<&secrets::KeyFile>, RkError> {
1345    if engine.key.is_none() && engine.key_unavailable.is_none() {
1346        match secrets::look_up_key_file(&engine.ctx.target)? {
1347            secrets::KeyLookup::Absent => {}
1348            secrets::KeyLookup::Found(key) => {
1349                engine.secrets.push(key.bytes.clone());
1350                engine.key = Some(key);
1351            }
1352            // A check reads the forge and changes nothing, so a key this
1353            // runtime cannot reach leaves the one step that needs it
1354            // undecided and lets every other step run. A run that would
1355            // mutate, or record a proof, still needs the key and refuses.
1356            secrets::KeyLookup::Unavailable(unavailable) if engine.key_boundary => {
1357                engine.key_unavailable = Some(unavailable.message);
1358            }
1359            secrets::KeyLookup::Unavailable(unavailable) => return Err(unavailable.refusal()),
1360        }
1361    }
1362    Ok(engine.key.as_ref())
1363}
1364
1365/// The run's App JWT, minted at most once: the key comes from the run's
1366/// one read, and the minted token and its signature segment become
1367/// redaction needles before anything else spawns. Every install-bot
1368/// observation and the grant's installation-id discovery reuse the one
1369/// token, whose nine-minute life covers a run's contiguous step easily.
1370///
1371/// The inner value is `Err` with a one-line detail where no token can
1372/// exist — absent exports, or a signer that failed — which an observation
1373/// reports as `unknown` and an apply turns into a refusal.
1374fn app_jwt_for(engine: &mut Engine) -> Result<Result<String, String>, RkError> {
1375    if let Some(jwt) = &engine.app_jwt {
1376        return Ok(Ok(jwt.clone()));
1377    }
1378    let app_id = app_jwt::app_id(engine.ctx.bot_app_id())?;
1379    let key_bytes = key_file_for(engine)?.map(|key| key.bytes.clone());
1380    if let Some(unreachable) = &engine.key_unavailable {
1381        return Ok(Err(format!(
1382            "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",
1383            secrets::PRIVATE_KEY_FILE
1384        )));
1385    }
1386    let (Some(app_id), Some(key_bytes)) = (app_id, key_bytes) else {
1387        return Ok(Err(format!(
1388            "the installation is readable only to the App itself; {}",
1389            app_jwt::REMEDIATION
1390        )));
1391    };
1392    let credentials = app_jwt::AppCredentials { app_id, key_bytes };
1393    let ctx = clone_ctx(&engine.ctx);
1394    Ok(match app_jwt::mint(&ctx, &credentials) {
1395        Ok(jwt) => {
1396            engine
1397                .secrets
1398                .push(Zeroizing::new(jwt.clone().into_bytes()));
1399            if let Some(signature) = jwt.rsplit('.').next() {
1400                engine
1401                    .secrets
1402                    .push(Zeroizing::new(signature.as_bytes().to_vec()));
1403            }
1404            engine.app_jwt = Some(jwt.clone());
1405            Ok(jwt)
1406        }
1407        Err(detail) => Err(detail),
1408    })
1409}
1410
1411fn state_detail(state: &StepState) -> String {
1412    state.detail().to_owned()
1413}
1414
1415/// Materialize the step's script into the run's private directory, prove
1416/// the written bytes by digest, and spawn it through the interpreter.
1417fn run_script(engine: &mut Engine, step: &StepSpec) -> Result<(Outcome, PathBuf), RkError> {
1418    run_script_with(engine, step, None, Vec::new())
1419}
1420
1421/// The same, with bytes written to the script's standard input.
1422///
1423/// A credential travels this way and no other: `rk` reads it, validates it,
1424/// and hands the child the bytes it validated, so nothing between the check
1425/// and the forge can substitute a different file.
1426fn run_script_with(
1427    engine: &mut Engine,
1428    step: &StepSpec,
1429    stdin: Option<Zeroizing<Vec<u8>>>,
1430    extra_env: Vec<(OsString, OsString)>,
1431) -> Result<(Outcome, PathBuf), RkError> {
1432    let forge = engine.ctx.adapter()?;
1433    let rel = format!("{}/{}", forge.as_str(), step.name);
1434    let bytes = embedded::SETUP
1435        .get_file(&rel)
1436        .map(include_dir::File::contents)
1437        .ok_or_else(|| RkError::Other(anyhow::anyhow!("no embedded script at setup/{rel}")))?;
1438    let journal = engine
1439        .journal
1440        .as_mut()
1441        .ok_or_else(|| RkError::Other(anyhow::anyhow!("an apply always has a journal")))?;
1442    let dir = journal.scripts_dir().join(forge.as_str());
1443    fs::create_dir_all(&dir)?;
1444    restrict(&dir, 0o700);
1445    let path = dir.join(step.name);
1446    fs::write(&path, bytes)?;
1447    restrict(&path, 0o600);
1448    let written = fs::read(&path)?;
1449    let digest = Digest::of(&written);
1450    if digest != Digest::of(bytes) {
1451        return Err(RkError::Other(anyhow::anyhow!(
1452            "the materialized script at {} differs from the embedded bytes",
1453            path.display()
1454        )));
1455    }
1456    journal.record_script(format!("scripts/{rel}"), digest.to_string());
1457    let mut env = engine.ctx.child_env(step.name);
1458    env.extend(extra_env);
1459    let exec = Exec {
1460        program: crate::probes::sh_bin(),
1461        args: vec![path.clone().into_os_string()],
1462        env,
1463        cwd: engine.ctx.target.as_std_path().to_path_buf(),
1464        stdin,
1465    };
1466    let outcome = engine.exec(&exec, true)?;
1467    Ok((outcome, path))
1468}
1469
1470/// Honest classification: `gh` documents exit 4 as authentication required;
1471/// beyond that only an HTTP status in the response says more, and a step
1472/// that fails for a reason nothing establishes stays `subprocess-failed`
1473/// with its own stderr surfaced verbatim.
1474fn classify_failure(engine: &Engine, step: &StepSpec, outcome: &Outcome) -> RkError {
1475    let stderr = String::from_utf8_lossy(&outcome.stderr);
1476    // A signalled child usually writes nothing before it dies, and
1477    // "no output" would blame the forge for a kill that came from
1478    // outside it. The adapter already resolved 128+N; say which.
1479    let last = if outcome.exit_code >= 128 {
1480        format!("killed by signal {}", outcome.exit_code - 128)
1481    } else {
1482        stderr
1483            .lines()
1484            .rev()
1485            .find(|line| !line.trim().is_empty())
1486            .unwrap_or("no output")
1487            .to_owned()
1488    };
1489    let reason = if (engine.ctx.forge == Some(Forge::Github) && outcome.exit_code == 4)
1490        || stderr.contains("HTTP 401")
1491    {
1492        Reason::ForgeAuthentication
1493    } else if stderr.contains("HTTP 403") {
1494        Reason::ForgePermission
1495    } else if stderr.contains("HTTP 429") || stderr.contains("rate limit") {
1496        Reason::ForgeRateLimit
1497    } else {
1498        Reason::SubprocessFailed
1499    };
1500    let diagnostic = Diagnostic::new(reason, format!("the forge refused '{}': {last}", step.name))
1501        .expected(step.proves.to_owned())
1502        .action(format!(
1503            "rk setup step {} --target {} --apply",
1504            step.name, engine.ctx.target
1505        ))
1506        .step(step.name);
1507    let diagnostic = match reason {
1508        Reason::ForgePermission => diagnostic.expected(format!(
1509            "repository administration write on {} for the authenticated account",
1510            engine.ctx.repo
1511        )),
1512        _ => diagnostic,
1513    };
1514    match reason {
1515        Reason::SubprocessFailed => RkError::subprocess(diagnostic),
1516        _ => RkError::refusal(diagnostic),
1517    }
1518}
1519
1520/// Fold the run's progress into the failure, so the diagnostic answers
1521/// what state the target is in.
1522fn attach_progress(
1523    error: RkError,
1524    done: &[(String, String)],
1525    failed: &StepSpec,
1526    steps: &[&StepSpec],
1527) -> RkError {
1528    let remaining = steps.len().saturating_sub(done.len() + 1);
1529    let state = format!(
1530        "{} completed; {} failed; {remaining} not attempted",
1531        step_count(done.len()),
1532        failed.name
1533    );
1534    match error {
1535        RkError::Refusal(mut diagnostic) => {
1536            diagnostic.target_state.get_or_insert(state);
1537            RkError::Refusal(diagnostic)
1538        }
1539        RkError::Subprocess(mut diagnostic) => {
1540            diagnostic.target_state.get_or_insert(state);
1541            RkError::Subprocess(diagnostic)
1542        }
1543        other => other,
1544    }
1545}
1546
1547/// Observe every step once, in table order, and render each row as it
1548/// lands, so a person reads the forge calls beside the step that made
1549/// them. The rows it returns are the only source of every later verdict.
1550///
1551/// SATISFIES setup-proof:one-report-owns-every-classification
1552fn observe_all(engine: &mut Engine) -> Result<Report, RkError> {
1553    let mut report = Report::default();
1554    for step in &STEPS {
1555        let clock = Instant::now();
1556        // A step the target declared it does not run is stated and judged
1557        // by nothing: no forge call, no verdict, and no weight in the exit
1558        // code. The reason travels with it, so a reader can tell a chosen
1559        // subset from an incomplete setup.
1560        let stance = stance(&engine.ctx, step);
1561        let row = if stance.acts() {
1562            let state = observe_with(engine, step.name)?;
1563            Row::observed(step, stance, &state)
1564        } else {
1565            Row::stated(step, stance)
1566        };
1567        engine.out.result_line(row.line());
1568        let (status, detail) = row.event_fields();
1569        let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
1570        finished.status = Some(status);
1571        finished.detail = Some(detail);
1572        finished.duration_ms = Some(elapsed_ms(clock));
1573        engine.emit(&finished);
1574        report.rows.push(row);
1575    }
1576    if report.judged() < STEPS.len() {
1577        engine.out.result_line(format!(
1578            "{} judged; the rest do not apply to this target or {} excludes them",
1579            step_count(report.judged()),
1580            crate::config::CONFIG_PATH
1581        ));
1582    }
1583    Ok(report)
1584}
1585
1586/// `rk setup check`: observe and verify every step, report per step, and
1587/// judge at the end. The mutating half is unreachable from this path: it
1588/// calls only the observe functions.
1589fn check(out: Output, ctx: Ctx) -> Result<(), RkError> {
1590    let mut engine = Engine::open(out, ctx, "setup check", false)?;
1591    engine.key_boundary = true;
1592    let report = observe_all(&mut engine)?;
1593    if !report.checkpointable() {
1594        let error = verdict(&engine.ctx, &report);
1595        return Err(fail(&mut engine, error));
1596    }
1597    engine
1598        .out
1599        .next(&["rk guide release orders the first release".to_owned()]);
1600    engine.finish(0, None);
1601    Ok(())
1602}
1603
1604/// The check's failure, by what it found. An unsatisfied step is drift,
1605/// and applying the setup is its remedy. An unknown step is an
1606/// observation this runtime could not make, which proves nothing wrong,
1607/// so it prescribes no apply: it names where the observation can be
1608/// repeated, and what the committed proof says meanwhile. A proof never
1609/// masks a step found wrong now.
1610///
1611/// SATISFIES setup-proof:an-unavailable-credential-is-an-observation-boundary
1612/// SATISFIES setup-proof:current-evidence-overrides-the-proof
1613fn verdict(ctx: &Ctx, report: &Report) -> RkError {
1614    let unsatisfied = report.unsatisfied();
1615    let unknown = report.unknown();
1616    let standing = proof::judge(&ctx.target, &proof::subject(ctx));
1617    let proven = match &standing {
1618        Standing::Compatible(proof) => Some(proof),
1619        _ => None,
1620    };
1621    if unsatisfied == 0 {
1622        let state = proven.map_or_else(
1623            || {
1624                format!(
1625                    "no setup defect was inferred; no compatible {} is committed, so the current state is unverified",
1626                    proof::PROOF_PATH
1627                )
1628            },
1629            |proof| {
1630                format!(
1631                    "no setup defect was inferred; the committed setup proof from {} by rk {} still matches this target's setup contract",
1632                    proof.verified_at, proof.rk_version
1633                )
1634            },
1635        );
1636        return RkError::check_failed(
1637            Diagnostic::new(
1638                Reason::ObservationIncomplete,
1639                format!(
1640                    "{} could not be verified from this runtime, and none is unsatisfied",
1641                    step_count(unknown)
1642                ),
1643            )
1644            .expected("every applicable step readable from where the check runs")
1645            .action("rerun rk setup check where each unknown step can be observed; its line names what it needs")
1646            .target_state(state),
1647        );
1648    }
1649    let mut message = format!(
1650        "{} {} not satisfied",
1651        step_count(unsatisfied),
1652        if unsatisfied == 1 { "is" } else { "are" }
1653    );
1654    let mut action = format!(
1655        "rk setup --target {} --apply re-asserts the unsatisfied ones",
1656        ctx.target
1657    );
1658    if unknown > 0 {
1659        use std::fmt::Write as _;
1660        let _ = write!(
1661            message,
1662            ", and {unknown} could not be verified from this runtime"
1663        );
1664        action.push_str("; the unknown ones need a runtime that can observe what each line names");
1665    }
1666    let mut diagnostic = Diagnostic::new(Reason::StateDrift, message)
1667        .expected("every step's proof column to hold")
1668        .action(action);
1669    if let Some(proof) = proven {
1670        diagnostic = diagnostic.target_state(format!(
1671            "this observation supersedes the committed setup proof from {} by rk {}",
1672            proof.verified_at, proof.rk_version
1673        ));
1674    }
1675    RkError::check_failed(diagnostic)
1676}
1677
1678/// `rk setup checkpoint`: observe every step as `check` does, and commit
1679/// the result only where the observation is complete. An incomplete one
1680/// writes nothing, so an earlier proof stays byte for byte.
1681///
1682/// SATISFIES setup-proof:a-checkpoint-records-only-a-complete-observation
1683fn checkpoint(out: Output, ctx: Ctx) -> Result<(), RkError> {
1684    let mut engine = Engine::open(out, ctx, "setup checkpoint", false)?;
1685    let report = observe_all(&mut engine)?;
1686    let Some(proof) = proof::of(&engine.ctx, &report, crate::applog::now_utc()) else {
1687        let error = RkError::check_failed(
1688            Diagnostic::new(
1689                Reason::StateDrift,
1690                format!(
1691                    "the observation is incomplete: {} not satisfied and {} could not be verified",
1692                    step_count(report.unsatisfied()),
1693                    report.unknown()
1694                ),
1695            )
1696            .expected("every applicable step observed and holding")
1697            .action(format!(
1698                "rk setup check --target {} names each gap; checkpoint again once none remains",
1699                engine.ctx.target
1700            ))
1701            .target_state(format!("{} was not written", proof::PROOF_PATH)),
1702        );
1703        return Err(fail(&mut engine, error));
1704    };
1705    if let Err(source) = proof::write(&engine.ctx.target, &proof) {
1706        let error = RkError::refusal(
1707            Diagnostic::new(
1708                Reason::Io,
1709                format!("{} cannot be written: {source}", proof::PROOF_PATH),
1710            )
1711            .expected("a writable .release-kit directory in the target")
1712            .target_state("the earlier proof, where one exists, is unchanged"),
1713        );
1714        return Err(fail(&mut engine, error));
1715    }
1716    engine.out.result_line(format!(
1717        "wrote {}: {} proven at {} by rk {}",
1718        proof::PROOF_PATH,
1719        step_count(proof.steps.len()),
1720        proof.verified_at,
1721        proof.rk_version
1722    ));
1723    engine.out.next(&[
1724        format!("commit {} with the setup it proves", proof::PROOF_PATH),
1725        format!(
1726            "rk setup status --target {} reads it offline",
1727            engine.ctx.target
1728        ),
1729    ]);
1730    engine.finish(0, None);
1731    Ok(())
1732}
1733
1734/// `rk setup status`: where the committed proof stands against this
1735/// target's contract now. It reads a file and resolves configuration,
1736/// and nothing else: no forge, no key, no journal.
1737///
1738/// SATISFIES setup-proof:the-status-reads-the-proof-offline
1739fn status(out: Output, ctx: &Ctx) -> Result<(), RkError> {
1740    let standing = proof::judge(&ctx.target, &proof::subject(ctx));
1741    out.emit(&proof::StatusDocument::of(&standing))?;
1742    match &standing {
1743        Standing::Absent => out.result_line(format!(
1744            "setup proof: absent — {} does not exist, so no complete observation is on record",
1745            proof::PROOF_PATH
1746        )),
1747        Standing::Compatible(proof) => out.result_line(format!(
1748            "setup proof: compatible — proven {} by rk {}, and this target's setup contract has not changed since",
1749            proof.verified_at, proof.rk_version
1750        )),
1751        Standing::Stale { proof, differences } => out.result_line(format!(
1752            "setup proof: stale — proven {} by rk {}, and the setup contract changed since: {}",
1753            proof.verified_at,
1754            proof.rk_version,
1755            differences.join(", ")
1756        )),
1757        Standing::Invalid { detail, .. } => {
1758            out.result_line(format!("setup proof: invalid — {detail}"));
1759        }
1760    }
1761    if let Standing::Compatible(proof) | Standing::Stale { proof, .. } = &standing {
1762        for step in &proof.steps {
1763            let limitation = step
1764                .limitation
1765                .as_ref()
1766                .map_or_else(String::new, |limit| format!(" (limitation: {limit})"));
1767            out.result_line(format!("  {} {}{limitation}", step.state, step.name));
1768        }
1769    }
1770    let next = match standing {
1771        Standing::Compatible(_) => format!(
1772            "rk setup check --target {} observes the forge now, where this runtime can",
1773            ctx.target
1774        ),
1775        _ => format!(
1776            "rk setup checkpoint --target {}, from a host that can observe every step, records a proof",
1777            ctx.target
1778        ),
1779    };
1780    out.next(&[next]);
1781    Ok(())
1782}
1783
1784/// Restrict a materialized path's mode: data, not an executable — nothing
1785/// ever executes a script directly, so no mode is load-bearing.
1786fn restrict(path: &std::path::Path, mode: u32) {
1787    #[cfg(unix)]
1788    {
1789        use std::os::unix::fs::PermissionsExt as _;
1790        let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
1791    }
1792    #[cfg(not(unix))]
1793    let _ = (path, mode);
1794}
1795
1796/// A POSIX shell must spawn before anything else does; every step runs
1797/// through it.
1798fn guard_sh() -> Result<(), RkError> {
1799    let ok = std::process::Command::new(crate::probes::sh_bin())
1800        .args(["-c", "exit 0"])
1801        .status()
1802        .is_ok_and(|status| status.success());
1803    if ok {
1804        Ok(())
1805    } else {
1806        Err(RkError::refusal(
1807            Diagnostic::new(Reason::PrerequisiteUnmet, "no POSIX sh runs on this host")
1808                .expected("a working sh on PATH; every step spawns through it")
1809                .action("install a POSIX shell, then rerun")
1810                .target_state("nothing was run and nothing changed"),
1811        ))
1812    }
1813}
1814
1815#[cfg(test)]
1816mod tests {
1817    /// Every summary line reports its count through one helper, so none of
1818    /// them can regrow a dangling plural.
1819    #[test]
1820    fn a_step_count_carries_a_noun_that_agrees_with_it() {
1821        assert_eq!(super::step_count(0), "0 steps");
1822        assert_eq!(super::step_count(1), "1 step");
1823        assert_eq!(super::step_count(2), "2 steps");
1824    }
1825}