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 named: bool,
384}
385
386impl Engine {
387 fn open(
392 out: Output,
393 ctx: Ctx,
394 command: &'static str,
395 journal_required: bool,
396 ) -> Result<Self, RkError> {
397 secrets::refuse_legacy_key()?;
400 let journal = match Journal::create(
401 command,
402 ctx.target.as_str(),
403 ctx.forge.map_or("none", Forge::as_str),
404 &ctx.repo,
405 ) {
406 Ok(journal) => Some(journal),
407 Err(source) if journal_required => {
408 return Err(RkError::refusal(
409 Diagnostic::new(
410 Reason::JournalUnavailable,
411 format!("the run journal cannot be created: {source}"),
412 )
413 .expected("a writable state root for the journal")
414 .target_state("nothing was run and nothing changed"),
415 ));
416 }
417 Err(source) => {
418 out.warn(format!("no run journal for this run: {source}"));
419 None
420 }
421 };
422 let run_id = journal
423 .as_ref()
424 .map_or_else(|| "unjournaled".to_owned(), |j| j.run_id().to_owned());
425 let mut engine = Self {
426 out,
427 ctx,
428 journal,
429 secrets: Ctx::secret_values(),
430 key: None,
431 app_jwt: None,
432 key_boundary: false,
433 key_unavailable: None,
434 seq: 0,
435 command,
436 run_id,
437 snapshot: None,
438 named: false,
439 };
440 let opening = Event::opening(
441 engine.next_seq(),
442 crate::applog::now_utc(),
443 engine.run_id.clone(),
444 engine.command,
445 );
446 engine.emit(&opening);
447 if engine.ctx.self_hosted_gitlab() {
448 engine.out.warn(
449 "this remote is a self-hosted GitLab: registry trusted publishing covers GitLab.com only, so the OIDC invariant cannot be satisfied here",
450 );
451 }
452 Ok(engine)
453 }
454
455 const fn next_seq(&mut self) -> u64 {
456 let seq = self.seq;
457 self.seq += 1;
458 seq
459 }
460
461 fn event(&mut self, kind: EventKind, step: Option<&str>) -> Event {
462 let mut event = Event::opening(
463 self.next_seq(),
464 crate::applog::now_utc(),
465 self.run_id.clone(),
466 self.command,
467 );
468 event.kind = kind;
469 event.step = step.map(str::to_owned);
470 event
471 }
472
473 fn emit(&mut self, event: &Event) {
474 self.out.event(event);
475 if let Some(journal) = &mut self.journal
476 && let Ok(line) = serde_json::to_string(event)
477 {
478 journal.event_line(&line);
479 }
480 }
481
482 fn exec(&mut self, exec: &Exec, passthrough: bool) -> Result<Outcome, RkError> {
485 let echo = exec.echo();
486 self.out.frame(&echo);
487 if let Some(journal) = &mut self.journal {
488 journal.transcript(echo.as_bytes());
489 journal.transcript(b"\n");
490 }
491 let secrets = std::mem::take(&mut self.secrets);
492 let step_name: Option<String> = None;
493 let mut chunks: Vec<(ChildStream, Vec<u8>)> = Vec::new();
494 let spawned = process::run(exec, |stream, chunk| {
495 chunks.push((stream, process::redact(chunk, &secrets)));
496 });
497 self.secrets = secrets;
498 for (stream, chunk) in chunks {
499 if passthrough {
500 self.out.child_passthrough(stream, &chunk);
501 }
502 let event = self.event(EventKind::ChildOutput, step_name.as_deref());
503 let event = event.child_output(stream, &chunk);
504 self.emit(&event);
505 if let Some(journal) = &mut self.journal {
506 journal.transcript(&chunk);
507 }
508 }
509 spawned.map_err(|source| {
510 RkError::refusal(
511 Diagnostic::new(
512 Reason::SubprocessSpawn,
513 format!("{} did not spawn: {source}", exec.program.to_string_lossy()),
514 )
515 .expected("a POSIX sh and the forge CLI on PATH")
516 .run(self.run_path()),
517 )
518 })
519 }
520
521 fn run_path(&self) -> String {
522 self.journal.as_ref().map_or_else(
523 || "no journal was written".to_owned(),
524 |j| j.dir.display().to_string(),
525 )
526 }
527
528 fn finish(&mut self, exit_code: i32, reason: Option<&str>) {
529 let mut event = self.event(EventKind::RunFinished, None);
530 event.exit_code = Some(exit_code);
531 event.status = Some(if exit_code == 0 {
532 "ok".into()
533 } else {
534 "failed".into()
535 });
536 self.emit(&event);
537 if let Some(journal) = &mut self.journal {
538 journal.finish(exit_code, reason);
539 }
540 }
541}
542
543fn fail(engine: &mut Engine, error: RkError) -> RkError {
545 let error = match error {
546 RkError::Refusal(mut diagnostic) => {
547 diagnostic.run.get_or_insert_with(|| engine.run_path());
548 RkError::Refusal(diagnostic)
549 }
550 RkError::Subprocess(mut diagnostic) => {
551 diagnostic.run.get_or_insert_with(|| engine.run_path());
552 RkError::Subprocess(diagnostic)
553 }
554 RkError::CheckFailed(mut diagnostic) => {
555 diagnostic.run.get_or_insert_with(|| engine.run_path());
556 RkError::CheckFailed(diagnostic)
557 }
558 other => other,
559 };
560 engine.finish(i32::from(error.exit_code()), Some(error.reason().as_str()));
561 error
562}
563
564fn preview(out: Output, ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
570 let mut engine = Engine::open(out, clone_ctx(ctx), "setup preview", false)?;
571 out.result_line(format!(
572 "DRY RUN: rk setup would run these steps against {} on {}; re-run with --apply",
573 engine.ctx.repo,
574 engine.ctx.forge.map_or("no forge", Forge::as_str)
575 ));
576 for (idx, step) in steps.iter().enumerate() {
577 out.result_line(format!(
578 "step {}/{} {} — proves {}",
579 idx + 1,
580 steps.len(),
581 step.name,
582 step.proves_for(ctx)
583 ));
584 let stance = stance(&engine.ctx, step);
585 if !stance.acts() {
586 out.result_line(format!(" {}", stance.framed()));
587 let mut event = engine.event(EventKind::StepFinished, Some(step.name));
588 event.status = Some(stance.word().into());
589 event.detail = Some(stance.detail());
590 engine.emit(&event);
591 continue;
592 }
593 if step.name == "bot-secrets" && engine.ctx.forge == Some(Forge::Github) {
597 secrets::resolve_key_file(&engine.ctx.target)?;
598 }
599 out.result_line(format!(" {}", render_invocation(&engine.ctx, step)));
600 if step.name == "protect-trunk"
606 && engine.ctx.forge == Some(Forge::Github)
607 && engine.ctx.required_check.is_none()
608 && engine
609 .ctx
610 .protection()
611 .owned_trunk_rules
612 .iter()
613 .any(|rule| rule == "required_status_checks")
614 {
615 out.result_line(" needs: --required-check <name> before apply");
616 }
617 if skipped_by_a_full_run(&engine.ctx, step, steps.len()) {
618 out.result_line(format!(
619 " optional: a full apply skips it; set setup.release_lines, or rk setup step {} --apply runs it",
620 step.name
621 ));
622 }
623 let excluded = excluded_prerequisites(&engine.ctx, step);
624 for line in &excluded {
625 out.result_line(format!(" {line}"));
626 }
627 let mut event = engine.event(EventKind::StepFinished, Some(step.name));
628 event.status = Some("previewed".into());
629 event.detail = prerequisite_detail(&excluded);
630 engine.emit(&event);
631 }
632 let next = next_for_apply(&engine.ctx, steps);
633 out.next(&[
634 next,
635 "rk setup check --target . proves what is already true".to_owned(),
636 ]);
637 engine.finish(0, None);
638 Ok(())
639}
640
641fn render_invocation(ctx: &Ctx, step: &StepSpec) -> String {
644 match step.name {
645 "branch-reminder" => {
646 "would write: the post-merge reminder hook at $(git rev-parse --git-path hooks)/post-merge".to_owned()
647 }
648 "package-check" => match ctx.tech {
649 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(),
650 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(),
651 Some("python") if ctx.reporting_policy() => "would run: python3 -m build; sdist and wheel SECURITY.md inclusion stays unproved".to_owned(),
652 Some("python") => "would run: python3 -m build".to_owned(),
653 Some("bash") => {
654 let command = ctx
655 .profile
656 .release
657 .build
658 .as_ref()
659 .and_then(|build| build.command.as_deref())
660 .unwrap_or(crate::profile::BuildAnswer::COMMAND);
661 format!(
662 "would judge the build command {command}, from profile.release.build.command: a make command needs a makefile and runs make -n; the tarball is not inspected"
663 )
664 }
665 _ => "needs: a version file naming the technology".to_owned(),
666 },
667 "forge-version" => {
668 let (major, minor) = observe::GITLAB_VERSION_FLOOR;
669 match ctx.forge {
670 Some(Forge::Github) => {
671 "nothing to read: github.com is a rolling service and declares no version floor"
672 .to_owned()
673 }
674 Some(Forge::Gitlab) => format!(
675 "would read: GET /version, and compare it against the {major}.{minor} floor; nothing is written"
676 ),
677 None => "nothing to read: the profile names no forge".to_owned(),
678 }
679 }
680 name => {
681 let check = ctx
682 .required_check
683 .as_ref()
684 .filter(|_| ctx.forge == Some(Forge::Github) && name == "protect-trunk")
685 .map(|value| format!(" RK_REQUIRED_CHECK={value}"))
686 .unwrap_or_default();
687 let ruleset = match name {
691 "protect-trunk" => format!(
692 " RK_TRUNK_RULESET={} RK_SAFETY_RULESET={} RK_TITLE_CHECK={}",
693 ctx.trunk_ruleset(),
694 ctx.safety_ruleset(),
695 ctx.title_check()
696 ),
697 "protect-tags" => format!(" RK_TAG_RULESET={}", ctx.tag_ruleset()),
698 "protect-release-lines" => format!(" RK_LINES_RULESET={}", ctx.lines_ruleset()),
699 _ => String::new(),
700 };
701 format!(
702 "would run: sh <embedded setup/{}/{name}> with RK_REPO={} RK_TRUNK_BRANCH={}{ruleset}{check}",
703 ctx.forge.map_or("<no forge>", Forge::as_str),
704 ctx.repo,
705 ctx.trunk()
706 )
707 }
708 }
709}
710
711fn next_for_apply(ctx: &Ctx, steps: &[&StepSpec]) -> String {
712 let check = ctx
713 .required_check
714 .as_ref()
715 .map(|value| format!(" --required-check {value}"))
716 .unwrap_or_default();
717 if steps.len() == 1 {
718 format!(
719 "rk setup step {} --target {} --apply{check}",
720 steps[0].name, ctx.target
721 )
722 } else {
723 format!("rk setup --target {} --apply{check}", ctx.target)
724 }
725}
726
727fn clone_ctx(ctx: &Ctx) -> Ctx {
729 ctx.clone()
730}
731
732fn execute(
734 out: Output,
735 ctx: Ctx,
736 steps: &[&StepSpec],
737 command: &'static str,
738) -> Result<(), RkError> {
739 guard_sh()?;
740 let mut engine = Engine::open(out, ctx, command, true)?;
741 engine.named = steps.len() == 1;
742 let mut done: Vec<(String, String)> = Vec::new();
743 for (idx, step) in steps.iter().enumerate() {
744 let stance = stance(&engine.ctx, step);
748 if !stance.acts() {
749 engine.out.frame(format!(
750 "step {}/{} {} — {}",
751 idx + 1,
752 steps.len(),
753 step.name,
754 stance.framed()
755 ));
756 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
757 finished.status = Some(stance.word().into());
758 finished.detail = Some(stance.detail());
759 engine.emit(&finished);
760 done.push((step.name.to_owned(), stance.word().to_owned()));
761 continue;
762 }
763 if skipped_by_a_full_run(&engine.ctx, step, steps.len()) {
766 engine.out.frame(format!(
767 "step {}/{} {} — skipped (optional; set setup.release_lines, or rk setup step {} --apply runs it)",
768 idx + 1,
769 steps.len(),
770 step.name,
771 step.name
772 ));
773 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
774 finished.status = Some("skipped".into());
775 engine.emit(&finished);
776 done.push((step.name.to_owned(), "skipped".to_owned()));
777 continue;
778 }
779 engine.out.frame(format!(
780 "step {}/{} {} — {}",
781 idx + 1,
782 steps.len(),
783 step.name,
784 step.proves_for(&engine.ctx)
785 ));
786 let excluded = excluded_prerequisites(&engine.ctx, step);
787 for line in &excluded {
788 engine.out.frame(format!(" {line}"));
789 }
790 let mut started = engine.event(EventKind::StepStarted, Some(step.name));
791 started.status = Some("running".into());
792 started.detail = prerequisite_detail(&excluded);
793 engine.emit(&started);
794 let clock = Instant::now();
795 let status = match apply_step(&mut engine, step) {
796 Ok(status) => status,
797 Err(error) => {
798 let error = attach_progress(error, &done, step, steps);
799 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
800 finished.status = Some("failed".into());
801 finished.reason = Some(error.reason());
802 finished.duration_ms = Some(elapsed_ms(clock));
803 engine.emit(&finished);
804 return Err(fail(&mut engine, error));
805 }
806 };
807 engine.out.frame(format!(
808 "{} {}: {}",
809 if matches!(status, Done::Skipped(_)) {
810 "skipped"
811 } else {
812 "ok"
813 },
814 step.name,
815 status.line()
816 ));
817 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
818 finished.status = Some(status.wire().into());
819 finished.detail = Some(status.line());
820 finished.exit_code = Some(0);
821 finished.duration_ms = Some(elapsed_ms(clock));
822 engine.emit(&finished);
823 done.push((step.name.to_owned(), status.wire().to_owned()));
824 }
825 engine.out.result_line(format!(
826 "setup: {} completed against {}",
827 step_count(done.len()),
828 engine.ctx.repo
829 ));
830 for (name, status) in &done {
831 engine.out.result_line(format!(" {status} {name}"));
832 }
833 engine.out.next(&[
834 format!("rk setup check --target {}", engine.ctx.target),
835 "rk guide setup orders what no command performs".to_owned(),
836 ]);
837 engine.finish(0, None);
838 Ok(())
839}
840
841fn step_count(count: usize) -> String {
844 format!("{count} {}", if count == 1 { "step" } else { "steps" })
845}
846
847fn elapsed_ms(clock: Instant) -> u64 {
848 u64::try_from(clock.elapsed().as_millis()).unwrap_or(u64::MAX)
849}
850
851enum Done {
853 Satisfied(String),
855 Skipped(String),
857 Changed(String, Option<String>),
859 Passed(String),
861}
862
863impl Done {
864 const fn wire(&self) -> &'static str {
865 match self {
866 Self::Satisfied(_) => "satisfied",
867 Self::Skipped(_) => "skipped",
868 Self::Changed(..) => "applied",
869 Self::Passed(_) => "passed",
870 }
871 }
872
873 fn line(&self) -> String {
874 match self {
875 Self::Satisfied(detail) | Self::Passed(detail) | Self::Skipped(detail) => {
876 detail.clone()
877 }
878 Self::Changed(detail, limitation) => limitation.as_ref().map_or_else(
879 || detail.clone(),
880 |limit| format!("{detail} (limitation: {limit})"),
881 ),
882 }
883 }
884}
885
886#[derive(Debug, PartialEq, Eq)]
890enum PrereqGate {
891 Silent,
894 Excluded(String),
897 Observe,
900}
901
902fn prereq_gate(stance: Stance) -> PrereqGate {
905 match stance {
906 Stance::Applies => PrereqGate::Observe,
907 Stance::NotApplicable(_) => PrereqGate::Silent,
908 Stance::Excluded(reason) | Stance::Redundant { reason, .. } => PrereqGate::Excluded(reason),
909 }
910}
911
912fn excluded_prerequisites(ctx: &Ctx, step: &StepSpec) -> Vec<String> {
915 step.prereqs
916 .iter()
917 .filter_map(|prereq| {
918 let held = spec(prereq)?;
919 match prereq_gate(stance(ctx, held)) {
920 PrereqGate::Excluded(reason) => {
921 Some(format!("prerequisite {prereq} excluded: {reason}"))
922 }
923 PrereqGate::Silent | PrereqGate::Observe => None,
924 }
925 })
926 .collect()
927}
928
929fn prerequisite_detail(lines: &[String]) -> Option<String> {
932 (!lines.is_empty()).then(|| lines.join("; "))
933}
934
935#[allow(
937 clippy::too_many_lines,
938 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"
939)]
940fn apply_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
941 for prereq in step.prereqs {
946 let gate = spec(prereq).map_or(PrereqGate::Observe, |held| {
947 prereq_gate(stance(&engine.ctx, held))
948 });
949 if gate != PrereqGate::Observe {
950 continue;
951 }
952 let state = observe_with(engine, prereq)?;
953 if !state.satisfied() {
954 return Err(RkError::refusal(
955 Diagnostic::new(
956 Reason::PrerequisiteUnmet,
957 format!(
958 "{} requires {prereq} first: {}",
959 step.name,
960 state_detail(&state)
961 ),
962 )
963 .expected(format!("{prereq} satisfied before {}", step.name))
964 .action(format!(
965 "rk setup step {prereq} --target {} --apply",
966 engine.ctx.target
967 ))
968 .step(step.name),
969 ));
970 }
971 }
972 match step.name {
973 "package-check" => {
974 if engine.ctx.tech.is_none() {
975 return Err(RkError::Usage(
976 "no version file names a technology; rk binding --list names the bindings"
977 .into(),
978 ));
979 }
980 let state = observe_with(engine, "package-check")?;
981 match state {
982 StepState::Satisfied { detail, .. } => Ok(Done::Passed(detail)),
983 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
984 Err(RkError::subprocess(
985 Diagnostic::new(
986 Reason::SubprocessFailed,
987 format!("package-check failed: {detail}"),
988 )
989 .expected(step.proves_for(&engine.ctx).to_owned())
990 .step(step.name),
991 ))
992 }
993 StepState::Unknown { detail } => Err(RkError::subprocess(
994 Diagnostic::new(
995 Reason::SubprocessFailed,
996 format!("package-check could not run: {detail}"),
997 )
998 .step(step.name),
999 )),
1000 }
1001 }
1002 "forge-version" => match observe_with(engine, "forge-version")? {
1008 StepState::Satisfied { detail, .. } => Ok(Done::Satisfied(detail)),
1009 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1010 Err(RkError::refusal(
1011 Diagnostic::new(Reason::PrerequisiteUnmet, detail)
1012 .expected(step.proves_for(&engine.ctx).to_owned())
1013 .action(if engine.ctx.on_gitlab_com() {
1014 "rerun after the next gitlab.com deployment"
1015 } else {
1016 "upgrade the instance, or host the project on gitlab.com"
1017 })
1018 .target_state("unchanged")
1019 .step(step.name),
1020 ))
1021 }
1022 StepState::Unknown { detail } => Err(RkError::refusal(
1023 Diagnostic::new(Reason::ForgeTemporary, detail)
1024 .expected("a readable forge version")
1025 .action("glab auth login, then rerun")
1026 .target_state("unchanged")
1027 .step(step.name),
1028 )),
1029 },
1030 "branch-reminder" => {
1031 use crate::setup::branch_reminder::{HookState, hook_body, hook_path, observe_hook};
1032 match observe_hook(&engine.ctx.target) {
1033 HookState::Installed => Ok(Done::Satisfied(
1034 "the post-merge reminder hook is installed".into(),
1035 )),
1036 HookState::Foreign => Err(RkError::refusal(
1037 Diagnostic::new(
1038 Reason::StateDrift,
1039 "a foreign post-merge hook exists; the reminder is never written over it",
1040 )
1041 .expected("no post-merge hook, or one carrying the release-kit marker")
1042 .action(
1043 "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`",
1044 )
1045 .target_state("unchanged")
1046 .step(step.name),
1047 )),
1048 HookState::Unreadable(detail) => Err(RkError::refusal(
1049 Diagnostic::new(
1050 Reason::StateDrift,
1051 format!("the post-merge hook cannot be read: {detail}"),
1052 )
1053 .target_state("unchanged")
1054 .step(step.name),
1055 )),
1056 HookState::Absent | HookState::Drifted => {
1057 let path = hook_path(&engine.ctx.target).map_err(|detail| {
1058 RkError::refusal(
1059 Diagnostic::new(
1060 Reason::PrerequisiteUnmet,
1061 format!("the hooks directory cannot be resolved: {detail}"),
1062 )
1063 .expected("a git repository whose hooks directory git can name")
1064 .step(step.name),
1065 )
1066 })?;
1067 crate::atomic::write(&path, hook_body())?;
1068 #[cfg(unix)]
1069 {
1070 use std::os::unix::fs::PermissionsExt as _;
1071 std::fs::set_permissions(
1072 &path,
1073 std::fs::Permissions::from_mode(0o755),
1074 )?;
1075 }
1076 Ok(Done::Changed(
1077 "wrote the post-merge reminder hook".into(),
1078 None,
1079 ))
1080 }
1081 }
1082 }
1083 "single-trunk" => {
1084 let guard = {
1085 let ctx = clone_ctx(&engine.ctx);
1086 let mut runner = |exec: &Exec| engine.exec(exec, false);
1087 observe::single_trunk_guard(&ctx, &mut runner)?
1088 };
1089 match &guard {
1092 StepState::Satisfied { .. } => {}
1093 StepState::Unsatisfied { detail }
1094 | StepState::Inapplicable { detail }
1095 | StepState::Unknown { detail } => {
1096 return Err(RkError::refusal(
1097 Diagnostic::new(
1098 Reason::DestructiveRefusal,
1099 format!("single-trunk refuses: {detail}"),
1100 )
1101 .expected(
1102 "proof that every candidate branch is absent, or an ancestor of the trunk",
1103 )
1104 .step(step.name),
1105 ));
1106 }
1107 }
1108 run_forge_step(engine, step)
1109 }
1110 "bot-secrets" => {
1111 let adapter = engine.ctx.adapter()?;
1117 let key = match adapter {
1118 Forge::Github => key_file_for(engine)?.map(|key| key.bytes.clone()),
1122 Forge::Gitlab => None,
1123 };
1124 let provided = match adapter {
1125 Forge::Github => secrets::value_of("RK_BOT_APP_ID").is_some() && key.is_some(),
1128 Forge::Gitlab => secrets::value_of("RK_BOT_TOKEN").is_some(),
1129 };
1130 let state = observe_with(engine, step.name)?;
1131 let mints = adapter == Forge::Gitlab && engine.ctx.on_gitlab_com();
1138 let rotates = if mints && engine.named && state.satisfied() && !provided {
1139 let ctx = clone_ctx(&engine.ctx);
1140 let mut runner = |exec: &Exec| engine.exec(exec, false);
1141 match observe::gitlab_bot_is_a_service_account(&ctx, &mut runner)? {
1142 Ok(service_account) => service_account,
1143 Err(detail) => {
1146 return Err(RkError::refusal(
1147 Diagnostic::new(
1148 Reason::ObservationIncomplete,
1149 format!("bot-secrets cannot tell which bot identity to rotate: {detail}"),
1150 )
1151 .expected("the namespace's tier, read from gitlab.com")
1152 .action("check that glab is authenticated against gitlab.com and reads the namespace, then rerun rk setup step bot-secrets --apply".to_owned())
1153 .step(step.name),
1154 ));
1155 }
1156 }
1157 } else {
1158 false
1159 };
1160 let runs = provided || rotates || (mints && !state.satisfied());
1161 if !runs {
1162 if state.satisfied() {
1163 return Ok(Done::Satisfied(state_detail(&state)));
1164 }
1165 let wanted = match adapter {
1166 Forge::Github => {
1167 "export RK_BOT_APP_ID and RK_BOT_PRIVATE_KEY_FILE, the second naming the .pem; rk forge github carries the walkthrough"
1168 }
1169 Forge::Gitlab => {
1170 "rk setup step install-bot --apply stores the token, or export RK_BOT_TOKEN to rotate one"
1171 }
1172 };
1173 return Err(RkError::refusal(
1174 Diagnostic::new(
1175 Reason::PrerequisiteUnmet,
1176 "bot-secrets has no credentials to store",
1177 )
1178 .expected("the bot credentials in the environment, the key as a path")
1179 .action(wanted.to_owned())
1180 .step(step.name),
1181 ));
1182 }
1183 if let Some(journal) = &mut engine.journal {
1184 for name in SECRET_VARS {
1185 if secrets::value_of(name).is_some() {
1186 journal.record_secret(name, true, "environment");
1187 }
1188 }
1189 if key.is_some() {
1190 journal.record_secret(secrets::PRIVATE_KEY_FILE, true, "file");
1191 }
1192 }
1193 let stdin = key;
1197 run_forge_step_with(engine, step, stdin, Vec::new())
1198 }
1199 "protections-check" => {
1200 let (outcome, _) = run_script(engine, step)?;
1201 if !outcome.success() {
1202 return Err(classify_failure(engine, step, &outcome));
1203 }
1204 match observe_with(engine, step.name)? {
1208 StepState::Satisfied { detail, limitation } => {
1209 Ok(Done::Passed(limitation.map_or_else(
1210 || detail.clone(),
1211 |limit| format!("{detail} (limitation: {limit})"),
1212 )))
1213 }
1214 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1215 Err(RkError::refusal(
1216 Diagnostic::new(
1217 Reason::StateDrift,
1218 format!("protections-check passed its script and the observation disagrees: {detail}"),
1219 )
1220 .expected(step.proves_for(&engine.ctx).to_owned())
1221 .step(step.name),
1222 ))
1223 }
1224 StepState::Unknown { detail } => Err(RkError::refusal(
1227 Diagnostic::new(
1228 Reason::ForgeTemporary,
1229 format!(
1230 "protections-check passed its script and the readback could not confirm it: {detail}"
1231 ),
1232 )
1233 .expected(step.proves_for(&engine.ctx).to_owned())
1234 .action("check authentication and connectivity, then rerun")
1235 .step(step.name),
1236 )),
1237 }
1238 }
1239 "install-bot" if engine.ctx.forge == Some(Forge::Github) => {
1245 match observe_with(engine, step.name)? {
1246 StepState::Satisfied { detail, .. } => {
1247 return Ok(Done::Satisfied(detail));
1248 }
1249 StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
1250 StepState::Unknown { detail } => {
1251 return Err(RkError::refusal(
1252 Diagnostic::new(
1253 Reason::ForgeTemporary,
1254 format!("{} cannot observe the current state: {detail}", step.name),
1255 )
1256 .expected("a readable forge answer before anything mutates")
1257 .action("check the App credentials and connectivity, then rerun")
1258 .step(step.name),
1259 ));
1260 }
1261 }
1262 let installation = github_installation_id(engine, step)?;
1263 run_forge_step_with(
1264 engine,
1265 step,
1266 None,
1267 vec![("RK_BOT_INSTALLATION".into(), installation.into())],
1268 )
1269 }
1270 _ => {
1271 if step.mutates == Mutates::Forge {
1272 match observe_with(engine, step.name)? {
1277 StepState::Satisfied { detail, limitation } => {
1278 let detail = if step.name == "private-vulnerability-reporting" {
1279 limitation.map_or_else(
1280 || detail.clone(),
1281 |limit| format!("{detail} (limitation: {limit})"),
1282 )
1283 } else {
1284 detail
1285 };
1286 return Ok(Done::Satisfied(detail));
1287 }
1288 StepState::Inapplicable { detail }
1289 if step.name == "private-vulnerability-reporting" =>
1290 {
1291 return Ok(Done::Skipped(detail));
1292 }
1293 StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
1294 StepState::Unknown { detail } => {
1295 return Err(RkError::refusal(
1296 Diagnostic::new(
1297 Reason::ForgeTemporary,
1298 format!("{} cannot observe the current state: {detail}", step.name),
1299 )
1300 .expected("a readable forge answer before anything mutates")
1301 .action("check authentication and connectivity, then rerun")
1302 .step(step.name),
1303 ));
1304 }
1305 }
1306 }
1307 run_forge_step(engine, step)
1308 }
1309 }
1310}
1311
1312fn github_installation_id(engine: &mut Engine, step: &StepSpec) -> Result<String, RkError> {
1320 let refuse = |message: String, action: &str| {
1321 RkError::refusal(
1322 Diagnostic::new(Reason::PrerequisiteUnmet, message)
1323 .expected("the App installed on the repository's owner")
1324 .action(action.to_owned())
1325 .step(step.name),
1326 )
1327 };
1328 let jwt = match app_jwt_for(engine)? {
1329 Ok(jwt) => jwt,
1330 Err(detail) => {
1331 return Err(refuse(
1332 format!("install-bot has no App token: {detail}"),
1333 app_jwt::REMEDIATION,
1334 ));
1335 }
1336 };
1337 let owner = engine
1338 .ctx
1339 .repo
1340 .split('/')
1341 .next()
1342 .unwrap_or_default()
1343 .to_owned();
1344 let ctx = clone_ctx(&engine.ctx);
1345 for path in [
1346 format!("users/{owner}/installation"),
1347 format!("orgs/{owner}/installation"),
1348 ] {
1349 match app_jwt::api_get(&ctx, &jwt, &path) {
1350 AppApi::Ok(body) => {
1351 return body["id"].as_i64().map(|id| id.to_string()).ok_or_else(|| {
1352 refuse(
1353 format!("the forge answered {path} without an installation id"),
1354 "check RK_BOT_APP_ID and the key file name the same App",
1355 )
1356 });
1357 }
1358 AppApi::Missing => {}
1359 AppApi::Refused(detail) => {
1360 return Err(refuse(
1361 detail,
1362 "check RK_BOT_APP_ID and the key file name the same App",
1363 ));
1364 }
1365 AppApi::Failed(detail) => {
1366 return Err(RkError::refusal(
1367 Diagnostic::new(
1368 Reason::ForgeTemporary,
1369 format!("install-bot cannot read the App's installation: {detail}"),
1370 )
1371 .action("check connectivity, then rerun")
1372 .step(step.name),
1373 ));
1374 }
1375 }
1376 }
1377 Err(refuse(
1378 format!("the App has no installation on {owner}"),
1379 "install the App on the account first; the setup guide's step 5 walks it",
1380 ))
1381}
1382
1383fn run_forge_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
1385 run_forge_step_with(engine, step, None, Vec::new())
1386}
1387
1388fn run_forge_step_with(
1391 engine: &mut Engine,
1392 step: &StepSpec,
1393 stdin: Option<Zeroizing<Vec<u8>>>,
1394 extra_env: Vec<(OsString, OsString)>,
1395) -> Result<Done, RkError> {
1396 if crate::setup::before_state::destructive(step.name) {
1397 save_before_state(engine, step)?;
1398 }
1399 let (outcome, _) = run_script_with(engine, step, stdin, extra_env)?;
1400 if !outcome.success() {
1401 return Err(classify_failure(engine, step, &outcome));
1402 }
1403 let state = observe_with(engine, step.name)?;
1404 match state {
1405 StepState::Satisfied { detail, limitation } => Ok(Done::Changed(detail, limitation)),
1406 StepState::Inapplicable { detail } if step.name == "private-vulnerability-reporting" => {
1407 Ok(Done::Skipped(detail))
1408 }
1409 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1410 Err(RkError::refusal(
1411 Diagnostic::new(
1412 Reason::StateDrift,
1413 format!(
1414 "{} ran and its postcondition does not hold: {detail}",
1415 step.name
1416 ),
1417 )
1418 .expected(step.proves_for(&engine.ctx).to_owned())
1419 .step(step.name),
1420 ))
1421 }
1422 StepState::Unknown { detail } => Err(RkError::refusal(
1426 Diagnostic::new(
1427 Reason::ForgeTemporary,
1428 format!(
1429 "{} ran and the readback could not confirm it: {detail}",
1430 step.name
1431 ),
1432 )
1433 .expected(step.proves_for(&engine.ctx).to_owned())
1434 .action(format!(
1435 "rk setup step {} --target {} --apply re-asserts and re-proves it",
1436 step.name, engine.ctx.target
1437 ))
1438 .step(step.name),
1439 )),
1440 }
1441}
1442
1443fn save_before_state(engine: &mut Engine, step: &StepSpec) -> Result<(), RkError> {
1448 if let Some(held) = migration_snapshot(&engine.ctx.target, step.name)? {
1453 engine.out.result_line(format!(
1454 "the migration holds the before-state of {} at {held}",
1455 step.name
1456 ));
1457 let mut event = engine.event(EventKind::Snapshot, Some(step.name));
1458 event.detail = Some(held);
1459 engine.emit(&event);
1460 return Ok(());
1461 }
1462 let snapshot = if let Some(snapshot) = &engine.snapshot {
1463 snapshot.clone()
1464 } else {
1465 let canonical = std::fs::canonicalize(engine.ctx.target.as_std_path())?;
1466 let snapshot = crate::snapshot::Snapshot::begin(&canonical)?;
1467 engine.snapshot = Some(snapshot.clone());
1468 snapshot
1469 };
1470 let ctx = clone_ctx(&engine.ctx);
1471 let mut runner = |exec: &Exec| engine.exec(exec, false);
1472 let path = crate::setup::before_state::save(&ctx, step.name, &snapshot, &mut runner)?;
1473 let path = path.display().to_string();
1474 engine
1475 .out
1476 .result_line(format!("saved the before-state of {} to {path}", step.name));
1477 let mut event = engine.event(EventKind::Snapshot, Some(step.name));
1478 event.detail = Some(path);
1479 engine.emit(&event);
1480 Ok(())
1481}
1482
1483#[must_use]
1485pub fn forge_snapshot_id(step: &str) -> String {
1486 format!("forge-{step}")
1487}
1488
1489fn migration_snapshot(target: &camino::Utf8Path, step: &str) -> Result<Option<String>, RkError> {
1498 let Some(id) = crate::migrate::open_id(target)? else {
1499 return Ok(None);
1500 };
1501 let Some(workspace) = crate::migrate::open_workspace(target)? else {
1502 return Err(crate::migrate::none_open());
1503 };
1504 if !workspace.join(crate::migrate::REGISTRY_FILE).is_file() {
1505 return Err(crate::migrate::workspace_missing(&id, &workspace));
1506 }
1507 let registry = crate::migrate::registry::load(&workspace)?;
1508 let wanted = forge_snapshot_id(step);
1509 let found = registry
1512 .snapshots
1513 .iter()
1514 .find(|snapshot| snapshot.id == wanted);
1515 let intact = match found {
1516 Some(snapshot) => {
1517 crate::migrate::registry::integrity(&workspace, snapshot)?
1518 == crate::migrate::registry::Integrity::Intact
1519 }
1520 None => false,
1521 };
1522 let Some(snapshot) = found.filter(|_| intact) else {
1523 return Err(RkError::refusal(
1524 Diagnostic::new(
1525 Reason::DestructiveRefusal,
1526 format!(
1527 "{step} changes forge state, and the migration {id} holds no intact snapshot of it (migration:a-destructive-step-waits-for-its-snapshot)"
1528 ),
1529 )
1530 .expected("the step's before-state in the migration registry")
1531 .action("rk migrate archive --apply --target . takes the missing forge snapshots on a host that holds the forge credentials")
1532 .target_state("unchanged")
1533 .step(step),
1534 ));
1535 };
1536 Ok(Some(workspace.join(&snapshot.file).to_string()))
1537}
1538
1539#[derive(Debug, Clone, PartialEq, Eq)]
1541pub enum ForgeSnapshot {
1542 Taken(PathBuf),
1544 Unknown(String),
1546}
1547
1548pub fn migration_snapshots(
1559 target: &camino::Utf8PathBuf,
1560 snapshot: &crate::snapshot::Snapshot,
1561 skip: &[String],
1562) -> Result<Vec<(&'static str, ForgeSnapshot)>, RkError> {
1563 let mut ctx = Ctx::resolve(target, None, None, None)?;
1564 let steps: Vec<&StepSpec> = STEPS
1565 .iter()
1566 .filter(|step| {
1567 crate::setup::before_state::destructive(step.name)
1568 && stance(&ctx, step).acts()
1569 && !skip.contains(&forge_snapshot_id(step.name))
1570 })
1571 .collect();
1572 if steps.is_empty() {
1573 return Ok(Vec::new());
1574 }
1575 if let Err(error) = ctx.require_cli(&steps) {
1576 return Ok(steps
1577 .iter()
1578 .map(|step| (step.name, ForgeSnapshot::Unknown(error.to_string())))
1579 .collect());
1580 }
1581 let mut engine = Engine::open(Output::new(false), ctx, "migrate", false)?;
1584 let mut out = Vec::new();
1585 for step in steps {
1586 let ctx = clone_ctx(&engine.ctx);
1587 let mut runner = |exec: &Exec| engine.exec(exec, false);
1588 let taken = match crate::setup::before_state::save(&ctx, step.name, snapshot, &mut runner) {
1589 Ok(path) => ForgeSnapshot::Taken(path),
1590 Err(error) => ForgeSnapshot::Unknown(error.to_string()),
1591 };
1592 out.push((step.name, taken));
1593 }
1594 Ok(out)
1595}
1596
1597pub fn destructive_steps(target: &camino::Utf8PathBuf) -> Result<Vec<&'static str>, RkError> {
1604 let ctx = Ctx::resolve(target, None, None, None)?;
1605 Ok(STEPS
1606 .iter()
1607 .filter(|step| {
1608 crate::setup::before_state::destructive(step.name) && stance(&ctx, step).acts()
1609 })
1610 .map(|step| step.name)
1611 .collect())
1612}
1613
1614pub fn proof_word(target: &camino::Utf8PathBuf) -> Result<&'static str, RkError> {
1621 let ctx = Ctx::resolve(target, None, None, None)?;
1622 Ok(proof::judge(&ctx.target, &proof::subject(&ctx)).word())
1623}
1624
1625fn observe_with(engine: &mut Engine, step: &str) -> Result<StepState, RkError> {
1632 if step == "install-bot" && engine.ctx.forge == Some(Forge::Github) {
1633 let jwt = match app_jwt_for(engine)? {
1634 Ok(jwt) => jwt,
1635 Err(detail) => return Ok(StepState::Unknown { detail }),
1636 };
1637 return Ok(observe::github_install_bot(&engine.ctx, &jwt));
1638 }
1639 let ctx = clone_ctx(&engine.ctx);
1640 let mut runner = |exec: &Exec| engine.exec(exec, false);
1641 observe::observe(&ctx, step, &mut runner)
1642}
1643
1644fn key_file_for(engine: &mut Engine) -> Result<Option<&secrets::KeyFile>, RkError> {
1650 if engine.key.is_none() && engine.key_unavailable.is_none() {
1651 match secrets::look_up_key_file(&engine.ctx.target)? {
1652 secrets::KeyLookup::Absent => {}
1653 secrets::KeyLookup::Found(key) => {
1654 engine.secrets.push(key.bytes.clone());
1655 engine.key = Some(key);
1656 }
1657 secrets::KeyLookup::Unavailable(unavailable) if engine.key_boundary => {
1662 engine.key_unavailable = Some(unavailable.message);
1663 }
1664 secrets::KeyLookup::Unavailable(unavailable) => return Err(unavailable.refusal()),
1665 }
1666 }
1667 Ok(engine.key.as_ref())
1668}
1669
1670fn app_jwt_for(engine: &mut Engine) -> Result<Result<String, String>, RkError> {
1680 if let Some(jwt) = &engine.app_jwt {
1681 return Ok(Ok(jwt.clone()));
1682 }
1683 let app_id = app_jwt::app_id(engine.ctx.bot_app_id())?;
1684 let key_bytes = key_file_for(engine)?.map(|key| key.bytes.clone());
1685 if let Some(unreachable) = &engine.key_unavailable {
1686 return Ok(Err(format!(
1687 "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",
1688 secrets::PRIVATE_KEY_FILE
1689 )));
1690 }
1691 let (Some(app_id), Some(key_bytes)) = (app_id, key_bytes) else {
1692 return Ok(Err(format!(
1693 "the installation is readable only to the App itself; {}",
1694 app_jwt::REMEDIATION
1695 )));
1696 };
1697 let credentials = app_jwt::AppCredentials { app_id, key_bytes };
1698 let ctx = clone_ctx(&engine.ctx);
1699 Ok(match app_jwt::mint(&ctx, &credentials) {
1700 Ok(jwt) => {
1701 engine
1702 .secrets
1703 .push(Zeroizing::new(jwt.clone().into_bytes()));
1704 if let Some(signature) = jwt.rsplit('.').next() {
1705 engine
1706 .secrets
1707 .push(Zeroizing::new(signature.as_bytes().to_vec()));
1708 }
1709 engine.app_jwt = Some(jwt.clone());
1710 Ok(jwt)
1711 }
1712 Err(detail) => Err(detail),
1713 })
1714}
1715
1716fn state_detail(state: &StepState) -> String {
1717 state.detail().to_owned()
1718}
1719
1720fn run_script(engine: &mut Engine, step: &StepSpec) -> Result<(Outcome, PathBuf), RkError> {
1723 run_script_with(engine, step, None, Vec::new())
1724}
1725
1726fn run_script_with(
1732 engine: &mut Engine,
1733 step: &StepSpec,
1734 stdin: Option<Zeroizing<Vec<u8>>>,
1735 extra_env: Vec<(OsString, OsString)>,
1736) -> Result<(Outcome, PathBuf), RkError> {
1737 let forge = engine.ctx.adapter()?;
1738 let rel = format!("{}/{}", forge.as_str(), step.name);
1739 let bytes = embedded::SETUP
1740 .get_file(&rel)
1741 .map(include_dir::File::contents)
1742 .ok_or_else(|| RkError::Other(anyhow::anyhow!("no embedded script at setup/{rel}")))?;
1743 let journal = engine
1744 .journal
1745 .as_mut()
1746 .ok_or_else(|| RkError::Other(anyhow::anyhow!("an apply always has a journal")))?;
1747 let dir = journal.scripts_dir().join(forge.as_str());
1748 fs::create_dir_all(&dir)?;
1749 restrict(&dir, 0o700);
1750 let path = dir.join(step.name);
1751 fs::write(&path, bytes)?;
1752 restrict(&path, 0o600);
1753 let written = fs::read(&path)?;
1754 let digest = Digest::of(&written);
1755 if digest != Digest::of(bytes) {
1756 return Err(RkError::Other(anyhow::anyhow!(
1757 "the materialized script at {} differs from the embedded bytes",
1758 path.display()
1759 )));
1760 }
1761 journal.record_script(format!("scripts/{rel}"), digest.to_string());
1762 let mut env = engine.ctx.child_env(step.name);
1763 env.extend(extra_env);
1764 let exec = Exec {
1765 program: crate::probes::sh_bin(),
1766 args: vec![path.clone().into_os_string()],
1767 env,
1768 cwd: engine.ctx.target.as_std_path().to_path_buf(),
1769 stdin,
1770 };
1771 let outcome = engine.exec(&exec, true)?;
1772 Ok((outcome, path))
1773}
1774
1775fn classify_failure(engine: &Engine, step: &StepSpec, outcome: &Outcome) -> RkError {
1780 let stderr = String::from_utf8_lossy(&outcome.stderr);
1781 let last = if outcome.exit_code >= 128 {
1785 format!("killed by signal {}", outcome.exit_code - 128)
1786 } else {
1787 stderr
1788 .lines()
1789 .rev()
1790 .find(|line| !line.trim().is_empty())
1791 .unwrap_or("no output")
1792 .to_owned()
1793 };
1794 let reason = if (engine.ctx.forge == Some(Forge::Github) && outcome.exit_code == 4)
1795 || stderr.contains("HTTP 401")
1796 {
1797 Reason::ForgeAuthentication
1798 } else if stderr.contains("HTTP 403") {
1799 Reason::ForgePermission
1800 } else if stderr.contains("HTTP 429") || stderr.contains("rate limit") {
1801 Reason::ForgeRateLimit
1802 } else {
1803 Reason::SubprocessFailed
1804 };
1805 let diagnostic = Diagnostic::new(reason, format!("the forge refused '{}': {last}", step.name))
1806 .expected(step.proves_for(&engine.ctx).to_owned())
1807 .action(format!(
1808 "rk setup step {} --target {} --apply",
1809 step.name, engine.ctx.target
1810 ))
1811 .step(step.name);
1812 let diagnostic = match reason {
1813 Reason::ForgePermission => diagnostic.expected(format!(
1814 "repository administration write on {} for the authenticated account",
1815 engine.ctx.repo
1816 )),
1817 _ => diagnostic,
1818 };
1819 match reason {
1820 Reason::SubprocessFailed => RkError::subprocess(diagnostic),
1821 _ => RkError::refusal(diagnostic),
1822 }
1823}
1824
1825fn attach_progress(
1828 error: RkError,
1829 done: &[(String, String)],
1830 failed: &StepSpec,
1831 steps: &[&StepSpec],
1832) -> RkError {
1833 let remaining = steps.len().saturating_sub(done.len() + 1);
1834 let state = format!(
1835 "{} completed; {} failed; {remaining} not attempted",
1836 step_count(done.len()),
1837 failed.name
1838 );
1839 match error {
1840 RkError::Refusal(mut diagnostic) => {
1841 diagnostic.target_state.get_or_insert(state);
1842 RkError::Refusal(diagnostic)
1843 }
1844 RkError::Subprocess(mut diagnostic) => {
1845 diagnostic.target_state.get_or_insert(state);
1846 RkError::Subprocess(diagnostic)
1847 }
1848 other => other,
1849 }
1850}
1851
1852fn observe_all(engine: &mut Engine) -> Result<Report, RkError> {
1858 let mut report = Report::default();
1859 for step in &STEPS {
1860 let clock = Instant::now();
1861 let stance = stance(&engine.ctx, step);
1866 let row = if stance.acts() {
1867 let state = observe_with(engine, step.name)?;
1868 Row::observed(step, stance, &state)
1869 } else {
1870 Row::stated(step, stance)
1871 };
1872 engine.out.result_line(row.line());
1873 let (status, detail) = row.event_fields();
1874 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
1875 finished.status = Some(status);
1876 finished.detail = Some(detail);
1877 finished.duration_ms = Some(elapsed_ms(clock));
1878 engine.emit(&finished);
1879 report.rows.push(row);
1880 }
1881 if report.judged() < STEPS.len() {
1882 engine.out.result_line(format!(
1883 "{} judged; the rest do not apply to this target or {} excludes them",
1884 step_count(report.judged()),
1885 crate::config::CONFIG_PATH
1886 ));
1887 }
1888 Ok(report)
1889}
1890
1891fn check(out: Output, ctx: Ctx) -> Result<(), RkError> {
1895 let mut engine = Engine::open(out, ctx, "setup check", false)?;
1896 engine.key_boundary = true;
1897 let report = observe_all(&mut engine)?;
1898 for milestone in crate::setup::milestones::observe(&engine.ctx) {
1901 engine.out.result_line(milestone.line());
1902 let mut event = engine.event(EventKind::Milestone, Some(milestone.id));
1903 event.status = Some(milestone.status.to_owned());
1904 event.detail = Some(milestone.detail);
1905 engine.emit(&event);
1906 }
1907 if !report.checkpointable() {
1908 let error = verdict(&engine.ctx, &report);
1909 return Err(fail(&mut engine, error));
1910 }
1911 engine
1912 .out
1913 .next(&["rk guide release orders the first release".to_owned()]);
1914 engine.finish(0, None);
1915 Ok(())
1916}
1917
1918fn verdict(ctx: &Ctx, report: &Report) -> RkError {
1928 let unsatisfied = report.unsatisfied();
1929 let unknown = report.unknown();
1930 let standing = proof::judge(&ctx.target, &proof::subject(ctx));
1931 let proven = match &standing {
1932 Standing::Compatible(proof) => Some(proof),
1933 _ => None,
1934 };
1935 if unsatisfied == 0 {
1936 let state = proven.map_or_else(
1937 || {
1938 format!(
1939 "no setup defect was inferred; no compatible {} is committed, so the current state is unverified",
1940 proof::PROOF_PATH
1941 )
1942 },
1943 |proof| {
1944 format!(
1945 "no setup defect was inferred; the committed setup proof from {} by rk {} still matches this target's setup contract",
1946 proof.verified_at, proof.rk_version
1947 )
1948 },
1949 );
1950 return RkError::check_failed(
1951 Diagnostic::new(
1952 Reason::ObservationIncomplete,
1953 format!(
1954 "{} could not be verified from this runtime, and none is unsatisfied",
1955 step_count(unknown)
1956 ),
1957 )
1958 .expected("every applicable step readable from where the check runs")
1959 .action("rerun rk setup check where each unknown step can be observed; its line names what it needs")
1960 .target_state(state),
1961 );
1962 }
1963 let mut message = format!(
1964 "{} {} not satisfied",
1965 step_count(unsatisfied),
1966 if unsatisfied == 1 { "is" } else { "are" }
1967 );
1968 let mut action = format!(
1969 "rk setup --target {} --apply re-asserts the unsatisfied ones",
1970 ctx.target
1971 );
1972 if unknown > 0 {
1973 use std::fmt::Write as _;
1974 let _ = write!(
1975 message,
1976 ", and {unknown} could not be verified from this runtime"
1977 );
1978 action.push_str("; the unknown ones need a runtime that can observe what each line names");
1979 }
1980 let mut diagnostic = Diagnostic::new(Reason::StateDrift, message)
1981 .expected("every step's proof column to hold")
1982 .action(action);
1983 if let Some(proof) = proven {
1984 diagnostic = diagnostic.target_state(format!(
1985 "this observation supersedes the committed setup proof from {} by rk {}",
1986 proof.verified_at, proof.rk_version
1987 ));
1988 }
1989 RkError::check_failed(diagnostic)
1990}
1991
1992fn checkpoint(out: Output, ctx: Ctx) -> Result<(), RkError> {
1998 let mut engine = Engine::open(out, ctx, "setup checkpoint", false)?;
1999 let report = observe_all(&mut engine)?;
2000 let Some(proof) = proof::of(&engine.ctx, &report, crate::applog::now_utc()) else {
2001 let error = RkError::check_failed(
2002 Diagnostic::new(
2003 Reason::StateDrift,
2004 format!(
2005 "the observation is incomplete: {} not satisfied and {} could not be verified",
2006 step_count(report.unsatisfied()),
2007 report.unknown()
2008 ),
2009 )
2010 .expected("every applicable step observed and holding")
2011 .action(format!(
2012 "rk setup check --target {} names each gap; checkpoint again once none remains",
2013 engine.ctx.target
2014 ))
2015 .target_state(format!("{} was not written", proof::PROOF_PATH)),
2016 );
2017 return Err(fail(&mut engine, error));
2018 };
2019 if let Err(source) = proof::write(&engine.ctx.target, &proof) {
2020 let error = RkError::refusal(
2021 Diagnostic::new(
2022 Reason::Io,
2023 format!("{} cannot be written: {source}", proof::PROOF_PATH),
2024 )
2025 .expected("a writable .release-kit directory in the target")
2026 .target_state("the earlier proof, where one exists, is unchanged"),
2027 );
2028 return Err(fail(&mut engine, error));
2029 }
2030 engine.out.result_line(format!(
2031 "wrote {}: {} proven at {} by rk {}",
2032 proof::PROOF_PATH,
2033 step_count(proof.steps.len()),
2034 proof.verified_at,
2035 proof.rk_version
2036 ));
2037 engine.out.next(&[
2038 format!("commit {} with the setup it proves", proof::PROOF_PATH),
2039 format!(
2040 "rk setup status --target {} reads it offline",
2041 engine.ctx.target
2042 ),
2043 ]);
2044 engine.finish(0, None);
2045 Ok(())
2046}
2047
2048fn status(out: Output, ctx: &Ctx) -> Result<(), RkError> {
2054 let standing = proof::judge(&ctx.target, &proof::subject(ctx));
2055 out.emit(&proof::StatusDocument::of(&standing))?;
2056 match &standing {
2057 Standing::Absent => out.result_line(format!(
2058 "setup proof: absent — {} does not exist, so no complete observation is on record",
2059 proof::PROOF_PATH
2060 )),
2061 Standing::Compatible(proof) => out.result_line(format!(
2062 "setup proof: compatible — proven {} by rk {}, and this target's setup contract has not changed since",
2063 proof.verified_at, proof.rk_version
2064 )),
2065 Standing::Stale { proof, differences } => out.result_line(format!(
2066 "setup proof: stale — proven {} by rk {}, and the setup contract changed since: {}",
2067 proof.verified_at,
2068 proof.rk_version,
2069 differences.join(", ")
2070 )),
2071 Standing::Invalid { detail, .. } => {
2072 out.result_line(format!("setup proof: invalid — {detail}"));
2073 }
2074 }
2075 if let Standing::Compatible(proof) | Standing::Stale { proof, .. } = &standing {
2076 for step in &proof.steps {
2077 let limitation = step
2078 .limitation
2079 .as_ref()
2080 .map_or_else(String::new, |limit| format!(" (limitation: {limit})"));
2081 out.result_line(format!(" {} {}{limitation}", step.state, step.name));
2082 }
2083 }
2084 let next = match standing {
2085 Standing::Compatible(_) => format!(
2086 "rk setup check --target {} observes the forge now, where this runtime can",
2087 ctx.target
2088 ),
2089 _ => format!(
2090 "rk setup checkpoint --target {}, from a host that can observe every step, records a proof",
2091 ctx.target
2092 ),
2093 };
2094 out.next(&[next]);
2095 Ok(())
2096}
2097
2098fn restrict(path: &std::path::Path, mode: u32) {
2101 #[cfg(unix)]
2102 {
2103 use std::os::unix::fs::PermissionsExt as _;
2104 let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
2105 }
2106 #[cfg(not(unix))]
2107 let _ = (path, mode);
2108}
2109
2110fn guard_sh() -> Result<(), RkError> {
2113 let ok = std::process::Command::new(crate::probes::sh_bin())
2114 .args(["-c", "exit 0"])
2115 .status()
2116 .is_ok_and(|status| status.success());
2117 if ok {
2118 Ok(())
2119 } else {
2120 Err(RkError::refusal(
2121 Diagnostic::new(Reason::PrerequisiteUnmet, "no POSIX sh runs on this host")
2122 .expected("a working sh on PATH; every step spawns through it")
2123 .action("install a POSIX shell, then rerun")
2124 .target_state("nothing was run and nothing changed"),
2125 ))
2126 }
2127}
2128
2129#[cfg(test)]
2130mod tests {
2131 #[test]
2134 fn a_step_count_carries_a_noun_that_agrees_with_it() {
2135 assert_eq!(super::step_count(0), "0 steps");
2136 assert_eq!(super::step_count(1), "1 step");
2137 assert_eq!(super::step_count(2), "2 steps");
2138 }
2139
2140 #[test]
2147 fn an_inapplicable_prerequisite_never_blocks_a_gate() {
2148 use super::{PrereqGate, Stance, prereq_gate};
2149 assert_eq!(
2150 prereq_gate(Stance::NotApplicable("the profile names no forge".into())),
2151 PrereqGate::Silent
2152 );
2153 assert_eq!(
2154 prereq_gate(Stance::Excluded("read by hand".into())),
2155 PrereqGate::Excluded("read by hand".into())
2156 );
2157 assert_eq!(
2158 prereq_gate(Stance::Redundant {
2159 reason: "read by hand".into(),
2160 inapplicable: "the profile names no forge".into(),
2161 }),
2162 PrereqGate::Excluded("read by hand".into())
2163 );
2164 assert_eq!(prereq_gate(Stance::Applies), PrereqGate::Observe);
2165 }
2166}