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