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 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 check(Output::new(*json), ctx)
57 }
58 Some(SetupAction::Step {
59 name,
60 target,
61 repo,
62 forge,
63 required_check,
64 apply,
65 json,
66 }) => {
67 let selected = spec(name).ok_or_else(|| {
68 RkError::Usage(format!("unknown step '{name}'; rk setup --list names them"))
69 })?;
70 let ctx = Ctx::resolve(
71 target,
72 repo.as_deref(),
73 forge.as_deref(),
74 required_check.as_deref(),
75 )?;
76 reject_check_flag_on_gitlab(&ctx)?;
77 if *apply {
78 require_check_for(&ctx, &[selected])?;
79 execute(Output::new(*json), ctx, &[selected], "setup step")
80 } else {
81 preview(Output::new(*json), &ctx, &[selected])
82 }
83 }
84 None if args.list => list(args.forge.as_deref()),
85 None => {
86 let target = args.target.clone().ok_or_else(|| {
87 RkError::Usage("name a --target, or pass --list to see the steps".into())
88 })?;
89 let ctx = Ctx::resolve(
90 &target,
91 args.repo.as_deref(),
92 args.forge.as_deref(),
93 args.required_check.as_deref(),
94 )?;
95 reject_check_flag_on_gitlab(&ctx)?;
96 let all: Vec<&StepSpec> = STEPS.iter().collect();
97 if args.apply {
98 require_check_for(&ctx, &all)?;
99 execute(Output::new(args.json), ctx, &all, "setup")
100 } else {
101 preview(Output::new(args.json), &ctx, &all)
102 }
103 }
104 }
105}
106
107fn skipped_by_a_full_run(ctx: &Ctx, step: &StepSpec, selected: usize) -> bool {
115 if !step.optional || selected <= 1 {
116 return false;
117 }
118 !(step.name == "protect-release-lines" && ctx.release_lines())
119}
120
121fn reject_check_flag_on_gitlab(ctx: &Ctx) -> Result<(), RkError> {
125 if ctx.forge == Forge::Gitlab && ctx.required_check.is_some() {
126 return Err(RkError::Usage(
127 "--required-check is refused on gitlab: the forge requires the whole pipeline and names no individual check".into(),
128 ));
129 }
130 Ok(())
131}
132
133fn require_check_for(ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
138 let needs = ctx.forge == Forge::Github
139 && ctx.required_check.is_none()
140 && steps.iter().any(|step| step.name == "protect-trunk");
141 if needs {
142 return Err(RkError::refusal(
143 Diagnostic::new(
144 Reason::PrerequisiteUnmet,
145 "protect-trunk refuses until the required check is named, and nothing was written",
146 )
147 .expected("the name of the CI check the release merge must pass")
148 .action(format!(
149 "set setup.required_check in {}, or pass --required-check <name>; gh api repos/{}/commits/HEAD/check-runs lists the project's check names",
150 crate::config::CONFIG_PATH,
151 ctx.repo
152 ))
153 .step("protect-trunk"),
154 ));
155 }
156 Ok(())
157}
158
159fn list(forge: Option<&str>) -> Result<(), RkError> {
163 let forge = forge
164 .map(|name| {
165 Forge::parse(name).ok_or_else(|| {
166 RkError::Usage(format!(
167 "unknown forge '{name}'; the forges are: github, gitlab"
168 ))
169 })
170 })
171 .transpose()?;
172 let out = Output::human();
173 for (idx, step) in STEPS.iter().enumerate() {
174 let mut line = format!(
175 "{:2}. {} [{}] proves: {}",
176 idx + 1,
177 step.name,
178 step.chapter,
179 step.proves
180 );
181 if step.name == "protect-trunk" && forge != Some(Forge::Gitlab) {
182 line.push_str(" (needs --required-check on github)");
183 }
184 if step.destructive {
185 line.push_str(" (destructive)");
186 }
187 if step.optional {
188 line.push_str(" (optional; a full apply skips it)");
189 }
190 out.result_line(line);
191 }
192 out.next(&[
193 "rk setup --target . previews every step".to_owned(),
194 "rk setup script <name> prints one embedded script".to_owned(),
195 ]);
196 Ok(())
197}
198
199fn script(name: &str, forge: Option<&str>) -> Result<(), RkError> {
202 if name == "package-check" {
203 return Err(RkError::Usage(
204 "package-check reads its command from the technology binding and has no script".into(),
205 ));
206 }
207 if name == "branch-reminder" {
208 return Err(RkError::Usage(
209 "branch-reminder writes an embedded hook body and has no script; rk setup step branch-reminder previews the write".into(),
210 ));
211 }
212 if name == "forge-version" {
213 return Err(RkError::Usage(
214 "forge-version reads the forge's own version and has no script; rk setup step forge-version previews the read".into(),
215 ));
216 }
217 let forge = match forge {
218 Some(value) => Forge::parse(value).ok_or_else(|| {
219 RkError::Usage(format!(
220 "unknown forge '{value}'; the forges are: github, gitlab"
221 ))
222 })?,
223 None => Forge::Github,
224 };
225 let path = format!("{}/{name}", forge.as_str());
226 let file = embedded::SETUP.get_file(&path).ok_or(RkError::NotFound {
227 kind: "setup step",
228 name: name.to_owned(),
229 })?;
230 Output::human().result_raw(&String::from_utf8_lossy(file.contents()));
231 Ok(())
232}
233
234struct Engine {
237 out: Output,
238 ctx: Ctx,
239 journal: Option<Journal>,
240 secrets: Vec<Zeroizing<Vec<u8>>>,
241 key: Option<secrets::KeyFile>,
243 app_jwt: Option<String>,
245 seq: u64,
246 command: &'static str,
247 run_id: String,
248}
249
250impl Engine {
251 fn open(
256 out: Output,
257 ctx: Ctx,
258 command: &'static str,
259 journal_required: bool,
260 ) -> Result<Self, RkError> {
261 secrets::refuse_legacy_key()?;
264 let journal =
265 match Journal::create(command, ctx.target.as_str(), ctx.forge.as_str(), &ctx.repo) {
266 Ok(journal) => Some(journal),
267 Err(source) if journal_required => {
268 return Err(RkError::refusal(
269 Diagnostic::new(
270 Reason::JournalUnavailable,
271 format!("the run journal cannot be created: {source}"),
272 )
273 .expected("a writable state root for the journal")
274 .target_state("nothing was run and nothing changed"),
275 ));
276 }
277 Err(source) => {
278 out.warn(format!("no run journal for this run: {source}"));
279 None
280 }
281 };
282 let run_id = journal
283 .as_ref()
284 .map_or_else(|| "unjournaled".to_owned(), |j| j.run_id().to_owned());
285 let mut engine = Self {
286 out,
287 ctx,
288 journal,
289 secrets: Ctx::secret_values(),
290 key: None,
291 app_jwt: None,
292 seq: 0,
293 command,
294 run_id,
295 };
296 let opening = Event::opening(
297 engine.next_seq(),
298 crate::applog::now_utc(),
299 engine.run_id.clone(),
300 engine.command,
301 );
302 engine.emit(&opening);
303 if engine.ctx.self_hosted_gitlab() {
304 engine.out.warn(
305 "this remote is a self-hosted GitLab: registry trusted publishing covers GitLab.com only, so the OIDC invariant cannot be satisfied here",
306 );
307 }
308 Ok(engine)
309 }
310
311 const fn next_seq(&mut self) -> u64 {
312 let seq = self.seq;
313 self.seq += 1;
314 seq
315 }
316
317 fn event(&mut self, kind: EventKind, step: Option<&str>) -> Event {
318 let mut event = Event::opening(
319 self.next_seq(),
320 crate::applog::now_utc(),
321 self.run_id.clone(),
322 self.command,
323 );
324 event.kind = kind;
325 event.step = step.map(str::to_owned);
326 event
327 }
328
329 fn emit(&mut self, event: &Event) {
330 self.out.event(event);
331 if let Some(journal) = &mut self.journal {
332 if let Ok(line) = serde_json::to_string(event) {
333 journal.event_line(&line);
334 }
335 }
336 }
337
338 fn exec(&mut self, exec: &Exec, passthrough: bool) -> Result<Outcome, RkError> {
341 let echo = exec.echo();
342 self.out.frame(&echo);
343 if let Some(journal) = &mut self.journal {
344 journal.transcript(echo.as_bytes());
345 journal.transcript(b"\n");
346 }
347 let secrets = std::mem::take(&mut self.secrets);
348 let step_name: Option<String> = None;
349 let mut chunks: Vec<(ChildStream, Vec<u8>)> = Vec::new();
350 let spawned = process::run(exec, |stream, chunk| {
351 chunks.push((stream, process::redact(chunk, &secrets)));
352 });
353 self.secrets = secrets;
354 for (stream, chunk) in chunks {
355 if passthrough {
356 self.out.child_passthrough(stream, &chunk);
357 }
358 let event = self.event(EventKind::ChildOutput, step_name.as_deref());
359 let event = event.child_output(stream, &chunk);
360 self.emit(&event);
361 if let Some(journal) = &mut self.journal {
362 journal.transcript(&chunk);
363 }
364 }
365 spawned.map_err(|source| {
366 RkError::refusal(
367 Diagnostic::new(
368 Reason::SubprocessSpawn,
369 format!("{} did not spawn: {source}", exec.program.to_string_lossy()),
370 )
371 .expected("a POSIX sh and the forge CLI on PATH")
372 .run(self.run_path()),
373 )
374 })
375 }
376
377 fn run_path(&self) -> String {
378 self.journal.as_ref().map_or_else(
379 || "no journal was written".to_owned(),
380 |j| j.dir.display().to_string(),
381 )
382 }
383
384 fn finish(&mut self, exit_code: i32, reason: Option<&str>) {
385 let mut event = self.event(EventKind::RunFinished, None);
386 event.exit_code = Some(exit_code);
387 event.status = Some(if exit_code == 0 {
388 "ok".into()
389 } else {
390 "failed".into()
391 });
392 self.emit(&event);
393 if let Some(journal) = &mut self.journal {
394 journal.finish(exit_code, reason);
395 }
396 }
397}
398
399fn fail(engine: &mut Engine, error: RkError) -> RkError {
401 let error = match error {
402 RkError::Refusal(mut diagnostic) => {
403 diagnostic.run.get_or_insert_with(|| engine.run_path());
404 RkError::Refusal(diagnostic)
405 }
406 RkError::Subprocess(mut diagnostic) => {
407 diagnostic.run.get_or_insert_with(|| engine.run_path());
408 RkError::Subprocess(diagnostic)
409 }
410 RkError::CheckFailed(mut diagnostic) => {
411 diagnostic.run.get_or_insert_with(|| engine.run_path());
412 RkError::CheckFailed(diagnostic)
413 }
414 other => other,
415 };
416 engine.finish(i32::from(error.exit_code()), Some(error.reason().as_str()));
417 error
418}
419
420fn preview(out: Output, ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
426 let mut engine = Engine::open(out, clone_ctx(ctx), "setup preview", false)?;
427 out.result_line(format!(
428 "DRY RUN: rk setup would run these steps against {} on {}; re-run with --apply",
429 engine.ctx.repo,
430 engine.ctx.forge.as_str()
431 ));
432 for (idx, step) in steps.iter().enumerate() {
433 out.result_line(format!(
434 "step {}/{} {} — proves {}",
435 idx + 1,
436 steps.len(),
437 step.name,
438 step.proves
439 ));
440 if step.name == "bot-secrets" && engine.ctx.forge == Forge::Github {
444 secrets::resolve_key_file(&engine.ctx.target)?;
445 }
446 out.result_line(format!(" {}", render_invocation(&engine.ctx, step)));
447 if step.name == "protect-trunk"
448 && engine.ctx.forge == Forge::Github
449 && engine.ctx.required_check.is_none()
450 {
451 out.result_line(" needs: --required-check <name> before apply");
452 }
453 if skipped_by_a_full_run(&engine.ctx, step, steps.len()) {
454 out.result_line(format!(
455 " optional: a full apply skips it; set setup.release_lines, or rk setup step {} --apply runs it",
456 step.name
457 ));
458 }
459 let mut event = engine.event(EventKind::StepFinished, Some(step.name));
460 event.status = Some("previewed".into());
461 engine.emit(&event);
462 }
463 let next = next_for_apply(&engine.ctx, steps);
464 out.next(&[
465 next,
466 "rk setup check --target . proves what is already true".to_owned(),
467 ]);
468 engine.finish(0, None);
469 Ok(())
470}
471
472fn render_invocation(ctx: &Ctx, step: &StepSpec) -> String {
475 match step.name {
476 "branch-reminder" => {
477 "would write: the post-merge reminder hook at $(git rev-parse --git-path hooks)/post-merge".to_owned()
478 }
479 "package-check" => match ctx.tech {
480 Some("rust") => "would run: cargo publish --dry-run --allow-dirty".to_owned(),
481 Some("python") => "would run: python3 -m build".to_owned(),
482 Some("bash") => "nothing to run: no registry for this technology".to_owned(),
483 _ => "needs: a version file naming the technology".to_owned(),
484 },
485 "forge-version" => {
486 let (major, minor) = observe::GITLAB_VERSION_FLOOR;
487 match ctx.forge {
488 Forge::Github => {
489 "nothing to read: github.com is a rolling service and declares no version floor"
490 .to_owned()
491 }
492 Forge::Gitlab => format!(
493 "would read: GET /version, and compare it against the {major}.{minor} floor; nothing is written"
494 ),
495 }
496 }
497 name => {
498 let check = ctx
499 .required_check
500 .as_ref()
501 .filter(|_| ctx.forge == Forge::Github && name == "protect-trunk")
502 .map(|value| format!(" RK_REQUIRED_CHECK={value}"))
503 .unwrap_or_default();
504 let ruleset = match name {
508 "protect-trunk" => format!(" RK_TRUNK_RULESET={} RK_TITLE_CHECK={}", ctx.trunk_ruleset(), ctx.title_check()),
509 "protect-tags" => format!(" RK_TAG_RULESET={}", ctx.tag_ruleset()),
510 "protect-release-lines" => format!(" RK_LINES_RULESET={}", ctx.lines_ruleset()),
511 _ => String::new(),
512 };
513 format!(
514 "would run: sh <embedded setup/{}/{name}> with RK_REPO={} RK_TRUNK_BRANCH={}{ruleset}{check}",
515 ctx.forge.as_str(),
516 ctx.repo,
517 ctx.trunk()
518 )
519 }
520 }
521}
522
523fn next_for_apply(ctx: &Ctx, steps: &[&StepSpec]) -> String {
524 let check = ctx
525 .required_check
526 .as_ref()
527 .map(|value| format!(" --required-check {value}"))
528 .unwrap_or_default();
529 if steps.len() == 1 {
530 format!(
531 "rk setup step {} --target {} --apply{check}",
532 steps[0].name, ctx.target
533 )
534 } else {
535 format!("rk setup --target {} --apply{check}", ctx.target)
536 }
537}
538
539fn clone_ctx(ctx: &Ctx) -> Ctx {
541 ctx.clone()
542}
543
544fn execute(
546 out: Output,
547 ctx: Ctx,
548 steps: &[&StepSpec],
549 command: &'static str,
550) -> Result<(), RkError> {
551 guard_sh()?;
552 let mut engine = Engine::open(out, ctx, command, true)?;
553 let mut done: Vec<(String, String)> = Vec::new();
554 for (idx, step) in steps.iter().enumerate() {
555 if skipped_by_a_full_run(&engine.ctx, step, steps.len()) {
558 engine.out.frame(format!(
559 "step {}/{} {} — skipped (optional; set setup.release_lines, or rk setup step {} --apply runs it)",
560 idx + 1,
561 steps.len(),
562 step.name,
563 step.name
564 ));
565 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
566 finished.status = Some("skipped".into());
567 engine.emit(&finished);
568 done.push((step.name.to_owned(), "skipped".to_owned()));
569 continue;
570 }
571 engine.out.frame(format!(
572 "step {}/{} {} — {}",
573 idx + 1,
574 steps.len(),
575 step.name,
576 step.proves
577 ));
578 let mut started = engine.event(EventKind::StepStarted, Some(step.name));
579 started.status = Some("running".into());
580 engine.emit(&started);
581 let clock = Instant::now();
582 let status = match apply_step(&mut engine, step) {
583 Ok(status) => status,
584 Err(error) => {
585 let error = attach_progress(error, &done, step, steps);
586 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
587 finished.status = Some("failed".into());
588 finished.reason = Some(error.reason());
589 finished.duration_ms = Some(elapsed_ms(clock));
590 engine.emit(&finished);
591 return Err(fail(&mut engine, error));
592 }
593 };
594 engine.out.frame(format!(
595 "{} {}: {}",
596 if matches!(status, Done::Skipped(_)) {
597 "skipped"
598 } else {
599 "ok"
600 },
601 step.name,
602 status.line()
603 ));
604 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
605 finished.status = Some(status.wire().into());
606 finished.exit_code = Some(0);
607 finished.duration_ms = Some(elapsed_ms(clock));
608 engine.emit(&finished);
609 done.push((step.name.to_owned(), status.wire().to_owned()));
610 }
611 engine.out.result_line(format!(
612 "setup: {} completed against {}",
613 step_count(done.len()),
614 engine.ctx.repo
615 ));
616 for (name, status) in &done {
617 engine.out.result_line(format!(" {status} {name}"));
618 }
619 engine.out.next(&[
620 format!("rk setup check --target {}", engine.ctx.target),
621 "rk guide setup orders what no command performs".to_owned(),
622 ]);
623 engine.finish(0, None);
624 Ok(())
625}
626
627fn step_count(count: usize) -> String {
630 format!("{count} {}", if count == 1 { "step" } else { "steps" })
631}
632
633fn elapsed_ms(clock: Instant) -> u64 {
634 u64::try_from(clock.elapsed().as_millis()).unwrap_or(u64::MAX)
635}
636
637enum Done {
639 Satisfied(String),
641 Skipped(String),
643 Changed(String, Option<String>),
645 Passed(String),
647}
648
649impl Done {
650 const fn wire(&self) -> &'static str {
651 match self {
652 Self::Satisfied(_) => "satisfied",
653 Self::Skipped(_) => "skipped",
654 Self::Changed(..) => "applied",
655 Self::Passed(_) => "passed",
656 }
657 }
658
659 fn line(&self) -> String {
660 match self {
661 Self::Satisfied(detail) | Self::Passed(detail) | Self::Skipped(detail) => {
662 detail.clone()
663 }
664 Self::Changed(detail, limitation) => limitation.as_ref().map_or_else(
665 || detail.clone(),
666 |limit| format!("{detail} (limitation: {limit})"),
667 ),
668 }
669 }
670}
671
672#[allow(clippy::too_many_lines)]
674fn apply_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
675 for prereq in step.prereqs {
678 let state = observe_with(engine, prereq)?;
679 if !state.satisfied() {
680 return Err(RkError::refusal(
681 Diagnostic::new(
682 Reason::PrerequisiteUnmet,
683 format!(
684 "{} requires {prereq} first: {}",
685 step.name,
686 state_detail(&state)
687 ),
688 )
689 .expected(format!("{prereq} satisfied before {}", step.name))
690 .action(format!(
691 "rk setup step {prereq} --target {} --apply",
692 engine.ctx.target
693 ))
694 .step(step.name),
695 ));
696 }
697 }
698 match step.name {
699 "package-check" => {
700 if engine.ctx.tech.is_none() {
701 return Err(RkError::Usage(
702 "no version file names a technology; rk binding --list names the bindings"
703 .into(),
704 ));
705 }
706 let state = observe_with(engine, "package-check")?;
707 match state {
708 StepState::Satisfied { detail, .. } => Ok(Done::Passed(detail)),
709 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
710 Err(RkError::subprocess(
711 Diagnostic::new(
712 Reason::SubprocessFailed,
713 format!("package-check failed: {detail}"),
714 )
715 .expected(step.proves.to_owned())
716 .step(step.name),
717 ))
718 }
719 StepState::Unknown { detail } => Err(RkError::subprocess(
720 Diagnostic::new(
721 Reason::SubprocessFailed,
722 format!("package-check could not run: {detail}"),
723 )
724 .step(step.name),
725 )),
726 }
727 }
728 "forge-version" => match observe_with(engine, "forge-version")? {
734 StepState::Satisfied { detail, .. } => Ok(Done::Satisfied(detail)),
735 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
736 Err(RkError::refusal(
737 Diagnostic::new(Reason::PrerequisiteUnmet, detail)
738 .expected(step.proves.to_owned())
739 .action("upgrade the instance, or host the project on gitlab.com")
740 .target_state("unchanged")
741 .step(step.name),
742 ))
743 }
744 StepState::Unknown { detail } => Err(RkError::refusal(
745 Diagnostic::new(Reason::ForgeTemporary, detail)
746 .expected("a readable forge version")
747 .action("glab auth login, then rerun")
748 .target_state("unchanged")
749 .step(step.name),
750 )),
751 },
752 "branch-reminder" => {
753 use crate::setup::branch_reminder::{HookState, hook_body, hook_path, observe_hook};
754 match observe_hook(&engine.ctx.target) {
755 HookState::Installed => Ok(Done::Satisfied(
756 "the post-merge reminder hook is installed".into(),
757 )),
758 HookState::Foreign => Err(RkError::refusal(
759 Diagnostic::new(
760 Reason::StateDrift,
761 "a foreign post-merge hook exists; the reminder is never written over it",
762 )
763 .expected("no post-merge hook, or one carrying the release-kit marker")
764 .action(
765 "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`",
766 )
767 .target_state("unchanged")
768 .step(step.name),
769 )),
770 HookState::Unreadable(detail) => Err(RkError::refusal(
771 Diagnostic::new(
772 Reason::StateDrift,
773 format!("the post-merge hook cannot be read: {detail}"),
774 )
775 .target_state("unchanged")
776 .step(step.name),
777 )),
778 HookState::Absent | HookState::Drifted => {
779 let path = hook_path(&engine.ctx.target).map_err(|detail| {
780 RkError::refusal(
781 Diagnostic::new(
782 Reason::PrerequisiteUnmet,
783 format!("the hooks directory cannot be resolved: {detail}"),
784 )
785 .expected("a git repository whose hooks directory git can name")
786 .step(step.name),
787 )
788 })?;
789 crate::atomic::write(&path, hook_body())?;
790 #[cfg(unix)]
791 {
792 use std::os::unix::fs::PermissionsExt as _;
793 std::fs::set_permissions(
794 &path,
795 std::fs::Permissions::from_mode(0o755),
796 )?;
797 }
798 Ok(Done::Changed(
799 "wrote the post-merge reminder hook".into(),
800 None,
801 ))
802 }
803 }
804 }
805 "single-trunk" => {
806 let guard = {
807 let ctx = clone_ctx(&engine.ctx);
808 let mut runner = |exec: &Exec| engine.exec(exec, false);
809 observe::single_trunk_guard(&ctx, &mut runner)?
810 };
811 match &guard {
814 StepState::Satisfied { .. } => {}
815 StepState::Unsatisfied { detail }
816 | StepState::Inapplicable { detail }
817 | StepState::Unknown { detail } => {
818 return Err(RkError::refusal(
819 Diagnostic::new(
820 Reason::DestructiveRefusal,
821 format!("single-trunk refuses: {detail}"),
822 )
823 .expected(
824 "proof that every candidate branch is absent, or an ancestor of the trunk",
825 )
826 .step(step.name),
827 ));
828 }
829 }
830 run_forge_step(engine, step)
831 }
832 "bot-secrets" => {
833 let key = match engine.ctx.forge {
839 Forge::Github => key_file_for(engine)?.map(|key| key.bytes.clone()),
843 Forge::Gitlab => None,
844 };
845 let provided = match engine.ctx.forge {
846 Forge::Github => secrets::value_of("RK_BOT_APP_ID").is_some() && key.is_some(),
849 Forge::Gitlab => secrets::value_of("RK_BOT_TOKEN").is_some(),
850 };
851 let state = observe_with(engine, step.name)?;
852 if !provided {
853 if state.satisfied() {
854 return Ok(Done::Satisfied(state_detail(&state)));
855 }
856 let wanted = match engine.ctx.forge {
857 Forge::Github => {
858 "export RK_BOT_APP_ID and RK_BOT_PRIVATE_KEY_FILE, the second naming the .pem; rk forge github carries the walkthrough"
859 }
860 Forge::Gitlab => {
861 "rk setup step install-bot --apply stores the token, or export RK_BOT_TOKEN to rotate one"
862 }
863 };
864 return Err(RkError::refusal(
865 Diagnostic::new(
866 Reason::PrerequisiteUnmet,
867 "bot-secrets has no credentials to store",
868 )
869 .expected("the bot credentials in the environment, the key as a path")
870 .action(wanted.to_owned())
871 .step(step.name),
872 ));
873 }
874 if let Some(journal) = &mut engine.journal {
875 for name in SECRET_VARS {
876 if secrets::value_of(name).is_some() {
877 journal.record_secret(name, true, "environment");
878 }
879 }
880 if key.is_some() {
881 journal.record_secret(secrets::PRIVATE_KEY_FILE, true, "file");
882 }
883 }
884 let stdin = key;
888 run_forge_step_with(engine, step, stdin, Vec::new())
889 }
890 "protections-check" => {
891 let (outcome, _) = run_script(engine, step)?;
892 if !outcome.success() {
893 return Err(classify_failure(engine, step, &outcome));
894 }
895 match observe_with(engine, step.name)? {
899 StepState::Satisfied { detail, limitation } => {
900 Ok(Done::Passed(limitation.map_or_else(
901 || detail.clone(),
902 |limit| format!("{detail} (limitation: {limit})"),
903 )))
904 }
905 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
906 Err(RkError::refusal(
907 Diagnostic::new(
908 Reason::StateDrift,
909 format!("protections-check passed its script and the observation disagrees: {detail}"),
910 )
911 .expected(step.proves.to_owned())
912 .step(step.name),
913 ))
914 }
915 StepState::Unknown { detail } => Err(RkError::refusal(
918 Diagnostic::new(
919 Reason::ForgeTemporary,
920 format!(
921 "protections-check passed its script and the readback could not confirm it: {detail}"
922 ),
923 )
924 .expected(step.proves.to_owned())
925 .action("check authentication and connectivity, then rerun")
926 .step(step.name),
927 )),
928 }
929 }
930 "install-bot" if engine.ctx.forge == Forge::Github => {
936 match observe_with(engine, step.name)? {
937 StepState::Satisfied { detail, .. } => {
938 return Ok(Done::Satisfied(detail));
939 }
940 StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
941 StepState::Unknown { detail } => {
942 return Err(RkError::refusal(
943 Diagnostic::new(
944 Reason::ForgeTemporary,
945 format!("{} cannot observe the current state: {detail}", step.name),
946 )
947 .expected("a readable forge answer before anything mutates")
948 .action("check the App credentials and connectivity, then rerun")
949 .step(step.name),
950 ));
951 }
952 }
953 let installation = github_installation_id(engine, step)?;
954 run_forge_step_with(
955 engine,
956 step,
957 None,
958 vec![("RK_BOT_INSTALLATION".into(), installation.into())],
959 )
960 }
961 _ => {
962 if step.mutates == Mutates::Forge {
963 match observe_with(engine, step.name)? {
968 StepState::Satisfied { detail, limitation } => {
969 let detail = if step.name == "private-vulnerability-reporting" {
970 limitation.map_or_else(
971 || detail.clone(),
972 |limit| format!("{detail} (limitation: {limit})"),
973 )
974 } else {
975 detail
976 };
977 return Ok(Done::Satisfied(detail));
978 }
979 StepState::Inapplicable { detail }
980 if step.name == "private-vulnerability-reporting" =>
981 {
982 return Ok(Done::Skipped(detail));
983 }
984 StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
985 StepState::Unknown { detail } => {
986 return Err(RkError::refusal(
987 Diagnostic::new(
988 Reason::ForgeTemporary,
989 format!("{} cannot observe the current state: {detail}", step.name),
990 )
991 .expected("a readable forge answer before anything mutates")
992 .action("check authentication and connectivity, then rerun")
993 .step(step.name),
994 ));
995 }
996 }
997 }
998 run_forge_step(engine, step)
999 }
1000 }
1001}
1002
1003fn github_installation_id(engine: &mut Engine, step: &StepSpec) -> Result<String, RkError> {
1011 let refuse = |message: String, action: &str| {
1012 RkError::refusal(
1013 Diagnostic::new(Reason::PrerequisiteUnmet, message)
1014 .expected("the App installed on the repository's owner")
1015 .action(action.to_owned())
1016 .step(step.name),
1017 )
1018 };
1019 let jwt = match app_jwt_for(engine)? {
1020 Ok(jwt) => jwt,
1021 Err(detail) => {
1022 return Err(refuse(
1023 format!("install-bot has no App token: {detail}"),
1024 app_jwt::REMEDIATION,
1025 ));
1026 }
1027 };
1028 let owner = engine
1029 .ctx
1030 .repo
1031 .split('/')
1032 .next()
1033 .unwrap_or_default()
1034 .to_owned();
1035 let ctx = clone_ctx(&engine.ctx);
1036 for path in [
1037 format!("users/{owner}/installation"),
1038 format!("orgs/{owner}/installation"),
1039 ] {
1040 match app_jwt::api_get(&ctx, &jwt, &path) {
1041 AppApi::Ok(body) => {
1042 return body["id"].as_i64().map(|id| id.to_string()).ok_or_else(|| {
1043 refuse(
1044 format!("the forge answered {path} without an installation id"),
1045 "check RK_BOT_APP_ID and the key file name the same App",
1046 )
1047 });
1048 }
1049 AppApi::Missing => {}
1050 AppApi::Refused(detail) => {
1051 return Err(refuse(
1052 detail,
1053 "check RK_BOT_APP_ID and the key file name the same App",
1054 ));
1055 }
1056 AppApi::Failed(detail) => {
1057 return Err(RkError::refusal(
1058 Diagnostic::new(
1059 Reason::ForgeTemporary,
1060 format!("install-bot cannot read the App's installation: {detail}"),
1061 )
1062 .action("check connectivity, then rerun")
1063 .step(step.name),
1064 ));
1065 }
1066 }
1067 }
1068 Err(refuse(
1069 format!("the App has no installation on {owner}"),
1070 "install the App on the account first; the setup guide's step 5 walks it",
1071 ))
1072}
1073
1074fn run_forge_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
1076 run_forge_step_with(engine, step, None, Vec::new())
1077}
1078
1079fn run_forge_step_with(
1082 engine: &mut Engine,
1083 step: &StepSpec,
1084 stdin: Option<Zeroizing<Vec<u8>>>,
1085 extra_env: Vec<(OsString, OsString)>,
1086) -> Result<Done, RkError> {
1087 let (outcome, _) = run_script_with(engine, step, stdin, extra_env)?;
1088 if !outcome.success() {
1089 return Err(classify_failure(engine, step, &outcome));
1090 }
1091 let state = observe_with(engine, step.name)?;
1092 match state {
1093 StepState::Satisfied { detail, limitation } => Ok(Done::Changed(detail, limitation)),
1094 StepState::Inapplicable { detail } if step.name == "private-vulnerability-reporting" => {
1095 Ok(Done::Skipped(detail))
1096 }
1097 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1098 Err(RkError::refusal(
1099 Diagnostic::new(
1100 Reason::StateDrift,
1101 format!(
1102 "{} ran and its postcondition does not hold: {detail}",
1103 step.name
1104 ),
1105 )
1106 .expected(step.proves.to_owned())
1107 .step(step.name),
1108 ))
1109 }
1110 StepState::Unknown { detail } => Err(RkError::refusal(
1114 Diagnostic::new(
1115 Reason::ForgeTemporary,
1116 format!(
1117 "{} ran and the readback could not confirm it: {detail}",
1118 step.name
1119 ),
1120 )
1121 .expected(step.proves.to_owned())
1122 .action(format!(
1123 "rk setup step {} --target {} --apply re-asserts and re-proves it",
1124 step.name, engine.ctx.target
1125 ))
1126 .step(step.name),
1127 )),
1128 }
1129}
1130
1131fn observe_with(engine: &mut Engine, step: &str) -> Result<StepState, RkError> {
1138 if step == "install-bot" && engine.ctx.forge == Forge::Github {
1139 let jwt = match app_jwt_for(engine)? {
1140 Ok(jwt) => jwt,
1141 Err(detail) => return Ok(StepState::Unknown { detail }),
1142 };
1143 return Ok(observe::github_install_bot(&engine.ctx, &jwt));
1144 }
1145 let ctx = clone_ctx(&engine.ctx);
1146 let mut runner = |exec: &Exec| engine.exec(exec, false);
1147 observe::observe(&ctx, step, &mut runner)
1148}
1149
1150fn key_file_for(engine: &mut Engine) -> Result<Option<&secrets::KeyFile>, RkError> {
1156 if engine.key.is_none() {
1157 engine.key = secrets::resolve_key_file(&engine.ctx.target)?;
1158 if let Some(key) = &engine.key {
1159 engine.secrets.push(key.bytes.clone());
1160 }
1161 }
1162 Ok(engine.key.as_ref())
1163}
1164
1165fn app_jwt_for(engine: &mut Engine) -> Result<Result<String, String>, RkError> {
1175 if let Some(jwt) = &engine.app_jwt {
1176 return Ok(Ok(jwt.clone()));
1177 }
1178 let app_id = app_jwt::app_id(engine.ctx.bot_app_id())?;
1179 let key_bytes = key_file_for(engine)?.map(|key| key.bytes.clone());
1180 let (Some(app_id), Some(key_bytes)) = (app_id, key_bytes) else {
1181 return Ok(Err(format!(
1182 "the installation is readable only to the App itself; {}",
1183 app_jwt::REMEDIATION
1184 )));
1185 };
1186 let credentials = app_jwt::AppCredentials { app_id, key_bytes };
1187 let ctx = clone_ctx(&engine.ctx);
1188 Ok(match app_jwt::mint(&ctx, &credentials) {
1189 Ok(jwt) => {
1190 engine
1191 .secrets
1192 .push(Zeroizing::new(jwt.clone().into_bytes()));
1193 if let Some(signature) = jwt.rsplit('.').next() {
1194 engine
1195 .secrets
1196 .push(Zeroizing::new(signature.as_bytes().to_vec()));
1197 }
1198 engine.app_jwt = Some(jwt.clone());
1199 Ok(jwt)
1200 }
1201 Err(detail) => Err(detail),
1202 })
1203}
1204
1205fn state_detail(state: &StepState) -> String {
1206 match state {
1207 StepState::Satisfied { detail, .. }
1208 | StepState::Unsatisfied { detail }
1209 | StepState::Inapplicable { detail }
1210 | StepState::Unknown { detail } => detail.clone(),
1211 }
1212}
1213
1214fn run_script(engine: &mut Engine, step: &StepSpec) -> Result<(Outcome, PathBuf), RkError> {
1217 run_script_with(engine, step, None, Vec::new())
1218}
1219
1220fn run_script_with(
1226 engine: &mut Engine,
1227 step: &StepSpec,
1228 stdin: Option<Zeroizing<Vec<u8>>>,
1229 extra_env: Vec<(OsString, OsString)>,
1230) -> Result<(Outcome, PathBuf), RkError> {
1231 let rel = format!("{}/{}", engine.ctx.forge.as_str(), step.name);
1232 let bytes = embedded::SETUP
1233 .get_file(&rel)
1234 .map(include_dir::File::contents)
1235 .ok_or_else(|| RkError::Other(anyhow::anyhow!("no embedded script at setup/{rel}")))?;
1236 let journal = engine
1237 .journal
1238 .as_mut()
1239 .ok_or_else(|| RkError::Other(anyhow::anyhow!("an apply always has a journal")))?;
1240 let dir = journal.scripts_dir().join(engine.ctx.forge.as_str());
1241 fs::create_dir_all(&dir)?;
1242 restrict(&dir, 0o700);
1243 let path = dir.join(step.name);
1244 fs::write(&path, bytes)?;
1245 restrict(&path, 0o600);
1246 let written = fs::read(&path)?;
1247 let digest = Digest::of(&written);
1248 if digest != Digest::of(bytes) {
1249 return Err(RkError::Other(anyhow::anyhow!(
1250 "the materialized script at {} differs from the embedded bytes",
1251 path.display()
1252 )));
1253 }
1254 journal.record_script(format!("scripts/{rel}"), digest.to_string());
1255 let mut env = engine.ctx.child_env(step.name);
1256 env.extend(extra_env);
1257 let exec = Exec {
1258 program: crate::probes::sh_bin(),
1259 args: vec![path.clone().into_os_string()],
1260 env,
1261 cwd: engine.ctx.target.as_std_path().to_path_buf(),
1262 stdin,
1263 };
1264 let outcome = engine.exec(&exec, true)?;
1265 Ok((outcome, path))
1266}
1267
1268fn classify_failure(engine: &Engine, step: &StepSpec, outcome: &Outcome) -> RkError {
1273 let stderr = String::from_utf8_lossy(&outcome.stderr);
1274 let last = if outcome.exit_code >= 128 {
1278 format!("killed by signal {}", outcome.exit_code - 128)
1279 } else {
1280 stderr
1281 .lines()
1282 .rev()
1283 .find(|line| !line.trim().is_empty())
1284 .unwrap_or("no output")
1285 .to_owned()
1286 };
1287 let reason = if (engine.ctx.forge == Forge::Github && outcome.exit_code == 4)
1288 || stderr.contains("HTTP 401")
1289 {
1290 Reason::ForgeAuthentication
1291 } else if stderr.contains("HTTP 403") {
1292 Reason::ForgePermission
1293 } else if stderr.contains("HTTP 429") || stderr.contains("rate limit") {
1294 Reason::ForgeRateLimit
1295 } else {
1296 Reason::SubprocessFailed
1297 };
1298 let diagnostic = Diagnostic::new(reason, format!("the forge refused '{}': {last}", step.name))
1299 .expected(step.proves.to_owned())
1300 .action(format!(
1301 "rk setup step {} --target {} --apply",
1302 step.name, engine.ctx.target
1303 ))
1304 .step(step.name);
1305 let diagnostic = match reason {
1306 Reason::ForgePermission => diagnostic.expected(format!(
1307 "repository administration write on {} for the authenticated account",
1308 engine.ctx.repo
1309 )),
1310 _ => diagnostic,
1311 };
1312 match reason {
1313 Reason::SubprocessFailed => RkError::subprocess(diagnostic),
1314 _ => RkError::refusal(diagnostic),
1315 }
1316}
1317
1318fn attach_progress(
1321 error: RkError,
1322 done: &[(String, String)],
1323 failed: &StepSpec,
1324 steps: &[&StepSpec],
1325) -> RkError {
1326 let remaining = steps.len().saturating_sub(done.len() + 1);
1327 let state = format!(
1328 "{} completed; {} failed; {remaining} not attempted",
1329 step_count(done.len()),
1330 failed.name
1331 );
1332 match error {
1333 RkError::Refusal(mut diagnostic) => {
1334 diagnostic.target_state.get_or_insert(state);
1335 RkError::Refusal(diagnostic)
1336 }
1337 RkError::Subprocess(mut diagnostic) => {
1338 diagnostic.target_state.get_or_insert(state);
1339 RkError::Subprocess(diagnostic)
1340 }
1341 other => other,
1342 }
1343}
1344
1345fn check(out: Output, ctx: Ctx) -> Result<(), RkError> {
1349 let mut engine = Engine::open(out, ctx, "setup check", false)?;
1350 let mut unsatisfied = 0usize;
1351 let mut unverifiable = 0usize;
1352 for step in &STEPS {
1353 let clock = Instant::now();
1354 let state = observe_with(&mut engine, step.name)?;
1355 let (label, wire) = match &state {
1356 StepState::Satisfied { .. } => ("ok", "satisfied"),
1357 StepState::Inapplicable { .. } => ("skipped", "skipped"),
1360 StepState::Unsatisfied { .. } => {
1361 unsatisfied += 1;
1362 ("unsatisfied", "unsatisfied")
1363 }
1364 StepState::Unknown { .. } => {
1367 unverifiable += 1;
1368 ("unknown", "unknown")
1369 }
1370 };
1371 let mut line = format!("{label} {} — {}", step.name, state_detail(&state));
1372 if let StepState::Satisfied {
1373 limitation: Some(limit),
1374 ..
1375 } = &state
1376 {
1377 use std::fmt::Write as _;
1378 let _ = write!(line, " (limitation: {limit})");
1379 }
1380 engine.out.result_line(line);
1381 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
1382 finished.status = Some(wire.into());
1383 finished.duration_ms = Some(elapsed_ms(clock));
1384 engine.emit(&finished);
1385 }
1386 if unsatisfied > 0 || unverifiable > 0 {
1387 let error = RkError::check_failed(
1388 Diagnostic::new(
1389 Reason::StateDrift,
1390 format!(
1391 "{} {} not satisfied and {unverifiable} could not be verified",
1392 step_count(unsatisfied),
1393 if unsatisfied == 1 { "is" } else { "are" }
1394 ),
1395 )
1396 .expected("every step's proof column to hold and to be readable")
1397 .action(format!(
1398 "rk setup --target {} --apply re-asserts them",
1399 engine.ctx.target
1400 )),
1401 );
1402 return Err(fail(&mut engine, error));
1403 }
1404 engine
1405 .out
1406 .next(&["rk guide release orders the first release".to_owned()]);
1407 engine.finish(0, None);
1408 Ok(())
1409}
1410
1411fn restrict(path: &std::path::Path, mode: u32) {
1414 #[cfg(unix)]
1415 {
1416 use std::os::unix::fs::PermissionsExt as _;
1417 let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
1418 }
1419 #[cfg(not(unix))]
1420 let _ = (path, mode);
1421}
1422
1423fn guard_sh() -> Result<(), RkError> {
1426 let ok = std::process::Command::new(crate::probes::sh_bin())
1427 .args(["-c", "exit 0"])
1428 .status()
1429 .is_ok_and(|status| status.success());
1430 if ok {
1431 Ok(())
1432 } else {
1433 Err(RkError::refusal(
1434 Diagnostic::new(Reason::PrerequisiteUnmet, "no POSIX sh runs on this host")
1435 .expected("a working sh on PATH; every step spawns through it")
1436 .action("install a POSIX shell, then rerun")
1437 .target_state("nothing was run and nothing changed"),
1438 ))
1439 }
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444 #[test]
1447 fn a_step_count_carries_a_noun_that_agrees_with_it() {
1448 assert_eq!(super::step_count(0), "0 steps");
1449 assert_eq!(super::step_count(1), "1 step");
1450 assert_eq!(super::step_count(2), "2 steps");
1451 }
1452}