Skip to main content

release_kit/commands/
setup.rs

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