Skip to main content

release_kit/commands/
release.rs

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