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