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",
|
"rand 0.8.6",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rsa",
|
"rsa",
|
||||||
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ reqwest = { version = "0.13", default-features = false, features = [
|
||||||
"json",
|
"json",
|
||||||
"query",
|
"query",
|
||||||
] }
|
] }
|
||||||
|
semver = "1"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ jsonwebtoken.workspace = true
|
||||||
keyring-core.workspace = true
|
keyring-core.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
ureq.workspace = true
|
ureq.workspace = true
|
||||||
|
semver.workspace = true
|
||||||
|
|
||||||
# The OS credential backend that `oauth.rs` registers as the keyring-core
|
# The OS credential backend that `oauth.rs` registers as the keyring-core
|
||||||
# default store — exactly one per platform, not the whole keyring meta-crate.
|
# default store — exactly one per platform, not the whole keyring meta-crate.
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ pub mod oauth;
|
||||||
pub mod quickadd;
|
pub mod quickadd;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
pub mod rpc;
|
pub mod rpc;
|
||||||
|
pub mod selfupdate;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod sync;
|
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