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