generated from eblume/project-template
C2(hephd-self-update): impl release poll + version-check helpers
Add crates/hephd/src/selfupdate.rs: a pure update_available() that
compares the running heph_core::VERSION (e.g. "1.0.3 (sha)") against a
release tag ("v1.0.4") via semver, ignoring the build suffix and v
prefix; plus parse_latest_tag() / fetch_latest_tag() for the forge
releases/latest feed. Decision logic and JSON parsing are unit-tested
against sample payloads; the network fetch is isolated. Adds the semver
workspace dep.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e6524fddbb
commit
fad8f2f4de
5 changed files with 121 additions and 0 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2274,6 +2274,7 @@ dependencies = [
|
|||
"rand 0.8.6",
|
||||
"reqwest",
|
||||
"rsa",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ reqwest = { version = "0.13", default-features = false, features = [
|
|||
"json",
|
||||
"query",
|
||||
] }
|
||||
semver = "1"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ jsonwebtoken.workspace = true
|
|||
keyring-core.workspace = true
|
||||
reqwest.workspace = true
|
||||
ureq.workspace = true
|
||||
semver.workspace = true
|
||||
|
||||
# The OS credential backend that `oauth.rs` registers as the keyring-core
|
||||
# default store — exactly one per platform, not the whole keyring meta-crate.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ pub mod oauth;
|
|||
pub mod quickadd;
|
||||
pub mod remote;
|
||||
pub mod rpc;
|
||||
pub mod selfupdate;
|
||||
pub mod server;
|
||||
pub mod sync;
|
||||
|
||||
|
|
|
|||
117
crates/hephd/src/selfupdate.rs
Normal file
117
crates/hephd/src/selfupdate.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
//! Opt-in self-update (cards: `docs/how-to/self-update/`). When enabled, hephd
|
||||
//! polls the forge for a newer tagged release and rebuilds + restarts onto it.
|
||||
//!
|
||||
//! This first slice is the **version check**: parse the running version, fetch
|
||||
//! the latest release tag from the forge, and decide whether an update exists.
|
||||
//! The decision logic is pure and unit-tested; the network fetch is isolated so
|
||||
//! its JSON parsing can be tested against a sample body without a live forge.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// The forge releases feed for this project — the latest tagged release.
|
||||
/// Uses the SSH-canonical host (`forge.ops.eblu.me`); see the
|
||||
/// `service-env-forge-access` card for the cargo/forge host caveat.
|
||||
pub const RELEASES_LATEST_URL: &str =
|
||||
"https://forge.ops.eblu.me/api/v1/repos/eblume/hephaestus/releases/latest";
|
||||
|
||||
/// Extract the bare `X.Y.Z` semver from a version string that may carry a build
|
||||
/// suffix (`heph_core::VERSION` is e.g. `"1.0.3 (aa376b4)"`) or a leading `v`
|
||||
/// (release tags are `v1.0.4`).
|
||||
fn parse_version(s: &str) -> Result<semver::Version> {
|
||||
let head = s
|
||||
.trim()
|
||||
.trim_start_matches('v')
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
semver::Version::parse(head).with_context(|| format!("parsing version {s:?}"))
|
||||
}
|
||||
|
||||
/// Whether `latest_tag` names a strictly newer release than `current` (the
|
||||
/// running `heph_core::VERSION`). A malformed version on either side is an
|
||||
/// error — never a silent "no update".
|
||||
pub fn update_available(current: &str, latest_tag: &str) -> Result<bool> {
|
||||
Ok(parse_version(latest_tag)? > parse_version(current)?)
|
||||
}
|
||||
|
||||
/// Pull the `tag_name` out of a Forgejo/Gitea `releases/latest` response body.
|
||||
/// Split out from the HTTP fetch so it can be tested against a sample payload.
|
||||
pub fn parse_latest_tag(body: &str) -> Result<String> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Release {
|
||||
tag_name: String,
|
||||
}
|
||||
let rel: Release =
|
||||
serde_json::from_str(body).context("parsing forge releases/latest response")?;
|
||||
Ok(rel.tag_name)
|
||||
}
|
||||
|
||||
/// Fetch the latest release tag from the forge over HTTP (reusing the daemon's
|
||||
/// shared `reqwest::Client`). Network/HTTP/JSON failures surface as `Err` for
|
||||
/// the caller to log-and-continue.
|
||||
pub async fn fetch_latest_tag(http: &reqwest::Client, url: &str) -> Result<String> {
|
||||
let body = http
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.context("requesting forge releases/latest")?
|
||||
.error_for_status()
|
||||
.context("forge releases/latest returned an error status")?
|
||||
.text()
|
||||
.await
|
||||
.context("reading forge releases/latest body")?;
|
||||
parse_latest_tag(&body)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn update_available_compares_ignoring_build_suffix_and_v_prefix() {
|
||||
// Running version carries a build-sha suffix; tags carry a `v`.
|
||||
assert!(update_available("1.0.3 (aa376b4)", "v1.0.4").unwrap());
|
||||
assert!(update_available("1.0.3 (aa376b4)", "v2.0.0").unwrap());
|
||||
// Same version → no update (a dirty rebuild of the same tag isn't newer).
|
||||
assert!(!update_available("1.0.3 (aa376b4-dirty)", "v1.0.3").unwrap());
|
||||
// Older tag than running → no update.
|
||||
assert!(!update_available("1.0.3", "v1.0.2").unwrap());
|
||||
// Patch/minor/major ordering.
|
||||
assert!(update_available("1.0.9", "v1.1.0").unwrap());
|
||||
assert!(!update_available("1.1.0", "v1.0.9").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_available_errors_on_malformed_version() {
|
||||
assert!(update_available("not-a-version", "v1.0.4").is_err());
|
||||
assert!(update_available("1.0.3", "vNope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_latest_tag_reads_tag_name_from_forge_body() {
|
||||
// A trimmed sample of a Forgejo releases/latest payload.
|
||||
let body = r#"{
|
||||
"id": 42,
|
||||
"tag_name": "v1.0.4",
|
||||
"name": "Release v1.0.4",
|
||||
"draft": false,
|
||||
"prerelease": false
|
||||
}"#;
|
||||
assert_eq!(parse_latest_tag(body).unwrap(), "v1.0.4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_latest_tag_errors_on_unexpected_body() {
|
||||
assert!(parse_latest_tag("{}").is_err());
|
||||
assert!(parse_latest_tag("not json").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_to_end_body_to_decision() {
|
||||
// Parse a release body, then decide against a fixed running version.
|
||||
let tag = parse_latest_tag(r#"{"tag_name": "v1.0.4"}"#).unwrap();
|
||||
assert!(update_available("1.0.3 (aa376b4)", &tag).unwrap());
|
||||
let tag = parse_latest_tag(r#"{"tag_name": "v1.0.3"}"#).unwrap();
|
||||
assert!(!update_available("1.0.3 (aa376b4)", &tag).unwrap());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue