1use 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
34pub 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
107fn 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
119fn 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
144fn 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
184fn 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 if name == "branch-reminder" {
193 return Err(RkError::Usage(
194 "branch-reminder writes an embedded hook body and has no script; rk setup step branch-reminder previews the write".into(),
195 ));
196 }
197 let forge = match forge {
198 Some(value) => Forge::parse(value).ok_or_else(|| {
199 RkError::Usage(format!(
200 "unknown forge '{value}'; the forges are: github, gitlab"
201 ))
202 })?,
203 None => Forge::Github,
204 };
205 let path = format!("{}/{name}", forge.as_str());
206 let file = embedded::SETUP.get_file(&path).ok_or(RkError::NotFound {
207 kind: "setup step",
208 name: name.to_owned(),
209 })?;
210 Output::human().result_raw(&String::from_utf8_lossy(file.contents()));
211 Ok(())
212}
213
214struct Engine {
217 out: Output,
218 ctx: Ctx,
219 journal: Option<Journal>,
220 secrets: Vec<Zeroizing<Vec<u8>>>,
221 key: Option<secrets::KeyFile>,
223 app_jwt: Option<String>,
225 seq: u64,
226 command: &'static str,
227 run_id: String,
228}
229
230impl Engine {
231 fn open(
236 out: Output,
237 ctx: Ctx,
238 command: &'static str,
239 journal_required: bool,
240 ) -> Result<Self, RkError> {
241 secrets::refuse_legacy_key()?;
244 let journal =
245 match Journal::create(command, ctx.target.as_str(), ctx.forge.as_str(), &ctx.repo) {
246 Ok(journal) => Some(journal),
247 Err(source) if journal_required => {
248 return Err(RkError::refusal(
249 Diagnostic::new(
250 Reason::JournalUnavailable,
251 format!("the run journal cannot be created: {source}"),
252 )
253 .expected("a writable state root for the journal")
254 .target_state("nothing was run and nothing changed"),
255 ));
256 }
257 Err(source) => {
258 out.warn(format!("no run journal for this run: {source}"));
259 None
260 }
261 };
262 let run_id = journal
263 .as_ref()
264 .map_or_else(|| "unjournaled".to_owned(), |j| j.run_id().to_owned());
265 let mut engine = Self {
266 out,
267 ctx,
268 journal,
269 secrets: Ctx::secret_values(),
270 key: None,
271 app_jwt: None,
272 seq: 0,
273 command,
274 run_id,
275 };
276 let opening = Event::opening(
277 engine.next_seq(),
278 crate::applog::now_utc(),
279 engine.run_id.clone(),
280 engine.command,
281 );
282 engine.emit(&opening);
283 if engine.ctx.self_hosted_gitlab() {
284 engine.out.warn(
285 "this remote is a self-hosted GitLab: registry trusted publishing covers GitLab.com only, so the OIDC invariant cannot be satisfied here",
286 );
287 }
288 Ok(engine)
289 }
290
291 const fn next_seq(&mut self) -> u64 {
292 let seq = self.seq;
293 self.seq += 1;
294 seq
295 }
296
297 fn event(&mut self, kind: EventKind, step: Option<&str>) -> Event {
298 let mut event = Event::opening(
299 self.next_seq(),
300 crate::applog::now_utc(),
301 self.run_id.clone(),
302 self.command,
303 );
304 event.kind = kind;
305 event.step = step.map(str::to_owned);
306 event
307 }
308
309 fn emit(&mut self, event: &Event) {
310 self.out.event(event);
311 if let Some(journal) = &mut self.journal {
312 if let Ok(line) = serde_json::to_string(event) {
313 journal.event_line(&line);
314 }
315 }
316 }
317
318 fn exec(&mut self, exec: &Exec, passthrough: bool) -> Result<Outcome, RkError> {
321 let echo = exec.echo();
322 self.out.frame(&echo);
323 if let Some(journal) = &mut self.journal {
324 journal.transcript(echo.as_bytes());
325 journal.transcript(b"\n");
326 }
327 let secrets = std::mem::take(&mut self.secrets);
328 let step_name: Option<String> = None;
329 let mut chunks: Vec<(ChildStream, Vec<u8>)> = Vec::new();
330 let spawned = process::run(exec, |stream, chunk| {
331 chunks.push((stream, process::redact(chunk, &secrets)));
332 });
333 self.secrets = secrets;
334 for (stream, chunk) in chunks {
335 if passthrough {
336 self.out.child_passthrough(stream, &chunk);
337 }
338 let event = self.event(EventKind::ChildOutput, step_name.as_deref());
339 let event = event.child_output(stream, &chunk);
340 self.emit(&event);
341 if let Some(journal) = &mut self.journal {
342 journal.transcript(&chunk);
343 }
344 }
345 spawned.map_err(|source| {
346 RkError::refusal(
347 Diagnostic::new(
348 Reason::SubprocessSpawn,
349 format!("{} did not spawn: {source}", exec.program.to_string_lossy()),
350 )
351 .expected("a POSIX sh and the forge CLI on PATH")
352 .run(self.run_path()),
353 )
354 })
355 }
356
357 fn run_path(&self) -> String {
358 self.journal.as_ref().map_or_else(
359 || "no journal was written".to_owned(),
360 |j| j.dir.display().to_string(),
361 )
362 }
363
364 fn finish(&mut self, exit_code: i32, reason: Option<&str>) {
365 let mut event = self.event(EventKind::RunFinished, None);
366 event.exit_code = Some(exit_code);
367 event.status = Some(if exit_code == 0 {
368 "ok".into()
369 } else {
370 "failed".into()
371 });
372 self.emit(&event);
373 if let Some(journal) = &mut self.journal {
374 journal.finish(exit_code, reason);
375 }
376 }
377}
378
379fn fail(engine: &mut Engine, error: RkError) -> RkError {
381 let error = match error {
382 RkError::Refusal(mut diagnostic) => {
383 diagnostic.run.get_or_insert_with(|| engine.run_path());
384 RkError::Refusal(diagnostic)
385 }
386 RkError::Subprocess(mut diagnostic) => {
387 diagnostic.run.get_or_insert_with(|| engine.run_path());
388 RkError::Subprocess(diagnostic)
389 }
390 RkError::CheckFailed(mut diagnostic) => {
391 diagnostic.run.get_or_insert_with(|| engine.run_path());
392 RkError::CheckFailed(diagnostic)
393 }
394 other => other,
395 };
396 engine.finish(i32::from(error.exit_code()), Some(error.reason().as_str()));
397 error
398}
399
400fn preview(out: Output, ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
406 let mut engine = Engine::open(out, clone_ctx(ctx), "setup preview", false)?;
407 out.result_line(format!(
408 "DRY RUN: rk setup would run these steps against {} on {}; re-run with --apply",
409 engine.ctx.repo,
410 engine.ctx.forge.as_str()
411 ));
412 for (idx, step) in steps.iter().enumerate() {
413 out.result_line(format!(
414 "step {}/{} {} — proves {}",
415 idx + 1,
416 steps.len(),
417 step.name,
418 step.proves
419 ));
420 if step.name == "bot-secrets" && engine.ctx.forge == Forge::Github {
424 secrets::resolve_key_file(&engine.ctx.target)?;
425 }
426 out.result_line(format!(" {}", render_invocation(&engine.ctx, step)));
427 if step.name == "protect-trunk"
428 && engine.ctx.forge == Forge::Github
429 && engine.ctx.required_check.is_none()
430 {
431 out.result_line(" needs: --required-check <name> before apply");
432 }
433 if step.optional && steps.len() > 1 {
434 out.result_line(format!(
435 " optional: a full apply skips it; rk setup step {} --apply runs it",
436 step.name
437 ));
438 }
439 let mut event = engine.event(EventKind::StepFinished, Some(step.name));
440 event.status = Some("previewed".into());
441 engine.emit(&event);
442 }
443 let next = next_for_apply(&engine.ctx, steps);
444 out.next(&[
445 next,
446 "rk setup check --target . proves what is already true".to_owned(),
447 ]);
448 engine.finish(0, None);
449 Ok(())
450}
451
452fn render_invocation(ctx: &Ctx, step: &StepSpec) -> String {
455 match step.name {
456 "branch-reminder" => {
457 "would write: the post-merge reminder hook at $(git rev-parse --git-path hooks)/post-merge".to_owned()
458 }
459 "package-check" => match ctx.tech {
460 Some("rust") => "would run: cargo publish --dry-run --allow-dirty".to_owned(),
461 Some("python") => "would run: python3 -m build".to_owned(),
462 Some("bash") => "nothing to run: no registry for this technology".to_owned(),
463 _ => "needs: a version file naming the technology".to_owned(),
464 },
465 name => {
466 let check = ctx
467 .required_check
468 .as_ref()
469 .filter(|_| ctx.forge == Forge::Github && name == "protect-trunk")
470 .map(|value| format!(" RK_REQUIRED_CHECK={value}"))
471 .unwrap_or_default();
472 format!(
473 "would run: sh <embedded setup/{}/{name}> with RK_REPO={} RK_TRUNK_BRANCH=master{check}",
474 ctx.forge.as_str(),
475 ctx.repo
476 )
477 }
478 }
479}
480
481fn next_for_apply(ctx: &Ctx, steps: &[&StepSpec]) -> String {
482 let check = ctx
483 .required_check
484 .as_ref()
485 .map(|value| format!(" --required-check {value}"))
486 .unwrap_or_default();
487 if steps.len() == 1 {
488 format!(
489 "rk setup step {} --target {} --apply{check}",
490 steps[0].name, ctx.target
491 )
492 } else {
493 format!("rk setup --target {} --apply{check}", ctx.target)
494 }
495}
496
497fn clone_ctx(ctx: &Ctx) -> Ctx {
499 Ctx {
500 target: ctx.target.clone(),
501 repo: ctx.repo.clone(),
502 forge: ctx.forge,
503 host: ctx.host.clone(),
504 required_check: ctx.required_check.clone(),
505 cli: ctx.cli.clone(),
506 tech: ctx.tech,
507 }
508}
509
510fn execute(
512 out: Output,
513 ctx: Ctx,
514 steps: &[&StepSpec],
515 command: &'static str,
516) -> Result<(), RkError> {
517 guard_sh()?;
518 let mut engine = Engine::open(out, ctx, command, true)?;
519 let mut done: Vec<(String, String)> = Vec::new();
520 for (idx, step) in steps.iter().enumerate() {
521 if step.optional && steps.len() > 1 {
524 engine.out.frame(format!(
525 "step {}/{} {} — skipped (optional; rk setup step {} --apply runs it)",
526 idx + 1,
527 steps.len(),
528 step.name,
529 step.name
530 ));
531 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
532 finished.status = Some("skipped".into());
533 engine.emit(&finished);
534 done.push((step.name.to_owned(), "skipped".to_owned()));
535 continue;
536 }
537 engine.out.frame(format!(
538 "step {}/{} {} — {}",
539 idx + 1,
540 steps.len(),
541 step.name,
542 step.proves
543 ));
544 let mut started = engine.event(EventKind::StepStarted, Some(step.name));
545 started.status = Some("running".into());
546 engine.emit(&started);
547 let clock = Instant::now();
548 let status = match apply_step(&mut engine, step) {
549 Ok(status) => status,
550 Err(error) => {
551 let error = attach_progress(error, &done, step, steps);
552 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
553 finished.status = Some("failed".into());
554 finished.reason = Some(error.reason());
555 finished.duration_ms = Some(elapsed_ms(clock));
556 engine.emit(&finished);
557 return Err(fail(&mut engine, error));
558 }
559 };
560 engine
561 .out
562 .frame(format!("ok {}: {}", step.name, status.line()));
563 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
564 finished.status = Some(status.wire().into());
565 finished.exit_code = Some(0);
566 finished.duration_ms = Some(elapsed_ms(clock));
567 engine.emit(&finished);
568 done.push((step.name.to_owned(), status.wire().to_owned()));
569 }
570 engine.out.result_line(format!(
571 "setup: {} completed against {}",
572 step_count(done.len()),
573 engine.ctx.repo
574 ));
575 for (name, status) in &done {
576 engine.out.result_line(format!(" {status} {name}"));
577 }
578 engine.out.next(&[
579 format!("rk setup check --target {}", engine.ctx.target),
580 "rk guide setup orders what no command performs".to_owned(),
581 ]);
582 engine.finish(0, None);
583 Ok(())
584}
585
586fn step_count(count: usize) -> String {
589 format!("{count} {}", if count == 1 { "step" } else { "steps" })
590}
591
592fn elapsed_ms(clock: Instant) -> u64 {
593 u64::try_from(clock.elapsed().as_millis()).unwrap_or(u64::MAX)
594}
595
596enum Done {
598 Satisfied(String),
600 Changed(String, Option<String>),
602 Passed(String),
604}
605
606impl Done {
607 const fn wire(&self) -> &'static str {
608 match self {
609 Self::Satisfied(_) => "satisfied",
610 Self::Changed(..) => "applied",
611 Self::Passed(_) => "passed",
612 }
613 }
614
615 fn line(&self) -> String {
616 match self {
617 Self::Satisfied(detail) | Self::Passed(detail) => detail.clone(),
618 Self::Changed(detail, limitation) => limitation.as_ref().map_or_else(
619 || detail.clone(),
620 |limit| format!("{detail} (limitation: {limit})"),
621 ),
622 }
623 }
624}
625
626#[allow(clippy::too_many_lines)]
628fn apply_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
629 for prereq in step.prereqs {
632 let state = observe_with(engine, prereq)?;
633 if !state.satisfied() {
634 return Err(RkError::refusal(
635 Diagnostic::new(
636 Reason::PrerequisiteUnmet,
637 format!(
638 "{} requires {prereq} first: {}",
639 step.name,
640 state_detail(&state)
641 ),
642 )
643 .expected(format!("{prereq} satisfied before {}", step.name))
644 .action(format!(
645 "rk setup step {prereq} --target {} --apply",
646 engine.ctx.target
647 ))
648 .step(step.name),
649 ));
650 }
651 }
652 match step.name {
653 "package-check" => {
654 if engine.ctx.tech.is_none() {
655 return Err(RkError::Usage(
656 "no version file names a technology; rk binding --list names the bindings"
657 .into(),
658 ));
659 }
660 let state = observe_with(engine, "package-check")?;
661 match state {
662 StepState::Satisfied { detail, .. } => Ok(Done::Passed(detail)),
663 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
664 Err(RkError::subprocess(
665 Diagnostic::new(
666 Reason::SubprocessFailed,
667 format!("package-check failed: {detail}"),
668 )
669 .expected(step.proves.to_owned())
670 .step(step.name),
671 ))
672 }
673 StepState::Unknown { detail } => Err(RkError::subprocess(
674 Diagnostic::new(
675 Reason::SubprocessFailed,
676 format!("package-check could not run: {detail}"),
677 )
678 .step(step.name),
679 )),
680 }
681 }
682 "branch-reminder" => {
683 use crate::setup::branch_reminder::{HOOK_BODY, HookState, hook_path, observe_hook};
684 match observe_hook(&engine.ctx.target) {
685 HookState::Installed => Ok(Done::Satisfied(
686 "the post-merge reminder hook is installed".into(),
687 )),
688 HookState::Foreign => Err(RkError::refusal(
689 Diagnostic::new(
690 Reason::StateDrift,
691 "a foreign post-merge hook exists; the reminder is never written over it",
692 )
693 .expected("no post-merge hook, or one carrying the release-kit marker")
694 .action(
695 "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`",
696 )
697 .target_state("unchanged")
698 .step(step.name),
699 )),
700 HookState::Unreadable(detail) => Err(RkError::refusal(
701 Diagnostic::new(
702 Reason::StateDrift,
703 format!("the post-merge hook cannot be read: {detail}"),
704 )
705 .target_state("unchanged")
706 .step(step.name),
707 )),
708 HookState::Absent | HookState::Drifted => {
709 let path = hook_path(&engine.ctx.target).map_err(|detail| {
710 RkError::refusal(
711 Diagnostic::new(
712 Reason::PrerequisiteUnmet,
713 format!("the hooks directory cannot be resolved: {detail}"),
714 )
715 .expected("a git repository whose hooks directory git can name")
716 .step(step.name),
717 )
718 })?;
719 crate::atomic::write(&path, HOOK_BODY.as_bytes())?;
720 #[cfg(unix)]
721 {
722 use std::os::unix::fs::PermissionsExt as _;
723 std::fs::set_permissions(
724 &path,
725 std::fs::Permissions::from_mode(0o755),
726 )?;
727 }
728 Ok(Done::Changed(
729 "wrote the post-merge reminder hook".into(),
730 None,
731 ))
732 }
733 }
734 }
735 "single-trunk" => {
736 let guard = {
737 let ctx = clone_ctx(&engine.ctx);
738 let mut runner = |exec: &Exec| engine.exec(exec, false);
739 observe::single_trunk_guard(&ctx, &mut runner)?
740 };
741 match &guard {
744 StepState::Satisfied { .. } => {}
745 StepState::Unsatisfied { detail }
746 | StepState::Inapplicable { detail }
747 | StepState::Unknown { detail } => {
748 return Err(RkError::refusal(
749 Diagnostic::new(
750 Reason::DestructiveRefusal,
751 format!("single-trunk refuses: {detail}"),
752 )
753 .expected(
754 "proof that every candidate branch is absent, or an ancestor of the trunk",
755 )
756 .step(step.name),
757 ));
758 }
759 }
760 run_forge_step(engine, step)
761 }
762 "bot-secrets" => {
763 let key = match engine.ctx.forge {
769 Forge::Github => key_file_for(engine)?.map(|key| key.bytes.clone()),
773 Forge::Gitlab => None,
774 };
775 let provided = match engine.ctx.forge {
776 Forge::Github => secrets::value_of("RK_BOT_APP_ID").is_some() && key.is_some(),
779 Forge::Gitlab => secrets::value_of("RK_BOT_TOKEN").is_some(),
780 };
781 let state = observe_with(engine, step.name)?;
782 if !provided {
783 if state.satisfied() {
784 return Ok(Done::Satisfied(state_detail(&state)));
785 }
786 let wanted = match engine.ctx.forge {
787 Forge::Github => {
788 "export RK_BOT_APP_ID and RK_BOT_PRIVATE_KEY_FILE, the second naming the .pem; rk forge github carries the walkthrough"
789 }
790 Forge::Gitlab => {
791 "rk setup step install-bot --apply stores the token, or export RK_BOT_TOKEN to rotate one"
792 }
793 };
794 return Err(RkError::refusal(
795 Diagnostic::new(
796 Reason::PrerequisiteUnmet,
797 "bot-secrets has no credentials to store",
798 )
799 .expected("the bot credentials in the environment, the key as a path")
800 .action(wanted.to_owned())
801 .step(step.name),
802 ));
803 }
804 if let Some(journal) = &mut engine.journal {
805 for name in SECRET_VARS {
806 if secrets::value_of(name).is_some() {
807 journal.record_secret(name, true, "environment");
808 }
809 }
810 if key.is_some() {
811 journal.record_secret(secrets::PRIVATE_KEY_FILE, true, "file");
812 }
813 }
814 let stdin = key;
818 run_forge_step_with(engine, step, stdin, Vec::new())
819 }
820 "protections-check" => {
821 let (outcome, _) = run_script(engine, step)?;
822 if !outcome.success() {
823 return Err(classify_failure(engine, step, &outcome));
824 }
825 match observe_with(engine, step.name)? {
829 StepState::Satisfied { detail, limitation } => {
830 Ok(Done::Passed(limitation.map_or_else(
831 || detail.clone(),
832 |limit| format!("{detail} (limitation: {limit})"),
833 )))
834 }
835 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
836 Err(RkError::refusal(
837 Diagnostic::new(
838 Reason::StateDrift,
839 format!("protections-check passed its script and the observation disagrees: {detail}"),
840 )
841 .expected(step.proves.to_owned())
842 .step(step.name),
843 ))
844 }
845 StepState::Unknown { detail } => Err(RkError::refusal(
848 Diagnostic::new(
849 Reason::ForgeTemporary,
850 format!(
851 "protections-check passed its script and the readback could not confirm it: {detail}"
852 ),
853 )
854 .expected(step.proves.to_owned())
855 .action("check authentication and connectivity, then rerun")
856 .step(step.name),
857 )),
858 }
859 }
860 "install-bot" if engine.ctx.forge == Forge::Github => {
866 match observe_with(engine, step.name)? {
867 StepState::Satisfied { detail, .. } => {
868 return Ok(Done::Satisfied(detail));
869 }
870 StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
871 StepState::Unknown { detail } => {
872 return Err(RkError::refusal(
873 Diagnostic::new(
874 Reason::ForgeTemporary,
875 format!("{} cannot observe the current state: {detail}", step.name),
876 )
877 .expected("a readable forge answer before anything mutates")
878 .action("check the App credentials and connectivity, then rerun")
879 .step(step.name),
880 ));
881 }
882 }
883 let installation = github_installation_id(engine, step)?;
884 run_forge_step_with(
885 engine,
886 step,
887 None,
888 vec![("RK_BOT_INSTALLATION".into(), installation.into())],
889 )
890 }
891 _ => {
892 if step.mutates == Mutates::Forge {
893 match observe_with(engine, step.name)? {
898 StepState::Satisfied { detail, .. } => {
899 return Ok(Done::Satisfied(detail));
900 }
901 StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
902 StepState::Unknown { detail } => {
903 return Err(RkError::refusal(
904 Diagnostic::new(
905 Reason::ForgeTemporary,
906 format!("{} cannot observe the current state: {detail}", step.name),
907 )
908 .expected("a readable forge answer before anything mutates")
909 .action("check authentication and connectivity, then rerun")
910 .step(step.name),
911 ));
912 }
913 }
914 }
915 run_forge_step(engine, step)
916 }
917 }
918}
919
920fn github_installation_id(engine: &mut Engine, step: &StepSpec) -> Result<String, RkError> {
928 let refuse = |message: String, action: &str| {
929 RkError::refusal(
930 Diagnostic::new(Reason::PrerequisiteUnmet, message)
931 .expected("the App installed on the repository's owner")
932 .action(action.to_owned())
933 .step(step.name),
934 )
935 };
936 let jwt = match app_jwt_for(engine)? {
937 Ok(jwt) => jwt,
938 Err(detail) => {
939 return Err(refuse(
940 format!("install-bot has no App token: {detail}"),
941 app_jwt::REMEDIATION,
942 ));
943 }
944 };
945 let owner = engine
946 .ctx
947 .repo
948 .split('/')
949 .next()
950 .unwrap_or_default()
951 .to_owned();
952 let ctx = clone_ctx(&engine.ctx);
953 for path in [
954 format!("users/{owner}/installation"),
955 format!("orgs/{owner}/installation"),
956 ] {
957 match app_jwt::api_get(&ctx, &jwt, &path) {
958 AppApi::Ok(body) => {
959 return body["id"].as_i64().map(|id| id.to_string()).ok_or_else(|| {
960 refuse(
961 format!("the forge answered {path} without an installation id"),
962 "check RK_BOT_APP_ID and the key file name the same App",
963 )
964 });
965 }
966 AppApi::Missing => {}
967 AppApi::Refused(detail) => {
968 return Err(refuse(
969 detail,
970 "check RK_BOT_APP_ID and the key file name the same App",
971 ));
972 }
973 AppApi::Failed(detail) => {
974 return Err(RkError::refusal(
975 Diagnostic::new(
976 Reason::ForgeTemporary,
977 format!("install-bot cannot read the App's installation: {detail}"),
978 )
979 .action("check connectivity, then rerun")
980 .step(step.name),
981 ));
982 }
983 }
984 }
985 Err(refuse(
986 format!("the App has no installation on {owner}"),
987 "install the App on the account first; the setup guide's step 5 walks it",
988 ))
989}
990
991fn run_forge_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
993 run_forge_step_with(engine, step, None, Vec::new())
994}
995
996fn run_forge_step_with(
999 engine: &mut Engine,
1000 step: &StepSpec,
1001 stdin: Option<Zeroizing<Vec<u8>>>,
1002 extra_env: Vec<(OsString, OsString)>,
1003) -> Result<Done, RkError> {
1004 let (outcome, _) = run_script_with(engine, step, stdin, extra_env)?;
1005 if !outcome.success() {
1006 return Err(classify_failure(engine, step, &outcome));
1007 }
1008 let state = observe_with(engine, step.name)?;
1009 match state {
1010 StepState::Satisfied { detail, limitation } => Ok(Done::Changed(detail, limitation)),
1011 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1012 Err(RkError::refusal(
1013 Diagnostic::new(
1014 Reason::StateDrift,
1015 format!(
1016 "{} ran and its postcondition does not hold: {detail}",
1017 step.name
1018 ),
1019 )
1020 .expected(step.proves.to_owned())
1021 .step(step.name),
1022 ))
1023 }
1024 StepState::Unknown { detail } => Err(RkError::refusal(
1028 Diagnostic::new(
1029 Reason::ForgeTemporary,
1030 format!(
1031 "{} ran and the readback could not confirm it: {detail}",
1032 step.name
1033 ),
1034 )
1035 .expected(step.proves.to_owned())
1036 .action(format!(
1037 "rk setup step {} --target {} --apply re-asserts and re-proves it",
1038 step.name, engine.ctx.target
1039 ))
1040 .step(step.name),
1041 )),
1042 }
1043}
1044
1045fn observe_with(engine: &mut Engine, step: &str) -> Result<StepState, RkError> {
1052 if step == "install-bot" && engine.ctx.forge == Forge::Github {
1053 let jwt = match app_jwt_for(engine)? {
1054 Ok(jwt) => jwt,
1055 Err(detail) => return Ok(StepState::Unknown { detail }),
1056 };
1057 return Ok(observe::github_install_bot(&engine.ctx, &jwt));
1058 }
1059 let ctx = clone_ctx(&engine.ctx);
1060 let mut runner = |exec: &Exec| engine.exec(exec, false);
1061 observe::observe(&ctx, step, &mut runner)
1062}
1063
1064fn key_file_for(engine: &mut Engine) -> Result<Option<&secrets::KeyFile>, RkError> {
1070 if engine.key.is_none() {
1071 engine.key = secrets::resolve_key_file(&engine.ctx.target)?;
1072 if let Some(key) = &engine.key {
1073 engine.secrets.push(key.bytes.clone());
1074 }
1075 }
1076 Ok(engine.key.as_ref())
1077}
1078
1079fn app_jwt_for(engine: &mut Engine) -> Result<Result<String, String>, RkError> {
1089 if let Some(jwt) = &engine.app_jwt {
1090 return Ok(Ok(jwt.clone()));
1091 }
1092 let app_id = app_jwt::app_id()?;
1093 let key_bytes = key_file_for(engine)?.map(|key| key.bytes.clone());
1094 let (Some(app_id), Some(key_bytes)) = (app_id, key_bytes) else {
1095 return Ok(Err(format!(
1096 "the installation is readable only to the App itself; {}",
1097 app_jwt::REMEDIATION
1098 )));
1099 };
1100 let credentials = app_jwt::AppCredentials { app_id, key_bytes };
1101 let ctx = clone_ctx(&engine.ctx);
1102 Ok(match app_jwt::mint(&ctx, &credentials) {
1103 Ok(jwt) => {
1104 engine
1105 .secrets
1106 .push(Zeroizing::new(jwt.clone().into_bytes()));
1107 if let Some(signature) = jwt.rsplit('.').next() {
1108 engine
1109 .secrets
1110 .push(Zeroizing::new(signature.as_bytes().to_vec()));
1111 }
1112 engine.app_jwt = Some(jwt.clone());
1113 Ok(jwt)
1114 }
1115 Err(detail) => Err(detail),
1116 })
1117}
1118
1119fn state_detail(state: &StepState) -> String {
1120 match state {
1121 StepState::Satisfied { detail, .. }
1122 | StepState::Unsatisfied { detail }
1123 | StepState::Inapplicable { detail }
1124 | StepState::Unknown { detail } => detail.clone(),
1125 }
1126}
1127
1128fn run_script(engine: &mut Engine, step: &StepSpec) -> Result<(Outcome, PathBuf), RkError> {
1131 run_script_with(engine, step, None, Vec::new())
1132}
1133
1134fn run_script_with(
1140 engine: &mut Engine,
1141 step: &StepSpec,
1142 stdin: Option<Zeroizing<Vec<u8>>>,
1143 extra_env: Vec<(OsString, OsString)>,
1144) -> Result<(Outcome, PathBuf), RkError> {
1145 let rel = format!("{}/{}", engine.ctx.forge.as_str(), step.name);
1146 let bytes = embedded::SETUP
1147 .get_file(&rel)
1148 .map(include_dir::File::contents)
1149 .ok_or_else(|| RkError::Other(anyhow::anyhow!("no embedded script at setup/{rel}")))?;
1150 let journal = engine
1151 .journal
1152 .as_mut()
1153 .ok_or_else(|| RkError::Other(anyhow::anyhow!("an apply always has a journal")))?;
1154 let dir = journal.scripts_dir().join(engine.ctx.forge.as_str());
1155 fs::create_dir_all(&dir)?;
1156 restrict(&dir, 0o700);
1157 let path = dir.join(step.name);
1158 fs::write(&path, bytes)?;
1159 restrict(&path, 0o600);
1160 let written = fs::read(&path)?;
1161 let digest = Digest::of(&written);
1162 if digest != Digest::of(bytes) {
1163 return Err(RkError::Other(anyhow::anyhow!(
1164 "the materialized script at {} differs from the embedded bytes",
1165 path.display()
1166 )));
1167 }
1168 journal.record_script(format!("scripts/{rel}"), digest.to_string());
1169 let mut env = engine.ctx.child_env(step.name);
1170 env.extend(extra_env);
1171 let exec = Exec {
1172 program: "sh".into(),
1173 args: vec![path.clone().into_os_string()],
1174 env,
1175 cwd: engine.ctx.target.as_std_path().to_path_buf(),
1176 stdin,
1177 };
1178 let outcome = engine.exec(&exec, true)?;
1179 Ok((outcome, path))
1180}
1181
1182fn classify_failure(engine: &Engine, step: &StepSpec, outcome: &Outcome) -> RkError {
1187 let stderr = String::from_utf8_lossy(&outcome.stderr);
1188 let last = if outcome.exit_code >= 128 {
1192 format!("killed by signal {}", outcome.exit_code - 128)
1193 } else {
1194 stderr
1195 .lines()
1196 .rev()
1197 .find(|line| !line.trim().is_empty())
1198 .unwrap_or("no output")
1199 .to_owned()
1200 };
1201 let reason = if (engine.ctx.forge == Forge::Github && outcome.exit_code == 4)
1202 || stderr.contains("HTTP 401")
1203 {
1204 Reason::ForgeAuthentication
1205 } else if stderr.contains("HTTP 403") {
1206 Reason::ForgePermission
1207 } else if stderr.contains("HTTP 429") || stderr.contains("rate limit") {
1208 Reason::ForgeRateLimit
1209 } else {
1210 Reason::SubprocessFailed
1211 };
1212 let diagnostic = Diagnostic::new(reason, format!("the forge refused '{}': {last}", step.name))
1213 .expected(step.proves.to_owned())
1214 .action(format!(
1215 "rk setup step {} --target {} --apply",
1216 step.name, engine.ctx.target
1217 ))
1218 .step(step.name);
1219 let diagnostic = match reason {
1220 Reason::ForgePermission => diagnostic.expected(format!(
1221 "repository administration write on {} for the authenticated account",
1222 engine.ctx.repo
1223 )),
1224 _ => diagnostic,
1225 };
1226 match reason {
1227 Reason::SubprocessFailed => RkError::subprocess(diagnostic),
1228 _ => RkError::refusal(diagnostic),
1229 }
1230}
1231
1232fn attach_progress(
1235 error: RkError,
1236 done: &[(String, String)],
1237 failed: &StepSpec,
1238 steps: &[&StepSpec],
1239) -> RkError {
1240 let remaining = steps.len().saturating_sub(done.len() + 1);
1241 let state = format!(
1242 "{} completed; {} failed; {remaining} not attempted",
1243 step_count(done.len()),
1244 failed.name
1245 );
1246 match error {
1247 RkError::Refusal(mut diagnostic) => {
1248 diagnostic.target_state.get_or_insert(state);
1249 RkError::Refusal(diagnostic)
1250 }
1251 RkError::Subprocess(mut diagnostic) => {
1252 diagnostic.target_state.get_or_insert(state);
1253 RkError::Subprocess(diagnostic)
1254 }
1255 other => other,
1256 }
1257}
1258
1259fn check(out: Output, ctx: Ctx) -> Result<(), RkError> {
1263 let mut engine = Engine::open(out, ctx, "setup check", false)?;
1264 let mut unsatisfied = 0usize;
1265 let mut unverifiable = 0usize;
1266 for step in &STEPS {
1267 let clock = Instant::now();
1268 let state = observe_with(&mut engine, step.name)?;
1269 let (label, wire) = match &state {
1270 StepState::Satisfied { .. } => ("ok", "satisfied"),
1271 StepState::Inapplicable { .. } => ("skipped", "skipped"),
1274 StepState::Unsatisfied { .. } => {
1275 unsatisfied += 1;
1276 ("unsatisfied", "unsatisfied")
1277 }
1278 StepState::Unknown { .. } => {
1281 unverifiable += 1;
1282 ("unknown", "unknown")
1283 }
1284 };
1285 let mut line = format!("{label} {} — {}", step.name, state_detail(&state));
1286 if let StepState::Satisfied {
1287 limitation: Some(limit),
1288 ..
1289 } = &state
1290 {
1291 use std::fmt::Write as _;
1292 let _ = write!(line, " (limitation: {limit})");
1293 }
1294 engine.out.result_line(line);
1295 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
1296 finished.status = Some(wire.into());
1297 finished.duration_ms = Some(elapsed_ms(clock));
1298 engine.emit(&finished);
1299 }
1300 if unsatisfied > 0 || unverifiable > 0 {
1301 let error = RkError::check_failed(
1302 Diagnostic::new(
1303 Reason::StateDrift,
1304 format!(
1305 "{} {} not satisfied and {unverifiable} could not be verified",
1306 step_count(unsatisfied),
1307 if unsatisfied == 1 { "is" } else { "are" }
1308 ),
1309 )
1310 .expected("every step's proof column to hold and to be readable")
1311 .action(format!(
1312 "rk setup --target {} --apply re-asserts them",
1313 engine.ctx.target
1314 )),
1315 );
1316 return Err(fail(&mut engine, error));
1317 }
1318 engine
1319 .out
1320 .next(&["rk guide release orders the first release".to_owned()]);
1321 engine.finish(0, None);
1322 Ok(())
1323}
1324
1325fn restrict(path: &std::path::Path, mode: u32) {
1328 #[cfg(unix)]
1329 {
1330 use std::os::unix::fs::PermissionsExt as _;
1331 let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
1332 }
1333 #[cfg(not(unix))]
1334 let _ = (path, mode);
1335}
1336
1337fn guard_sh() -> Result<(), RkError> {
1340 let ok = std::process::Command::new("sh")
1341 .args(["-c", "exit 0"])
1342 .status()
1343 .is_ok_and(|status| status.success());
1344 if ok {
1345 Ok(())
1346 } else {
1347 Err(RkError::refusal(
1348 Diagnostic::new(Reason::PrerequisiteUnmet, "no POSIX sh runs on this host")
1349 .expected("a working sh on PATH; every step spawns through it")
1350 .action("install a POSIX shell, then rerun")
1351 .target_state("nothing was run and nothing changed"),
1352 ))
1353 }
1354}
1355
1356#[cfg(test)]
1357mod tests {
1358 #[test]
1361 fn a_step_count_carries_a_noun_that_agrees_with_it() {
1362 assert_eq!(super::step_count(0), "0 steps");
1363 assert_eq!(super::step_count(1), "1 step");
1364 assert_eq!(super::step_count(2), "2 steps");
1365 }
1366}