Compare commits

..

2 Commits

Author SHA1 Message Date
Dorian
9fc9696dbd release(v1.7.19-alpha): kill stale available_update + numeric version compare
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 11m36s
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) <noreply@anthropic.com>
2026-04-21 04:04:20 -04:00
Dorian
062e1fada2 release(v1.7.18-alpha): transitive peers default Trusted + update-flow logs
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 10m40s
Flip transitively-discovered federation peers to Trusted instead of
Observer. Hints are already only ingested from peers we trust and only
peers we trust are re-exported via build_local_state, so the chain of
trust is already vetted end-to-end — making the user promote each
newcomer by hand was friction with no security win.

Backend:
- federation/sync.rs: merge_transitive_peers now inserts TrustLevel::Trusted
  (doc comment updated to explain the transitive-trust rationale)
- update.rs: info! log at download start (version, components, total_bytes,
  staging path), cancel (staging wiped?, marker cleared?), and apply (backup
  path) so journalctl reveals where a stuck update actually is

Frontend:
- SystemUpdate What's New block gets a v1.7.18-alpha entry

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:20:36 -04:00
12 changed files with 177 additions and 41 deletions

2
core/Cargo.lock generated
View File

@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.17-alpha"
version = "1.7.19-alpha"
dependencies = [
"anyhow",
"archipelago-container",

View File

@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.17-alpha"
version = "1.7.19-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]

View File

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

View File

@@ -107,8 +107,10 @@ pub async fn sync_with_peer_by_did(
}
/// Merge peers advertised by a Trusted federated node into our own
/// federation list. New peers are added at `Observer` trust (not
/// Trusted — that requires a direct invite). Existing peers get their
/// federation list. New peers are added at `Trusted` — hints only
/// arrive from peers we already trust, and `build_local_state` only
/// re-exports our Trusted list, so transitive membership carries the
/// same trust the direct-invite path gives. Existing peers get their
/// `fips_npub` refreshed if we hadn't learned it yet.
///
/// Peers we are (us) or that we already track by DID are skipped.
@@ -142,7 +144,7 @@ async fn merge_transitive_peers(
pubkey: hint.pubkey.clone(),
onion: hint.onion.clone(),
name: hint.name.clone(),
trust_level: TrustLevel::Observer,
trust_level: TrustLevel::Trusted,
added_at: chrono::Utc::now().to_rfc3339(),
last_seen: None,
last_state: None,

View File

@@ -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<UpdateState> {
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<UpdateState> {
Ok(resp) if resp.status().is_success() => {
match resp.json::<UpdateManifest>().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<UpdateState> {
);
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;
@@ -244,6 +278,14 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
let mut downloaded = 0u64;
let total_bytes: u64 = manifest.components.iter().map(|c| c.size_bytes).sum();
info!(
version = %manifest.version,
components = manifest.components.len(),
total_bytes,
staging = %staging_dir.display(),
"Starting update download"
);
// Clear any stale cancel flag from a prior aborted run, then seed
// the live counters so polls during the handshake show the right
// denominator immediately instead of 0/0 → NaN%.
@@ -477,17 +519,27 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
let staging = data_dir.join("update-staging");
if staging.exists() {
let _ = tokio::fs::remove_dir_all(&staging).await;
}
let wiped = if staging.exists() {
tokio::fs::remove_dir_all(&staging).await.is_ok()
} else {
false
};
// Clear the "downloaded, ready to apply" marker too — a canceled
// download is not a staged update.
let mut cleared_marker = false;
if let Ok(mut state) = load_state(data_dir).await {
if state.update_in_progress {
state.update_in_progress = false;
let _ = save_state(data_dir, &state).await;
cleared_marker = true;
}
}
info!(
staging = %staging.display(),
wiped,
cleared_marker,
"Update download canceled"
);
Ok(())
}
@@ -529,6 +581,12 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
.await
.context("Failed to create backup dir")?;
info!(
staging = %staging_dir.display(),
backup = %backup_dir.display(),
"Applying staged update"
);
// Back up current backend binary
let current_binary = Path::new("/usr/local/bin/archipelago");
if current_binary.exists() {
@@ -902,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();
@@ -937,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]
@@ -993,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);
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.3.5",
"version": "1.6.0-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.3.5",
"version": "1.6.0-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",

View File

@@ -180,6 +180,29 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.19-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.19-alpha</span>
<span class="text-xs text-white/40">Apr 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>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.</p>
<p>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.</p>
<p>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.</p>
</div>
</div>
<!-- v1.7.18-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.18-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Nodes discovered through a trusted peer now land as Trusted instead of Observer. When your federated peer shares its own peer list with you, those nodes get the same trust level as a direct invite the link they came through is already one you vetted, so you no longer need to promote them by hand before they can be used normally.</p>
<p>The update flow now writes clearer logs at every step. Start of download, cancel, and apply each emit a one-line entry to the system journal with the staging path and the affected files, so if a download misbehaves on your node it's easy to see exactly where it got to.</p>
</div>
</div>
<!-- v1.7.17-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">

View File

@@ -1,27 +1,27 @@
{
"version": "1.7.17-alpha",
"release_date": "2026-04-20",
"version": "1.7.19-alpha",
"release_date": "2026-04-21",
"changelog": [
"When a download gets stuck, you can now cancel it. A new Cancel Download button sits next to the progress bar — it stops the transfer, clears the partial file, and returns you to a clean state so you can retry. No more staring at a frozen bar with no way to recover.",
"Downloads that stall for 30 seconds or more now say so. The progress bar turns amber and shows 'Download appears stuck — try Cancel and start again' instead of just sitting silently at whatever percent it reached.",
"Canceling is fast. It no longer has to wait out the retry timer — the download bails within half a second, so you're not stuck watching a stuck screen while you wait to unstick it."
"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 older version is dropped on next restart — the System Update page only offers genuinely newer releases.",
"Version comparison is now numeric rather than alphabetic. 1.7.10 correctly outranks 1.7.9 (the old string-order would've got this backwards once patch numbers hit double digits), so update prompts 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, the node treats that as 'up to date' rather than offering the older version as an update."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.16-alpha",
"new_version": "1.7.17-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.17-alpha/archipelago",
"sha256": "57020053d8c587feb9e4761ca66dd3fac43edafe0e8198c399e7ca4246e7752d",
"size_bytes": 40649896
"current_version": "1.7.18-alpha",
"new_version": "1.7.19-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.19-alpha/archipelago",
"sha256": "c6ffb65ea999c5212e0f93201a9ad77941810c00ea91dfb7397a07358a8a464e",
"size_bytes": 40648312
},
{
"name": "archipelago-frontend-1.7.17-alpha.tar.gz",
"current_version": "1.7.16-alpha",
"new_version": "1.7.17-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.17-alpha/archipelago-frontend-1.7.17-alpha.tar.gz",
"sha256": "59679f6d45c11f44ffb5dbd060ffca00022789aa830e731640bcb41be07d7a93",
"size_bytes": 162083786
"name": "archipelago-frontend-1.7.19-alpha.tar.gz",
"current_version": "1.7.18-alpha",
"new_version": "1.7.19-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.19-alpha/archipelago-frontend-1.7.19-alpha.tar.gz",
"sha256": "5f70534a3df7012d20ccd8b4134a84a197a082910a9cd45774af249eae9f8d6c",
"size_bytes": 162081700
}
]
}

Binary file not shown.

Binary file not shown.