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