From 4e2c6d210b51117e92527c729b54a2f59db94977 Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 21 Apr 2026 04:04:20 -0400 Subject: [PATCH] release(v1.7.19-alpha): kill stale available_update + numeric version compare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_state now drops any stored available_update whenever the running binary version differs from what's on disk — the old migration only cleared it when the stale entry happened to match the new version, so skipping releases (e.g. sideloading 1.7.16 → 1.7.18 without 1.7.17) left a pointer to an intermediate version as the "update available", which the UI then offered as a downgrade prompt. check_for_updates also uses a numeric version comparator so a stale or cached manifest with an older version can't offer itself as an update, and 1.7.10 correctly outranks 1.7.9 past the single-digit patch boundary. Co-Authored-By: Claude Opus 4.7 (1M context) --- core/Cargo.lock | 2 +- core/archipelago/Cargo.toml | 2 +- core/archipelago/src/federation/invites.rs | 4 + core/archipelago/src/update.rs | 113 +++++++++++++++--- .../src/views/settings/AccountInfoSection.vue | 12 ++ 5 files changed, 116 insertions(+), 17 deletions(-) diff --git a/core/Cargo.lock b/core/Cargo.lock index 2cfc92fc..e2f3c62b 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "archipelago" -version = "1.7.18-alpha" +version = "1.7.19-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 11d07d1c..297c6fd7 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.7.18-alpha" +version = "1.7.19-alpha" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/core/archipelago/src/federation/invites.rs b/core/archipelago/src/federation/invites.rs index 1c982546..c88d4062 100644 --- a/core/archipelago/src/federation/invites.rs +++ b/core/archipelago/src/federation/invites.rs @@ -342,6 +342,7 @@ mod tests { "local.onion", "localpub", None, + None, |_| "test-sig".to_string(), ) .await @@ -376,6 +377,7 @@ mod tests { "local.onion", "localpub", None, + None, |_| "test-sig".to_string(), ) .await @@ -409,6 +411,7 @@ mod tests { "local.onion", "localpub", None, + None, |_| "test-sig".to_string(), ) .await @@ -421,6 +424,7 @@ mod tests { "local.onion", "localpub", None, + None, |_| "test-sig".to_string(), ) .await diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index 62c2ed73..00619f9a 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -36,6 +36,31 @@ fn is_canceled() -> bool { DOWNLOAD_CANCEL.load(Ordering::Relaxed) } +/// Parse "MAJOR.MINOR.PATCH[-suffix]" into a tuple; suffix is ignored. +/// Returns None if the numeric portion can't be parsed — callers should +/// fall back to string comparison in that case so we don't silently +/// mis-rank versions we don't understand. +fn parse_version_triple(v: &str) -> Option<(u32, u32, u32)> { + let core = v.split('-').next().unwrap_or(v); + let mut parts = core.split('.'); + let major: u32 = parts.next()?.parse().ok()?; + let minor: u32 = parts.next()?.parse().ok()?; + let patch: u32 = parts.next()?.parse().ok()?; + Some((major, minor, patch)) +} + +/// Is `candidate` strictly newer than `current`? Used to guard against +/// the manifest offering a version we've already passed (e.g. a stale +/// cached manifest or a node that sideloaded past the manifest's +/// latest). Falls back to string inequality if either version doesn't +/// parse, preserving the old behaviour for unusual version strings. +fn is_newer(candidate: &str, current: &str) -> bool { + match (parse_version_triple(candidate), parse_version_triple(current)) { + (Some(a), Some(b)) => a > b, + _ => candidate != current, + } +} + const DEFAULT_UPDATE_MANIFEST_URL: &str = "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"; const UPDATE_STATE_FILE: &str = "update_state.json"; @@ -117,13 +142,13 @@ pub async fn load_state(data_dir: &Path) -> Result { let running = env!("CARGO_PKG_VERSION"); if state.current_version != running { state.current_version = running.to_string(); - // Clear any stale "available_update" that matched the old - // current_version — the new binary will re-check on its own. - if let Some(ref avail) = state.available_update { - if avail.version == running { - state.available_update = None; - } - } + // Binary version changed (sideload or apply). Any stored + // `available_update` is either redundant (points at the running + // version) or stale (points at a version we've already passed — + // which would surface as a "downgrade" offer in the UI). Clear + // it unconditionally; the next check_for_updates will repopulate + // if there's genuinely something newer. + state.available_update = None; save_state(data_dir, &state).await?; } Ok(state) @@ -161,7 +186,7 @@ pub async fn check_for_updates(data_dir: &Path) -> Result { Ok(resp) if resp.status().is_success() => { match resp.json::().await { Ok(manifest) => { - if manifest.version != state.current_version { + if is_newer(&manifest.version, &state.current_version) { info!( current = %state.current_version, available = %manifest.version, @@ -169,7 +194,16 @@ pub async fn check_for_updates(data_dir: &Path) -> Result { ); state.available_update = Some(manifest); } else { - debug!("Already on latest version: {}", state.current_version); + // Manifest version matches us or is behind + // us — either we're current, or the remote + // manifest is stale. Either way don't offer + // it as an "update" (that would be a + // downgrade prompt). + debug!( + current = %state.current_version, + manifest = %manifest.version, + "No newer version in manifest" + ); state.available_update = None; } handled = true; @@ -926,6 +960,52 @@ mod tests { assert_eq!(state.schedule, UpdateSchedule::DailyCheck); } + #[test] + fn test_parse_version_triple() { + assert_eq!(parse_version_triple("1.7.18"), Some((1, 7, 18))); + assert_eq!(parse_version_triple("1.7.18-alpha"), Some((1, 7, 18))); + assert_eq!(parse_version_triple("0.0.1"), Some((0, 0, 1))); + assert_eq!(parse_version_triple("garbage"), None); + assert_eq!(parse_version_triple("1.2"), None); + } + + #[test] + fn test_is_newer() { + assert!(is_newer("1.7.19-alpha", "1.7.18-alpha")); + assert!(is_newer("1.8.0-alpha", "1.7.99-alpha")); + assert!(is_newer("1.7.10-alpha", "1.7.9-alpha")); // numeric, not lexical + assert!(!is_newer("1.7.18-alpha", "1.7.18-alpha")); + assert!(!is_newer("1.7.17-alpha", "1.7.18-alpha")); // would-be downgrade + assert!(!is_newer("1.7.9-alpha", "1.7.10-alpha")); + } + + #[tokio::test] + async fn test_load_state_clears_stale_available_on_version_bump() { + // Simulates a sideload: state file on disk says we're on + // 1.7.16-alpha with 1.7.17-alpha staged as the pending update, + // but the running binary is 1.7.18-alpha (skipped a version). + // load_state must drop the stale available_update so the UI + // doesn't offer a downgrade. + let dir = tempfile::tempdir().unwrap(); + let stale = UpdateState { + current_version: "1.7.16-alpha".to_string(), + available_update: Some(UpdateManifest { + version: "1.7.17-alpha".to_string(), + release_date: "2026-04-20".to_string(), + changelog: vec![], + components: vec![], + }), + ..UpdateState::default() + }; + save_state(dir.path(), &stale).await.unwrap(); + let loaded = load_state(dir.path()).await.unwrap(); + assert_eq!(loaded.current_version, env!("CARGO_PKG_VERSION")); + assert!( + loaded.available_update.is_none(), + "stale available_update must be cleared after version bump" + ); + } + #[tokio::test] async fn test_load_state_creates_default_when_missing() { let dir = tempfile::tempdir().unwrap(); @@ -961,13 +1041,14 @@ mod tests { }; save_state(dir.path(), &state).await.unwrap(); let loaded = load_state(dir.path()).await.unwrap(); - assert_eq!(loaded.current_version, "1.0.0"); + // load_state rewrites current_version to match the running + // binary (sideload self-heal), so don't assert on the saved + // value. The migration also clears available_update when the + // version changes — check the other fields survived. + assert_eq!(loaded.current_version, env!("CARGO_PKG_VERSION")); assert!(loaded.update_in_progress); assert_eq!(loaded.schedule, UpdateSchedule::Manual); - let manifest = loaded.available_update.unwrap(); - assert_eq!(manifest.version, "1.1.0"); - assert_eq!(manifest.components.len(), 1); - assert_eq!(manifest.components[0].size_bytes, 5000); + assert!(loaded.available_update.is_none()); } #[tokio::test] @@ -1017,7 +1098,9 @@ mod tests { }; save_state(dir.path(), &state).await.unwrap(); let status = get_status(dir.path()).await.unwrap(); - assert_eq!(status.current_version, "3.0.0"); + // get_status → load_state, which rewrites current_version to + // match the running binary (see the sideload-self-heal path). + assert_eq!(status.current_version, env!("CARGO_PKG_VERSION")); assert!(status.rollback_available); } } diff --git a/neode-ui/src/views/settings/AccountInfoSection.vue b/neode-ui/src/views/settings/AccountInfoSection.vue index 3af00db9..932006bc 100644 --- a/neode-ui/src/views/settings/AccountInfoSection.vue +++ b/neode-ui/src/views/settings/AccountInfoSection.vue @@ -180,6 +180,18 @@ init()
+ +
+
+ v1.7.19-alpha + Apr 21, 2026 +
+
+

Your node no longer offers a version you've already passed as an "available update". If you sideload or skip a release, any stored pointer to an earlier version is dropped on next restart, and the System Update page offers only the genuinely newer release — no more seeing an older version listed as something to install.

+

Version comparison is now numeric, not alphabetic. 1.7.10 correctly outranks 1.7.9 (earlier naive string-order would have got this backwards once the patch number hits double digits), so update prompts and "up to date" checks stay accurate past the nines.

+

A stale manifest from a slow cache or proxy can no longer downgrade your node. If the manifest reports a version equal to or behind what's running, your node treats that as "up to date" rather than offering the older version as an update.

+
+