1use std::time::{Duration, Instant};
14
15use camino::Utf8Path;
16use serde::Serialize;
17use serde_json::Value;
18
19use crate::cli::release::{ReleaseAction, ReleaseArgs};
20use crate::commands::lines::{self, LineRow};
21use crate::detect::Forge;
22use crate::diagnostic::{Diagnostic, Reason};
23use crate::error::RkError;
24use crate::landing::manifest::{self, CheckoutMode, Integration, Manifest, Style};
25use crate::output::Output;
26use crate::profile::ReleaseMode;
27use crate::release_state::{
28 self, Answer, Api, ForgeCli, LastRelease, Merged, Page, Pair, Request, Run, Symptom,
29};
30
31const TAG_PAGE: usize = 100;
33
34const TAG_PAGES: usize = 50;
36
37const SCHEMA: &str = "rk.release-status/1";
39
40const WAIT_INTERVAL_MS: u64 = 15_000;
43
44const WAIT_INTERVAL_VAR: &str = "RK_RELEASE_WAIT_INTERVAL_MS";
46
47#[derive(Debug, Serialize)]
49struct ForgeState {
50 name: Option<String>,
52 repo: Option<String>,
54 read: Answer,
56 reason: Option<String>,
58}
59
60#[derive(Debug, Serialize)]
62struct Wait {
63 sha: String,
65 publish: Option<Run>,
67 artifact: Option<Run>,
69 tag: Option<String>,
71 timed_out: bool,
73}
74
75#[derive(Debug, Serialize)]
77struct Report {
78 schema: &'static str,
80 landed: bool,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 style: Option<Style>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 integration: Option<Integration>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 checkout_mode: Option<CheckoutMode>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 trunk: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 forge: Option<ForgeState>,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 request: Option<Request>,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 last_release: Option<LastRelease>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 lines: Option<Vec<LineRow>>,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 symptoms: Option<Vec<Symptom>>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 wait: Option<Wait>,
112}
113
114pub fn run(args: &ReleaseArgs) -> Result<(), RkError> {
120 match &args.action {
121 ReleaseAction::Status {
122 target,
123 wait,
124 timeout,
125 json,
126 } => status(target, wait.as_deref(), *timeout, Output::new(*json)),
127 }
128}
129
130struct Observed {
132 request: Request,
133 last: LastRelease,
134 symptoms: Vec<Symptom>,
135 wait: Option<Wait>,
136}
137
138#[allow(
139 clippy::too_many_lines,
140 reason = "one pass reads the record, the lines, and the forge, and renders the one report"
141)]
142fn status(target: &Utf8Path, wait: Option<&str>, timeout: u64, out: Output) -> Result<(), RkError> {
143 if !target.is_dir() {
144 return Err(RkError::missing(
145 Diagnostic::new(
146 Reason::TargetNotFound,
147 format!("target {target} is not a directory"),
148 )
149 .expected("an existing repository to report on"),
150 ));
151 }
152 let Some(manifest) = manifest::load(target)? else {
153 out.result_line(format!("no landing at {target}"));
154 out.next(&[format!(
155 "rk status --target {target} classifies the target; the rk-setup skill lands the convention"
156 )]);
157 return out.emit(&Report {
158 schema: SCHEMA,
159 landed: false,
160 style: None,
161 integration: None,
162 checkout_mode: None,
163 trunk: None,
164 forge: None,
165 request: None,
166 last_release: None,
167 lines: None,
168 symptoms: None,
169 wait: None,
170 });
171 };
172 let lines = lines::rows(target)?;
173 let local = local_release_tags(target);
174 let pair = pair_of(target, &manifest);
175 let (forge, observed) = match &pair {
176 Err(reason) => (
177 ForgeState {
178 name: manifest.profile.forge.clone(),
179 repo: (!manifest.parameters.repo.is_empty())
180 .then(|| manifest.parameters.repo.clone()),
181 read: Answer::NotApplicable,
182 reason: Some(reason.clone()),
183 },
184 Observed {
185 request: Request::none(),
186 last: LastRelease {
187 tag: newest_local(&local),
188 published: Answer::NotApplicable,
189 artifacts_verified: Answer::NotApplicable,
190 },
191 symptoms: Vec::new(),
192 wait: None,
193 },
194 ),
195 Ok(pair) => match ForgeCli::resolve(pair.forge, target) {
196 Err(error) => (
197 ForgeState {
198 name: Some(pair.forge.as_str().to_owned()),
199 repo: Some(pair.repo.clone()),
200 read: Answer::Unknown,
201 reason: Some(error.to_string()),
202 },
203 Observed {
204 request: Request::unknown(),
205 last: LastRelease {
206 tag: newest_local(&local),
207 published: Answer::Unknown,
208 artifacts_verified: if pair.artifacts {
209 Answer::Unknown
210 } else {
211 Answer::NotApplicable
212 },
213 },
214 symptoms: Vec::new(),
215 wait: None,
216 },
217 ),
218 Ok(cli) => {
219 let mut reader = Reader {
220 cli: &cli,
221 pair,
222 read: Answer::Yes,
223 failures: std::cell::RefCell::new(Vec::new()),
224 };
225 let line_names: Vec<&str> = lines.iter().map(|row| row.line.as_str()).collect();
226 let observed = reader.observe(&local, wait, timeout, &line_names);
227 (
228 ForgeState {
229 name: Some(pair.forge.as_str().to_owned()),
230 repo: Some(pair.repo.clone()),
231 read: reader.read,
232 reason: reader.reason(),
233 },
234 observed,
235 )
236 }
237 },
238 };
239 if wait.is_some() && observed.wait.is_none() {
240 return Err(RkError::refusal(
241 Diagnostic::new(
242 Reason::ObservationIncomplete,
243 format!(
244 "--wait needs the forge's runs, and this runtime cannot read them: {}",
245 forge
246 .reason
247 .as_deref()
248 .unwrap_or("the forge did not answer")
249 ),
250 )
251 .expected("a forge CLI that can read this project's runs")
252 .action("rerun where the forge CLI is authenticated for this project")
253 .target_state("unchanged"),
254 ));
255 }
256 render(out, &manifest, &forge, &observed, &lines);
257 let failure = observed.wait.as_ref().and_then(wait_failure);
258 out.emit(&Report {
259 schema: SCHEMA,
260 landed: true,
261 style: manifest.profile.release.style,
262 integration: Some(manifest.git.integration),
263 checkout_mode: Some(manifest.git.checkout_mode),
264 trunk: Some(manifest.git.trunk),
265 forge: Some(forge),
266 request: Some(observed.request),
267 last_release: Some(observed.last),
268 lines: Some(lines),
269 symptoms: Some(observed.symptoms),
270 wait: observed.wait,
271 })?;
272 failure.map_or(Ok(()), Err)
273}
274
275fn pair_of(target: &Utf8Path, manifest: &Manifest) -> Result<Pair, String> {
277 if manifest.profile.release.mode != ReleaseMode::Automatic {
278 return Err("the record selects no automated release".to_owned());
279 }
280 let driver = manifest
281 .profile
282 .release
283 .driver
284 .clone()
285 .ok_or_else(|| "the record names no release driver".to_owned())?;
286 let forge = manifest
287 .profile
288 .forge
289 .as_deref()
290 .and_then(Forge::parse)
291 .ok_or_else(|| "the record names no forge this binary reads".to_owned())?;
292 if manifest.parameters.repo.is_empty() {
293 return Err("the record names no forge repository".to_owned());
294 }
295 let artifacts = match (forge, driver.as_str()) {
296 (Forge::Github, "rust") => manifest.file("dist-workspace.toml").is_some(),
297 (Forge::Github | Forge::Gitlab, "bash") => true,
298 _ => false,
299 };
300 Ok(Pair {
301 package: release_state::package_name(target.as_std_path(), &driver),
302 registry_answer: manifest.profile.release.registry,
303 driver,
304 forge,
305 repo: manifest.parameters.repo.clone(),
306 trunk: manifest.git.trunk.clone(),
307 artifacts,
308 })
309}
310
311fn local_release_tags(target: &Utf8Path) -> Vec<(String, String)> {
316 let mut command = std::process::Command::new(crate::probes::git_bin());
317 for var in crate::maintenance::GIT_HOOK_VARS {
318 command.env_remove(var);
319 }
320 let Ok(output) = command
321 .arg("-C")
322 .arg(target.as_std_path())
323 .args([
324 "for-each-ref",
325 "--format=%(refname:strip=2) %(objectname) %(*objectname)",
326 "refs/tags/v*",
327 ])
328 .output()
329 else {
330 return Vec::new();
331 };
332 if !output.status.success() {
333 return Vec::new();
334 }
335 String::from_utf8_lossy(&output.stdout)
336 .lines()
337 .filter_map(|line| {
338 let mut fields = line.split_whitespace();
339 let tag = fields.next()?;
340 let object = fields.next()?;
341 let commit = fields.next().unwrap_or(object);
342 tag.strip_prefix('v')
343 .is_some_and(release_state::is_version)
344 .then(|| (tag.to_owned(), commit.to_owned()))
345 })
346 .collect()
347}
348
349fn borrowed(tags: &[(String, String)]) -> Vec<(&str, &str)> {
351 tags.iter()
352 .map(|(tag, commit)| (tag.as_str(), commit.as_str()))
353 .collect()
354}
355
356fn newest_local(local: &[(String, String)]) -> Option<String> {
358 release_state::newest_release(local.iter().map(|(tag, _)| tag.as_str()))
359}
360
361fn wait_failure(wait: &Wait) -> Option<RkError> {
364 if wait.timed_out {
365 return Some(RkError::refusal(
366 Diagnostic::new(
367 Reason::ObservationIncomplete,
368 format!(
369 "the runs on {} did not conclude before the timeout",
370 wait.sha
371 ),
372 )
373 .expected("the publish run, and the artifact run where the pair builds one, concluded")
374 .action("rerun with a longer --timeout, or read the runs on the forge")
375 .target_state("unchanged"),
376 ));
377 }
378 let failed = [&wait.publish, &wait.artifact]
379 .into_iter()
380 .flatten()
381 .find(|run| run.concluded() && !run.succeeded())?;
382 Some(RkError::check_failed(
383 Diagnostic::new(
384 Reason::StateDrift,
385 format!(
386 "run {} on {} concluded {}",
387 failed.id,
388 wait.sha,
389 failed.conclusion.as_deref().unwrap_or("without success")
390 ),
391 )
392 .expected("every run on the release commit concluded success")
393 .action("rk method recovery names the path for the symptom this report shows"),
394 ))
395}
396
397struct Reader<'a> {
399 cli: &'a ForgeCli,
400 pair: &'a Pair,
401 read: Answer,
402 failures: std::cell::RefCell<Vec<String>>,
403}
404
405impl Reader<'_> {
406 fn reason(&self) -> Option<String> {
407 self.failures.borrow().first().cloned()
408 }
409
410 fn api(&self, path: &str) -> Option<Value> {
413 match self.cli.api(path) {
414 Api::Ok(body) => Some(body),
415 Api::Missing => None,
416 Api::Failed(reason) => {
417 self.failures.borrow_mut().push(reason);
418 None
419 }
420 }
421 }
422
423 fn api_answer(&self, path: &str) -> Api {
425 let answer = self.cli.api(path);
426 if let Api::Failed(reason) = &answer {
427 self.failures.borrow_mut().push(reason.clone());
428 }
429 answer
430 }
431
432 fn project(&self) -> String {
433 release_state::gitlab_project(&self.pair.repo)
434 }
435
436 fn observe(
437 &mut self,
438 local: &[(String, String)],
439 wait: Option<&str>,
440 timeout: u64,
441 lines: &[&str],
442 ) -> Observed {
443 let request = self.open_request();
444 let mut wait = wait.map(|sha| self.wait(sha, timeout));
445 let tags = self.tags();
448 if let Some(wait) = wait.as_mut().filter(|wait| wait.tag.is_none()) {
449 wait.tag = tags.as_ref().and_then(|tags| {
450 tags.iter()
451 .find(|(name, commit)| *commit == wait.sha && name.starts_with('v'))
452 .map(|(name, _)| name.clone())
453 });
454 }
455 let unread = tags.is_none();
458 let tag = wait.as_ref().map_or_else(
461 || {
462 tags.as_ref().map_or_else(
463 || newest_local(local),
464 |tags| {
465 release_state::newest_release(tags.iter().map(|(name, _)| name.as_str()))
466 },
467 )
468 },
469 |wait| wait.tag.clone(),
470 );
471 let from_clone = unread && wait.is_none();
475 let page = if from_clone {
476 None
477 } else {
478 tag.as_deref().and_then(|tag| self.page(tag))
479 };
480 let last = LastRelease {
481 published: if from_clone {
482 Answer::Unknown
483 } else {
484 tag.as_deref().map_or(
485 if unread {
486 Answer::Unknown
487 } else {
488 Answer::NotApplicable
489 },
490 |tag| self.published(tag, page.as_ref()),
491 )
492 },
493 artifacts_verified: if !self.pair.artifacts {
494 Answer::NotApplicable
495 } else if from_clone {
496 Answer::Unknown
497 } else if tag.is_none() {
498 if unread { Answer::Unknown } else { Answer::No }
499 } else {
500 page.as_ref().map_or(Answer::Unknown, Page::verified)
501 },
502 tag,
503 };
504 let merged = self.merged().map(|(number, version)| {
505 let name = format!("v{version}");
506 let tagged = tags.as_ref().map_or(Answer::Unknown, |tags| {
507 Answer::of(tags.iter().any(|(tag, _)| *tag == name))
508 });
509 let published = if last.tag.as_deref() == Some(name.as_str()) {
510 last.published
511 } else if tagged == Answer::No && self.pair.registry().is_none() {
512 Answer::No
513 } else {
514 self.published(&name, None)
515 };
516 Merged {
517 number,
518 version,
519 tagged,
520 published,
521 }
522 });
523 let mut symptoms =
524 release_state::symptoms(merged.as_ref(), &last, page.as_ref(), self.pair.artifacts);
525 if let Some(tags) = tags.as_ref() {
526 let names: Vec<&str> = tags.iter().map(|(name, _)| name.as_str()).collect();
527 symptoms.extend(release_state::orphan_tags(&names, lines, |tag| {
528 let read = if last.tag.as_deref() == Some(tag) {
531 page.clone()
532 } else {
533 self.page(tag)
534 };
535 read.map_or(Answer::Unknown, |page| self.complete(&page))
536 }));
537 symptoms.extend(release_state::local_tag_drift(
538 &borrowed(local),
539 &borrowed(tags),
540 ));
541 }
542 if !self.failures.borrow().is_empty() {
543 self.read = Answer::Unknown;
544 }
545 Observed {
546 request,
547 last,
548 symptoms,
549 wait,
550 }
551 }
552
553 const fn complete(&self, page: &Page) -> Answer {
556 Answer::of(page.exists && (!self.pair.artifacts || !page.assets.is_empty()))
557 }
558
559 fn published(&self, tag: &str, page: Option<&Page>) -> Answer {
562 let version = tag.strip_prefix('v').unwrap_or(tag);
563 match (self.pair.registry(), self.pair.package.as_deref()) {
564 (Some(registry), Some(package)) => {
565 release_state::registry_serves(registry, package, Some(version))
566 }
567 (Some(_), None) => Answer::Unknown,
568 (None, _) => page
569 .cloned()
570 .or_else(|| self.page(tag))
571 .map_or(Answer::Unknown, |page| Answer::of(page.exists)),
572 }
573 }
574
575 fn open_request(&self) -> Request {
576 let Some(prefix) = self.pair.request_prefix() else {
577 return Request::none();
578 };
579 match self.pair.forge {
580 Forge::Github => self.github_request(prefix),
581 Forge::Gitlab => self.gitlab_request(prefix),
582 }
583 }
584
585 fn github_request(&self, prefix: &str) -> Request {
586 let repo = &self.pair.repo;
587 let Some(body) = self.api(&format!(
588 "repos/{repo}/pulls?state=open&base={}&per_page=100",
589 self.pair.trunk
590 )) else {
591 return Request::unknown();
592 };
593 let Some(request) = body.as_array().and_then(|requests| {
594 requests.iter().find(|request| {
595 request["head"]["ref"]
596 .as_str()
597 .is_some_and(|head| head.starts_with(prefix))
598 })
599 }) else {
600 return Request::none();
601 };
602 let sha = request["head"]["sha"].as_str().unwrap_or_default();
603 let checks = self
604 .api(&format!(
605 "repos/{repo}/commits/{sha}/check-runs?per_page=100"
606 ))
607 .map_or(release_state::Checks::Unknown, |body| {
608 release_state::github_checks(&body)
609 });
610 let behind_trunk = self
611 .api(&format!("repos/{repo}/compare/{}...{sha}", self.pair.trunk))
612 .and_then(|body| body["behind_by"].as_u64())
613 .map_or(Answer::Unknown, |behind| Answer::of(behind > 0));
614 Request {
615 state: "open",
616 number: request["number"].as_u64(),
617 proposed_version: request["title"]
618 .as_str()
619 .and_then(release_state::proposed_version),
620 armed: Answer::of(!request["auto_merge"].is_null()),
621 checks,
622 behind_trunk,
623 }
624 }
625
626 fn gitlab_request(&self, prefix: &str) -> Request {
627 let project = self.project();
628 let Some(body) = self.api(&format!(
629 "projects/{project}/merge_requests?state=opened&target_branch={}&per_page=100",
630 self.pair.trunk
631 )) else {
632 return Request::unknown();
633 };
634 let Some(request) = body.as_array().and_then(|requests| {
635 requests.iter().find(|request| {
636 request["source_branch"]
637 .as_str()
638 .is_some_and(|head| head.starts_with(prefix))
639 })
640 }) else {
641 return Request::none();
642 };
643 let iid = request["iid"].as_u64();
644 let detail = iid.and_then(|iid| {
645 self.api(&format!(
646 "projects/{project}/merge_requests/{iid}?include_diverged_commits_count=true"
647 ))
648 });
649 let armed = request["merge_when_pipeline_succeeds"]
650 .as_bool()
651 .or_else(|| request["auto_merge_enabled"].as_bool())
652 .map_or(Answer::Unknown, Answer::of);
653 Request {
654 state: "open",
655 number: iid,
656 proposed_version: request["title"]
657 .as_str()
658 .and_then(release_state::proposed_version),
659 armed,
660 checks: detail
661 .as_ref()
662 .map_or(release_state::Checks::Unknown, |detail| {
663 release_state::gitlab_checks(detail["head_pipeline"]["status"].as_str())
664 }),
665 behind_trunk: detail
666 .as_ref()
667 .and_then(|detail| detail["diverged_commits_count"].as_u64())
668 .map_or(Answer::Unknown, |behind| Answer::of(behind > 0)),
669 }
670 }
671
672 fn merged(&self) -> Option<(u64, String)> {
675 let prefix = self.pair.request_prefix()?;
676 let (path, head, merged_at, number) = match self.pair.forge {
677 Forge::Github => (
678 format!(
679 "repos/{}/pulls?state=closed&base={}&sort=updated&direction=desc&per_page=30",
680 self.pair.repo, self.pair.trunk
681 ),
682 "/head/ref",
683 "/merged_at",
684 "/number",
685 ),
686 Forge::Gitlab => (
687 format!(
688 "projects/{}/merge_requests?state=merged&target_branch={}&order_by=updated_at&sort=desc&per_page=30",
689 self.project(),
690 self.pair.trunk
691 ),
692 "/source_branch",
693 "/merged_at",
694 "/iid",
695 ),
696 };
697 let body = self.api(&path)?;
698 body.as_array()?
699 .iter()
700 .filter(|request| {
701 request
702 .pointer(head)
703 .and_then(Value::as_str)
704 .is_some_and(|head| head.starts_with(prefix))
705 })
706 .filter_map(|request| {
707 let at = request.pointer(merged_at)?.as_str()?.to_owned();
708 let version = release_state::proposed_version(request["title"].as_str()?)?;
709 Some((at, request.pointer(number)?.as_u64()?, version))
710 })
711 .max_by(|left, right| left.0.cmp(&right.0))
712 .map(|(_, number, version)| (number, version))
713 }
714
715 fn tags(&self) -> Option<Vec<(String, String)>> {
719 let (path, commit) = match self.pair.forge {
720 Forge::Github => (
721 format!("repos/{}/tags?per_page={TAG_PAGE}", self.pair.repo),
722 "/commit/sha",
723 ),
724 Forge::Gitlab => (
725 format!(
726 "projects/{}/repository/tags?per_page={TAG_PAGE}",
727 self.project()
728 ),
729 "/commit/id",
730 ),
731 };
732 let mut tags = Vec::new();
733 for page in 1..=TAG_PAGES {
734 let body = if page == 1 {
735 self.api(&path)?
736 } else {
737 self.api(&format!("{path}&page={page}"))?
738 };
739 let entries = body.as_array()?;
740 tags.extend(entries.iter().filter_map(|tag| {
741 Some((
742 tag["name"].as_str()?.to_owned(),
743 tag.pointer(commit)?.as_str()?.to_owned(),
744 ))
745 }));
746 if entries.len() < TAG_PAGE {
747 return Some(tags);
748 }
749 }
750 None
751 }
752
753 fn page(&self, tag: &str) -> Option<Page> {
755 match self.pair.forge {
756 Forge::Github => {
757 let repo = &self.pair.repo;
758 let body = match self.api_answer(&format!("repos/{repo}/releases/tags/{tag}")) {
759 Api::Ok(body) => body,
760 Api::Missing => return Some(Page::default()),
761 Api::Failed(_) => return None,
762 };
763 let mut page = Page {
764 exists: true,
765 ..Page::default()
766 };
767 for asset in body["assets"].as_array().into_iter().flatten() {
768 let Some(name) = asset["name"].as_str() else {
769 continue;
770 };
771 page.assets.push(name.to_owned());
772 let Some(digest) = asset["digest"].as_str() else {
773 page.attestation_unknown = true;
774 continue;
775 };
776 match self.api_answer(&format!("repos/{repo}/attestations/{digest}")) {
777 Api::Ok(body)
778 if body["attestations"]
779 .as_array()
780 .is_some_and(|list| !list.is_empty()) => {}
781 Api::Ok(_) | Api::Missing => page.unattested.push(name.to_owned()),
782 Api::Failed(_) => page.attestation_unknown = true,
783 }
784 }
785 Some(page)
786 }
787 Forge::Gitlab => {
788 let body =
789 match self.api_answer(&format!("projects/{}/releases/{tag}", self.project())) {
790 Api::Ok(body) => body,
791 Api::Missing => return Some(Page::default()),
792 Api::Failed(_) => return None,
793 };
794 let names: Vec<String> = body["assets"]["links"]
795 .as_array()
796 .into_iter()
797 .flatten()
798 .filter_map(|link| link["name"].as_str().map(str::to_owned))
799 .collect();
800 let bundles: Vec<&String> = names
801 .iter()
802 .filter(|name| name.ends_with(".sigstore.json"))
803 .collect();
804 let assets: Vec<String> = names
805 .iter()
806 .filter(|name| !name.ends_with(".sigstore.json"))
807 .cloned()
808 .collect();
809 let unattested = assets
810 .iter()
811 .filter(|name| name.ends_with(".tar.gz"))
812 .filter(|name| {
813 !bundles
814 .iter()
815 .any(|bundle| **bundle == format!("{name}.sigstore.json"))
816 })
817 .cloned()
818 .collect();
819 Some(Page {
820 exists: true,
821 assets,
822 unattested,
823 attestation_unknown: false,
824 })
825 }
826 }
827 }
828
829 fn wait(&self, sha: &str, timeout: u64) -> Wait {
832 let interval = std::env::var(WAIT_INTERVAL_VAR)
833 .ok()
834 .and_then(|value| value.parse().ok())
835 .unwrap_or(WAIT_INTERVAL_MS);
836 let deadline = Instant::now() + Duration::from_secs(timeout);
837 loop {
838 let (publish, artifact) = self.runs(sha);
839 let concluded = publish.as_ref().is_some_and(Run::concluded)
840 && (!self.pair.artifacts || artifact.as_ref().is_some_and(Run::concluded));
841 let failed = [&publish, &artifact]
842 .into_iter()
843 .flatten()
844 .any(|run| run.concluded() && !run.succeeded());
845 if concluded || failed || Instant::now() >= deadline {
846 let tag = artifact
847 .as_ref()
848 .and_then(|run| run.head_branch.clone())
849 .filter(|branch| branch.starts_with('v'));
850 return Wait {
851 sha: sha.to_owned(),
852 timed_out: !concluded && !failed,
853 publish,
854 artifact,
855 tag,
856 };
857 }
858 std::thread::sleep(Duration::from_millis(interval));
859 }
860 }
861
862 fn runs(&self, sha: &str) -> (Option<Run>, Option<Run>) {
864 match self.pair.forge {
865 Forge::Github => {
866 let Some(body) = self.api(&format!(
867 "repos/{}/actions/runs?head_sha={sha}&per_page=100",
868 self.pair.repo
869 )) else {
870 return (None, None);
871 };
872 let publish = self
873 .pair
874 .publish_workflow()
875 .and_then(|workflow| release_state::github_run_on(&body, workflow, sha));
876 let artifact = self
877 .pair
878 .artifact_workflow()
879 .and_then(|workflow| release_state::github_run_on(&body, workflow, sha));
880 (publish, artifact)
881 }
882 Forge::Gitlab => {
883 let Some(body) = self.api(&format!(
884 "projects/{}/pipelines?sha={sha}&per_page=100",
885 self.project()
886 )) else {
887 return (None, None);
888 };
889 let trunk = self.pair.trunk.clone();
890 let publish =
891 release_state::gitlab_pipeline_on(&body, |reference| reference == trunk);
892 let artifact = self
893 .pair
894 .artifacts
895 .then(|| {
896 release_state::gitlab_pipeline_on(&body, |reference| {
897 reference.starts_with('v')
898 })
899 })
900 .flatten();
901 (publish, artifact)
902 }
903 }
904 }
905}
906
907fn render(
909 out: Output,
910 manifest: &Manifest,
911 forge: &ForgeState,
912 observed: &Observed,
913 lines: &[LineRow],
914) {
915 out.result_line(format!(
916 "release: {} style, {} integration, {} checkout, trunk {}",
917 manifest
918 .profile
919 .release
920 .style
921 .map_or("no", |style| style.as_str()),
922 manifest.git.integration.as_str(),
923 manifest.git.checkout_mode.as_str(),
924 manifest.git.trunk
925 ));
926 out.result_line(format!(
927 "forge: {} {} ({}{})",
928 forge.name.as_deref().unwrap_or("none"),
929 forge.repo.as_deref().unwrap_or(""),
930 forge.read.as_str(),
931 forge
932 .reason
933 .as_deref()
934 .map_or_else(String::new, |reason| format!(": {reason}"))
935 ));
936 let request = &observed.request;
937 match request.state {
938 "open" => out.result_line(format!(
939 "request: #{} proposing {}, armed {}, checks {}, behind the trunk {}",
940 request.number.unwrap_or_default(),
941 request
942 .proposed_version
943 .as_deref()
944 .unwrap_or("an unread version"),
945 request.armed.as_str(),
946 request.checks.as_str(),
947 request.behind_trunk.as_str()
948 )),
949 state => out.result_line(format!("request: {state}")),
950 }
951 let last = &observed.last;
952 out.result_line(format!(
953 "last release: {}, published {}, artifacts verified {}",
954 last.tag.as_deref().unwrap_or("none"),
955 last.published.as_str(),
956 last.artifacts_verified.as_str()
957 ));
958 for row in lines {
959 out.result_line(format!(
960 "line {}: newest release {}",
961 row.branch,
962 row.newest_release.as_deref().unwrap_or("none")
963 ));
964 }
965 if let Some(wait) = &observed.wait {
966 let describe = |run: &Option<Run>| {
967 run.as_ref().map_or_else(
968 || "not found".to_owned(),
969 |run| {
970 format!(
971 "{} {}",
972 run.id,
973 run.conclusion.as_deref().unwrap_or(run.status.as_str())
974 )
975 },
976 )
977 };
978 out.result_line(format!(
979 "wait {}: publish run {}, artifact run {}{}",
980 wait.sha,
981 describe(&wait.publish),
982 describe(&wait.artifact),
983 if wait.timed_out { ", timed out" } else { "" }
984 ));
985 }
986 for symptom in &observed.symptoms {
987 out.result_line(format!(
988 "SYMPTOM {}: {} (rk method recovery: {})",
989 symptom.code, symptom.evidence, symptom.recovery
990 ));
991 }
992 let mut next = Vec::new();
993 if !observed.symptoms.is_empty() {
994 next.push("rk method recovery owns the path for each symptom".to_owned());
995 }
996 next.push("rk guide release orders the release".to_owned());
997 out.next(&next);
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use super::{ForgeState, Report, Wait};
1003 use crate::commands::lines::LineRow;
1004 use crate::landing::manifest::{CheckoutMode, Integration, Style};
1005 use crate::release_state::{
1006 Answer, Checks, LastRelease, MERGED_NOTHING_PUBLISHED, Request, Run, Symptom,
1007 };
1008
1009 #[test]
1012 fn the_release_status_schema_snapshot_holds() {
1013 let landed = Report {
1014 schema: super::SCHEMA,
1015 landed: true,
1016 style: Some(Style::Trunk),
1017 integration: Some(Integration::Local),
1018 checkout_mode: Some(CheckoutMode::LinkedWorktree),
1019 trunk: Some("master".into()),
1020 forge: Some(ForgeState {
1021 name: Some("github".into()),
1022 repo: Some("acme/widget".into()),
1023 read: Answer::Yes,
1024 reason: None,
1025 }),
1026 request: Some(Request {
1027 state: "open",
1028 number: Some(12),
1029 proposed_version: Some("1.2.3".into()),
1030 armed: Answer::Yes,
1031 checks: Checks::Passing,
1032 behind_trunk: Answer::No,
1033 }),
1034 last_release: Some(LastRelease {
1035 tag: Some("v1.2.2".into()),
1036 published: Answer::Yes,
1037 artifacts_verified: Answer::NotApplicable,
1038 }),
1039 lines: Some(vec![LineRow {
1040 line: "1.4".into(),
1041 branch: "release/1.4".into(),
1042 presence: "remote",
1043 newest_release: Some("v1.4.5".into()),
1044 newest_candidate: None,
1045 tag_covered: None,
1046 seat: None,
1047 }]),
1048 symptoms: Some(vec![Symptom {
1049 code: MERGED_NOTHING_PUBLISHED,
1050 evidence: "release request #11 proposing 1.2.2 merged, and no tag v1.2.2 exists"
1051 .into(),
1052 recovery: "The release request merged and nothing published",
1053 }]),
1054 wait: Some(Wait {
1055 sha: "aaa".into(),
1056 publish: Some(Run {
1057 id: 20,
1058 status: "completed".into(),
1059 conclusion: Some("success".into()),
1060 head_branch: Some("master".into()),
1061 }),
1062 artifact: None,
1063 tag: Some("v1.2.2".into()),
1064 timed_out: false,
1065 }),
1066 };
1067 assert_eq!(
1068 serde_json::to_string(&landed).expect("a report serializes"),
1069 r#"{"schema":"rk.release-status/1","landed":true,"style":"trunk","integration":"local","checkout_mode":"linked-worktree","trunk":"master","forge":{"name":"github","repo":"acme/widget","read":"yes","reason":null},"request":{"state":"open","number":12,"proposed_version":"1.2.3","armed":"yes","checks":"passing","behind_trunk":"no"},"last_release":{"tag":"v1.2.2","published":"yes","artifacts_verified":"not-applicable"},"lines":[{"line":"1.4","branch":"release/1.4","presence":"remote","newest_release":"v1.4.5"}],"symptoms":[{"code":"request-merged-nothing-published","evidence":"release request #11 proposing 1.2.2 merged, and no tag v1.2.2 exists","recovery":"The release request merged and nothing published"}],"wait":{"sha":"aaa","publish":{"id":20,"status":"completed","conclusion":"success","head_branch":"master"},"artifact":null,"tag":"v1.2.2","timed_out":false}}"#
1070 );
1071 let absent = Report {
1072 landed: false,
1073 style: None,
1074 integration: None,
1075 checkout_mode: None,
1076 trunk: None,
1077 forge: None,
1078 request: None,
1079 last_release: None,
1080 lines: None,
1081 symptoms: None,
1082 wait: None,
1083 ..landed
1084 };
1085 assert_eq!(
1086 serde_json::to_string(&absent).expect("a report serializes"),
1087 r#"{"schema":"rk.release-status/1","landed":false}"#
1088 );
1089 }
1090}