use crate::github::GithubEndpoints; use crate::source::Endpoints; use anyhow::{Context, Result, bail}; use regex::Regex; use serde::Deserialize; /// Resolves the latest release tag for `repo` on whichever service /// `endpoints` points at. pub fn latest_release( client: &reqwest::blocking::Client, endpoints: &Endpoints, repo: &str, ) -> Result { match endpoints { Endpoints::Github(github) => latest_github_release(client, github, repo), Endpoints::Forgejo { api } => latest_forgejo_release(client, api, repo), } } /// Resolves the latest release tag for `repo` on a Forgejo/Gitea instance. /// /// One call, unlike GitHub's feed-then-confirm dance below: Forgejo's /// `releases/latest` already returns only the newest non-draft, /// non-prerelease *release object*, so a stray tag with no release behind /// it (GitHub's scaleway-cli `-dbg1` problem) can't be returned. pub fn latest_forgejo_release( client: &reqwest::blocking::Client, api: &str, repo: &str, ) -> Result { #[derive(Deserialize)] struct Latest { tag_name: String, } let url = format!("{api}/repos/{repo}/releases/latest"); let response = client.get(&url).send()?; // Forgejo answers 404 both for an unknown repo and for one with no // releases yet — the common state for a project's very first release. if response.status() == reqwest::StatusCode::NOT_FOUND { bail!("no published release found at {url} (repo missing, or nothing released yet)"); } let latest: Latest = response .error_for_status() .with_context(|| format!("fetching latest release from {url}"))? .json()?; Ok(latest.tag_name) } /// 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")); } #[test] fn latest_forgejo_release_returns_tag_name() { let mut server = mockito::Server::new(); let _latest = server .mock("GET", "/repos/o/r/releases/latest") .with_status(200) .with_body(r#"{"tag_name": "v0.1.0", "assets": []}"#) .create(); let client = reqwest::blocking::Client::new(); let tag = latest_forgejo_release(&client, &server.url(), "o/r").unwrap(); assert_eq!(tag, "v0.1.0"); } #[test] fn latest_forgejo_release_names_the_no_releases_case() { let mut server = mockito::Server::new(); let _latest = server .mock("GET", "/repos/o/r/releases/latest") .with_status(404) .create(); let client = reqwest::blocking::Client::new(); let err = latest_forgejo_release(&client, &server.url(), "o/r").unwrap_err(); assert!(err.to_string().contains("no published release")); } #[test] fn latest_forgejo_release_surfaces_server_errors() { let mut server = mockito::Server::new(); let _latest = server .mock("GET", "/repos/o/r/releases/latest") .with_status(500) .create(); let client = reqwest::blocking::Client::new(); let err = latest_forgejo_release(&client, &server.url(), "o/r").unwrap_err(); assert!(err.to_string().contains("fetching latest release")); } #[test] fn latest_release_dispatches_on_endpoint_kind() { let mut server = mockito::Server::new(); let _forgejo = server .mock("GET", "/repos/o/r/releases/latest") .with_status(200) .with_body(r#"{"tag_name": "v2.0.0"}"#) .create(); let _feed = server .mock("GET", "/o/r/releases.atom") .with_body(atom_feed(&["v1.0.0"])) .create(); let _release = server .mock("GET", "/repos/o/r/releases/tags/v1.0.0") .with_status(200) .with_body("{}") .create(); let client = reqwest::blocking::Client::new(); let forgejo = Endpoints::Forgejo { api: server.url() }; assert_eq!(latest_release(&client, &forgejo, "o/r").unwrap(), "v2.0.0"); let github = Endpoints::Github(GithubEndpoints { web: server.url(), api: server.url(), }); assert_eq!(latest_release(&client, &github, "o/r").unwrap(), "v1.0.0"); } }