Skip to main content

release_kit/commands/
setup.rs

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