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