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> {
268 if ctx.forge == Some(Forge::Gitlab) && ctx.required_check.is_some() {
269 return Err(RkError::Usage(
270 "--required-check is refused on gitlab: the forge requires the whole pipeline and names no individual check".into(),
271 ));
272 }
273 Ok(())
274}
275
276fn require_check_for(ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
285 let needs = ctx.forge == Some(Forge::Github)
286 && ctx.required_check.is_none()
287 && ctx
288 .protection()
289 .owned_trunk_rules
290 .iter()
291 .any(|rule| rule == "required_status_checks")
292 && steps
293 .iter()
294 .any(|step| step.name == "protect-trunk" && stance(ctx, step).acts());
295 if needs {
296 return Err(RkError::refusal(
297 Diagnostic::new(
298 Reason::PrerequisiteUnmet,
299 "protect-trunk refuses until the required check is named, and nothing was written",
300 )
301 .expected("the name of the CI check the release merge must pass")
302 .action(format!(
303 "set setup.required_check in {}, or pass --required-check <name>; gh api repos/{}/commits/HEAD/check-runs lists the project's check names",
304 crate::config::CONFIG_PATH,
305 ctx.repo
306 ))
307 .step("protect-trunk"),
308 ));
309 }
310 Ok(())
311}
312
313fn list(forge: Option<&str>) -> Result<(), RkError> {
317 let forge = forge
318 .map(|name| {
319 Forge::parse(name).ok_or_else(|| {
320 RkError::Usage(format!(
321 "unknown forge '{name}'; the forges are: github, gitlab"
322 ))
323 })
324 })
325 .transpose()?;
326 let out = Output::human();
327 for (idx, step) in STEPS.iter().enumerate() {
328 let mut line = format!(
329 "{:2}. {} [{}] proves: {}",
330 idx + 1,
331 step.name,
332 step.chapter,
333 step.proves
334 );
335 if step.name == "protect-trunk" && forge != Some(Forge::Gitlab) {
336 line.push_str(" (needs --required-check on github)");
337 }
338 if step.destructive {
339 line.push_str(" (destructive)");
340 }
341 if step.optional {
342 line.push_str(" (optional; a full apply skips it)");
343 }
344 out.result_line(line);
345 }
346 out.next(&[
347 "rk setup --target . previews every step".to_owned(),
348 "rk setup script <name> prints one embedded script".to_owned(),
349 ]);
350 Ok(())
351}
352
353fn script(name: &str, forge: Option<&str>) -> Result<(), RkError> {
356 if name == "package-check" {
357 return Err(RkError::Usage(
358 "package-check reads its command from the technology binding and has no script".into(),
359 ));
360 }
361 if name == "branch-reminder" {
362 return Err(RkError::Usage(
363 "branch-reminder writes an embedded hook body and has no script; rk setup step branch-reminder previews the write".into(),
364 ));
365 }
366 if name == "forge-version" {
367 return Err(RkError::Usage(
368 "forge-version reads the forge's own version and has no script; rk setup step forge-version previews the read".into(),
369 ));
370 }
371 let forge = match forge {
372 Some(value) => Forge::parse(value).ok_or_else(|| {
373 RkError::Usage(format!(
374 "unknown forge '{value}'; the forges are: github, gitlab"
375 ))
376 })?,
377 None => Forge::Github,
378 };
379 let path = format!("{}/{name}", forge.as_str());
380 let file = embedded::SETUP.get_file(&path).ok_or(RkError::NotFound {
381 kind: "setup step",
382 name: name.to_owned(),
383 })?;
384 Output::human().result_raw(&String::from_utf8_lossy(file.contents()));
385 Ok(())
386}
387
388struct Engine {
391 out: Output,
392 ctx: Ctx,
393 journal: Option<Journal>,
394 secrets: Vec<Zeroizing<Vec<u8>>>,
395 key: Option<secrets::KeyFile>,
397 app_jwt: Option<String>,
399 seq: u64,
400 command: &'static str,
401 run_id: String,
402}
403
404impl Engine {
405 fn open(
410 out: Output,
411 ctx: Ctx,
412 command: &'static str,
413 journal_required: bool,
414 ) -> Result<Self, RkError> {
415 secrets::refuse_legacy_key()?;
418 let journal = match Journal::create(
419 command,
420 ctx.target.as_str(),
421 ctx.forge.map_or("none", Forge::as_str),
422 &ctx.repo,
423 ) {
424 Ok(journal) => Some(journal),
425 Err(source) if journal_required => {
426 return Err(RkError::refusal(
427 Diagnostic::new(
428 Reason::JournalUnavailable,
429 format!("the run journal cannot be created: {source}"),
430 )
431 .expected("a writable state root for the journal")
432 .target_state("nothing was run and nothing changed"),
433 ));
434 }
435 Err(source) => {
436 out.warn(format!("no run journal for this run: {source}"));
437 None
438 }
439 };
440 let run_id = journal
441 .as_ref()
442 .map_or_else(|| "unjournaled".to_owned(), |j| j.run_id().to_owned());
443 let mut engine = Self {
444 out,
445 ctx,
446 journal,
447 secrets: Ctx::secret_values(),
448 key: None,
449 app_jwt: None,
450 seq: 0,
451 command,
452 run_id,
453 };
454 let opening = Event::opening(
455 engine.next_seq(),
456 crate::applog::now_utc(),
457 engine.run_id.clone(),
458 engine.command,
459 );
460 engine.emit(&opening);
461 if engine.ctx.self_hosted_gitlab() {
462 engine.out.warn(
463 "this remote is a self-hosted GitLab: registry trusted publishing covers GitLab.com only, so the OIDC invariant cannot be satisfied here",
464 );
465 }
466 Ok(engine)
467 }
468
469 const fn next_seq(&mut self) -> u64 {
470 let seq = self.seq;
471 self.seq += 1;
472 seq
473 }
474
475 fn event(&mut self, kind: EventKind, step: Option<&str>) -> Event {
476 let mut event = Event::opening(
477 self.next_seq(),
478 crate::applog::now_utc(),
479 self.run_id.clone(),
480 self.command,
481 );
482 event.kind = kind;
483 event.step = step.map(str::to_owned);
484 event
485 }
486
487 fn emit(&mut self, event: &Event) {
488 self.out.event(event);
489 if let Some(journal) = &mut self.journal
490 && let Ok(line) = serde_json::to_string(event)
491 {
492 journal.event_line(&line);
493 }
494 }
495
496 fn exec(&mut self, exec: &Exec, passthrough: bool) -> Result<Outcome, RkError> {
499 let echo = exec.echo();
500 self.out.frame(&echo);
501 if let Some(journal) = &mut self.journal {
502 journal.transcript(echo.as_bytes());
503 journal.transcript(b"\n");
504 }
505 let secrets = std::mem::take(&mut self.secrets);
506 let step_name: Option<String> = None;
507 let mut chunks: Vec<(ChildStream, Vec<u8>)> = Vec::new();
508 let spawned = process::run(exec, |stream, chunk| {
509 chunks.push((stream, process::redact(chunk, &secrets)));
510 });
511 self.secrets = secrets;
512 for (stream, chunk) in chunks {
513 if passthrough {
514 self.out.child_passthrough(stream, &chunk);
515 }
516 let event = self.event(EventKind::ChildOutput, step_name.as_deref());
517 let event = event.child_output(stream, &chunk);
518 self.emit(&event);
519 if let Some(journal) = &mut self.journal {
520 journal.transcript(&chunk);
521 }
522 }
523 spawned.map_err(|source| {
524 RkError::refusal(
525 Diagnostic::new(
526 Reason::SubprocessSpawn,
527 format!("{} did not spawn: {source}", exec.program.to_string_lossy()),
528 )
529 .expected("a POSIX sh and the forge CLI on PATH")
530 .run(self.run_path()),
531 )
532 })
533 }
534
535 fn run_path(&self) -> String {
536 self.journal.as_ref().map_or_else(
537 || "no journal was written".to_owned(),
538 |j| j.dir.display().to_string(),
539 )
540 }
541
542 fn finish(&mut self, exit_code: i32, reason: Option<&str>) {
543 let mut event = self.event(EventKind::RunFinished, None);
544 event.exit_code = Some(exit_code);
545 event.status = Some(if exit_code == 0 {
546 "ok".into()
547 } else {
548 "failed".into()
549 });
550 self.emit(&event);
551 if let Some(journal) = &mut self.journal {
552 journal.finish(exit_code, reason);
553 }
554 }
555}
556
557fn fail(engine: &mut Engine, error: RkError) -> RkError {
559 let error = match error {
560 RkError::Refusal(mut diagnostic) => {
561 diagnostic.run.get_or_insert_with(|| engine.run_path());
562 RkError::Refusal(diagnostic)
563 }
564 RkError::Subprocess(mut diagnostic) => {
565 diagnostic.run.get_or_insert_with(|| engine.run_path());
566 RkError::Subprocess(diagnostic)
567 }
568 RkError::CheckFailed(mut diagnostic) => {
569 diagnostic.run.get_or_insert_with(|| engine.run_path());
570 RkError::CheckFailed(diagnostic)
571 }
572 other => other,
573 };
574 engine.finish(i32::from(error.exit_code()), Some(error.reason().as_str()));
575 error
576}
577
578fn preview(out: Output, ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
584 let mut engine = Engine::open(out, clone_ctx(ctx), "setup preview", false)?;
585 out.result_line(format!(
586 "DRY RUN: rk setup would run these steps against {} on {}; re-run with --apply",
587 engine.ctx.repo,
588 engine.ctx.forge.map_or("no forge", Forge::as_str)
589 ));
590 for (idx, step) in steps.iter().enumerate() {
591 out.result_line(format!(
592 "step {}/{} {} — proves {}",
593 idx + 1,
594 steps.len(),
595 step.name,
596 step.proves
597 ));
598 let stance = stance(&engine.ctx, step);
599 if !stance.acts() {
600 out.result_line(format!(" {}", stance.framed()));
601 let mut event = engine.event(EventKind::StepFinished, Some(step.name));
602 event.status = Some(stance.word().into());
603 event.detail = Some(stance.detail());
604 engine.emit(&event);
605 continue;
606 }
607 if step.name == "bot-secrets" && engine.ctx.forge == Some(Forge::Github) {
611 secrets::resolve_key_file(&engine.ctx.target)?;
612 }
613 out.result_line(format!(" {}", render_invocation(&engine.ctx, step)));
614 if step.name == "protect-trunk"
620 && engine.ctx.forge == Some(Forge::Github)
621 && engine.ctx.required_check.is_none()
622 && engine
623 .ctx
624 .protection()
625 .owned_trunk_rules
626 .iter()
627 .any(|rule| rule == "required_status_checks")
628 {
629 out.result_line(" needs: --required-check <name> before apply");
630 }
631 if skipped_by_a_full_run(&engine.ctx, step, steps.len()) {
632 out.result_line(format!(
633 " optional: a full apply skips it; set setup.release_lines, or rk setup step {} --apply runs it",
634 step.name
635 ));
636 }
637 let mut event = engine.event(EventKind::StepFinished, Some(step.name));
638 event.status = Some("previewed".into());
639 engine.emit(&event);
640 }
641 let next = next_for_apply(&engine.ctx, steps);
642 out.next(&[
643 next,
644 "rk setup check --target . proves what is already true".to_owned(),
645 ]);
646 engine.finish(0, None);
647 Ok(())
648}
649
650fn render_invocation(ctx: &Ctx, step: &StepSpec) -> String {
653 match step.name {
654 "branch-reminder" => {
655 "would write: the post-merge reminder hook at $(git rev-parse --git-path hooks)/post-merge".to_owned()
656 }
657 "package-check" => match ctx.tech {
658 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(),
659 Some("python") => "would run: python3 -m build; sdist and wheel SECURITY.md inclusion stays unproved".to_owned(),
660 Some("bash") => "nothing to run: no registry for this technology; the make dist tarball is not inspected".to_owned(),
661 _ => "needs: a version file naming the technology".to_owned(),
662 },
663 "forge-version" => {
664 let (major, minor) = observe::GITLAB_VERSION_FLOOR;
665 match ctx.forge {
666 Some(Forge::Github) => {
667 "nothing to read: github.com is a rolling service and declares no version floor"
668 .to_owned()
669 }
670 Some(Forge::Gitlab) => format!(
671 "would read: GET /version, and compare it against the {major}.{minor} floor; nothing is written"
672 ),
673 None => "nothing to read: the profile names no forge".to_owned(),
674 }
675 }
676 name => {
677 let check = ctx
678 .required_check
679 .as_ref()
680 .filter(|_| ctx.forge == Some(Forge::Github) && name == "protect-trunk")
681 .map(|value| format!(" RK_REQUIRED_CHECK={value}"))
682 .unwrap_or_default();
683 let ruleset = match name {
687 "protect-trunk" => format!(" RK_TRUNK_RULESET={} RK_TITLE_CHECK={}", ctx.trunk_ruleset(), ctx.title_check()),
688 "protect-tags" => format!(" RK_TAG_RULESET={}", ctx.tag_ruleset()),
689 "protect-release-lines" => format!(" RK_LINES_RULESET={}", ctx.lines_ruleset()),
690 _ => String::new(),
691 };
692 format!(
693 "would run: sh <embedded setup/{}/{name}> with RK_REPO={} RK_TRUNK_BRANCH={}{ruleset}{check}",
694 ctx.forge.map_or("<no forge>", Forge::as_str),
695 ctx.repo,
696 ctx.trunk()
697 )
698 }
699 }
700}
701
702fn next_for_apply(ctx: &Ctx, steps: &[&StepSpec]) -> String {
703 let check = ctx
704 .required_check
705 .as_ref()
706 .map(|value| format!(" --required-check {value}"))
707 .unwrap_or_default();
708 if steps.len() == 1 {
709 format!(
710 "rk setup step {} --target {} --apply{check}",
711 steps[0].name, ctx.target
712 )
713 } else {
714 format!("rk setup --target {} --apply{check}", ctx.target)
715 }
716}
717
718fn clone_ctx(ctx: &Ctx) -> Ctx {
720 ctx.clone()
721}
722
723fn execute(
725 out: Output,
726 ctx: Ctx,
727 steps: &[&StepSpec],
728 command: &'static str,
729) -> Result<(), RkError> {
730 guard_sh()?;
731 let mut engine = Engine::open(out, ctx, command, true)?;
732 let mut done: Vec<(String, String)> = Vec::new();
733 for (idx, step) in steps.iter().enumerate() {
734 let stance = stance(&engine.ctx, step);
738 if !stance.acts() {
739 engine.out.frame(format!(
740 "step {}/{} {} — {}",
741 idx + 1,
742 steps.len(),
743 step.name,
744 stance.framed()
745 ));
746 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
747 finished.status = Some(stance.word().into());
748 finished.detail = Some(stance.detail());
749 engine.emit(&finished);
750 done.push((step.name.to_owned(), stance.word().to_owned()));
751 continue;
752 }
753 if skipped_by_a_full_run(&engine.ctx, step, steps.len()) {
756 engine.out.frame(format!(
757 "step {}/{} {} — skipped (optional; set setup.release_lines, or rk setup step {} --apply runs it)",
758 idx + 1,
759 steps.len(),
760 step.name,
761 step.name
762 ));
763 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
764 finished.status = Some("skipped".into());
765 engine.emit(&finished);
766 done.push((step.name.to_owned(), "skipped".to_owned()));
767 continue;
768 }
769 engine.out.frame(format!(
770 "step {}/{} {} — {}",
771 idx + 1,
772 steps.len(),
773 step.name,
774 step.proves
775 ));
776 let mut started = engine.event(EventKind::StepStarted, Some(step.name));
777 started.status = Some("running".into());
778 engine.emit(&started);
779 let clock = Instant::now();
780 let status = match apply_step(&mut engine, step) {
781 Ok(status) => status,
782 Err(error) => {
783 let error = attach_progress(error, &done, step, steps);
784 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
785 finished.status = Some("failed".into());
786 finished.reason = Some(error.reason());
787 finished.duration_ms = Some(elapsed_ms(clock));
788 engine.emit(&finished);
789 return Err(fail(&mut engine, error));
790 }
791 };
792 engine.out.frame(format!(
793 "{} {}: {}",
794 if matches!(status, Done::Skipped(_)) {
795 "skipped"
796 } else {
797 "ok"
798 },
799 step.name,
800 status.line()
801 ));
802 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
803 finished.status = Some(status.wire().into());
804 finished.detail = Some(status.line());
805 finished.exit_code = Some(0);
806 finished.duration_ms = Some(elapsed_ms(clock));
807 engine.emit(&finished);
808 done.push((step.name.to_owned(), status.wire().to_owned()));
809 }
810 engine.out.result_line(format!(
811 "setup: {} completed against {}",
812 step_count(done.len()),
813 engine.ctx.repo
814 ));
815 for (name, status) in &done {
816 engine.out.result_line(format!(" {status} {name}"));
817 }
818 engine.out.next(&[
819 format!("rk setup check --target {}", engine.ctx.target),
820 "rk guide setup orders what no command performs".to_owned(),
821 ]);
822 engine.finish(0, None);
823 Ok(())
824}
825
826fn step_count(count: usize) -> String {
829 format!("{count} {}", if count == 1 { "step" } else { "steps" })
830}
831
832fn elapsed_ms(clock: Instant) -> u64 {
833 u64::try_from(clock.elapsed().as_millis()).unwrap_or(u64::MAX)
834}
835
836enum Done {
838 Satisfied(String),
840 Skipped(String),
842 Changed(String, Option<String>),
844 Passed(String),
846}
847
848impl Done {
849 const fn wire(&self) -> &'static str {
850 match self {
851 Self::Satisfied(_) => "satisfied",
852 Self::Skipped(_) => "skipped",
853 Self::Changed(..) => "applied",
854 Self::Passed(_) => "passed",
855 }
856 }
857
858 fn line(&self) -> String {
859 match self {
860 Self::Satisfied(detail) | Self::Passed(detail) | Self::Skipped(detail) => {
861 detail.clone()
862 }
863 Self::Changed(detail, limitation) => limitation.as_ref().map_or_else(
864 || detail.clone(),
865 |limit| format!("{detail} (limitation: {limit})"),
866 ),
867 }
868 }
869}
870
871#[allow(
873 clippy::too_many_lines,
874 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"
875)]
876fn apply_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
877 for prereq in step.prereqs {
880 let state = observe_with(engine, prereq)?;
881 if !state.satisfied() {
882 return Err(RkError::refusal(
883 Diagnostic::new(
884 Reason::PrerequisiteUnmet,
885 format!(
886 "{} requires {prereq} first: {}",
887 step.name,
888 state_detail(&state)
889 ),
890 )
891 .expected(format!("{prereq} satisfied before {}", step.name))
892 .action(format!(
893 "rk setup step {prereq} --target {} --apply",
894 engine.ctx.target
895 ))
896 .step(step.name),
897 ));
898 }
899 }
900 match step.name {
901 "package-check" => {
902 if engine.ctx.tech.is_none() {
903 return Err(RkError::Usage(
904 "no version file names a technology; rk binding --list names the bindings"
905 .into(),
906 ));
907 }
908 let state = observe_with(engine, "package-check")?;
909 match state {
910 StepState::Satisfied { detail, .. } => Ok(Done::Passed(detail)),
911 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
912 Err(RkError::subprocess(
913 Diagnostic::new(
914 Reason::SubprocessFailed,
915 format!("package-check failed: {detail}"),
916 )
917 .expected(step.proves.to_owned())
918 .step(step.name),
919 ))
920 }
921 StepState::Unknown { detail } => Err(RkError::subprocess(
922 Diagnostic::new(
923 Reason::SubprocessFailed,
924 format!("package-check could not run: {detail}"),
925 )
926 .step(step.name),
927 )),
928 }
929 }
930 "forge-version" => match observe_with(engine, "forge-version")? {
936 StepState::Satisfied { detail, .. } => Ok(Done::Satisfied(detail)),
937 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
938 Err(RkError::refusal(
939 Diagnostic::new(Reason::PrerequisiteUnmet, detail)
940 .expected(step.proves.to_owned())
941 .action("upgrade the instance, or host the project on gitlab.com")
942 .target_state("unchanged")
943 .step(step.name),
944 ))
945 }
946 StepState::Unknown { detail } => Err(RkError::refusal(
947 Diagnostic::new(Reason::ForgeTemporary, detail)
948 .expected("a readable forge version")
949 .action("glab auth login, then rerun")
950 .target_state("unchanged")
951 .step(step.name),
952 )),
953 },
954 "branch-reminder" => {
955 use crate::setup::branch_reminder::{HookState, hook_body, hook_path, observe_hook};
956 match observe_hook(&engine.ctx.target) {
957 HookState::Installed => Ok(Done::Satisfied(
958 "the post-merge reminder hook is installed".into(),
959 )),
960 HookState::Foreign => Err(RkError::refusal(
961 Diagnostic::new(
962 Reason::StateDrift,
963 "a foreign post-merge hook exists; the reminder is never written over it",
964 )
965 .expected("no post-merge hook, or one carrying the release-kit marker")
966 .action(
967 "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`",
968 )
969 .target_state("unchanged")
970 .step(step.name),
971 )),
972 HookState::Unreadable(detail) => Err(RkError::refusal(
973 Diagnostic::new(
974 Reason::StateDrift,
975 format!("the post-merge hook cannot be read: {detail}"),
976 )
977 .target_state("unchanged")
978 .step(step.name),
979 )),
980 HookState::Absent | HookState::Drifted => {
981 let path = hook_path(&engine.ctx.target).map_err(|detail| {
982 RkError::refusal(
983 Diagnostic::new(
984 Reason::PrerequisiteUnmet,
985 format!("the hooks directory cannot be resolved: {detail}"),
986 )
987 .expected("a git repository whose hooks directory git can name")
988 .step(step.name),
989 )
990 })?;
991 crate::atomic::write(&path, hook_body())?;
992 #[cfg(unix)]
993 {
994 use std::os::unix::fs::PermissionsExt as _;
995 std::fs::set_permissions(
996 &path,
997 std::fs::Permissions::from_mode(0o755),
998 )?;
999 }
1000 Ok(Done::Changed(
1001 "wrote the post-merge reminder hook".into(),
1002 None,
1003 ))
1004 }
1005 }
1006 }
1007 "single-trunk" => {
1008 let guard = {
1009 let ctx = clone_ctx(&engine.ctx);
1010 let mut runner = |exec: &Exec| engine.exec(exec, false);
1011 observe::single_trunk_guard(&ctx, &mut runner)?
1012 };
1013 match &guard {
1016 StepState::Satisfied { .. } => {}
1017 StepState::Unsatisfied { detail }
1018 | StepState::Inapplicable { detail }
1019 | StepState::Unknown { detail } => {
1020 return Err(RkError::refusal(
1021 Diagnostic::new(
1022 Reason::DestructiveRefusal,
1023 format!("single-trunk refuses: {detail}"),
1024 )
1025 .expected(
1026 "proof that every candidate branch is absent, or an ancestor of the trunk",
1027 )
1028 .step(step.name),
1029 ));
1030 }
1031 }
1032 run_forge_step(engine, step)
1033 }
1034 "bot-secrets" => {
1035 let adapter = engine.ctx.adapter()?;
1041 let key = match adapter {
1042 Forge::Github => key_file_for(engine)?.map(|key| key.bytes.clone()),
1046 Forge::Gitlab => None,
1047 };
1048 let provided = match adapter {
1049 Forge::Github => secrets::value_of("RK_BOT_APP_ID").is_some() && key.is_some(),
1052 Forge::Gitlab => secrets::value_of("RK_BOT_TOKEN").is_some(),
1053 };
1054 let state = observe_with(engine, step.name)?;
1055 if !provided {
1056 if state.satisfied() {
1057 return Ok(Done::Satisfied(state_detail(&state)));
1058 }
1059 let wanted = match adapter {
1060 Forge::Github => {
1061 "export RK_BOT_APP_ID and RK_BOT_PRIVATE_KEY_FILE, the second naming the .pem; rk forge github carries the walkthrough"
1062 }
1063 Forge::Gitlab => {
1064 "rk setup step install-bot --apply stores the token, or export RK_BOT_TOKEN to rotate one"
1065 }
1066 };
1067 return Err(RkError::refusal(
1068 Diagnostic::new(
1069 Reason::PrerequisiteUnmet,
1070 "bot-secrets has no credentials to store",
1071 )
1072 .expected("the bot credentials in the environment, the key as a path")
1073 .action(wanted.to_owned())
1074 .step(step.name),
1075 ));
1076 }
1077 if let Some(journal) = &mut engine.journal {
1078 for name in SECRET_VARS {
1079 if secrets::value_of(name).is_some() {
1080 journal.record_secret(name, true, "environment");
1081 }
1082 }
1083 if key.is_some() {
1084 journal.record_secret(secrets::PRIVATE_KEY_FILE, true, "file");
1085 }
1086 }
1087 let stdin = key;
1091 run_forge_step_with(engine, step, stdin, Vec::new())
1092 }
1093 "protections-check" => {
1094 let (outcome, _) = run_script(engine, step)?;
1095 if !outcome.success() {
1096 return Err(classify_failure(engine, step, &outcome));
1097 }
1098 match observe_with(engine, step.name)? {
1102 StepState::Satisfied { detail, limitation } => {
1103 Ok(Done::Passed(limitation.map_or_else(
1104 || detail.clone(),
1105 |limit| format!("{detail} (limitation: {limit})"),
1106 )))
1107 }
1108 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1109 Err(RkError::refusal(
1110 Diagnostic::new(
1111 Reason::StateDrift,
1112 format!("protections-check passed its script and the observation disagrees: {detail}"),
1113 )
1114 .expected(step.proves.to_owned())
1115 .step(step.name),
1116 ))
1117 }
1118 StepState::Unknown { detail } => Err(RkError::refusal(
1121 Diagnostic::new(
1122 Reason::ForgeTemporary,
1123 format!(
1124 "protections-check passed its script and the readback could not confirm it: {detail}"
1125 ),
1126 )
1127 .expected(step.proves.to_owned())
1128 .action("check authentication and connectivity, then rerun")
1129 .step(step.name),
1130 )),
1131 }
1132 }
1133 "install-bot" if engine.ctx.forge == Some(Forge::Github) => {
1139 match observe_with(engine, step.name)? {
1140 StepState::Satisfied { detail, .. } => {
1141 return Ok(Done::Satisfied(detail));
1142 }
1143 StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
1144 StepState::Unknown { detail } => {
1145 return Err(RkError::refusal(
1146 Diagnostic::new(
1147 Reason::ForgeTemporary,
1148 format!("{} cannot observe the current state: {detail}", step.name),
1149 )
1150 .expected("a readable forge answer before anything mutates")
1151 .action("check the App credentials and connectivity, then rerun")
1152 .step(step.name),
1153 ));
1154 }
1155 }
1156 let installation = github_installation_id(engine, step)?;
1157 run_forge_step_with(
1158 engine,
1159 step,
1160 None,
1161 vec![("RK_BOT_INSTALLATION".into(), installation.into())],
1162 )
1163 }
1164 _ => {
1165 if step.mutates == Mutates::Forge {
1166 match observe_with(engine, step.name)? {
1171 StepState::Satisfied { detail, limitation } => {
1172 let detail = if step.name == "private-vulnerability-reporting" {
1173 limitation.map_or_else(
1174 || detail.clone(),
1175 |limit| format!("{detail} (limitation: {limit})"),
1176 )
1177 } else {
1178 detail
1179 };
1180 return Ok(Done::Satisfied(detail));
1181 }
1182 StepState::Inapplicable { detail }
1183 if step.name == "private-vulnerability-reporting" =>
1184 {
1185 return Ok(Done::Skipped(detail));
1186 }
1187 StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
1188 StepState::Unknown { detail } => {
1189 return Err(RkError::refusal(
1190 Diagnostic::new(
1191 Reason::ForgeTemporary,
1192 format!("{} cannot observe the current state: {detail}", step.name),
1193 )
1194 .expected("a readable forge answer before anything mutates")
1195 .action("check authentication and connectivity, then rerun")
1196 .step(step.name),
1197 ));
1198 }
1199 }
1200 }
1201 run_forge_step(engine, step)
1202 }
1203 }
1204}
1205
1206fn github_installation_id(engine: &mut Engine, step: &StepSpec) -> Result<String, RkError> {
1214 let refuse = |message: String, action: &str| {
1215 RkError::refusal(
1216 Diagnostic::new(Reason::PrerequisiteUnmet, message)
1217 .expected("the App installed on the repository's owner")
1218 .action(action.to_owned())
1219 .step(step.name),
1220 )
1221 };
1222 let jwt = match app_jwt_for(engine)? {
1223 Ok(jwt) => jwt,
1224 Err(detail) => {
1225 return Err(refuse(
1226 format!("install-bot has no App token: {detail}"),
1227 app_jwt::REMEDIATION,
1228 ));
1229 }
1230 };
1231 let owner = engine
1232 .ctx
1233 .repo
1234 .split('/')
1235 .next()
1236 .unwrap_or_default()
1237 .to_owned();
1238 let ctx = clone_ctx(&engine.ctx);
1239 for path in [
1240 format!("users/{owner}/installation"),
1241 format!("orgs/{owner}/installation"),
1242 ] {
1243 match app_jwt::api_get(&ctx, &jwt, &path) {
1244 AppApi::Ok(body) => {
1245 return body["id"].as_i64().map(|id| id.to_string()).ok_or_else(|| {
1246 refuse(
1247 format!("the forge answered {path} without an installation id"),
1248 "check RK_BOT_APP_ID and the key file name the same App",
1249 )
1250 });
1251 }
1252 AppApi::Missing => {}
1253 AppApi::Refused(detail) => {
1254 return Err(refuse(
1255 detail,
1256 "check RK_BOT_APP_ID and the key file name the same App",
1257 ));
1258 }
1259 AppApi::Failed(detail) => {
1260 return Err(RkError::refusal(
1261 Diagnostic::new(
1262 Reason::ForgeTemporary,
1263 format!("install-bot cannot read the App's installation: {detail}"),
1264 )
1265 .action("check connectivity, then rerun")
1266 .step(step.name),
1267 ));
1268 }
1269 }
1270 }
1271 Err(refuse(
1272 format!("the App has no installation on {owner}"),
1273 "install the App on the account first; the setup guide's step 5 walks it",
1274 ))
1275}
1276
1277fn run_forge_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
1279 run_forge_step_with(engine, step, None, Vec::new())
1280}
1281
1282fn run_forge_step_with(
1285 engine: &mut Engine,
1286 step: &StepSpec,
1287 stdin: Option<Zeroizing<Vec<u8>>>,
1288 extra_env: Vec<(OsString, OsString)>,
1289) -> Result<Done, RkError> {
1290 let (outcome, _) = run_script_with(engine, step, stdin, extra_env)?;
1291 if !outcome.success() {
1292 return Err(classify_failure(engine, step, &outcome));
1293 }
1294 let state = observe_with(engine, step.name)?;
1295 match state {
1296 StepState::Satisfied { detail, limitation } => Ok(Done::Changed(detail, limitation)),
1297 StepState::Inapplicable { detail } if step.name == "private-vulnerability-reporting" => {
1298 Ok(Done::Skipped(detail))
1299 }
1300 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1301 Err(RkError::refusal(
1302 Diagnostic::new(
1303 Reason::StateDrift,
1304 format!(
1305 "{} ran and its postcondition does not hold: {detail}",
1306 step.name
1307 ),
1308 )
1309 .expected(step.proves.to_owned())
1310 .step(step.name),
1311 ))
1312 }
1313 StepState::Unknown { detail } => Err(RkError::refusal(
1317 Diagnostic::new(
1318 Reason::ForgeTemporary,
1319 format!(
1320 "{} ran and the readback could not confirm it: {detail}",
1321 step.name
1322 ),
1323 )
1324 .expected(step.proves.to_owned())
1325 .action(format!(
1326 "rk setup step {} --target {} --apply re-asserts and re-proves it",
1327 step.name, engine.ctx.target
1328 ))
1329 .step(step.name),
1330 )),
1331 }
1332}
1333
1334fn observe_with(engine: &mut Engine, step: &str) -> Result<StepState, RkError> {
1341 if step == "install-bot" && engine.ctx.forge == Some(Forge::Github) {
1342 let jwt = match app_jwt_for(engine)? {
1343 Ok(jwt) => jwt,
1344 Err(detail) => return Ok(StepState::Unknown { detail }),
1345 };
1346 return Ok(observe::github_install_bot(&engine.ctx, &jwt));
1347 }
1348 let ctx = clone_ctx(&engine.ctx);
1349 let mut runner = |exec: &Exec| engine.exec(exec, false);
1350 observe::observe(&ctx, step, &mut runner)
1351}
1352
1353fn key_file_for(engine: &mut Engine) -> Result<Option<&secrets::KeyFile>, RkError> {
1359 if engine.key.is_none() {
1360 engine.key = secrets::resolve_key_file(&engine.ctx.target)?;
1361 if let Some(key) = &engine.key {
1362 engine.secrets.push(key.bytes.clone());
1363 }
1364 }
1365 Ok(engine.key.as_ref())
1366}
1367
1368fn app_jwt_for(engine: &mut Engine) -> Result<Result<String, String>, RkError> {
1378 if let Some(jwt) = &engine.app_jwt {
1379 return Ok(Ok(jwt.clone()));
1380 }
1381 let app_id = app_jwt::app_id(engine.ctx.bot_app_id())?;
1382 let key_bytes = key_file_for(engine)?.map(|key| key.bytes.clone());
1383 let (Some(app_id), Some(key_bytes)) = (app_id, key_bytes) else {
1384 return Ok(Err(format!(
1385 "the installation is readable only to the App itself; {}",
1386 app_jwt::REMEDIATION
1387 )));
1388 };
1389 let credentials = app_jwt::AppCredentials { app_id, key_bytes };
1390 let ctx = clone_ctx(&engine.ctx);
1391 Ok(match app_jwt::mint(&ctx, &credentials) {
1392 Ok(jwt) => {
1393 engine
1394 .secrets
1395 .push(Zeroizing::new(jwt.clone().into_bytes()));
1396 if let Some(signature) = jwt.rsplit('.').next() {
1397 engine
1398 .secrets
1399 .push(Zeroizing::new(signature.as_bytes().to_vec()));
1400 }
1401 engine.app_jwt = Some(jwt.clone());
1402 Ok(jwt)
1403 }
1404 Err(detail) => Err(detail),
1405 })
1406}
1407
1408fn state_detail(state: &StepState) -> String {
1409 match state {
1410 StepState::Satisfied { detail, .. }
1411 | StepState::Unsatisfied { detail }
1412 | StepState::Inapplicable { detail }
1413 | StepState::Unknown { detail } => detail.clone(),
1414 }
1415}
1416
1417fn run_script(engine: &mut Engine, step: &StepSpec) -> Result<(Outcome, PathBuf), RkError> {
1420 run_script_with(engine, step, None, Vec::new())
1421}
1422
1423fn run_script_with(
1429 engine: &mut Engine,
1430 step: &StepSpec,
1431 stdin: Option<Zeroizing<Vec<u8>>>,
1432 extra_env: Vec<(OsString, OsString)>,
1433) -> Result<(Outcome, PathBuf), RkError> {
1434 let forge = engine.ctx.adapter()?;
1435 let rel = format!("{}/{}", forge.as_str(), step.name);
1436 let bytes = embedded::SETUP
1437 .get_file(&rel)
1438 .map(include_dir::File::contents)
1439 .ok_or_else(|| RkError::Other(anyhow::anyhow!("no embedded script at setup/{rel}")))?;
1440 let journal = engine
1441 .journal
1442 .as_mut()
1443 .ok_or_else(|| RkError::Other(anyhow::anyhow!("an apply always has a journal")))?;
1444 let dir = journal.scripts_dir().join(forge.as_str());
1445 fs::create_dir_all(&dir)?;
1446 restrict(&dir, 0o700);
1447 let path = dir.join(step.name);
1448 fs::write(&path, bytes)?;
1449 restrict(&path, 0o600);
1450 let written = fs::read(&path)?;
1451 let digest = Digest::of(&written);
1452 if digest != Digest::of(bytes) {
1453 return Err(RkError::Other(anyhow::anyhow!(
1454 "the materialized script at {} differs from the embedded bytes",
1455 path.display()
1456 )));
1457 }
1458 journal.record_script(format!("scripts/{rel}"), digest.to_string());
1459 let mut env = engine.ctx.child_env(step.name);
1460 env.extend(extra_env);
1461 let exec = Exec {
1462 program: crate::probes::sh_bin(),
1463 args: vec![path.clone().into_os_string()],
1464 env,
1465 cwd: engine.ctx.target.as_std_path().to_path_buf(),
1466 stdin,
1467 };
1468 let outcome = engine.exec(&exec, true)?;
1469 Ok((outcome, path))
1470}
1471
1472fn classify_failure(engine: &Engine, step: &StepSpec, outcome: &Outcome) -> RkError {
1477 let stderr = String::from_utf8_lossy(&outcome.stderr);
1478 let last = if outcome.exit_code >= 128 {
1482 format!("killed by signal {}", outcome.exit_code - 128)
1483 } else {
1484 stderr
1485 .lines()
1486 .rev()
1487 .find(|line| !line.trim().is_empty())
1488 .unwrap_or("no output")
1489 .to_owned()
1490 };
1491 let reason = if (engine.ctx.forge == Some(Forge::Github) && outcome.exit_code == 4)
1492 || stderr.contains("HTTP 401")
1493 {
1494 Reason::ForgeAuthentication
1495 } else if stderr.contains("HTTP 403") {
1496 Reason::ForgePermission
1497 } else if stderr.contains("HTTP 429") || stderr.contains("rate limit") {
1498 Reason::ForgeRateLimit
1499 } else {
1500 Reason::SubprocessFailed
1501 };
1502 let diagnostic = Diagnostic::new(reason, format!("the forge refused '{}': {last}", step.name))
1503 .expected(step.proves.to_owned())
1504 .action(format!(
1505 "rk setup step {} --target {} --apply",
1506 step.name, engine.ctx.target
1507 ))
1508 .step(step.name);
1509 let diagnostic = match reason {
1510 Reason::ForgePermission => diagnostic.expected(format!(
1511 "repository administration write on {} for the authenticated account",
1512 engine.ctx.repo
1513 )),
1514 _ => diagnostic,
1515 };
1516 match reason {
1517 Reason::SubprocessFailed => RkError::subprocess(diagnostic),
1518 _ => RkError::refusal(diagnostic),
1519 }
1520}
1521
1522fn attach_progress(
1525 error: RkError,
1526 done: &[(String, String)],
1527 failed: &StepSpec,
1528 steps: &[&StepSpec],
1529) -> RkError {
1530 let remaining = steps.len().saturating_sub(done.len() + 1);
1531 let state = format!(
1532 "{} completed; {} failed; {remaining} not attempted",
1533 step_count(done.len()),
1534 failed.name
1535 );
1536 match error {
1537 RkError::Refusal(mut diagnostic) => {
1538 diagnostic.target_state.get_or_insert(state);
1539 RkError::Refusal(diagnostic)
1540 }
1541 RkError::Subprocess(mut diagnostic) => {
1542 diagnostic.target_state.get_or_insert(state);
1543 RkError::Subprocess(diagnostic)
1544 }
1545 other => other,
1546 }
1547}
1548
1549fn check(out: Output, ctx: Ctx) -> Result<(), RkError> {
1553 let mut engine = Engine::open(out, ctx, "setup check", false)?;
1554 let mut unsatisfied = 0usize;
1555 let mut unverifiable = 0usize;
1556 for step in &STEPS {
1557 let clock = Instant::now();
1558 let stance = stance(&engine.ctx, step);
1563 if !stance.acts() {
1564 engine.out.result_line(format!(
1565 "{} {} — {}",
1566 stance.word(),
1567 step.name,
1568 stance.detail()
1569 ));
1570 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
1571 finished.status = Some(stance.word().into());
1572 finished.detail = Some(stance.detail());
1573 finished.duration_ms = Some(elapsed_ms(clock));
1574 engine.emit(&finished);
1575 continue;
1576 }
1577 let state = observe_with(&mut engine, step.name)?;
1578 let (label, wire) = match &state {
1579 StepState::Satisfied { .. } => ("ok", "satisfied"),
1580 StepState::Inapplicable { .. } => ("skipped", "skipped"),
1583 StepState::Unsatisfied { .. } => {
1584 unsatisfied += 1;
1585 ("unsatisfied", "unsatisfied")
1586 }
1587 StepState::Unknown { .. } => {
1590 unverifiable += 1;
1591 ("unknown", "unknown")
1592 }
1593 };
1594 let mut line = format!("{label} {} — {}", step.name, state_detail(&state));
1595 if let StepState::Satisfied {
1596 limitation: Some(limit),
1597 ..
1598 } = &state
1599 {
1600 use std::fmt::Write as _;
1601 let _ = write!(line, " (limitation: {limit})");
1602 }
1603 engine.out.result_line(line);
1604 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
1605 finished.status = Some(wire.into());
1606 finished.detail = Some(state_detail(&state));
1607 finished.duration_ms = Some(elapsed_ms(clock));
1608 engine.emit(&finished);
1609 }
1610 let judged = STEPS
1611 .iter()
1612 .filter(|step| stance(&engine.ctx, step).acts())
1613 .count();
1614 if judged < STEPS.len() {
1615 engine.out.result_line(format!(
1616 "{} judged; the rest do not apply to this target or {} excludes them",
1617 step_count(judged),
1618 crate::config::CONFIG_PATH
1619 ));
1620 }
1621 if unsatisfied > 0 || unverifiable > 0 {
1622 let error = RkError::check_failed(
1623 Diagnostic::new(
1624 Reason::StateDrift,
1625 format!(
1626 "{} {} not satisfied and {unverifiable} could not be verified",
1627 step_count(unsatisfied),
1628 if unsatisfied == 1 { "is" } else { "are" }
1629 ),
1630 )
1631 .expected("every step's proof column to hold and to be readable")
1632 .action(format!(
1633 "rk setup --target {} --apply re-asserts them",
1634 engine.ctx.target
1635 )),
1636 );
1637 return Err(fail(&mut engine, error));
1638 }
1639 engine
1640 .out
1641 .next(&["rk guide release orders the first release".to_owned()]);
1642 engine.finish(0, None);
1643 Ok(())
1644}
1645
1646fn restrict(path: &std::path::Path, mode: u32) {
1649 #[cfg(unix)]
1650 {
1651 use std::os::unix::fs::PermissionsExt as _;
1652 let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
1653 }
1654 #[cfg(not(unix))]
1655 let _ = (path, mode);
1656}
1657
1658fn guard_sh() -> Result<(), RkError> {
1661 let ok = std::process::Command::new(crate::probes::sh_bin())
1662 .args(["-c", "exit 0"])
1663 .status()
1664 .is_ok_and(|status| status.success());
1665 if ok {
1666 Ok(())
1667 } else {
1668 Err(RkError::refusal(
1669 Diagnostic::new(Reason::PrerequisiteUnmet, "no POSIX sh runs on this host")
1670 .expected("a working sh on PATH; every step spawns through it")
1671 .action("install a POSIX shell, then rerun")
1672 .target_state("nothing was run and nothing changed"),
1673 ))
1674 }
1675}
1676
1677#[cfg(test)]
1678mod tests {
1679 #[test]
1682 fn a_step_count_carries_a_noun_that_agrees_with_it() {
1683 assert_eq!(super::step_count(0), "0 steps");
1684 assert_eq!(super::step_count(1), "1 step");
1685 assert_eq!(super::step_count(2), "2 steps");
1686 }
1687}