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::proof::{self, Standing};
32use crate::setup::report::{Report, Row, Stance, stance};
33use crate::setup::secrets;
34use crate::setup::steps::{Mutates, STEPS, StepSpec, spec};
35
36pub fn run(args: &SetupArgs) -> Result<(), RkError> {
42 match &args.action {
43 Some(SetupAction::Script { name, forge }) => script(name, forge.as_deref()),
44 Some(SetupAction::Check {
45 target,
46 repo,
47 forge,
48 required_check,
49 json,
50 }) => check(
51 Output::new(*json),
52 observing(
53 target,
54 repo.as_deref(),
55 forge.as_deref(),
56 required_check.as_deref(),
57 )?,
58 ),
59 Some(SetupAction::Checkpoint {
60 target,
61 repo,
62 forge,
63 required_check,
64 json,
65 }) => checkpoint(
66 Output::new(*json),
67 observing(
68 target,
69 repo.as_deref(),
70 forge.as_deref(),
71 required_check.as_deref(),
72 )?,
73 ),
74 Some(SetupAction::Status {
77 target,
78 repo,
79 forge,
80 required_check,
81 json,
82 }) => status(
83 Output::new(*json),
84 &Ctx::resolve(
85 target,
86 repo.as_deref(),
87 forge.as_deref(),
88 required_check.as_deref(),
89 )?,
90 ),
91 Some(action @ SetupAction::Step { .. }) => step(action),
92 None if args.list => list(args.forge.as_deref()),
93 None => {
94 let target = args.target.clone().ok_or_else(|| {
95 RkError::Usage("name a --target, or pass --list to see the steps".into())
96 })?;
97 let all: Vec<&StepSpec> = STEPS.iter().collect();
98 let mut ctx = Ctx::resolve(
99 &target,
100 args.repo.as_deref(),
101 args.forge.as_deref(),
102 args.required_check.as_deref(),
103 )?;
104 reject_check_flag_on_gitlab(&ctx)?;
105 if args.apply {
106 require_check_for(&ctx, &all)?;
107 let acted: Vec<&StepSpec> = all
110 .iter()
111 .copied()
112 .filter(|step| !skipped_by_a_full_run(&ctx, step, all.len()))
113 .collect();
114 ctx.require_cli(&acted)?;
115 execute(Output::new(args.json), ctx, &all, "setup")
116 } else {
117 let acted: Vec<&StepSpec> = all
118 .iter()
119 .copied()
120 .filter(|step| !skipped_by_a_full_run(&ctx, step, all.len()))
121 .collect();
122 ctx.require_cli(&acted)?;
123 preview(Output::new(args.json), &ctx, &all)
124 }
125 }
126 }
127}
128
129fn step(action: &SetupAction) -> Result<(), RkError> {
131 let SetupAction::Step {
132 name,
133 target,
134 repo,
135 forge,
136 required_check,
137 apply,
138 json,
139 } = action
140 else {
141 unreachable!("the dispatch routes only a step here");
142 };
143 let selected = spec(name).ok_or_else(|| {
144 RkError::Usage(format!("unknown step '{name}'; rk setup --list names them"))
145 })?;
146 let mut ctx = Ctx::resolve(
147 target,
148 repo.as_deref(),
149 forge.as_deref(),
150 required_check.as_deref(),
151 )?;
152 reject_check_flag_on_gitlab(&ctx)?;
153 if *apply {
154 refuse_an_excluded_step(&ctx, selected)?;
155 require_check_for(&ctx, &[selected])?;
156 ctx.require_cli(&[selected])?;
159 execute(Output::new(*json), ctx, &[selected], "setup step")
160 } else {
161 ctx.require_cli(&[selected])?;
165 preview(Output::new(*json), &ctx, &[selected])
166 }
167}
168
169fn observing(
173 target: &camino::Utf8PathBuf,
174 repo: Option<&str>,
175 forge: Option<&str>,
176 required_check: Option<&str>,
177) -> Result<Ctx, RkError> {
178 let mut ctx = Ctx::resolve(target, repo, forge, required_check)?;
179 reject_check_flag_on_gitlab(&ctx)?;
180 let all: Vec<&StepSpec> = STEPS.iter().collect();
181 ctx.require_cli(&all)?;
182 Ok(ctx)
183}
184
185fn skipped_by_a_full_run(ctx: &Ctx, step: &StepSpec, selected: usize) -> bool {
193 if !step.optional || selected <= 1 {
194 return false;
195 }
196 !(step.name == "protect-release-lines" && ctx.release_lines())
197}
198
199fn refuse_an_excluded_step(ctx: &Ctx, step: &StepSpec) -> Result<(), RkError> {
205 match stance(ctx, step) {
206 Stance::Applies => Ok(()),
207 Stance::Excluded(reason) | Stance::Redundant { reason, .. } => {
208 Err(RkError::Usage(format!(
209 "{} is excluded by {}: {reason}; remove it from setup.excluded_steps to run it",
210 step.name,
211 crate::config::CONFIG_PATH
212 )))
213 }
214 Stance::NotApplicable(reason) => Err(RkError::refusal(
218 Diagnostic::new(
219 Reason::PrerequisiteUnmet,
220 format!("{} does not apply to this target: {reason}", step.name),
221 )
222 .expected("a target configuration that selects this step")
223 .action("rk profile --target . reports what this target resolves to")
224 .target_state("unchanged")
225 .step(step.name),
226 )),
227 }
228}
229
230fn reject_check_flag_on_gitlab(ctx: &Ctx) -> Result<(), RkError> {
238 if ctx.forge == Some(Forge::Gitlab) && ctx.required_check.is_some() {
239 return Err(RkError::Usage(
240 "--required-check is refused on gitlab: the forge requires the whole pipeline and names no individual check".into(),
241 ));
242 }
243 Ok(())
244}
245
246fn require_check_for(ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
256 let needs = ctx.forge == Some(Forge::Github)
257 && ctx.required_check.is_none()
258 && ctx
259 .protection()
260 .owned_trunk_rules
261 .iter()
262 .any(|rule| rule == "required_status_checks")
263 && steps
264 .iter()
265 .any(|step| step.name == "protect-trunk" && stance(ctx, step).acts());
266 if needs {
267 return Err(RkError::refusal(
268 Diagnostic::new(
269 Reason::PrerequisiteUnmet,
270 "protect-trunk refuses until the required check is named, and nothing was written",
271 )
272 .expected("the name of the CI check the release merge must pass")
273 .action(format!(
274 "set setup.required_check in {}, or pass --required-check <name>; gh api repos/{}/commits/HEAD/check-runs lists the project's check names",
275 crate::config::CONFIG_PATH,
276 ctx.repo
277 ))
278 .step("protect-trunk"),
279 ));
280 }
281 Ok(())
282}
283
284fn list(forge: Option<&str>) -> Result<(), RkError> {
288 let forge = forge
289 .map(|name| {
290 Forge::parse(name).ok_or_else(|| {
291 RkError::Usage(format!(
292 "unknown forge '{name}'; the forges are: github, gitlab"
293 ))
294 })
295 })
296 .transpose()?;
297 let out = Output::human();
298 for (idx, step) in STEPS.iter().enumerate() {
299 let mut line = format!(
300 "{:2}. {} [{}] proves: {}",
301 idx + 1,
302 step.name,
303 step.chapter,
304 step.proves
305 );
306 if step.name == "protect-trunk" && forge != Some(Forge::Gitlab) {
307 line.push_str(" (needs --required-check on github)");
308 }
309 if step.destructive {
310 line.push_str(" (destructive)");
311 }
312 if step.optional {
313 line.push_str(" (optional; a full apply skips it)");
314 }
315 out.result_line(line);
316 }
317 out.next(&[
318 "rk setup --target . previews every step".to_owned(),
319 "rk setup script <name> prints one embedded script".to_owned(),
320 ]);
321 Ok(())
322}
323
324fn script(name: &str, forge: Option<&str>) -> Result<(), RkError> {
327 if name == "package-check" {
328 return Err(RkError::Usage(
329 "package-check reads its command from the technology binding and has no script".into(),
330 ));
331 }
332 if name == "branch-reminder" {
333 return Err(RkError::Usage(
334 "branch-reminder writes an embedded hook body and has no script; rk setup step branch-reminder previews the write".into(),
335 ));
336 }
337 if name == "forge-version" {
338 return Err(RkError::Usage(
339 "forge-version reads the forge's own version and has no script; rk setup step forge-version previews the read".into(),
340 ));
341 }
342 let forge = match forge {
343 Some(value) => Forge::parse(value).ok_or_else(|| {
344 RkError::Usage(format!(
345 "unknown forge '{value}'; the forges are: github, gitlab"
346 ))
347 })?,
348 None => Forge::Github,
349 };
350 let path = format!("{}/{name}", forge.as_str());
351 let file = embedded::SETUP.get_file(&path).ok_or(RkError::NotFound {
352 kind: "setup step",
353 name: name.to_owned(),
354 })?;
355 Output::human().result_raw(&String::from_utf8_lossy(file.contents()));
356 Ok(())
357}
358
359struct Engine {
362 out: Output,
363 ctx: Ctx,
364 journal: Option<Journal>,
365 secrets: Vec<Zeroizing<Vec<u8>>>,
366 key: Option<secrets::KeyFile>,
368 app_jwt: Option<String>,
370 key_boundary: bool,
373 key_unavailable: Option<String>,
375 seq: u64,
376 command: &'static str,
377 run_id: String,
378 snapshot: Option<crate::snapshot::Snapshot>,
381}
382
383impl Engine {
384 fn open(
389 out: Output,
390 ctx: Ctx,
391 command: &'static str,
392 journal_required: bool,
393 ) -> Result<Self, RkError> {
394 secrets::refuse_legacy_key()?;
397 let journal = match Journal::create(
398 command,
399 ctx.target.as_str(),
400 ctx.forge.map_or("none", Forge::as_str),
401 &ctx.repo,
402 ) {
403 Ok(journal) => Some(journal),
404 Err(source) if journal_required => {
405 return Err(RkError::refusal(
406 Diagnostic::new(
407 Reason::JournalUnavailable,
408 format!("the run journal cannot be created: {source}"),
409 )
410 .expected("a writable state root for the journal")
411 .target_state("nothing was run and nothing changed"),
412 ));
413 }
414 Err(source) => {
415 out.warn(format!("no run journal for this run: {source}"));
416 None
417 }
418 };
419 let run_id = journal
420 .as_ref()
421 .map_or_else(|| "unjournaled".to_owned(), |j| j.run_id().to_owned());
422 let mut engine = Self {
423 out,
424 ctx,
425 journal,
426 secrets: Ctx::secret_values(),
427 key: None,
428 app_jwt: None,
429 key_boundary: false,
430 key_unavailable: None,
431 seq: 0,
432 command,
433 run_id,
434 snapshot: None,
435 };
436 let opening = Event::opening(
437 engine.next_seq(),
438 crate::applog::now_utc(),
439 engine.run_id.clone(),
440 engine.command,
441 );
442 engine.emit(&opening);
443 if engine.ctx.self_hosted_gitlab() {
444 engine.out.warn(
445 "this remote is a self-hosted GitLab: registry trusted publishing covers GitLab.com only, so the OIDC invariant cannot be satisfied here",
446 );
447 }
448 Ok(engine)
449 }
450
451 const fn next_seq(&mut self) -> u64 {
452 let seq = self.seq;
453 self.seq += 1;
454 seq
455 }
456
457 fn event(&mut self, kind: EventKind, step: Option<&str>) -> Event {
458 let mut event = Event::opening(
459 self.next_seq(),
460 crate::applog::now_utc(),
461 self.run_id.clone(),
462 self.command,
463 );
464 event.kind = kind;
465 event.step = step.map(str::to_owned);
466 event
467 }
468
469 fn emit(&mut self, event: &Event) {
470 self.out.event(event);
471 if let Some(journal) = &mut self.journal
472 && let Ok(line) = serde_json::to_string(event)
473 {
474 journal.event_line(&line);
475 }
476 }
477
478 fn exec(&mut self, exec: &Exec, passthrough: bool) -> Result<Outcome, RkError> {
481 let echo = exec.echo();
482 self.out.frame(&echo);
483 if let Some(journal) = &mut self.journal {
484 journal.transcript(echo.as_bytes());
485 journal.transcript(b"\n");
486 }
487 let secrets = std::mem::take(&mut self.secrets);
488 let step_name: Option<String> = None;
489 let mut chunks: Vec<(ChildStream, Vec<u8>)> = Vec::new();
490 let spawned = process::run(exec, |stream, chunk| {
491 chunks.push((stream, process::redact(chunk, &secrets)));
492 });
493 self.secrets = secrets;
494 for (stream, chunk) in chunks {
495 if passthrough {
496 self.out.child_passthrough(stream, &chunk);
497 }
498 let event = self.event(EventKind::ChildOutput, step_name.as_deref());
499 let event = event.child_output(stream, &chunk);
500 self.emit(&event);
501 if let Some(journal) = &mut self.journal {
502 journal.transcript(&chunk);
503 }
504 }
505 spawned.map_err(|source| {
506 RkError::refusal(
507 Diagnostic::new(
508 Reason::SubprocessSpawn,
509 format!("{} did not spawn: {source}", exec.program.to_string_lossy()),
510 )
511 .expected("a POSIX sh and the forge CLI on PATH")
512 .run(self.run_path()),
513 )
514 })
515 }
516
517 fn run_path(&self) -> String {
518 self.journal.as_ref().map_or_else(
519 || "no journal was written".to_owned(),
520 |j| j.dir.display().to_string(),
521 )
522 }
523
524 fn finish(&mut self, exit_code: i32, reason: Option<&str>) {
525 let mut event = self.event(EventKind::RunFinished, None);
526 event.exit_code = Some(exit_code);
527 event.status = Some(if exit_code == 0 {
528 "ok".into()
529 } else {
530 "failed".into()
531 });
532 self.emit(&event);
533 if let Some(journal) = &mut self.journal {
534 journal.finish(exit_code, reason);
535 }
536 }
537}
538
539fn fail(engine: &mut Engine, error: RkError) -> RkError {
541 let error = match error {
542 RkError::Refusal(mut diagnostic) => {
543 diagnostic.run.get_or_insert_with(|| engine.run_path());
544 RkError::Refusal(diagnostic)
545 }
546 RkError::Subprocess(mut diagnostic) => {
547 diagnostic.run.get_or_insert_with(|| engine.run_path());
548 RkError::Subprocess(diagnostic)
549 }
550 RkError::CheckFailed(mut diagnostic) => {
551 diagnostic.run.get_or_insert_with(|| engine.run_path());
552 RkError::CheckFailed(diagnostic)
553 }
554 other => other,
555 };
556 engine.finish(i32::from(error.exit_code()), Some(error.reason().as_str()));
557 error
558}
559
560fn preview(out: Output, ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
566 let mut engine = Engine::open(out, clone_ctx(ctx), "setup preview", false)?;
567 out.result_line(format!(
568 "DRY RUN: rk setup would run these steps against {} on {}; re-run with --apply",
569 engine.ctx.repo,
570 engine.ctx.forge.map_or("no forge", Forge::as_str)
571 ));
572 for (idx, step) in steps.iter().enumerate() {
573 out.result_line(format!(
574 "step {}/{} {} — proves {}",
575 idx + 1,
576 steps.len(),
577 step.name,
578 step.proves_for(ctx)
579 ));
580 let stance = stance(&engine.ctx, step);
581 if !stance.acts() {
582 out.result_line(format!(" {}", stance.framed()));
583 let mut event = engine.event(EventKind::StepFinished, Some(step.name));
584 event.status = Some(stance.word().into());
585 event.detail = Some(stance.detail());
586 engine.emit(&event);
587 continue;
588 }
589 if step.name == "bot-secrets" && engine.ctx.forge == Some(Forge::Github) {
593 secrets::resolve_key_file(&engine.ctx.target)?;
594 }
595 out.result_line(format!(" {}", render_invocation(&engine.ctx, step)));
596 if step.name == "protect-trunk"
602 && engine.ctx.forge == Some(Forge::Github)
603 && engine.ctx.required_check.is_none()
604 && engine
605 .ctx
606 .protection()
607 .owned_trunk_rules
608 .iter()
609 .any(|rule| rule == "required_status_checks")
610 {
611 out.result_line(" needs: --required-check <name> before apply");
612 }
613 if skipped_by_a_full_run(&engine.ctx, step, steps.len()) {
614 out.result_line(format!(
615 " optional: a full apply skips it; set setup.release_lines, or rk setup step {} --apply runs it",
616 step.name
617 ));
618 }
619 let excluded = excluded_prerequisites(&engine.ctx, step);
620 for line in &excluded {
621 out.result_line(format!(" {line}"));
622 }
623 let mut event = engine.event(EventKind::StepFinished, Some(step.name));
624 event.status = Some("previewed".into());
625 event.detail = prerequisite_detail(&excluded);
626 engine.emit(&event);
627 }
628 let next = next_for_apply(&engine.ctx, steps);
629 out.next(&[
630 next,
631 "rk setup check --target . proves what is already true".to_owned(),
632 ]);
633 engine.finish(0, None);
634 Ok(())
635}
636
637fn render_invocation(ctx: &Ctx, step: &StepSpec) -> String {
640 match step.name {
641 "branch-reminder" => {
642 "would write: the post-merge reminder hook at $(git rev-parse --git-path hooks)/post-merge".to_owned()
643 }
644 "package-check" => match ctx.tech {
645 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(),
646 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(),
647 Some("python") if ctx.reporting_policy() => "would run: python3 -m build; sdist and wheel SECURITY.md inclusion stays unproved".to_owned(),
648 Some("python") => "would run: python3 -m build".to_owned(),
649 Some("bash") if ctx.reporting_policy() => "nothing to run: no registry for this technology; the make dist tarball is not inspected".to_owned(),
650 Some("bash") => "nothing to run: no registry for this technology".to_owned(),
651 _ => "needs: a version file naming the technology".to_owned(),
652 },
653 "forge-version" => {
654 let (major, minor) = observe::GITLAB_VERSION_FLOOR;
655 match ctx.forge {
656 Some(Forge::Github) => {
657 "nothing to read: github.com is a rolling service and declares no version floor"
658 .to_owned()
659 }
660 Some(Forge::Gitlab) => format!(
661 "would read: GET /version, and compare it against the {major}.{minor} floor; nothing is written"
662 ),
663 None => "nothing to read: the profile names no forge".to_owned(),
664 }
665 }
666 name => {
667 let check = ctx
668 .required_check
669 .as_ref()
670 .filter(|_| ctx.forge == Some(Forge::Github) && name == "protect-trunk")
671 .map(|value| format!(" RK_REQUIRED_CHECK={value}"))
672 .unwrap_or_default();
673 let ruleset = match name {
677 "protect-trunk" => format!(
678 " RK_TRUNK_RULESET={} RK_SAFETY_RULESET={} RK_TITLE_CHECK={}",
679 ctx.trunk_ruleset(),
680 ctx.safety_ruleset(),
681 ctx.title_check()
682 ),
683 "protect-tags" => format!(" RK_TAG_RULESET={}", ctx.tag_ruleset()),
684 "protect-release-lines" => format!(" RK_LINES_RULESET={}", ctx.lines_ruleset()),
685 _ => String::new(),
686 };
687 format!(
688 "would run: sh <embedded setup/{}/{name}> with RK_REPO={} RK_TRUNK_BRANCH={}{ruleset}{check}",
689 ctx.forge.map_or("<no forge>", Forge::as_str),
690 ctx.repo,
691 ctx.trunk()
692 )
693 }
694 }
695}
696
697fn next_for_apply(ctx: &Ctx, steps: &[&StepSpec]) -> String {
698 let check = ctx
699 .required_check
700 .as_ref()
701 .map(|value| format!(" --required-check {value}"))
702 .unwrap_or_default();
703 if steps.len() == 1 {
704 format!(
705 "rk setup step {} --target {} --apply{check}",
706 steps[0].name, ctx.target
707 )
708 } else {
709 format!("rk setup --target {} --apply{check}", ctx.target)
710 }
711}
712
713fn clone_ctx(ctx: &Ctx) -> Ctx {
715 ctx.clone()
716}
717
718fn execute(
720 out: Output,
721 ctx: Ctx,
722 steps: &[&StepSpec],
723 command: &'static str,
724) -> Result<(), RkError> {
725 guard_sh()?;
726 let mut engine = Engine::open(out, ctx, command, true)?;
727 let mut done: Vec<(String, String)> = Vec::new();
728 for (idx, step) in steps.iter().enumerate() {
729 let stance = stance(&engine.ctx, step);
733 if !stance.acts() {
734 engine.out.frame(format!(
735 "step {}/{} {} — {}",
736 idx + 1,
737 steps.len(),
738 step.name,
739 stance.framed()
740 ));
741 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
742 finished.status = Some(stance.word().into());
743 finished.detail = Some(stance.detail());
744 engine.emit(&finished);
745 done.push((step.name.to_owned(), stance.word().to_owned()));
746 continue;
747 }
748 if skipped_by_a_full_run(&engine.ctx, step, steps.len()) {
751 engine.out.frame(format!(
752 "step {}/{} {} — skipped (optional; set setup.release_lines, or rk setup step {} --apply runs it)",
753 idx + 1,
754 steps.len(),
755 step.name,
756 step.name
757 ));
758 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
759 finished.status = Some("skipped".into());
760 engine.emit(&finished);
761 done.push((step.name.to_owned(), "skipped".to_owned()));
762 continue;
763 }
764 engine.out.frame(format!(
765 "step {}/{} {} — {}",
766 idx + 1,
767 steps.len(),
768 step.name,
769 step.proves_for(&engine.ctx)
770 ));
771 let excluded = excluded_prerequisites(&engine.ctx, step);
772 for line in &excluded {
773 engine.out.frame(format!(" {line}"));
774 }
775 let mut started = engine.event(EventKind::StepStarted, Some(step.name));
776 started.status = Some("running".into());
777 started.detail = prerequisite_detail(&excluded);
778 engine.emit(&started);
779 let clock = Instant::now();
780 let status = match apply_step(&mut engine, step) {
781 Ok(status) => status,
782 Err(error) => {
783 let error = attach_progress(error, &done, step, steps);
784 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
785 finished.status = Some("failed".into());
786 finished.reason = Some(error.reason());
787 finished.duration_ms = Some(elapsed_ms(clock));
788 engine.emit(&finished);
789 return Err(fail(&mut engine, error));
790 }
791 };
792 engine.out.frame(format!(
793 "{} {}: {}",
794 if matches!(status, Done::Skipped(_)) {
795 "skipped"
796 } else {
797 "ok"
798 },
799 step.name,
800 status.line()
801 ));
802 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
803 finished.status = Some(status.wire().into());
804 finished.detail = Some(status.line());
805 finished.exit_code = Some(0);
806 finished.duration_ms = Some(elapsed_ms(clock));
807 engine.emit(&finished);
808 done.push((step.name.to_owned(), status.wire().to_owned()));
809 }
810 engine.out.result_line(format!(
811 "setup: {} completed against {}",
812 step_count(done.len()),
813 engine.ctx.repo
814 ));
815 for (name, status) in &done {
816 engine.out.result_line(format!(" {status} {name}"));
817 }
818 engine.out.next(&[
819 format!("rk setup check --target {}", engine.ctx.target),
820 "rk guide setup orders what no command performs".to_owned(),
821 ]);
822 engine.finish(0, None);
823 Ok(())
824}
825
826fn step_count(count: usize) -> String {
829 format!("{count} {}", if count == 1 { "step" } else { "steps" })
830}
831
832fn elapsed_ms(clock: Instant) -> u64 {
833 u64::try_from(clock.elapsed().as_millis()).unwrap_or(u64::MAX)
834}
835
836enum Done {
838 Satisfied(String),
840 Skipped(String),
842 Changed(String, Option<String>),
844 Passed(String),
846}
847
848impl Done {
849 const fn wire(&self) -> &'static str {
850 match self {
851 Self::Satisfied(_) => "satisfied",
852 Self::Skipped(_) => "skipped",
853 Self::Changed(..) => "applied",
854 Self::Passed(_) => "passed",
855 }
856 }
857
858 fn line(&self) -> String {
859 match self {
860 Self::Satisfied(detail) | Self::Passed(detail) | Self::Skipped(detail) => {
861 detail.clone()
862 }
863 Self::Changed(detail, limitation) => limitation.as_ref().map_or_else(
864 || detail.clone(),
865 |limit| format!("{detail} (limitation: {limit})"),
866 ),
867 }
868 }
869}
870
871#[derive(Debug, PartialEq, Eq)]
875enum PrereqGate {
876 Silent,
879 Excluded(String),
882 Observe,
885}
886
887fn prereq_gate(stance: Stance) -> PrereqGate {
890 match stance {
891 Stance::Applies => PrereqGate::Observe,
892 Stance::NotApplicable(_) => PrereqGate::Silent,
893 Stance::Excluded(reason) | Stance::Redundant { reason, .. } => PrereqGate::Excluded(reason),
894 }
895}
896
897fn excluded_prerequisites(ctx: &Ctx, step: &StepSpec) -> Vec<String> {
900 step.prereqs
901 .iter()
902 .filter_map(|prereq| {
903 let held = spec(prereq)?;
904 match prereq_gate(stance(ctx, held)) {
905 PrereqGate::Excluded(reason) => {
906 Some(format!("prerequisite {prereq} excluded: {reason}"))
907 }
908 PrereqGate::Silent | PrereqGate::Observe => None,
909 }
910 })
911 .collect()
912}
913
914fn prerequisite_detail(lines: &[String]) -> Option<String> {
917 (!lines.is_empty()).then(|| lines.join("; "))
918}
919
920#[allow(
922 clippy::too_many_lines,
923 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"
924)]
925fn apply_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
926 for prereq in step.prereqs {
931 let gate = spec(prereq).map_or(PrereqGate::Observe, |held| {
932 prereq_gate(stance(&engine.ctx, held))
933 });
934 if gate != PrereqGate::Observe {
935 continue;
936 }
937 let state = observe_with(engine, prereq)?;
938 if !state.satisfied() {
939 return Err(RkError::refusal(
940 Diagnostic::new(
941 Reason::PrerequisiteUnmet,
942 format!(
943 "{} requires {prereq} first: {}",
944 step.name,
945 state_detail(&state)
946 ),
947 )
948 .expected(format!("{prereq} satisfied before {}", step.name))
949 .action(format!(
950 "rk setup step {prereq} --target {} --apply",
951 engine.ctx.target
952 ))
953 .step(step.name),
954 ));
955 }
956 }
957 match step.name {
958 "package-check" => {
959 if engine.ctx.tech.is_none() {
960 return Err(RkError::Usage(
961 "no version file names a technology; rk binding --list names the bindings"
962 .into(),
963 ));
964 }
965 let state = observe_with(engine, "package-check")?;
966 match state {
967 StepState::Satisfied { detail, .. } => Ok(Done::Passed(detail)),
968 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
969 Err(RkError::subprocess(
970 Diagnostic::new(
971 Reason::SubprocessFailed,
972 format!("package-check failed: {detail}"),
973 )
974 .expected(step.proves_for(&engine.ctx).to_owned())
975 .step(step.name),
976 ))
977 }
978 StepState::Unknown { detail } => Err(RkError::subprocess(
979 Diagnostic::new(
980 Reason::SubprocessFailed,
981 format!("package-check could not run: {detail}"),
982 )
983 .step(step.name),
984 )),
985 }
986 }
987 "forge-version" => match observe_with(engine, "forge-version")? {
993 StepState::Satisfied { detail, .. } => Ok(Done::Satisfied(detail)),
994 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
995 Err(RkError::refusal(
996 Diagnostic::new(Reason::PrerequisiteUnmet, detail)
997 .expected(step.proves_for(&engine.ctx).to_owned())
998 .action(if engine.ctx.on_gitlab_com() {
999 "rerun after the next gitlab.com deployment"
1000 } else {
1001 "upgrade the instance, or host the project on gitlab.com"
1002 })
1003 .target_state("unchanged")
1004 .step(step.name),
1005 ))
1006 }
1007 StepState::Unknown { detail } => Err(RkError::refusal(
1008 Diagnostic::new(Reason::ForgeTemporary, detail)
1009 .expected("a readable forge version")
1010 .action("glab auth login, then rerun")
1011 .target_state("unchanged")
1012 .step(step.name),
1013 )),
1014 },
1015 "branch-reminder" => {
1016 use crate::setup::branch_reminder::{HookState, hook_body, hook_path, observe_hook};
1017 match observe_hook(&engine.ctx.target) {
1018 HookState::Installed => Ok(Done::Satisfied(
1019 "the post-merge reminder hook is installed".into(),
1020 )),
1021 HookState::Foreign => Err(RkError::refusal(
1022 Diagnostic::new(
1023 Reason::StateDrift,
1024 "a foreign post-merge hook exists; the reminder is never written over it",
1025 )
1026 .expected("no post-merge hook, or one carrying the release-kit marker")
1027 .action(
1028 "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`",
1029 )
1030 .target_state("unchanged")
1031 .step(step.name),
1032 )),
1033 HookState::Unreadable(detail) => Err(RkError::refusal(
1034 Diagnostic::new(
1035 Reason::StateDrift,
1036 format!("the post-merge hook cannot be read: {detail}"),
1037 )
1038 .target_state("unchanged")
1039 .step(step.name),
1040 )),
1041 HookState::Absent | HookState::Drifted => {
1042 let path = hook_path(&engine.ctx.target).map_err(|detail| {
1043 RkError::refusal(
1044 Diagnostic::new(
1045 Reason::PrerequisiteUnmet,
1046 format!("the hooks directory cannot be resolved: {detail}"),
1047 )
1048 .expected("a git repository whose hooks directory git can name")
1049 .step(step.name),
1050 )
1051 })?;
1052 crate::atomic::write(&path, hook_body())?;
1053 #[cfg(unix)]
1054 {
1055 use std::os::unix::fs::PermissionsExt as _;
1056 std::fs::set_permissions(
1057 &path,
1058 std::fs::Permissions::from_mode(0o755),
1059 )?;
1060 }
1061 Ok(Done::Changed(
1062 "wrote the post-merge reminder hook".into(),
1063 None,
1064 ))
1065 }
1066 }
1067 }
1068 "single-trunk" => {
1069 let guard = {
1070 let ctx = clone_ctx(&engine.ctx);
1071 let mut runner = |exec: &Exec| engine.exec(exec, false);
1072 observe::single_trunk_guard(&ctx, &mut runner)?
1073 };
1074 match &guard {
1077 StepState::Satisfied { .. } => {}
1078 StepState::Unsatisfied { detail }
1079 | StepState::Inapplicable { detail }
1080 | StepState::Unknown { detail } => {
1081 return Err(RkError::refusal(
1082 Diagnostic::new(
1083 Reason::DestructiveRefusal,
1084 format!("single-trunk refuses: {detail}"),
1085 )
1086 .expected(
1087 "proof that every candidate branch is absent, or an ancestor of the trunk",
1088 )
1089 .step(step.name),
1090 ));
1091 }
1092 }
1093 run_forge_step(engine, step)
1094 }
1095 "bot-secrets" => {
1096 let adapter = engine.ctx.adapter()?;
1102 let key = match adapter {
1103 Forge::Github => key_file_for(engine)?.map(|key| key.bytes.clone()),
1107 Forge::Gitlab => None,
1108 };
1109 let provided = match adapter {
1110 Forge::Github => secrets::value_of("RK_BOT_APP_ID").is_some() && key.is_some(),
1113 Forge::Gitlab => secrets::value_of("RK_BOT_TOKEN").is_some(),
1114 };
1115 let state = observe_with(engine, step.name)?;
1116 if !provided {
1117 if state.satisfied() {
1118 return Ok(Done::Satisfied(state_detail(&state)));
1119 }
1120 let wanted = match adapter {
1121 Forge::Github => {
1122 "export RK_BOT_APP_ID and RK_BOT_PRIVATE_KEY_FILE, the second naming the .pem; rk forge github carries the walkthrough"
1123 }
1124 Forge::Gitlab => {
1125 "rk setup step install-bot --apply stores the token, or export RK_BOT_TOKEN to rotate one"
1126 }
1127 };
1128 return Err(RkError::refusal(
1129 Diagnostic::new(
1130 Reason::PrerequisiteUnmet,
1131 "bot-secrets has no credentials to store",
1132 )
1133 .expected("the bot credentials in the environment, the key as a path")
1134 .action(wanted.to_owned())
1135 .step(step.name),
1136 ));
1137 }
1138 if let Some(journal) = &mut engine.journal {
1139 for name in SECRET_VARS {
1140 if secrets::value_of(name).is_some() {
1141 journal.record_secret(name, true, "environment");
1142 }
1143 }
1144 if key.is_some() {
1145 journal.record_secret(secrets::PRIVATE_KEY_FILE, true, "file");
1146 }
1147 }
1148 let stdin = key;
1152 run_forge_step_with(engine, step, stdin, Vec::new())
1153 }
1154 "protections-check" => {
1155 let (outcome, _) = run_script(engine, step)?;
1156 if !outcome.success() {
1157 return Err(classify_failure(engine, step, &outcome));
1158 }
1159 match observe_with(engine, step.name)? {
1163 StepState::Satisfied { detail, limitation } => {
1164 Ok(Done::Passed(limitation.map_or_else(
1165 || detail.clone(),
1166 |limit| format!("{detail} (limitation: {limit})"),
1167 )))
1168 }
1169 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1170 Err(RkError::refusal(
1171 Diagnostic::new(
1172 Reason::StateDrift,
1173 format!("protections-check passed its script and the observation disagrees: {detail}"),
1174 )
1175 .expected(step.proves_for(&engine.ctx).to_owned())
1176 .step(step.name),
1177 ))
1178 }
1179 StepState::Unknown { detail } => Err(RkError::refusal(
1182 Diagnostic::new(
1183 Reason::ForgeTemporary,
1184 format!(
1185 "protections-check passed its script and the readback could not confirm it: {detail}"
1186 ),
1187 )
1188 .expected(step.proves_for(&engine.ctx).to_owned())
1189 .action("check authentication and connectivity, then rerun")
1190 .step(step.name),
1191 )),
1192 }
1193 }
1194 "install-bot" if engine.ctx.forge == Some(Forge::Github) => {
1200 match observe_with(engine, step.name)? {
1201 StepState::Satisfied { detail, .. } => {
1202 return Ok(Done::Satisfied(detail));
1203 }
1204 StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
1205 StepState::Unknown { detail } => {
1206 return Err(RkError::refusal(
1207 Diagnostic::new(
1208 Reason::ForgeTemporary,
1209 format!("{} cannot observe the current state: {detail}", step.name),
1210 )
1211 .expected("a readable forge answer before anything mutates")
1212 .action("check the App credentials and connectivity, then rerun")
1213 .step(step.name),
1214 ));
1215 }
1216 }
1217 let installation = github_installation_id(engine, step)?;
1218 run_forge_step_with(
1219 engine,
1220 step,
1221 None,
1222 vec![("RK_BOT_INSTALLATION".into(), installation.into())],
1223 )
1224 }
1225 _ => {
1226 if step.mutates == Mutates::Forge {
1227 match observe_with(engine, step.name)? {
1232 StepState::Satisfied { detail, limitation } => {
1233 let detail = if step.name == "private-vulnerability-reporting" {
1234 limitation.map_or_else(
1235 || detail.clone(),
1236 |limit| format!("{detail} (limitation: {limit})"),
1237 )
1238 } else {
1239 detail
1240 };
1241 return Ok(Done::Satisfied(detail));
1242 }
1243 StepState::Inapplicable { detail }
1244 if step.name == "private-vulnerability-reporting" =>
1245 {
1246 return Ok(Done::Skipped(detail));
1247 }
1248 StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
1249 StepState::Unknown { detail } => {
1250 return Err(RkError::refusal(
1251 Diagnostic::new(
1252 Reason::ForgeTemporary,
1253 format!("{} cannot observe the current state: {detail}", step.name),
1254 )
1255 .expected("a readable forge answer before anything mutates")
1256 .action("check authentication and connectivity, then rerun")
1257 .step(step.name),
1258 ));
1259 }
1260 }
1261 }
1262 run_forge_step(engine, step)
1263 }
1264 }
1265}
1266
1267fn github_installation_id(engine: &mut Engine, step: &StepSpec) -> Result<String, RkError> {
1275 let refuse = |message: String, action: &str| {
1276 RkError::refusal(
1277 Diagnostic::new(Reason::PrerequisiteUnmet, message)
1278 .expected("the App installed on the repository's owner")
1279 .action(action.to_owned())
1280 .step(step.name),
1281 )
1282 };
1283 let jwt = match app_jwt_for(engine)? {
1284 Ok(jwt) => jwt,
1285 Err(detail) => {
1286 return Err(refuse(
1287 format!("install-bot has no App token: {detail}"),
1288 app_jwt::REMEDIATION,
1289 ));
1290 }
1291 };
1292 let owner = engine
1293 .ctx
1294 .repo
1295 .split('/')
1296 .next()
1297 .unwrap_or_default()
1298 .to_owned();
1299 let ctx = clone_ctx(&engine.ctx);
1300 for path in [
1301 format!("users/{owner}/installation"),
1302 format!("orgs/{owner}/installation"),
1303 ] {
1304 match app_jwt::api_get(&ctx, &jwt, &path) {
1305 AppApi::Ok(body) => {
1306 return body["id"].as_i64().map(|id| id.to_string()).ok_or_else(|| {
1307 refuse(
1308 format!("the forge answered {path} without an installation id"),
1309 "check RK_BOT_APP_ID and the key file name the same App",
1310 )
1311 });
1312 }
1313 AppApi::Missing => {}
1314 AppApi::Refused(detail) => {
1315 return Err(refuse(
1316 detail,
1317 "check RK_BOT_APP_ID and the key file name the same App",
1318 ));
1319 }
1320 AppApi::Failed(detail) => {
1321 return Err(RkError::refusal(
1322 Diagnostic::new(
1323 Reason::ForgeTemporary,
1324 format!("install-bot cannot read the App's installation: {detail}"),
1325 )
1326 .action("check connectivity, then rerun")
1327 .step(step.name),
1328 ));
1329 }
1330 }
1331 }
1332 Err(refuse(
1333 format!("the App has no installation on {owner}"),
1334 "install the App on the account first; the setup guide's step 5 walks it",
1335 ))
1336}
1337
1338fn run_forge_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
1340 run_forge_step_with(engine, step, None, Vec::new())
1341}
1342
1343fn run_forge_step_with(
1346 engine: &mut Engine,
1347 step: &StepSpec,
1348 stdin: Option<Zeroizing<Vec<u8>>>,
1349 extra_env: Vec<(OsString, OsString)>,
1350) -> Result<Done, RkError> {
1351 if crate::setup::before_state::destructive(step.name) {
1352 save_before_state(engine, step)?;
1353 }
1354 let (outcome, _) = run_script_with(engine, step, stdin, extra_env)?;
1355 if !outcome.success() {
1356 return Err(classify_failure(engine, step, &outcome));
1357 }
1358 let state = observe_with(engine, step.name)?;
1359 match state {
1360 StepState::Satisfied { detail, limitation } => Ok(Done::Changed(detail, limitation)),
1361 StepState::Inapplicable { detail } if step.name == "private-vulnerability-reporting" => {
1362 Ok(Done::Skipped(detail))
1363 }
1364 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1365 Err(RkError::refusal(
1366 Diagnostic::new(
1367 Reason::StateDrift,
1368 format!(
1369 "{} ran and its postcondition does not hold: {detail}",
1370 step.name
1371 ),
1372 )
1373 .expected(step.proves_for(&engine.ctx).to_owned())
1374 .step(step.name),
1375 ))
1376 }
1377 StepState::Unknown { detail } => Err(RkError::refusal(
1381 Diagnostic::new(
1382 Reason::ForgeTemporary,
1383 format!(
1384 "{} ran and the readback could not confirm it: {detail}",
1385 step.name
1386 ),
1387 )
1388 .expected(step.proves_for(&engine.ctx).to_owned())
1389 .action(format!(
1390 "rk setup step {} --target {} --apply re-asserts and re-proves it",
1391 step.name, engine.ctx.target
1392 ))
1393 .step(step.name),
1394 )),
1395 }
1396}
1397
1398fn save_before_state(engine: &mut Engine, step: &StepSpec) -> Result<(), RkError> {
1403 if let Some(held) = migration_snapshot(&engine.ctx.target, step.name)? {
1408 engine.out.result_line(format!(
1409 "the migration holds the before-state of {} at {held}",
1410 step.name
1411 ));
1412 let mut event = engine.event(EventKind::Snapshot, Some(step.name));
1413 event.detail = Some(held);
1414 engine.emit(&event);
1415 return Ok(());
1416 }
1417 let snapshot = if let Some(snapshot) = &engine.snapshot {
1418 snapshot.clone()
1419 } else {
1420 let canonical = std::fs::canonicalize(engine.ctx.target.as_std_path())?;
1421 let snapshot = crate::snapshot::Snapshot::begin(&canonical)?;
1422 engine.snapshot = Some(snapshot.clone());
1423 snapshot
1424 };
1425 let ctx = clone_ctx(&engine.ctx);
1426 let mut runner = |exec: &Exec| engine.exec(exec, false);
1427 let path = crate::setup::before_state::save(&ctx, step.name, &snapshot, &mut runner)?;
1428 let path = path.display().to_string();
1429 engine
1430 .out
1431 .result_line(format!("saved the before-state of {} to {path}", step.name));
1432 let mut event = engine.event(EventKind::Snapshot, Some(step.name));
1433 event.detail = Some(path);
1434 engine.emit(&event);
1435 Ok(())
1436}
1437
1438#[must_use]
1440pub fn forge_snapshot_id(step: &str) -> String {
1441 format!("forge-{step}")
1442}
1443
1444fn migration_snapshot(target: &camino::Utf8Path, step: &str) -> Result<Option<String>, RkError> {
1453 let Some(id) = crate::migrate::open_id(target)? else {
1454 return Ok(None);
1455 };
1456 let Some(workspace) = crate::migrate::open_workspace(target)? else {
1457 return Err(crate::migrate::none_open());
1458 };
1459 if !workspace.join(crate::migrate::REGISTRY_FILE).is_file() {
1460 return Err(crate::migrate::workspace_missing(&id, &workspace));
1461 }
1462 let registry = crate::migrate::registry::load(&workspace)?;
1463 let wanted = forge_snapshot_id(step);
1464 let found = registry
1467 .snapshots
1468 .iter()
1469 .find(|snapshot| snapshot.id == wanted);
1470 let intact = match found {
1471 Some(snapshot) => {
1472 crate::migrate::registry::integrity(&workspace, snapshot)?
1473 == crate::migrate::registry::Integrity::Intact
1474 }
1475 None => false,
1476 };
1477 let Some(snapshot) = found.filter(|_| intact) else {
1478 return Err(RkError::refusal(
1479 Diagnostic::new(
1480 Reason::DestructiveRefusal,
1481 format!(
1482 "{step} changes forge state, and the migration {id} holds no intact snapshot of it (migration:a-destructive-step-waits-for-its-snapshot)"
1483 ),
1484 )
1485 .expected("the step's before-state in the migration registry")
1486 .action("rk migrate archive --apply --target . takes the missing forge snapshots on a host that holds the forge credentials")
1487 .target_state("unchanged")
1488 .step(step),
1489 ));
1490 };
1491 Ok(Some(workspace.join(&snapshot.file).to_string()))
1492}
1493
1494#[derive(Debug, Clone, PartialEq, Eq)]
1496pub enum ForgeSnapshot {
1497 Taken(PathBuf),
1499 Unknown(String),
1501}
1502
1503pub fn migration_snapshots(
1514 target: &camino::Utf8PathBuf,
1515 snapshot: &crate::snapshot::Snapshot,
1516 skip: &[String],
1517) -> Result<Vec<(&'static str, ForgeSnapshot)>, RkError> {
1518 let mut ctx = Ctx::resolve(target, None, None, None)?;
1519 let steps: Vec<&StepSpec> = STEPS
1520 .iter()
1521 .filter(|step| {
1522 crate::setup::before_state::destructive(step.name)
1523 && stance(&ctx, step).acts()
1524 && !skip.contains(&forge_snapshot_id(step.name))
1525 })
1526 .collect();
1527 if steps.is_empty() {
1528 return Ok(Vec::new());
1529 }
1530 if let Err(error) = ctx.require_cli(&steps) {
1531 return Ok(steps
1532 .iter()
1533 .map(|step| (step.name, ForgeSnapshot::Unknown(error.to_string())))
1534 .collect());
1535 }
1536 let mut engine = Engine::open(Output::new(false), ctx, "migrate", false)?;
1539 let mut out = Vec::new();
1540 for step in steps {
1541 let ctx = clone_ctx(&engine.ctx);
1542 let mut runner = |exec: &Exec| engine.exec(exec, false);
1543 let taken = match crate::setup::before_state::save(&ctx, step.name, snapshot, &mut runner) {
1544 Ok(path) => ForgeSnapshot::Taken(path),
1545 Err(error) => ForgeSnapshot::Unknown(error.to_string()),
1546 };
1547 out.push((step.name, taken));
1548 }
1549 Ok(out)
1550}
1551
1552pub fn destructive_steps(target: &camino::Utf8PathBuf) -> Result<Vec<&'static str>, RkError> {
1559 let ctx = Ctx::resolve(target, None, None, None)?;
1560 Ok(STEPS
1561 .iter()
1562 .filter(|step| {
1563 crate::setup::before_state::destructive(step.name) && stance(&ctx, step).acts()
1564 })
1565 .map(|step| step.name)
1566 .collect())
1567}
1568
1569pub fn proof_word(target: &camino::Utf8PathBuf) -> Result<&'static str, RkError> {
1576 let ctx = Ctx::resolve(target, None, None, None)?;
1577 Ok(proof::judge(&ctx.target, &proof::subject(&ctx)).word())
1578}
1579
1580fn observe_with(engine: &mut Engine, step: &str) -> Result<StepState, RkError> {
1587 if step == "install-bot" && engine.ctx.forge == Some(Forge::Github) {
1588 let jwt = match app_jwt_for(engine)? {
1589 Ok(jwt) => jwt,
1590 Err(detail) => return Ok(StepState::Unknown { detail }),
1591 };
1592 return Ok(observe::github_install_bot(&engine.ctx, &jwt));
1593 }
1594 let ctx = clone_ctx(&engine.ctx);
1595 let mut runner = |exec: &Exec| engine.exec(exec, false);
1596 observe::observe(&ctx, step, &mut runner)
1597}
1598
1599fn key_file_for(engine: &mut Engine) -> Result<Option<&secrets::KeyFile>, RkError> {
1605 if engine.key.is_none() && engine.key_unavailable.is_none() {
1606 match secrets::look_up_key_file(&engine.ctx.target)? {
1607 secrets::KeyLookup::Absent => {}
1608 secrets::KeyLookup::Found(key) => {
1609 engine.secrets.push(key.bytes.clone());
1610 engine.key = Some(key);
1611 }
1612 secrets::KeyLookup::Unavailable(unavailable) if engine.key_boundary => {
1617 engine.key_unavailable = Some(unavailable.message);
1618 }
1619 secrets::KeyLookup::Unavailable(unavailable) => return Err(unavailable.refusal()),
1620 }
1621 }
1622 Ok(engine.key.as_ref())
1623}
1624
1625fn app_jwt_for(engine: &mut Engine) -> Result<Result<String, String>, RkError> {
1635 if let Some(jwt) = &engine.app_jwt {
1636 return Ok(Ok(jwt.clone()));
1637 }
1638 let app_id = app_jwt::app_id(engine.ctx.bot_app_id())?;
1639 let key_bytes = key_file_for(engine)?.map(|key| key.bytes.clone());
1640 if let Some(unreachable) = &engine.key_unavailable {
1641 return Ok(Err(format!(
1642 "the installation is readable only to the App itself, and this runtime cannot reach the key {} names: {unreachable}; a host that holds the key re-verifies it",
1643 secrets::PRIVATE_KEY_FILE
1644 )));
1645 }
1646 let (Some(app_id), Some(key_bytes)) = (app_id, key_bytes) else {
1647 return Ok(Err(format!(
1648 "the installation is readable only to the App itself; {}",
1649 app_jwt::REMEDIATION
1650 )));
1651 };
1652 let credentials = app_jwt::AppCredentials { app_id, key_bytes };
1653 let ctx = clone_ctx(&engine.ctx);
1654 Ok(match app_jwt::mint(&ctx, &credentials) {
1655 Ok(jwt) => {
1656 engine
1657 .secrets
1658 .push(Zeroizing::new(jwt.clone().into_bytes()));
1659 if let Some(signature) = jwt.rsplit('.').next() {
1660 engine
1661 .secrets
1662 .push(Zeroizing::new(signature.as_bytes().to_vec()));
1663 }
1664 engine.app_jwt = Some(jwt.clone());
1665 Ok(jwt)
1666 }
1667 Err(detail) => Err(detail),
1668 })
1669}
1670
1671fn state_detail(state: &StepState) -> String {
1672 state.detail().to_owned()
1673}
1674
1675fn run_script(engine: &mut Engine, step: &StepSpec) -> Result<(Outcome, PathBuf), RkError> {
1678 run_script_with(engine, step, None, Vec::new())
1679}
1680
1681fn run_script_with(
1687 engine: &mut Engine,
1688 step: &StepSpec,
1689 stdin: Option<Zeroizing<Vec<u8>>>,
1690 extra_env: Vec<(OsString, OsString)>,
1691) -> Result<(Outcome, PathBuf), RkError> {
1692 let forge = engine.ctx.adapter()?;
1693 let rel = format!("{}/{}", forge.as_str(), step.name);
1694 let bytes = embedded::SETUP
1695 .get_file(&rel)
1696 .map(include_dir::File::contents)
1697 .ok_or_else(|| RkError::Other(anyhow::anyhow!("no embedded script at setup/{rel}")))?;
1698 let journal = engine
1699 .journal
1700 .as_mut()
1701 .ok_or_else(|| RkError::Other(anyhow::anyhow!("an apply always has a journal")))?;
1702 let dir = journal.scripts_dir().join(forge.as_str());
1703 fs::create_dir_all(&dir)?;
1704 restrict(&dir, 0o700);
1705 let path = dir.join(step.name);
1706 fs::write(&path, bytes)?;
1707 restrict(&path, 0o600);
1708 let written = fs::read(&path)?;
1709 let digest = Digest::of(&written);
1710 if digest != Digest::of(bytes) {
1711 return Err(RkError::Other(anyhow::anyhow!(
1712 "the materialized script at {} differs from the embedded bytes",
1713 path.display()
1714 )));
1715 }
1716 journal.record_script(format!("scripts/{rel}"), digest.to_string());
1717 let mut env = engine.ctx.child_env(step.name);
1718 env.extend(extra_env);
1719 let exec = Exec {
1720 program: crate::probes::sh_bin(),
1721 args: vec![path.clone().into_os_string()],
1722 env,
1723 cwd: engine.ctx.target.as_std_path().to_path_buf(),
1724 stdin,
1725 };
1726 let outcome = engine.exec(&exec, true)?;
1727 Ok((outcome, path))
1728}
1729
1730fn classify_failure(engine: &Engine, step: &StepSpec, outcome: &Outcome) -> RkError {
1735 let stderr = String::from_utf8_lossy(&outcome.stderr);
1736 let last = if outcome.exit_code >= 128 {
1740 format!("killed by signal {}", outcome.exit_code - 128)
1741 } else {
1742 stderr
1743 .lines()
1744 .rev()
1745 .find(|line| !line.trim().is_empty())
1746 .unwrap_or("no output")
1747 .to_owned()
1748 };
1749 let reason = if (engine.ctx.forge == Some(Forge::Github) && outcome.exit_code == 4)
1750 || stderr.contains("HTTP 401")
1751 {
1752 Reason::ForgeAuthentication
1753 } else if stderr.contains("HTTP 403") {
1754 Reason::ForgePermission
1755 } else if stderr.contains("HTTP 429") || stderr.contains("rate limit") {
1756 Reason::ForgeRateLimit
1757 } else {
1758 Reason::SubprocessFailed
1759 };
1760 let diagnostic = Diagnostic::new(reason, format!("the forge refused '{}': {last}", step.name))
1761 .expected(step.proves_for(&engine.ctx).to_owned())
1762 .action(format!(
1763 "rk setup step {} --target {} --apply",
1764 step.name, engine.ctx.target
1765 ))
1766 .step(step.name);
1767 let diagnostic = match reason {
1768 Reason::ForgePermission => diagnostic.expected(format!(
1769 "repository administration write on {} for the authenticated account",
1770 engine.ctx.repo
1771 )),
1772 _ => diagnostic,
1773 };
1774 match reason {
1775 Reason::SubprocessFailed => RkError::subprocess(diagnostic),
1776 _ => RkError::refusal(diagnostic),
1777 }
1778}
1779
1780fn attach_progress(
1783 error: RkError,
1784 done: &[(String, String)],
1785 failed: &StepSpec,
1786 steps: &[&StepSpec],
1787) -> RkError {
1788 let remaining = steps.len().saturating_sub(done.len() + 1);
1789 let state = format!(
1790 "{} completed; {} failed; {remaining} not attempted",
1791 step_count(done.len()),
1792 failed.name
1793 );
1794 match error {
1795 RkError::Refusal(mut diagnostic) => {
1796 diagnostic.target_state.get_or_insert(state);
1797 RkError::Refusal(diagnostic)
1798 }
1799 RkError::Subprocess(mut diagnostic) => {
1800 diagnostic.target_state.get_or_insert(state);
1801 RkError::Subprocess(diagnostic)
1802 }
1803 other => other,
1804 }
1805}
1806
1807fn observe_all(engine: &mut Engine) -> Result<Report, RkError> {
1813 let mut report = Report::default();
1814 for step in &STEPS {
1815 let clock = Instant::now();
1816 let stance = stance(&engine.ctx, step);
1821 let row = if stance.acts() {
1822 let state = observe_with(engine, step.name)?;
1823 Row::observed(step, stance, &state)
1824 } else {
1825 Row::stated(step, stance)
1826 };
1827 engine.out.result_line(row.line());
1828 let (status, detail) = row.event_fields();
1829 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
1830 finished.status = Some(status);
1831 finished.detail = Some(detail);
1832 finished.duration_ms = Some(elapsed_ms(clock));
1833 engine.emit(&finished);
1834 report.rows.push(row);
1835 }
1836 if report.judged() < STEPS.len() {
1837 engine.out.result_line(format!(
1838 "{} judged; the rest do not apply to this target or {} excludes them",
1839 step_count(report.judged()),
1840 crate::config::CONFIG_PATH
1841 ));
1842 }
1843 Ok(report)
1844}
1845
1846fn check(out: Output, ctx: Ctx) -> Result<(), RkError> {
1850 let mut engine = Engine::open(out, ctx, "setup check", false)?;
1851 engine.key_boundary = true;
1852 let report = observe_all(&mut engine)?;
1853 for milestone in crate::setup::milestones::observe(&engine.ctx) {
1856 engine.out.result_line(milestone.line());
1857 let mut event = engine.event(EventKind::Milestone, Some(milestone.id));
1858 event.status = Some(milestone.status.to_owned());
1859 event.detail = Some(milestone.detail);
1860 engine.emit(&event);
1861 }
1862 if !report.checkpointable() {
1863 let error = verdict(&engine.ctx, &report);
1864 return Err(fail(&mut engine, error));
1865 }
1866 engine
1867 .out
1868 .next(&["rk guide release orders the first release".to_owned()]);
1869 engine.finish(0, None);
1870 Ok(())
1871}
1872
1873fn verdict(ctx: &Ctx, report: &Report) -> RkError {
1883 let unsatisfied = report.unsatisfied();
1884 let unknown = report.unknown();
1885 let standing = proof::judge(&ctx.target, &proof::subject(ctx));
1886 let proven = match &standing {
1887 Standing::Compatible(proof) => Some(proof),
1888 _ => None,
1889 };
1890 if unsatisfied == 0 {
1891 let state = proven.map_or_else(
1892 || {
1893 format!(
1894 "no setup defect was inferred; no compatible {} is committed, so the current state is unverified",
1895 proof::PROOF_PATH
1896 )
1897 },
1898 |proof| {
1899 format!(
1900 "no setup defect was inferred; the committed setup proof from {} by rk {} still matches this target's setup contract",
1901 proof.verified_at, proof.rk_version
1902 )
1903 },
1904 );
1905 return RkError::check_failed(
1906 Diagnostic::new(
1907 Reason::ObservationIncomplete,
1908 format!(
1909 "{} could not be verified from this runtime, and none is unsatisfied",
1910 step_count(unknown)
1911 ),
1912 )
1913 .expected("every applicable step readable from where the check runs")
1914 .action("rerun rk setup check where each unknown step can be observed; its line names what it needs")
1915 .target_state(state),
1916 );
1917 }
1918 let mut message = format!(
1919 "{} {} not satisfied",
1920 step_count(unsatisfied),
1921 if unsatisfied == 1 { "is" } else { "are" }
1922 );
1923 let mut action = format!(
1924 "rk setup --target {} --apply re-asserts the unsatisfied ones",
1925 ctx.target
1926 );
1927 if unknown > 0 {
1928 use std::fmt::Write as _;
1929 let _ = write!(
1930 message,
1931 ", and {unknown} could not be verified from this runtime"
1932 );
1933 action.push_str("; the unknown ones need a runtime that can observe what each line names");
1934 }
1935 let mut diagnostic = Diagnostic::new(Reason::StateDrift, message)
1936 .expected("every step's proof column to hold")
1937 .action(action);
1938 if let Some(proof) = proven {
1939 diagnostic = diagnostic.target_state(format!(
1940 "this observation supersedes the committed setup proof from {} by rk {}",
1941 proof.verified_at, proof.rk_version
1942 ));
1943 }
1944 RkError::check_failed(diagnostic)
1945}
1946
1947fn checkpoint(out: Output, ctx: Ctx) -> Result<(), RkError> {
1953 let mut engine = Engine::open(out, ctx, "setup checkpoint", false)?;
1954 let report = observe_all(&mut engine)?;
1955 let Some(proof) = proof::of(&engine.ctx, &report, crate::applog::now_utc()) else {
1956 let error = RkError::check_failed(
1957 Diagnostic::new(
1958 Reason::StateDrift,
1959 format!(
1960 "the observation is incomplete: {} not satisfied and {} could not be verified",
1961 step_count(report.unsatisfied()),
1962 report.unknown()
1963 ),
1964 )
1965 .expected("every applicable step observed and holding")
1966 .action(format!(
1967 "rk setup check --target {} names each gap; checkpoint again once none remains",
1968 engine.ctx.target
1969 ))
1970 .target_state(format!("{} was not written", proof::PROOF_PATH)),
1971 );
1972 return Err(fail(&mut engine, error));
1973 };
1974 if let Err(source) = proof::write(&engine.ctx.target, &proof) {
1975 let error = RkError::refusal(
1976 Diagnostic::new(
1977 Reason::Io,
1978 format!("{} cannot be written: {source}", proof::PROOF_PATH),
1979 )
1980 .expected("a writable .release-kit directory in the target")
1981 .target_state("the earlier proof, where one exists, is unchanged"),
1982 );
1983 return Err(fail(&mut engine, error));
1984 }
1985 engine.out.result_line(format!(
1986 "wrote {}: {} proven at {} by rk {}",
1987 proof::PROOF_PATH,
1988 step_count(proof.steps.len()),
1989 proof.verified_at,
1990 proof.rk_version
1991 ));
1992 engine.out.next(&[
1993 format!("commit {} with the setup it proves", proof::PROOF_PATH),
1994 format!(
1995 "rk setup status --target {} reads it offline",
1996 engine.ctx.target
1997 ),
1998 ]);
1999 engine.finish(0, None);
2000 Ok(())
2001}
2002
2003fn status(out: Output, ctx: &Ctx) -> Result<(), RkError> {
2009 let standing = proof::judge(&ctx.target, &proof::subject(ctx));
2010 out.emit(&proof::StatusDocument::of(&standing))?;
2011 match &standing {
2012 Standing::Absent => out.result_line(format!(
2013 "setup proof: absent — {} does not exist, so no complete observation is on record",
2014 proof::PROOF_PATH
2015 )),
2016 Standing::Compatible(proof) => out.result_line(format!(
2017 "setup proof: compatible — proven {} by rk {}, and this target's setup contract has not changed since",
2018 proof.verified_at, proof.rk_version
2019 )),
2020 Standing::Stale { proof, differences } => out.result_line(format!(
2021 "setup proof: stale — proven {} by rk {}, and the setup contract changed since: {}",
2022 proof.verified_at,
2023 proof.rk_version,
2024 differences.join(", ")
2025 )),
2026 Standing::Invalid { detail, .. } => {
2027 out.result_line(format!("setup proof: invalid — {detail}"));
2028 }
2029 }
2030 if let Standing::Compatible(proof) | Standing::Stale { proof, .. } = &standing {
2031 for step in &proof.steps {
2032 let limitation = step
2033 .limitation
2034 .as_ref()
2035 .map_or_else(String::new, |limit| format!(" (limitation: {limit})"));
2036 out.result_line(format!(" {} {}{limitation}", step.state, step.name));
2037 }
2038 }
2039 let next = match standing {
2040 Standing::Compatible(_) => format!(
2041 "rk setup check --target {} observes the forge now, where this runtime can",
2042 ctx.target
2043 ),
2044 _ => format!(
2045 "rk setup checkpoint --target {}, from a host that can observe every step, records a proof",
2046 ctx.target
2047 ),
2048 };
2049 out.next(&[next]);
2050 Ok(())
2051}
2052
2053fn restrict(path: &std::path::Path, mode: u32) {
2056 #[cfg(unix)]
2057 {
2058 use std::os::unix::fs::PermissionsExt as _;
2059 let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
2060 }
2061 #[cfg(not(unix))]
2062 let _ = (path, mode);
2063}
2064
2065fn guard_sh() -> Result<(), RkError> {
2068 let ok = std::process::Command::new(crate::probes::sh_bin())
2069 .args(["-c", "exit 0"])
2070 .status()
2071 .is_ok_and(|status| status.success());
2072 if ok {
2073 Ok(())
2074 } else {
2075 Err(RkError::refusal(
2076 Diagnostic::new(Reason::PrerequisiteUnmet, "no POSIX sh runs on this host")
2077 .expected("a working sh on PATH; every step spawns through it")
2078 .action("install a POSIX shell, then rerun")
2079 .target_state("nothing was run and nothing changed"),
2080 ))
2081 }
2082}
2083
2084#[cfg(test)]
2085mod tests {
2086 #[test]
2089 fn a_step_count_carries_a_noun_that_agrees_with_it() {
2090 assert_eq!(super::step_count(0), "0 steps");
2091 assert_eq!(super::step_count(1), "1 step");
2092 assert_eq!(super::step_count(2), "2 steps");
2093 }
2094
2095 #[test]
2102 fn an_inapplicable_prerequisite_never_blocks_a_gate() {
2103 use super::{PrereqGate, Stance, prereq_gate};
2104 assert_eq!(
2105 prereq_gate(Stance::NotApplicable("the profile names no forge".into())),
2106 PrereqGate::Silent
2107 );
2108 assert_eq!(
2109 prereq_gate(Stance::Excluded("read by hand".into())),
2110 PrereqGate::Excluded("read by hand".into())
2111 );
2112 assert_eq!(
2113 prereq_gate(Stance::Redundant {
2114 reason: "read by hand".into(),
2115 inapplicable: "the profile names no forge".into(),
2116 }),
2117 PrereqGate::Excluded("read by hand".into())
2118 );
2119 assert_eq!(prereq_gate(Stance::Applies), PrereqGate::Observe);
2120 }
2121}