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