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