use crate::github::GithubEndpoints; use anyhow::{Result, bail}; use regex::Regex; /// Resolves the latest release tag for `repo` via its public Atom feed. /// /// Deliberately not a full XML parse: the feed lists entries newest-first, /// and each `"/>` is matched /// in document order. Revisit with a real XML parser if GitHub's feed /// shape ever changes. /// /// The feed can list a tag newer than any tag with a real Release object /// behind it — observed on scaleway/scaleway-cli, which pushes a /// `vX.Y.Z-dbg1` tag (no corresponding Release; `releases/tags/` /// 404s) right after each real release, and that tag sorts newest in the /// feed. So each candidate is confirmed against the releases API in feed /// order, returning the first that actually resolves. pub fn latest_github_release( client: &reqwest::blocking::Client, endpoints: &GithubEndpoints, repo: &str, ) -> Result { let url = format!("{}/{repo}/releases.atom", endpoints.web); let body = client.get(&url).send()?.error_for_status()?.text()?; let re = Regex::new(r#"releases/tag/([^"]+)""#)?; let mut candidates = re .captures_iter(&body) .map(|caps| caps[1].to_string()) .peekable(); if candidates.peek().is_none() { bail!("no release tag found in {url}"); } for tag in candidates { let release_url = format!("{}/repos/{repo}/releases/tags/{tag}", endpoints.api); if client.get(&release_url).send()?.status().is_success() { return Ok(tag); } } bail!("no release tag in {url} resolved to a real release via the API") } /// Strips a leading `v` from a release tag, e.g. `v2.62.0` -> `2.62.0`. /// /// Some projects (uv) tag releases with the bare version and use it /// verbatim in asset filenames; others (scaleway-cli) tag `vX.Y.Z` but /// still use the bare version in filenames. This is the version string /// substituted into `{version}` placeholders in `asset_pattern` / /// `checksum_asset_pattern`, not the tag used for API/attestation calls. pub fn version_from_tag(tag: &str) -> &str { tag.strip_prefix('v').unwrap_or(tag) } #[cfg(test)] mod tests { use super::*; #[test] fn version_from_tag_strips_leading_v() { assert_eq!(version_from_tag("v2.62.0"), "2.62.0"); } #[test] fn version_from_tag_leaves_bare_version_unchanged() { assert_eq!(version_from_tag("0.12.15"), "0.12.15"); } fn atom_feed(tags: &[&str]) -> String { let entries: String = tags .iter() .map(|t| { format!(r#""#) }) .collect(); format!("{entries}") } #[test] fn latest_github_release_skips_tags_with_no_real_release() { let mut server = mockito::Server::new(); let endpoints = GithubEndpoints { web: server.url(), api: server.url(), }; // Mirrors the real scaleway-cli case: newest feed entry (a -dbg1 // tag) has no Release object behind it and 404s. let _feed = server .mock("GET", "/o/r/releases.atom") .with_status(200) .with_body(atom_feed(&["v2.62.0-dbg1", "v2.62.0"])) .create(); let _missing = server .mock("GET", "/repos/o/r/releases/tags/v2.62.0-dbg1") .with_status(404) .create(); let _real = server .mock("GET", "/repos/o/r/releases/tags/v2.62.0") .with_status(200) .with_body("{}") .create(); let client = reqwest::blocking::Client::new(); let tag = latest_github_release(&client, &endpoints, "o/r").unwrap(); assert_eq!(tag, "v2.62.0"); } #[test] fn latest_github_release_errors_when_feed_has_no_tags() { let mut server = mockito::Server::new(); let endpoints = GithubEndpoints { web: server.url(), api: server.url(), }; let _feed = server .mock("GET", "/o/r/releases.atom") .with_status(200) .with_body("") .create(); let client = reqwest::blocking::Client::new(); let err = latest_github_release(&client, &endpoints, "o/r").unwrap_err(); assert!(err.to_string().contains("no release tag found")); } #[test] fn latest_github_release_errors_when_no_candidate_resolves() { let mut server = mockito::Server::new(); let endpoints = GithubEndpoints { web: server.url(), api: server.url(), }; let _feed = server .mock("GET", "/o/r/releases.atom") .with_status(200) .with_body(atom_feed(&["v1.0.0-dbg1"])) .create(); let _missing = server .mock("GET", "/repos/o/r/releases/tags/v1.0.0-dbg1") .with_status(404) .create(); let client = reqwest::blocking::Client::new(); let err = latest_github_release(&client, &endpoints, "o/r").unwrap_err(); assert!(err.to_string().contains("resolved to a real release")); } }