latestRelease: the newest real release of a project, for update notices (#239)

A frontend asks with a GitHub repository ("Moho-Chat/moho") and gets the
newest release that is published, not a pre-release, and tagged exactly
vMAJOR.MINOR.PATCH - compared as numbers, so 1.0.10 follows 1.0.9 - or
null while there is none. Drafts, release candidates and moving tags such
as the retired rolling `latest` never count.

Asked the way any request belonging to no account is: through Tor or the
proxy whenever anything is routed. Checked live against GitHub: moho has no
release yet (null), electron/electron's newest is found, a malformed
repository is refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-04 16:22:10 -04:00
co-authored by Claude Opus 5.5
parent 8eca16e175
commit b697fbb23c
3 changed files with 130 additions and 0 deletions
+1
View File
@@ -14,6 +14,7 @@ mod net;
mod oggopus;
mod opus_packet;
mod profile;
mod releases;
mod rpc;
mod runtime;
mod secure;
+117
View File
@@ -0,0 +1,117 @@
//! The newest released version of a project, from GitHub.
//!
//! So a frontend can say a newer version is out (moho does, in its mentions
//! inbox). Here rather than in a window because it is a request off this
//! machine like any other, and goes the way requests that belong to no
//! account go: through Tor or the proxy when anything is routed.
//!
//! Only a real release counts: published, not a pre-release, and tagged
//! `vMAJOR.MINOR.PATCH`. A draft, a release candidate and a moving tag such
//! as the retired rolling `latest` are not versions anybody should be told
//! to install.
use anyhow::{bail, Context, Result};
use serde::Serialize;
#[derive(Serialize, Debug, PartialEq)]
pub struct Release {
/// "1.0.1", without the v.
pub version: String,
pub name: String,
/// The release's page.
pub url: String,
#[serde(rename = "publishedAt")]
pub published_at: String,
}
/// MAJOR.MINOR.PATCH from a tag, if it is exactly one.
pub fn parse_version(tag: &str) -> Option<(u64, u64, u64)> {
let mut parts = tag.strip_prefix('v')?.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
parts.next().is_none().then_some((major, minor, patch))
}
/// The newest real release among what GitHub listed.
pub fn newest(listed: &serde_json::Value) -> Option<Release> {
listed
.as_array()?
.iter()
.filter(|r| !r["draft"].as_bool().unwrap_or(true) && !r["prerelease"].as_bool().unwrap_or(true))
.filter_map(|r| {
let tag = r["tag_name"].as_str()?;
let version = parse_version(tag)?;
Some((
version,
Release {
version: tag.trim_start_matches('v').to_string(),
name: r["name"].as_str().filter(|n| !n.is_empty()).unwrap_or(tag).to_string(),
url: r["html_url"].as_str()?.to_string(),
published_at: r["published_at"].as_str().unwrap_or_default().to_string(),
},
))
})
.max_by_key(|(version, _)| *version)
.map(|(_, release)| release)
}
/// The newest real release of `repo` ("owner/name") on GitHub, or None when it
/// has none yet.
pub async fn latest(repo: &str) -> Result<Option<Release>> {
let valid = |part: &str| !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b));
match repo.split_once('/') {
Some((owner, name)) if valid(owner) && valid(name) && !name.contains('/') => {}
_ => bail!("\"{repo}\" is not a GitHub repository"),
}
let router = crate::net::route::router();
router.ready_for_general().await.context("starting Tor or reaching the proxy")?;
let client = router.client_if("releases", router.general_routed(), |builder| {
builder
.user_agent(concat!("nobilis/", env!("CARGO_PKG_VERSION")))
.timeout(std::time::Duration::from_secs(20))
});
let response = client
.get(format!("https://api.github.com/repos/{repo}/releases?per_page=30"))
.header("Accept", "application/vnd.github+json")
.send()
.await
.context("asking GitHub for releases")?;
if !response.status().is_success() {
bail!("GitHub answered {} for {repo}'s releases", response.status());
}
let listed: serde_json::Value = response.json().await.context("reading GitHub's list of releases")?;
Ok(newest(&listed))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_exact_versions_parse() {
assert_eq!(parse_version("v1.0.0"), Some((1, 0, 0)));
assert_eq!(parse_version("v1.10.2"), Some((1, 10, 2)));
assert_eq!(parse_version("1.0.0"), None);
assert_eq!(parse_version("v1.0.0-rc.1"), None);
assert_eq!(parse_version("latest"), None);
assert_eq!(parse_version("v1.0"), None);
assert_eq!(parse_version("v1.0.0.1"), None);
}
#[test]
fn the_newest_real_release_is_picked() {
let listed = serde_json::json!([
{ "tag_name": "latest", "name": "Latest build", "draft": false, "prerelease": false, "html_url": "https://x/latest", "published_at": "2026-10-09" },
{ "tag_name": "v1.1.0-rc.1", "name": "moho 1.1.0-rc.1", "draft": false, "prerelease": true, "html_url": "https://x/rc", "published_at": "2026-10-08" },
{ "tag_name": "v1.2.0", "name": "moho 1.2.0", "draft": true, "prerelease": false, "html_url": "https://x/draft", "published_at": "" },
{ "tag_name": "v1.0.10", "name": "moho 1.0.10", "draft": false, "prerelease": false, "html_url": "https://x/1010", "published_at": "2026-10-07" },
{ "tag_name": "v1.0.9", "name": "moho 1.0.9", "draft": false, "prerelease": false, "html_url": "https://x/109", "published_at": "2026-10-06" }
]);
let newest = newest(&listed).unwrap();
// 1.0.10 is newer than 1.0.9 - compared as numbers, not as text.
assert_eq!(newest.version, "1.0.10");
assert_eq!(newest.url, "https://x/1010");
assert_eq!(super::newest(&serde_json::json!([])), None);
}
}
+12
View File
@@ -304,6 +304,18 @@ pub async fn dispatch(
None,
),
// The newest real release of a project, so a frontend can say when
// there is one newer than itself. Null while the project has none.
"latestRelease" => {
let Some(repo) = p_str_opt(params, "repo") else {
return (None, Some("latestRelease requires \"repo\"".to_string()));
};
match crate::releases::latest(repo).await {
Ok(found) => (Some(serde_json::to_value(found).unwrap()), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
"listProtocols" => (
Some(serde_json::json!([
{ "id": "irc", "name": "IRC", "available": true },