1use std::ffi::OsString;
12use std::fs;
13use std::path::PathBuf;
14use std::time::Instant;
15
16use zeroize::Zeroizing;
17
18use crate::cli::setup::{SetupAction, SetupArgs};
19use crate::detect::Forge;
20use crate::diagnostic::{Diagnostic, Reason};
21use crate::digest::Digest;
22use crate::embedded;
23use crate::error::RkError;
24use crate::events::{ChildStream, Event, EventKind};
25use crate::output::Output;
26use crate::setup::app_jwt::{self, AppApi};
27use crate::setup::context::{Ctx, SECRET_VARS};
28use crate::setup::journal::Journal;
29use crate::setup::observe::{self, StepState};
30use crate::setup::process::{self, Exec, Outcome};
31use crate::setup::secrets;
32use crate::setup::steps::{Mutates, STEPS, StepSpec, spec};
33
34pub fn run(args: &SetupArgs) -> Result<(), RkError> {
40 match &args.action {
41 Some(SetupAction::Script { name, forge }) => script(name, forge.as_deref()),
42 Some(SetupAction::Check {
43 target,
44 repo,
45 forge,
46 required_check,
47 json,
48 }) => {
49 let mut ctx = Ctx::resolve(
50 target,
51 repo.as_deref(),
52 forge.as_deref(),
53 required_check.as_deref(),
54 )?;
55 reject_check_flag_on_gitlab(&ctx)?;
56 let all: Vec<&StepSpec> = STEPS.iter().collect();
59 ctx.require_cli(&all)?;
60 check(Output::new(*json), ctx)
61 }
62 Some(SetupAction::Step {
63 name,
64 target,
65 repo,
66 forge,
67 required_check,
68 apply,
69 json,
70 }) => {
71 let selected = spec(name).ok_or_else(|| {
72 RkError::Usage(format!("unknown step '{name}'; rk setup --list names them"))
73 })?;
74 let mut ctx = Ctx::resolve(
75 target,
76 repo.as_deref(),
77 forge.as_deref(),
78 required_check.as_deref(),
79 )?;
80 reject_check_flag_on_gitlab(&ctx)?;
81 if *apply {
82 refuse_an_excluded_step(&ctx, selected)?;
83 require_check_for(&ctx, &[selected])?;
84 ctx.require_cli(&[selected])?;
87 execute(Output::new(*json), ctx, &[selected], "setup step")
88 } else {
89 ctx.require_cli(&[selected])?;
93 preview(Output::new(*json), &ctx, &[selected])
94 }
95 }
96 None if args.list => list(args.forge.as_deref()),
97 None => {
98 let target = args.target.clone().ok_or_else(|| {
99 RkError::Usage("name a --target, or pass --list to see the steps".into())
100 })?;
101 let all: Vec<&StepSpec> = STEPS.iter().collect();
102 let mut ctx = Ctx::resolve(
103 &target,
104 args.repo.as_deref(),
105 args.forge.as_deref(),
106 args.required_check.as_deref(),
107 )?;
108 reject_check_flag_on_gitlab(&ctx)?;
109 if args.apply {
110 require_check_for(&ctx, &all)?;
111 let acted: Vec<&StepSpec> = all
114 .iter()
115 .copied()
116 .filter(|step| !skipped_by_a_full_run(&ctx, step, all.len()))
117 .collect();
118 ctx.require_cli(&acted)?;
119 execute(Output::new(args.json), ctx, &all, "setup")
120 } else {
121 let acted: Vec<&StepSpec> = all
122 .iter()
123 .copied()
124 .filter(|step| !skipped_by_a_full_run(&ctx, step, all.len()))
125 .collect();
126 ctx.require_cli(&acted)?;
127 preview(Output::new(args.json), &ctx, &all)
128 }
129 }
130 }
131}
132
133#[derive(Debug, Clone)]
142pub(crate) enum Stance {
143 Applies,
145 NotApplicable(String),
148 Excluded(String),
150 Redundant {
152 reason: String,
154 inapplicable: String,
156 },
157}
158
159impl Stance {
160 const fn word(&self) -> &'static str {
162 match self {
163 Self::Applies => "applicable",
164 Self::NotApplicable(_) => "not-applicable",
165 Self::Excluded(_) => "excluded",
166 Self::Redundant { .. } => "redundant",
167 }
168 }
169
170 pub(crate) const fn acts(&self) -> bool {
172 matches!(self, Self::Applies)
173 }
174
175 fn detail(&self) -> String {
177 match self {
178 Self::Applies => String::new(),
179 Self::NotApplicable(reason) | Self::Excluded(reason) => reason.clone(),
180 Self::Redundant {
181 reason,
182 inapplicable,
183 } => format!("{inapplicable}; the stated reason was {reason}"),
184 }
185 }
186
187 fn framed(&self) -> String {
190 match self {
191 Self::Applies => String::new(),
192 Self::NotApplicable(reason) => format!("not applicable: {reason}"),
193 Self::Excluded(reason) => {
194 format!("excluded by {}: {reason}", crate::config::CONFIG_PATH)
195 }
196 Self::Redundant { .. } => format!(
197 "{} excludes a step that does not apply here: {}",
198 crate::config::CONFIG_PATH,
199 self.detail()
200 ),
201 }
202 }
203}
204
205pub(crate) fn stance(ctx: &Ctx, step: &StepSpec) -> Stance {
207 let inapplicable = (step.applies)(ctx);
208 match (ctx.excluded(step.name).map(str::to_owned), inapplicable) {
209 (Some(reason), Some(inapplicable)) => Stance::Redundant {
210 reason,
211 inapplicable,
212 },
213 (Some(reason), None) => Stance::Excluded(reason),
214 (None, Some(reason)) => Stance::NotApplicable(reason),
215 (None, None) => Stance::Applies,
216 }
217}
218
219fn skipped_by_a_full_run(ctx: &Ctx, step: &StepSpec, selected: usize) -> bool {
227 if !step.optional || selected <= 1 {
228 return false;
229 }
230 !(step.name == "protect-release-lines" && ctx.release_lines())
231}
232
233fn refuse_an_excluded_step(ctx: &Ctx, step: &StepSpec) -> Result<(), RkError> {
239 match stance(ctx, step) {
240 Stance::Applies => Ok(()),
241 Stance::Excluded(reason) | Stance::Redundant { reason, .. } => {
242 Err(RkError::Usage(format!(
243 "{} is excluded by {}: {reason}; remove it from setup.excluded_steps to run it",
244 step.name,
245 crate::config::CONFIG_PATH
246 )))
247 }
248 Stance::NotApplicable(reason) => Err(RkError::refusal(
252 Diagnostic::new(
253 Reason::PrerequisiteUnmet,
254 format!("{} does not apply to this target: {reason}", step.name),
255 )
256 .expected("a target configuration that selects this step")
257 .action("rk profile --target . reports what this target resolves to")
258 .target_state("unchanged")
259 .step(step.name),
260 )),
261 }
262}
263
264fn reject_check_flag_on_gitlab(ctx: &Ctx) -> Result<(), RkError> {
272 if ctx.forge == Some(Forge::Gitlab) && ctx.required_check.is_some() {
273 return Err(RkError::Usage(
274 "--required-check is refused on gitlab: the forge requires the whole pipeline and names no individual check".into(),
275 ));
276 }
277 Ok(())
278}
279
280fn require_check_for(ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
294 let needs = ctx.forge == Some(Forge::Github)
295 && ctx.required_check.is_none()
296 && ctx
297 .protection()
298 .owned_trunk_rules
299 .iter()
300 .any(|rule| rule == "required_status_checks")
301 && steps
302 .iter()
303 .any(|step| step.name == "protect-trunk" && stance(ctx, step).acts());
304 if needs {
305 return Err(RkError::refusal(
306 Diagnostic::new(
307 Reason::PrerequisiteUnmet,
308 "protect-trunk refuses until the required check is named, and nothing was written",
309 )
310 .expected("the name of the CI check the release merge must pass")
311 .action(format!(
312 "set setup.required_check in {}, or pass --required-check <name>; gh api repos/{}/commits/HEAD/check-runs lists the project's check names",
313 crate::config::CONFIG_PATH,
314 ctx.repo
315 ))
316 .step("protect-trunk"),
317 ));
318 }
319 Ok(())
320}
321
322fn list(forge: Option<&str>) -> Result<(), RkError> {
326 let forge = forge
327 .map(|name| {
328 Forge::parse(name).ok_or_else(|| {
329 RkError::Usage(format!(
330 "unknown forge '{name}'; the forges are: github, gitlab"
331 ))
332 })
333 })
334 .transpose()?;
335 let out = Output::human();
336 for (idx, step) in STEPS.iter().enumerate() {
337 let mut line = format!(
338 "{:2}. {} [{}] proves: {}",
339 idx + 1,
340 step.name,
341 step.chapter,
342 step.proves
343 );
344 if step.name == "protect-trunk" && forge != Some(Forge::Gitlab) {
345 line.push_str(" (needs --required-check on github)");
346 }
347 if step.destructive {
348 line.push_str(" (destructive)");
349 }
350 if step.optional {
351 line.push_str(" (optional; a full apply skips it)");
352 }
353 out.result_line(line);
354 }
355 out.next(&[
356 "rk setup --target . previews every step".to_owned(),
357 "rk setup script <name> prints one embedded script".to_owned(),
358 ]);
359 Ok(())
360}
361
362fn script(name: &str, forge: Option<&str>) -> Result<(), RkError> {
365 if name == "package-check" {
366 return Err(RkError::Usage(
367 "package-check reads its command from the technology binding and has no script".into(),
368 ));
369 }
370 if name == "branch-reminder" {
371 return Err(RkError::Usage(
372 "branch-reminder writes an embedded hook body and has no script; rk setup step branch-reminder previews the write".into(),
373 ));
374 }
375 if name == "forge-version" {
376 return Err(RkError::Usage(
377 "forge-version reads the forge's own version and has no script; rk setup step forge-version previews the read".into(),
378 ));
379 }
380 let forge = match forge {
381 Some(value) => Forge::parse(value).ok_or_else(|| {
382 RkError::Usage(format!(
383 "unknown forge '{value}'; the forges are: github, gitlab"
384 ))
385 })?,
386 None => Forge::Github,
387 };
388 let path = format!("{}/{name}", forge.as_str());
389 let file = embedded::SETUP.get_file(&path).ok_or(RkError::NotFound {
390 kind: "setup step",
391 name: name.to_owned(),
392 })?;
393 Output::human().result_raw(&String::from_utf8_lossy(file.contents()));
394 Ok(())
395}
396
397struct Engine {
400 out: Output,
401 ctx: Ctx,
402 journal: Option<Journal>,
403 secrets: Vec<Zeroizing<Vec<u8>>>,
404 key: Option<secrets::KeyFile>,
406 app_jwt: Option<String>,
408 seq: u64,
409 command: &'static str,
410 run_id: String,
411}
412
413impl Engine {
414 fn open(
419 out: Output,
420 ctx: Ctx,
421 command: &'static str,
422 journal_required: bool,
423 ) -> Result<Self, RkError> {
424 secrets::refuse_legacy_key()?;
427 let journal = match Journal::create(
428 command,
429 ctx.target.as_str(),
430 ctx.forge.map_or("none", Forge::as_str),
431 &ctx.repo,
432 ) {
433 Ok(journal) => Some(journal),
434 Err(source) if journal_required => {
435 return Err(RkError::refusal(
436 Diagnostic::new(
437 Reason::JournalUnavailable,
438 format!("the run journal cannot be created: {source}"),
439 )
440 .expected("a writable state root for the journal")
441 .target_state("nothing was run and nothing changed"),
442 ));
443 }
444 Err(source) => {
445 out.warn(format!("no run journal for this run: {source}"));
446 None
447 }
448 };
449 let run_id = journal
450 .as_ref()
451 .map_or_else(|| "unjournaled".to_owned(), |j| j.run_id().to_owned());
452 let mut engine = Self {
453 out,
454 ctx,
455 journal,
456 secrets: Ctx::secret_values(),
457 key: None,
458 app_jwt: None,
459 seq: 0,
460 command,
461 run_id,
462 };
463 let opening = Event::opening(
464 engine.next_seq(),
465 crate::applog::now_utc(),
466 engine.run_id.clone(),
467 engine.command,
468 );
469 engine.emit(&opening);
470 if engine.ctx.self_hosted_gitlab() {
471 engine.out.warn(
472 "this remote is a self-hosted GitLab: registry trusted publishing covers GitLab.com only, so the OIDC invariant cannot be satisfied here",
473 );
474 }
475 Ok(engine)
476 }
477
478 const fn next_seq(&mut self) -> u64 {
479 let seq = self.seq;
480 self.seq += 1;
481 seq
482 }
483
484 fn event(&mut self, kind: EventKind, step: Option<&str>) -> Event {
485 let mut event = Event::opening(
486 self.next_seq(),
487 crate::applog::now_utc(),
488 self.run_id.clone(),
489 self.command,
490 );
491 event.kind = kind;
492 event.step = step.map(str::to_owned);
493 event
494 }
495
496 fn emit(&mut self, event: &Event) {
497 self.out.event(event);
498 if let Some(journal) = &mut self.journal
499 && let Ok(line) = serde_json::to_string(event)
500 {
501 journal.event_line(&line);
502 }
503 }
504
505 fn exec(&mut self, exec: &Exec, passthrough: bool) -> Result<Outcome, RkError> {
508 let echo = exec.echo();
509 self.out.frame(&echo);
510 if let Some(journal) = &mut self.journal {
511 journal.transcript(echo.as_bytes());
512 journal.transcript(b"\n");
513 }
514 let secrets = std::mem::take(&mut self.secrets);
515 let step_name: Option<String> = None;
516 let mut chunks: Vec<(ChildStream, Vec<u8>)> = Vec::new();
517 let spawned = process::run(exec, |stream, chunk| {
518 chunks.push((stream, process::redact(chunk, &secrets)));
519 });
520 self.secrets = secrets;
521 for (stream, chunk) in chunks {
522 if passthrough {
523 self.out.child_passthrough(stream, &chunk);
524 }
525 let event = self.event(EventKind::ChildOutput, step_name.as_deref());
526 let event = event.child_output(stream, &chunk);
527 self.emit(&event);
528 if let Some(journal) = &mut self.journal {
529 journal.transcript(&chunk);
530 }
531 }
532 spawned.map_err(|source| {
533 RkError::refusal(
534 Diagnostic::new(
535 Reason::SubprocessSpawn,
536 format!("{} did not spawn: {source}", exec.program.to_string_lossy()),
537 )
538 .expected("a POSIX sh and the forge CLI on PATH")
539 .run(self.run_path()),
540 )
541 })
542 }
543
544 fn run_path(&self) -> String {
545 self.journal.as_ref().map_or_else(
546 || "no journal was written".to_owned(),
547 |j| j.dir.display().to_string(),
548 )
549 }
550
551 fn finish(&mut self, exit_code: i32, reason: Option<&str>) {
552 let mut event = self.event(EventKind::RunFinished, None);
553 event.exit_code = Some(exit_code);
554 event.status = Some(if exit_code == 0 {
555 "ok".into()
556 } else {
557 "failed".into()
558 });
559 self.emit(&event);
560 if let Some(journal) = &mut self.journal {
561 journal.finish(exit_code, reason);
562 }
563 }
564}
565
566fn fail(engine: &mut Engine, error: RkError) -> RkError {
568 let error = match error {
569 RkError::Refusal(mut diagnostic) => {
570 diagnostic.run.get_or_insert_with(|| engine.run_path());
571 RkError::Refusal(diagnostic)
572 }
573 RkError::Subprocess(mut diagnostic) => {
574 diagnostic.run.get_or_insert_with(|| engine.run_path());
575 RkError::Subprocess(diagnostic)
576 }
577 RkError::CheckFailed(mut diagnostic) => {
578 diagnostic.run.get_or_insert_with(|| engine.run_path());
579 RkError::CheckFailed(diagnostic)
580 }
581 other => other,
582 };
583 engine.finish(i32::from(error.exit_code()), Some(error.reason().as_str()));
584 error
585}
586
587fn preview(out: Output, ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
593 let mut engine = Engine::open(out, clone_ctx(ctx), "setup preview", false)?;
594 out.result_line(format!(
595 "DRY RUN: rk setup would run these steps against {} on {}; re-run with --apply",
596 engine.ctx.repo,
597 engine.ctx.forge.map_or("no forge", Forge::as_str)
598 ));
599 for (idx, step) in steps.iter().enumerate() {
600 out.result_line(format!(
601 "step {}/{} {} — proves {}",
602 idx + 1,
603 steps.len(),
604 step.name,
605 step.proves
606 ));
607 let stance = stance(&engine.ctx, step);
608 if !stance.acts() {
609 out.result_line(format!(" {}", stance.framed()));
610 let mut event = engine.event(EventKind::StepFinished, Some(step.name));
611 event.status = Some(stance.word().into());
612 event.detail = Some(stance.detail());
613 engine.emit(&event);
614 continue;
615 }
616 if step.name == "bot-secrets" && engine.ctx.forge == Some(Forge::Github) {
620 secrets::resolve_key_file(&engine.ctx.target)?;
621 }
622 out.result_line(format!(" {}", render_invocation(&engine.ctx, step)));
623 if step.name == "protect-trunk"
629 && engine.ctx.forge == Some(Forge::Github)
630 && engine.ctx.required_check.is_none()
631 && engine
632 .ctx
633 .protection()
634 .owned_trunk_rules
635 .iter()
636 .any(|rule| rule == "required_status_checks")
637 {
638 out.result_line(" needs: --required-check <name> before apply");
639 }
640 if skipped_by_a_full_run(&engine.ctx, step, steps.len()) {
641 out.result_line(format!(
642 " optional: a full apply skips it; set setup.release_lines, or rk setup step {} --apply runs it",
643 step.name
644 ));
645 }
646 let mut event = engine.event(EventKind::StepFinished, Some(step.name));
647 event.status = Some("previewed".into());
648 engine.emit(&event);
649 }
650 let next = next_for_apply(&engine.ctx, steps);
651 out.next(&[
652 next,
653 "rk setup check --target . proves what is already true".to_owned(),
654 ]);
655 engine.finish(0, None);
656 Ok(())
657}
658
659fn render_invocation(ctx: &Ctx, step: &StepSpec) -> String {
662 match step.name {
663 "branch-reminder" => {
664 "would write: the post-merge reminder hook at $(git rev-parse --git-path hooks)/post-merge".to_owned()
665 }
666 "package-check" => match ctx.tech {
667 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; another workspace shape reports SECURITY.md inclusion as unproved".to_owned(),
668 Some("python") => "would run: python3 -m build; sdist and wheel SECURITY.md inclusion stays unproved".to_owned(),
669 Some("bash") => "nothing to run: no registry for this technology; the make dist tarball is not inspected".to_owned(),
670 _ => "needs: a version file naming the technology".to_owned(),
671 },
672 "forge-version" => {
673 let (major, minor) = observe::GITLAB_VERSION_FLOOR;
674 match ctx.forge {
675 Some(Forge::Github) => {
676 "nothing to read: github.com is a rolling service and declares no version floor"
677 .to_owned()
678 }
679 Some(Forge::Gitlab) => format!(
680 "would read: GET /version, and compare it against the {major}.{minor} floor; nothing is written"
681 ),
682 None => "nothing to read: the profile names no forge".to_owned(),
683 }
684 }
685 name => {
686 let check = ctx
687 .required_check
688 .as_ref()
689 .filter(|_| ctx.forge == Some(Forge::Github) && name == "protect-trunk")
690 .map(|value| format!(" RK_REQUIRED_CHECK={value}"))
691 .unwrap_or_default();
692 let ruleset = match name {
696 "protect-trunk" => format!(" RK_TRUNK_RULESET={} RK_TITLE_CHECK={}", ctx.trunk_ruleset(), ctx.title_check()),
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 let mut done: Vec<(String, String)> = Vec::new();
742 for (idx, step) in steps.iter().enumerate() {
743 let stance = stance(&engine.ctx, step);
747 if !stance.acts() {
748 engine.out.frame(format!(
749 "step {}/{} {} — {}",
750 idx + 1,
751 steps.len(),
752 step.name,
753 stance.framed()
754 ));
755 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
756 finished.status = Some(stance.word().into());
757 finished.detail = Some(stance.detail());
758 engine.emit(&finished);
759 done.push((step.name.to_owned(), stance.word().to_owned()));
760 continue;
761 }
762 if skipped_by_a_full_run(&engine.ctx, step, steps.len()) {
765 engine.out.frame(format!(
766 "step {}/{} {} — skipped (optional; set setup.release_lines, or rk setup step {} --apply runs it)",
767 idx + 1,
768 steps.len(),
769 step.name,
770 step.name
771 ));
772 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
773 finished.status = Some("skipped".into());
774 engine.emit(&finished);
775 done.push((step.name.to_owned(), "skipped".to_owned()));
776 continue;
777 }
778 engine.out.frame(format!(
779 "step {}/{} {} — {}",
780 idx + 1,
781 steps.len(),
782 step.name,
783 step.proves
784 ));
785 let mut started = engine.event(EventKind::StepStarted, Some(step.name));
786 started.status = Some("running".into());
787 engine.emit(&started);
788 let clock = Instant::now();
789 let status = match apply_step(&mut engine, step) {
790 Ok(status) => status,
791 Err(error) => {
792 let error = attach_progress(error, &done, step, steps);
793 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
794 finished.status = Some("failed".into());
795 finished.reason = Some(error.reason());
796 finished.duration_ms = Some(elapsed_ms(clock));
797 engine.emit(&finished);
798 return Err(fail(&mut engine, error));
799 }
800 };
801 engine.out.frame(format!(
802 "{} {}: {}",
803 if matches!(status, Done::Skipped(_)) {
804 "skipped"
805 } else {
806 "ok"
807 },
808 step.name,
809 status.line()
810 ));
811 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
812 finished.status = Some(status.wire().into());
813 finished.detail = Some(status.line());
814 finished.exit_code = Some(0);
815 finished.duration_ms = Some(elapsed_ms(clock));
816 engine.emit(&finished);
817 done.push((step.name.to_owned(), status.wire().to_owned()));
818 }
819 engine.out.result_line(format!(
820 "setup: {} completed against {}",
821 step_count(done.len()),
822 engine.ctx.repo
823 ));
824 for (name, status) in &done {
825 engine.out.result_line(format!(" {status} {name}"));
826 }
827 engine.out.next(&[
828 format!("rk setup check --target {}", engine.ctx.target),
829 "rk guide setup orders what no command performs".to_owned(),
830 ]);
831 engine.finish(0, None);
832 Ok(())
833}
834
835fn step_count(count: usize) -> String {
838 format!("{count} {}", if count == 1 { "step" } else { "steps" })
839}
840
841fn elapsed_ms(clock: Instant) -> u64 {
842 u64::try_from(clock.elapsed().as_millis()).unwrap_or(u64::MAX)
843}
844
845enum Done {
847 Satisfied(String),
849 Skipped(String),
851 Changed(String, Option<String>),
853 Passed(String),
855}
856
857impl Done {
858 const fn wire(&self) -> &'static str {
859 match self {
860 Self::Satisfied(_) => "satisfied",
861 Self::Skipped(_) => "skipped",
862 Self::Changed(..) => "applied",
863 Self::Passed(_) => "passed",
864 }
865 }
866
867 fn line(&self) -> String {
868 match self {
869 Self::Satisfied(detail) | Self::Passed(detail) | Self::Skipped(detail) => {
870 detail.clone()
871 }
872 Self::Changed(detail, limitation) => limitation.as_ref().map_or_else(
873 || detail.clone(),
874 |limit| format!("{detail} (limitation: {limit})"),
875 ),
876 }
877 }
878}
879
880#[allow(
882 clippy::too_many_lines,
883 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"
884)]
885fn apply_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
886 for prereq in step.prereqs {
889 let state = observe_with(engine, prereq)?;
890 if !state.satisfied() {
891 return Err(RkError::refusal(
892 Diagnostic::new(
893 Reason::PrerequisiteUnmet,
894 format!(
895 "{} requires {prereq} first: {}",
896 step.name,
897 state_detail(&state)
898 ),
899 )
900 .expected(format!("{prereq} satisfied before {}", step.name))
901 .action(format!(
902 "rk setup step {prereq} --target {} --apply",
903 engine.ctx.target
904 ))
905 .step(step.name),
906 ));
907 }
908 }
909 match step.name {
910 "package-check" => {
911 if engine.ctx.tech.is_none() {
912 return Err(RkError::Usage(
913 "no version file names a technology; rk binding --list names the bindings"
914 .into(),
915 ));
916 }
917 let state = observe_with(engine, "package-check")?;
918 match state {
919 StepState::Satisfied { detail, .. } => Ok(Done::Passed(detail)),
920 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
921 Err(RkError::subprocess(
922 Diagnostic::new(
923 Reason::SubprocessFailed,
924 format!("package-check failed: {detail}"),
925 )
926 .expected(step.proves.to_owned())
927 .step(step.name),
928 ))
929 }
930 StepState::Unknown { detail } => Err(RkError::subprocess(
931 Diagnostic::new(
932 Reason::SubprocessFailed,
933 format!("package-check could not run: {detail}"),
934 )
935 .step(step.name),
936 )),
937 }
938 }
939 "forge-version" => match observe_with(engine, "forge-version")? {
945 StepState::Satisfied { detail, .. } => Ok(Done::Satisfied(detail)),
946 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
947 Err(RkError::refusal(
948 Diagnostic::new(Reason::PrerequisiteUnmet, detail)
949 .expected(step.proves.to_owned())
950 .action("upgrade the instance, or host the project on gitlab.com")
951 .target_state("unchanged")
952 .step(step.name),
953 ))
954 }
955 StepState::Unknown { detail } => Err(RkError::refusal(
956 Diagnostic::new(Reason::ForgeTemporary, detail)
957 .expected("a readable forge version")
958 .action("glab auth login, then rerun")
959 .target_state("unchanged")
960 .step(step.name),
961 )),
962 },
963 "branch-reminder" => {
964 use crate::setup::branch_reminder::{HookState, hook_body, hook_path, observe_hook};
965 match observe_hook(&engine.ctx.target) {
966 HookState::Installed => Ok(Done::Satisfied(
967 "the post-merge reminder hook is installed".into(),
968 )),
969 HookState::Foreign => Err(RkError::refusal(
970 Diagnostic::new(
971 Reason::StateDrift,
972 "a foreign post-merge hook exists; the reminder is never written over it",
973 )
974 .expected("no post-merge hook, or one carrying the release-kit marker")
975 .action(
976 "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`",
977 )
978 .target_state("unchanged")
979 .step(step.name),
980 )),
981 HookState::Unreadable(detail) => Err(RkError::refusal(
982 Diagnostic::new(
983 Reason::StateDrift,
984 format!("the post-merge hook cannot be read: {detail}"),
985 )
986 .target_state("unchanged")
987 .step(step.name),
988 )),
989 HookState::Absent | HookState::Drifted => {
990 let path = hook_path(&engine.ctx.target).map_err(|detail| {
991 RkError::refusal(
992 Diagnostic::new(
993 Reason::PrerequisiteUnmet,
994 format!("the hooks directory cannot be resolved: {detail}"),
995 )
996 .expected("a git repository whose hooks directory git can name")
997 .step(step.name),
998 )
999 })?;
1000 crate::atomic::write(&path, hook_body())?;
1001 #[cfg(unix)]
1002 {
1003 use std::os::unix::fs::PermissionsExt as _;
1004 std::fs::set_permissions(
1005 &path,
1006 std::fs::Permissions::from_mode(0o755),
1007 )?;
1008 }
1009 Ok(Done::Changed(
1010 "wrote the post-merge reminder hook".into(),
1011 None,
1012 ))
1013 }
1014 }
1015 }
1016 "single-trunk" => {
1017 let guard = {
1018 let ctx = clone_ctx(&engine.ctx);
1019 let mut runner = |exec: &Exec| engine.exec(exec, false);
1020 observe::single_trunk_guard(&ctx, &mut runner)?
1021 };
1022 match &guard {
1025 StepState::Satisfied { .. } => {}
1026 StepState::Unsatisfied { detail }
1027 | StepState::Inapplicable { detail }
1028 | StepState::Unknown { detail } => {
1029 return Err(RkError::refusal(
1030 Diagnostic::new(
1031 Reason::DestructiveRefusal,
1032 format!("single-trunk refuses: {detail}"),
1033 )
1034 .expected(
1035 "proof that every candidate branch is absent, or an ancestor of the trunk",
1036 )
1037 .step(step.name),
1038 ));
1039 }
1040 }
1041 run_forge_step(engine, step)
1042 }
1043 "bot-secrets" => {
1044 let adapter = engine.ctx.adapter()?;
1050 let key = match adapter {
1051 Forge::Github => key_file_for(engine)?.map(|key| key.bytes.clone()),
1055 Forge::Gitlab => None,
1056 };
1057 let provided = match adapter {
1058 Forge::Github => secrets::value_of("RK_BOT_APP_ID").is_some() && key.is_some(),
1061 Forge::Gitlab => secrets::value_of("RK_BOT_TOKEN").is_some(),
1062 };
1063 let state = observe_with(engine, step.name)?;
1064 if !provided {
1065 if state.satisfied() {
1066 return Ok(Done::Satisfied(state_detail(&state)));
1067 }
1068 let wanted = match adapter {
1069 Forge::Github => {
1070 "export RK_BOT_APP_ID and RK_BOT_PRIVATE_KEY_FILE, the second naming the .pem; rk forge github carries the walkthrough"
1071 }
1072 Forge::Gitlab => {
1073 "rk setup step install-bot --apply stores the token, or export RK_BOT_TOKEN to rotate one"
1074 }
1075 };
1076 return Err(RkError::refusal(
1077 Diagnostic::new(
1078 Reason::PrerequisiteUnmet,
1079 "bot-secrets has no credentials to store",
1080 )
1081 .expected("the bot credentials in the environment, the key as a path")
1082 .action(wanted.to_owned())
1083 .step(step.name),
1084 ));
1085 }
1086 if let Some(journal) = &mut engine.journal {
1087 for name in SECRET_VARS {
1088 if secrets::value_of(name).is_some() {
1089 journal.record_secret(name, true, "environment");
1090 }
1091 }
1092 if key.is_some() {
1093 journal.record_secret(secrets::PRIVATE_KEY_FILE, true, "file");
1094 }
1095 }
1096 let stdin = key;
1100 run_forge_step_with(engine, step, stdin, Vec::new())
1101 }
1102 "protections-check" => {
1103 let (outcome, _) = run_script(engine, step)?;
1104 if !outcome.success() {
1105 return Err(classify_failure(engine, step, &outcome));
1106 }
1107 match observe_with(engine, step.name)? {
1111 StepState::Satisfied { detail, limitation } => {
1112 Ok(Done::Passed(limitation.map_or_else(
1113 || detail.clone(),
1114 |limit| format!("{detail} (limitation: {limit})"),
1115 )))
1116 }
1117 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1118 Err(RkError::refusal(
1119 Diagnostic::new(
1120 Reason::StateDrift,
1121 format!("protections-check passed its script and the observation disagrees: {detail}"),
1122 )
1123 .expected(step.proves.to_owned())
1124 .step(step.name),
1125 ))
1126 }
1127 StepState::Unknown { detail } => Err(RkError::refusal(
1130 Diagnostic::new(
1131 Reason::ForgeTemporary,
1132 format!(
1133 "protections-check passed its script and the readback could not confirm it: {detail}"
1134 ),
1135 )
1136 .expected(step.proves.to_owned())
1137 .action("check authentication and connectivity, then rerun")
1138 .step(step.name),
1139 )),
1140 }
1141 }
1142 "install-bot" if engine.ctx.forge == Some(Forge::Github) => {
1148 match observe_with(engine, step.name)? {
1149 StepState::Satisfied { detail, .. } => {
1150 return Ok(Done::Satisfied(detail));
1151 }
1152 StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
1153 StepState::Unknown { detail } => {
1154 return Err(RkError::refusal(
1155 Diagnostic::new(
1156 Reason::ForgeTemporary,
1157 format!("{} cannot observe the current state: {detail}", step.name),
1158 )
1159 .expected("a readable forge answer before anything mutates")
1160 .action("check the App credentials and connectivity, then rerun")
1161 .step(step.name),
1162 ));
1163 }
1164 }
1165 let installation = github_installation_id(engine, step)?;
1166 run_forge_step_with(
1167 engine,
1168 step,
1169 None,
1170 vec![("RK_BOT_INSTALLATION".into(), installation.into())],
1171 )
1172 }
1173 _ => {
1174 if step.mutates == Mutates::Forge {
1175 match observe_with(engine, step.name)? {
1180 StepState::Satisfied { detail, limitation } => {
1181 let detail = if step.name == "private-vulnerability-reporting" {
1182 limitation.map_or_else(
1183 || detail.clone(),
1184 |limit| format!("{detail} (limitation: {limit})"),
1185 )
1186 } else {
1187 detail
1188 };
1189 return Ok(Done::Satisfied(detail));
1190 }
1191 StepState::Inapplicable { detail }
1192 if step.name == "private-vulnerability-reporting" =>
1193 {
1194 return Ok(Done::Skipped(detail));
1195 }
1196 StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
1197 StepState::Unknown { detail } => {
1198 return Err(RkError::refusal(
1199 Diagnostic::new(
1200 Reason::ForgeTemporary,
1201 format!("{} cannot observe the current state: {detail}", step.name),
1202 )
1203 .expected("a readable forge answer before anything mutates")
1204 .action("check authentication and connectivity, then rerun")
1205 .step(step.name),
1206 ));
1207 }
1208 }
1209 }
1210 run_forge_step(engine, step)
1211 }
1212 }
1213}
1214
1215fn github_installation_id(engine: &mut Engine, step: &StepSpec) -> Result<String, RkError> {
1223 let refuse = |message: String, action: &str| {
1224 RkError::refusal(
1225 Diagnostic::new(Reason::PrerequisiteUnmet, message)
1226 .expected("the App installed on the repository's owner")
1227 .action(action.to_owned())
1228 .step(step.name),
1229 )
1230 };
1231 let jwt = match app_jwt_for(engine)? {
1232 Ok(jwt) => jwt,
1233 Err(detail) => {
1234 return Err(refuse(
1235 format!("install-bot has no App token: {detail}"),
1236 app_jwt::REMEDIATION,
1237 ));
1238 }
1239 };
1240 let owner = engine
1241 .ctx
1242 .repo
1243 .split('/')
1244 .next()
1245 .unwrap_or_default()
1246 .to_owned();
1247 let ctx = clone_ctx(&engine.ctx);
1248 for path in [
1249 format!("users/{owner}/installation"),
1250 format!("orgs/{owner}/installation"),
1251 ] {
1252 match app_jwt::api_get(&ctx, &jwt, &path) {
1253 AppApi::Ok(body) => {
1254 return body["id"].as_i64().map(|id| id.to_string()).ok_or_else(|| {
1255 refuse(
1256 format!("the forge answered {path} without an installation id"),
1257 "check RK_BOT_APP_ID and the key file name the same App",
1258 )
1259 });
1260 }
1261 AppApi::Missing => {}
1262 AppApi::Refused(detail) => {
1263 return Err(refuse(
1264 detail,
1265 "check RK_BOT_APP_ID and the key file name the same App",
1266 ));
1267 }
1268 AppApi::Failed(detail) => {
1269 return Err(RkError::refusal(
1270 Diagnostic::new(
1271 Reason::ForgeTemporary,
1272 format!("install-bot cannot read the App's installation: {detail}"),
1273 )
1274 .action("check connectivity, then rerun")
1275 .step(step.name),
1276 ));
1277 }
1278 }
1279 }
1280 Err(refuse(
1281 format!("the App has no installation on {owner}"),
1282 "install the App on the account first; the setup guide's step 5 walks it",
1283 ))
1284}
1285
1286fn run_forge_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
1288 run_forge_step_with(engine, step, None, Vec::new())
1289}
1290
1291fn run_forge_step_with(
1294 engine: &mut Engine,
1295 step: &StepSpec,
1296 stdin: Option<Zeroizing<Vec<u8>>>,
1297 extra_env: Vec<(OsString, OsString)>,
1298) -> Result<Done, RkError> {
1299 let (outcome, _) = run_script_with(engine, step, stdin, extra_env)?;
1300 if !outcome.success() {
1301 return Err(classify_failure(engine, step, &outcome));
1302 }
1303 let state = observe_with(engine, step.name)?;
1304 match state {
1305 StepState::Satisfied { detail, limitation } => Ok(Done::Changed(detail, limitation)),
1306 StepState::Inapplicable { detail } if step.name == "private-vulnerability-reporting" => {
1307 Ok(Done::Skipped(detail))
1308 }
1309 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1310 Err(RkError::refusal(
1311 Diagnostic::new(
1312 Reason::StateDrift,
1313 format!(
1314 "{} ran and its postcondition does not hold: {detail}",
1315 step.name
1316 ),
1317 )
1318 .expected(step.proves.to_owned())
1319 .step(step.name),
1320 ))
1321 }
1322 StepState::Unknown { detail } => Err(RkError::refusal(
1326 Diagnostic::new(
1327 Reason::ForgeTemporary,
1328 format!(
1329 "{} ran and the readback could not confirm it: {detail}",
1330 step.name
1331 ),
1332 )
1333 .expected(step.proves.to_owned())
1334 .action(format!(
1335 "rk setup step {} --target {} --apply re-asserts and re-proves it",
1336 step.name, engine.ctx.target
1337 ))
1338 .step(step.name),
1339 )),
1340 }
1341}
1342
1343fn observe_with(engine: &mut Engine, step: &str) -> Result<StepState, RkError> {
1350 if step == "install-bot" && engine.ctx.forge == Some(Forge::Github) {
1351 let jwt = match app_jwt_for(engine)? {
1352 Ok(jwt) => jwt,
1353 Err(detail) => return Ok(StepState::Unknown { detail }),
1354 };
1355 return Ok(observe::github_install_bot(&engine.ctx, &jwt));
1356 }
1357 let ctx = clone_ctx(&engine.ctx);
1358 let mut runner = |exec: &Exec| engine.exec(exec, false);
1359 observe::observe(&ctx, step, &mut runner)
1360}
1361
1362fn key_file_for(engine: &mut Engine) -> Result<Option<&secrets::KeyFile>, RkError> {
1368 if engine.key.is_none() {
1369 engine.key = secrets::resolve_key_file(&engine.ctx.target)?;
1370 if let Some(key) = &engine.key {
1371 engine.secrets.push(key.bytes.clone());
1372 }
1373 }
1374 Ok(engine.key.as_ref())
1375}
1376
1377fn app_jwt_for(engine: &mut Engine) -> Result<Result<String, String>, RkError> {
1387 if let Some(jwt) = &engine.app_jwt {
1388 return Ok(Ok(jwt.clone()));
1389 }
1390 let app_id = app_jwt::app_id(engine.ctx.bot_app_id())?;
1391 let key_bytes = key_file_for(engine)?.map(|key| key.bytes.clone());
1392 let (Some(app_id), Some(key_bytes)) = (app_id, key_bytes) else {
1393 return Ok(Err(format!(
1394 "the installation is readable only to the App itself; {}",
1395 app_jwt::REMEDIATION
1396 )));
1397 };
1398 let credentials = app_jwt::AppCredentials { app_id, key_bytes };
1399 let ctx = clone_ctx(&engine.ctx);
1400 Ok(match app_jwt::mint(&ctx, &credentials) {
1401 Ok(jwt) => {
1402 engine
1403 .secrets
1404 .push(Zeroizing::new(jwt.clone().into_bytes()));
1405 if let Some(signature) = jwt.rsplit('.').next() {
1406 engine
1407 .secrets
1408 .push(Zeroizing::new(signature.as_bytes().to_vec()));
1409 }
1410 engine.app_jwt = Some(jwt.clone());
1411 Ok(jwt)
1412 }
1413 Err(detail) => Err(detail),
1414 })
1415}
1416
1417fn state_detail(state: &StepState) -> String {
1418 match state {
1419 StepState::Satisfied { detail, .. }
1420 | StepState::Unsatisfied { detail }
1421 | StepState::Inapplicable { detail }
1422 | StepState::Unknown { detail } => detail.clone(),
1423 }
1424}
1425
1426fn run_script(engine: &mut Engine, step: &StepSpec) -> Result<(Outcome, PathBuf), RkError> {
1429 run_script_with(engine, step, None, Vec::new())
1430}
1431
1432fn run_script_with(
1438 engine: &mut Engine,
1439 step: &StepSpec,
1440 stdin: Option<Zeroizing<Vec<u8>>>,
1441 extra_env: Vec<(OsString, OsString)>,
1442) -> Result<(Outcome, PathBuf), RkError> {
1443 let forge = engine.ctx.adapter()?;
1444 let rel = format!("{}/{}", forge.as_str(), step.name);
1445 let bytes = embedded::SETUP
1446 .get_file(&rel)
1447 .map(include_dir::File::contents)
1448 .ok_or_else(|| RkError::Other(anyhow::anyhow!("no embedded script at setup/{rel}")))?;
1449 let journal = engine
1450 .journal
1451 .as_mut()
1452 .ok_or_else(|| RkError::Other(anyhow::anyhow!("an apply always has a journal")))?;
1453 let dir = journal.scripts_dir().join(forge.as_str());
1454 fs::create_dir_all(&dir)?;
1455 restrict(&dir, 0o700);
1456 let path = dir.join(step.name);
1457 fs::write(&path, bytes)?;
1458 restrict(&path, 0o600);
1459 let written = fs::read(&path)?;
1460 let digest = Digest::of(&written);
1461 if digest != Digest::of(bytes) {
1462 return Err(RkError::Other(anyhow::anyhow!(
1463 "the materialized script at {} differs from the embedded bytes",
1464 path.display()
1465 )));
1466 }
1467 journal.record_script(format!("scripts/{rel}"), digest.to_string());
1468 let mut env = engine.ctx.child_env(step.name);
1469 env.extend(extra_env);
1470 let exec = Exec {
1471 program: crate::probes::sh_bin(),
1472 args: vec![path.clone().into_os_string()],
1473 env,
1474 cwd: engine.ctx.target.as_std_path().to_path_buf(),
1475 stdin,
1476 };
1477 let outcome = engine.exec(&exec, true)?;
1478 Ok((outcome, path))
1479}
1480
1481fn classify_failure(engine: &Engine, step: &StepSpec, outcome: &Outcome) -> RkError {
1486 let stderr = String::from_utf8_lossy(&outcome.stderr);
1487 let last = if outcome.exit_code >= 128 {
1491 format!("killed by signal {}", outcome.exit_code - 128)
1492 } else {
1493 stderr
1494 .lines()
1495 .rev()
1496 .find(|line| !line.trim().is_empty())
1497 .unwrap_or("no output")
1498 .to_owned()
1499 };
1500 let reason = if (engine.ctx.forge == Some(Forge::Github) && outcome.exit_code == 4)
1501 || stderr.contains("HTTP 401")
1502 {
1503 Reason::ForgeAuthentication
1504 } else if stderr.contains("HTTP 403") {
1505 Reason::ForgePermission
1506 } else if stderr.contains("HTTP 429") || stderr.contains("rate limit") {
1507 Reason::ForgeRateLimit
1508 } else {
1509 Reason::SubprocessFailed
1510 };
1511 let diagnostic = Diagnostic::new(reason, format!("the forge refused '{}': {last}", step.name))
1512 .expected(step.proves.to_owned())
1513 .action(format!(
1514 "rk setup step {} --target {} --apply",
1515 step.name, engine.ctx.target
1516 ))
1517 .step(step.name);
1518 let diagnostic = match reason {
1519 Reason::ForgePermission => diagnostic.expected(format!(
1520 "repository administration write on {} for the authenticated account",
1521 engine.ctx.repo
1522 )),
1523 _ => diagnostic,
1524 };
1525 match reason {
1526 Reason::SubprocessFailed => RkError::subprocess(diagnostic),
1527 _ => RkError::refusal(diagnostic),
1528 }
1529}
1530
1531fn attach_progress(
1534 error: RkError,
1535 done: &[(String, String)],
1536 failed: &StepSpec,
1537 steps: &[&StepSpec],
1538) -> RkError {
1539 let remaining = steps.len().saturating_sub(done.len() + 1);
1540 let state = format!(
1541 "{} completed; {} failed; {remaining} not attempted",
1542 step_count(done.len()),
1543 failed.name
1544 );
1545 match error {
1546 RkError::Refusal(mut diagnostic) => {
1547 diagnostic.target_state.get_or_insert(state);
1548 RkError::Refusal(diagnostic)
1549 }
1550 RkError::Subprocess(mut diagnostic) => {
1551 diagnostic.target_state.get_or_insert(state);
1552 RkError::Subprocess(diagnostic)
1553 }
1554 other => other,
1555 }
1556}
1557
1558fn check(out: Output, ctx: Ctx) -> Result<(), RkError> {
1562 let mut engine = Engine::open(out, ctx, "setup check", false)?;
1563 let mut unsatisfied = 0usize;
1564 let mut unverifiable = 0usize;
1565 for step in &STEPS {
1566 let clock = Instant::now();
1567 let stance = stance(&engine.ctx, step);
1572 if !stance.acts() {
1573 engine.out.result_line(format!(
1574 "{} {} — {}",
1575 stance.word(),
1576 step.name,
1577 stance.detail()
1578 ));
1579 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
1580 finished.status = Some(stance.word().into());
1581 finished.detail = Some(stance.detail());
1582 finished.duration_ms = Some(elapsed_ms(clock));
1583 engine.emit(&finished);
1584 continue;
1585 }
1586 let state = observe_with(&mut engine, step.name)?;
1587 let (label, wire) = match &state {
1588 StepState::Satisfied { .. } => ("ok", "satisfied"),
1589 StepState::Inapplicable { .. } => ("skipped", "skipped"),
1592 StepState::Unsatisfied { .. } => {
1593 unsatisfied += 1;
1594 ("unsatisfied", "unsatisfied")
1595 }
1596 StepState::Unknown { .. } => {
1599 unverifiable += 1;
1600 ("unknown", "unknown")
1601 }
1602 };
1603 let mut line = format!("{label} {} — {}", step.name, state_detail(&state));
1604 if let StepState::Satisfied {
1605 limitation: Some(limit),
1606 ..
1607 } = &state
1608 {
1609 use std::fmt::Write as _;
1610 let _ = write!(line, " (limitation: {limit})");
1611 }
1612 engine.out.result_line(line);
1613 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
1614 finished.status = Some(wire.into());
1615 finished.detail = Some(state_detail(&state));
1616 finished.duration_ms = Some(elapsed_ms(clock));
1617 engine.emit(&finished);
1618 }
1619 let judged = STEPS
1620 .iter()
1621 .filter(|step| stance(&engine.ctx, step).acts())
1622 .count();
1623 if judged < STEPS.len() {
1624 engine.out.result_line(format!(
1625 "{} judged; the rest do not apply to this target or {} excludes them",
1626 step_count(judged),
1627 crate::config::CONFIG_PATH
1628 ));
1629 }
1630 if unsatisfied > 0 || unverifiable > 0 {
1631 let error = RkError::check_failed(
1632 Diagnostic::new(
1633 Reason::StateDrift,
1634 format!(
1635 "{} {} not satisfied and {unverifiable} could not be verified",
1636 step_count(unsatisfied),
1637 if unsatisfied == 1 { "is" } else { "are" }
1638 ),
1639 )
1640 .expected("every step's proof column to hold and to be readable")
1641 .action(format!(
1642 "rk setup --target {} --apply re-asserts them",
1643 engine.ctx.target
1644 )),
1645 );
1646 return Err(fail(&mut engine, error));
1647 }
1648 engine
1649 .out
1650 .next(&["rk guide release orders the first release".to_owned()]);
1651 engine.finish(0, None);
1652 Ok(())
1653}
1654
1655fn restrict(path: &std::path::Path, mode: u32) {
1658 #[cfg(unix)]
1659 {
1660 use std::os::unix::fs::PermissionsExt as _;
1661 let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
1662 }
1663 #[cfg(not(unix))]
1664 let _ = (path, mode);
1665}
1666
1667fn guard_sh() -> Result<(), RkError> {
1670 let ok = std::process::Command::new(crate::probes::sh_bin())
1671 .args(["-c", "exit 0"])
1672 .status()
1673 .is_ok_and(|status| status.success());
1674 if ok {
1675 Ok(())
1676 } else {
1677 Err(RkError::refusal(
1678 Diagnostic::new(Reason::PrerequisiteUnmet, "no POSIX sh runs on this host")
1679 .expected("a working sh on PATH; every step spawns through it")
1680 .action("install a POSIX shell, then rerun")
1681 .target_state("nothing was run and nothing changed"),
1682 ))
1683 }
1684}
1685
1686#[cfg(test)]
1687mod tests {
1688 #[test]
1691 fn a_step_count_carries_a_noun_that_agrees_with_it() {
1692 assert_eq!(super::step_count(0), "0 steps");
1693 assert_eq!(super::step_count(1), "1 step");
1694 assert_eq!(super::step_count(2), "2 steps");
1695 }
1696}