From ba901df95362fadd91e51d44e70d07584133400e Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 16 Aug 2026 21:53:25 -0400 Subject: [PATCH] v0.2.14 : fallback web GitHub pour les releases - quand l API est rate-limitee, le tag vient de la redirection /releases/latest et les assets de expanded_assets (aucune limite) - jcode et zeroclaw installables sans GITHUB_TOKEN --- src/download.rs | 114 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/src/download.rs b/src/download.rs index e13cc9b..1b3e909 100644 --- a/src/download.rs +++ b/src/download.rs @@ -150,6 +150,18 @@ fn github_auth_header() -> Option { } pub fn latest_release(repo: &str) -> Result { + match latest_release_api(repo) { + Ok(release) => Ok(release), + Err(e) if format!("{e:#}").contains("rate limit") => { + // The API is rate limited (60 unauthenticated requests/hour): + // fall back to the GitHub web pages, which are not limited. + latest_release_web(repo) + } + Err(e) => Err(e), + } +} + +fn latest_release_api(repo: &str) -> Result { let url = format!("https://api.github.com/repos/{repo}/releases/latest"); let mut req = ureq::get(&url).set("User-Agent", USER_AGENT); if let Some(auth) = github_auth_header() { @@ -170,6 +182,90 @@ pub fn latest_release(repo: &str) -> Result { serde_json::from_str::(&text).context("parsing GitHub API response") } +/// Discover the latest release without the API: the /releases/latest page +/// redirects to the tagged page (tag from the final URL), then the +/// expanded_assets endpoint lists every asset. These web pages are not +/// subject to the API rate limit. +fn latest_release_web(repo: &str) -> Result { + let latest = format!("https://github.com/{repo}/releases/latest"); + let resp = ureq::get(&latest) + .set("User-Agent", USER_AGENT) + .call() + .map_err(|e| anyhow!("cannot reach GitHub releases page for '{repo}': {e}"))?; + let final_url = resp.get_url().to_string(); + let tag = final_url + .rsplit('/') + .next() + .map(|s| s.to_string()) + .filter(|s| !s.is_empty() && final_url.contains("/releases/tag/")) + .ok_or_else(|| { + anyhow!("no GitHub releases found for repository '{repo}' (no tagged release page)") + })?; + + let assets_url = format!("https://github.com/{repo}/releases/expanded_assets/{tag}"); + let resp = ureq::get(&assets_url) + .set("User-Agent", USER_AGENT) + .call() + .map_err(|e| anyhow!("cannot list GitHub release assets for '{repo}': {e}"))?; + let html = resp + .into_string() + .context("reading GitHub expanded_assets response")?; + let mut assets = parse_expanded_assets(&html, repo, &tag); + if assets.is_empty() { + anyhow::bail!( + "GitHub release '{repo}@{tag}' has no downloadable assets (web fallback)" + ); + } + assets.sort_by(|a, b| a.name.cmp(&b.name)); + assets.dedup_by(|a, b| a.name == b.name); + Ok(GhRelease { + tag_name: tag, + assets, + }) +} + +/// Extract the asset links from a GitHub expanded_assets HTML fragment. +fn parse_expanded_assets(html: &str, repo: &str, tag: &str) -> Vec { + let marker = format!("/{repo}/releases/download/"); + let mut assets = Vec::new(); + let mut rest = html; + while let Some(pos) = rest.find(&marker) { + let start = pos + marker.len(); + let end = rest[start..] + .find(['"', '\'', '<']) + .map(|i| start + i) + .unwrap_or(rest.len()); + let raw = &rest[start..end]; + // The href reads "download//": keep the name only. + let raw = raw.strip_prefix(&format!("{tag}/")).unwrap_or(raw); + // GitHub sometimes hides zero-width characters inside hrefs. + let name = raw.replace('\u{200b}', ""); + let name = decode_html_entities(&name); + if !name.is_empty() { + assets.push(GhAsset { + name: name.clone(), + browser_download_url: format!( + "https://github.com/{repo}/releases/download/{tag}/{name}" + ), + size: 0, + }); + } + rest = &rest[end..]; + } + assets +} + +fn decode_html_entities(s: &str) -> String { + s.replace("&", "&") + .replace(""", "\"") + .replace("'", "'") + .replace(""", "\"") + .replace(" ", " ") + .replace("<", "<") + .replace(">", ">") + .replace(" ", " ") +} + pub struct PickedAsset { pub name: String, pub url: String, @@ -519,6 +615,24 @@ mod tests { assert!(validate_url("javascript:alert(1)").is_err()); } + #[test] + fn parses_expanded_assets_fragment() { + let html = r#"
x +y +ignore me +z
"#; + let assets = parse_expanded_assets(html, "owner/repo", "v1.2.3"); + let names: Vec<&str> = assets.iter().map(|a| a.name.as_str()).collect(); + assert!(names.contains(&"tool-windows-x86_64.exe"), "{names:?}"); + assert!(names.contains(&"tool-windows-x86_64 copy.zip"), "{names:?}"); + assert!(names.contains(&"SHA256SUMS"), "{names:?}"); + assert_eq!(assets.len(), 3); + assert_eq!( + assets[0].browser_download_url, + "https://github.com/owner/repo/releases/download/v1.2.3/tool-windows-x86_64.exe" + ); + } + #[test] fn sha256_verification() { let dir = tempfile::tempdir().unwrap();