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:
Erich Blume 2026-06-04 13:36:55 -07:00
commit fad8f2f4de
5 changed files with 121 additions and 0 deletions

1
Cargo.lock generated
View file

@ -2274,6 +2274,7 @@ dependencies = [
"rand 0.8.6",
"reqwest",
"rsa",
"semver",
"serde",
"serde_json",
"tempfile",

View file

@ -59,6 +59,7 @@ reqwest = { version = "0.13", default-features = false, features = [
"json",
"query",
] }
semver = "1"
[profile.release]
lto = "thin"

View file

@ -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.

View file

@ -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;

View 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());
}
}