Adds two new gates to the existing format/lint/test/audit pipeline (Makefile.toml `cargo make ci`, .forgejo/workflows/ci.yml): - Cognitive complexity via clippy's nursery cognitive_complexity lint (clippy.toml, threshold 15), scoped to --bins so test code's naturally higher branch count doesn't get gated. Went with this over the closest real cyclomatic-complexity tool (rust-code-analysis-cli) because that crate hasn't shipped a release since Jan 2023. - Test coverage via cargo-llvm-cov, chosen over cargo-tarpaulin for friendlier behavior in containerized/dind CI (no ptrace). Report-only for now (no --fail-under-lines) since a real threshold needs real usage data first — see below. main.rs is excluded: it's orchestration glue exercised by the real end-to-end `cargo run`, not unit tests. Getting both gates running required writing pkgwatch's first tests (previously zero). To make the GitHub-facing modules unit-testable without hitting real github.com/api.github.com, added `GithubEndpoints` (src/github.rs) so checker/fetcher/verifier take injectable base URLs, and added mockito + tempfile as dev-dependencies. Result: 27 tests, 94% region / 96% line coverage excluding main.rs. Also: cargo audit (now wired into `cargo make ci`) immediately caught a real, currently-open advisory (RUSTSEC-2026-0285, published days ago) in the transitive rustls dependency — bumped 0.23.44 -> 0.23.45 to clear it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
148 lines
5.2 KiB
Rust
148 lines
5.2 KiB
Rust
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 `<link rel="alternate" .../releases/tag/<tag>"/>` 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/<tag>`
|
|
/// 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<String> {
|
|
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#"<link rel="alternate" href="https://github.com/o/r/releases/tag/{t}"/>"#)
|
|
})
|
|
.collect();
|
|
format!("<feed>{entries}</feed>")
|
|
}
|
|
|
|
#[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("<feed></feed>")
|
|
.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"));
|
|
}
|
|
}
|