use crate::config::UpgradeChannel;
use crate::error::{Error, Result};
use crate::logging::{debug, info, warn};
use crate::upgrade::release_cache::ReleaseCache;
use crate::upgrade::rollout::StagedRollout;
use crate::upgrade::UpgradeInfo;
use semver::Version;
use serde::Deserialize;
use std::time::{Duration, Instant};
#[derive(Debug, Deserialize)]
pub struct GitHubRelease {
pub tag_name: String,
pub name: String,
pub body: String,
pub prerelease: bool,
pub assets: Vec<Asset>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Asset {
pub name: String,
pub browser_download_url: String,
}
pub struct UpgradeMonitor {
repo: String,
channel: UpgradeChannel,
check_interval: Duration,
current_version: Version,
client: reqwest::Client,
staged_rollout: Option<StagedRollout>,
release_cache: Option<ReleaseCache>,
pending_upgrade_detected: Option<Instant>,
pending_upgrade_version: Option<Version>,
}
impl UpgradeMonitor {
#[must_use]
pub fn new(repo: String, channel: UpgradeChannel, check_interval_hours: u64) -> Self {
let current_version =
Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or_else(|_| Version::new(0, 0, 0));
let client = reqwest::Client::builder()
.user_agent(concat!("ant-node/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(30))
.build()
.unwrap_or_else(|e| {
warn!("Failed to build reqwest client for upgrades: {e}");
reqwest::Client::new()
});
Self {
repo,
channel,
check_interval: Duration::from_secs(check_interval_hours * 3600),
current_version,
client,
staged_rollout: None,
release_cache: None,
pending_upgrade_detected: None,
pending_upgrade_version: None,
}
}
#[must_use]
pub fn with_release_cache(mut self, cache: ReleaseCache) -> Self {
self.release_cache = Some(cache);
self
}
#[must_use]
pub fn with_staged_rollout(mut self, node_id: &[u8], max_delay_hours: u64) -> Self {
if max_delay_hours > 0 {
self.staged_rollout = Some(StagedRollout::new(node_id, max_delay_hours));
info!("Staged rollout enabled: {} hour window", max_delay_hours);
}
self
}
#[cfg(test)]
#[must_use]
pub fn with_version(
repo: String,
channel: UpgradeChannel,
check_interval_hours: u64,
current_version: Version,
) -> Self {
let client = reqwest::Client::builder()
.user_agent(concat!("ant-node/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(30))
.build()
.unwrap_or_else(|e| {
warn!("Failed to build reqwest client for upgrades: {e}");
reqwest::Client::new()
});
Self {
repo,
channel,
check_interval: Duration::from_secs(check_interval_hours * 3600),
current_version,
client,
staged_rollout: None,
release_cache: None,
pending_upgrade_detected: None,
pending_upgrade_version: None,
}
}
#[must_use]
pub fn check_interval(&self) -> Duration {
self.check_interval
}
#[must_use]
pub fn current_version(&self) -> &Version {
&self.current_version
}
#[must_use]
pub fn repo(&self) -> &str {
&self.repo
}
#[must_use]
pub fn version_matches_channel(&self, version: &Version) -> bool {
match self.channel {
UpgradeChannel::Stable => version.pre.is_empty(),
UpgradeChannel::Beta => true, }
}
pub async fn check_for_updates(&self) -> Result<Option<UpgradeInfo>> {
if let Some(ref cache) = self.release_cache {
if let Some(cached_releases) = cache.read_if_valid(&self.repo) {
info!(
"Using cached release info ({} releases)",
cached_releases.len()
);
return Ok(select_upgrade_from_releases(
&cached_releases,
&self.current_version,
self.channel,
));
}
let cache_clone = cache.clone();
let repo_clone = self.repo.clone();
let (lock_guard, rechecked) =
tokio::task::spawn_blocking(move || cache_clone.lock_and_recheck(&repo_clone))
.await
.map_err(|e| Error::Upgrade(format!("Cache lock task failed: {e}")))??;
if let Some(cached_releases) = rechecked {
info!(
"Using cached release info after lock ({} releases)",
cached_releases.len()
);
return Ok(select_upgrade_from_releases(
&cached_releases,
&self.current_version,
self.channel,
));
}
info!("No valid cache under lock, fetching from API");
let releases = self.fetch_releases_from_api().await?;
if let Err(e) = cache.write_under_lock(lock_guard, &self.repo, &releases) {
warn!("Failed to write release cache: {e}");
}
return Ok(select_upgrade_from_releases(
&releases,
&self.current_version,
self.channel,
));
}
let releases = self.fetch_releases_from_api().await?;
Ok(select_upgrade_from_releases(
&releases,
&self.current_version,
self.channel,
))
}
pub async fn check_for_ready_upgrade(&mut self) -> Result<Option<UpgradeInfo>> {
let upgrade_info = self.check_for_updates().await?;
let Some(info) = upgrade_info else {
self.pending_upgrade_detected = None;
self.pending_upgrade_version = None;
return Ok(None);
};
let Some(ref rollout) = self.staged_rollout else {
let restart_time = chrono::Utc::now();
info!(
"Node will stop/restart for upgrade at {}",
restart_time.to_rfc3339()
);
return Ok(Some(info));
};
let is_new_version = self
.pending_upgrade_version
.as_ref()
.map_or(true, |v| *v != info.version);
if is_new_version {
self.pending_upgrade_detected = Some(Instant::now());
self.pending_upgrade_version = Some(info.version.clone());
let delay = rollout.calculate_delay_for_version(&info.version);
let restart_time = chrono::Utc::now()
+ chrono::Duration::from_std(delay).unwrap_or_else(|_| chrono::Duration::hours(1));
info!(
new_version = %info.version,
delay_hours = delay.as_secs() / 3600,
delay_minutes = (delay.as_secs() % 3600) / 60,
"New version detected, staged rollout delay calculated"
);
info!(
"Node will stop/restart for upgrade at {}",
restart_time.to_rfc3339()
);
}
let Some(detected_at) = self.pending_upgrade_detected else {
warn!("Pending upgrade detected but no timestamp recorded");
return Ok(Some(info));
};
let delay = rollout.calculate_delay_for_version(&info.version);
let elapsed = detected_at.elapsed();
if elapsed >= delay {
info!(
version = %info.version,
"Staged rollout delay elapsed, ready to upgrade"
);
Ok(Some(info))
} else {
let remaining = delay.saturating_sub(elapsed);
debug!(
"Staged rollout: {}h {}m remaining before upgrade to {}",
remaining.as_secs() / 3600,
(remaining.as_secs() % 3600) / 60,
info.version
);
Ok(None)
}
}
async fn fetch_releases_from_api(&self) -> Result<Vec<GitHubRelease>> {
let api_url = format!("https://api.github.com/repos/{}/releases", self.repo);
debug!("Checking for updates from: {}", api_url);
let response = self
.client
.get(&api_url)
.header("Accept", "application/vnd.github+json")
.send()
.await
.map_err(|e| Error::Network(format!("GitHub API request failed: {e}")))?;
if !response.status().is_success() {
return Err(Error::Network(format!(
"GitHub API returned status: {}",
response.status()
)));
}
response
.json()
.await
.map_err(|e| Error::Network(format!("Failed to parse releases: {e}")))
}
#[must_use]
pub fn time_until_upgrade(&self) -> Option<Duration> {
let rollout = self.staged_rollout.as_ref()?;
let version = self.pending_upgrade_version.as_ref()?;
let detected_at = self.pending_upgrade_detected?;
let delay = rollout.calculate_delay_for_version(version);
let elapsed = detected_at.elapsed();
if elapsed >= delay {
Some(Duration::ZERO)
} else {
Some(delay.saturating_sub(elapsed))
}
}
#[must_use]
pub fn has_staged_rollout(&self) -> bool {
self.staged_rollout.is_some()
}
#[must_use]
pub fn pending_version(&self) -> Option<&Version> {
self.pending_upgrade_version.as_ref()
}
#[allow(dead_code)]
fn process_release(&self, release: &GitHubRelease) -> Option<UpgradeInfo> {
let latest_version = version_from_tag(&release.tag_name)?;
if latest_version <= self.current_version {
debug!("Current version {} is up to date", self.current_version);
return None;
}
if !self.version_matches_channel(&latest_version) {
debug!(
"Version {} doesn't match channel {:?}",
latest_version, self.channel
);
return None;
}
let binary_asset = find_platform_asset(&release.assets)?;
let sig_name = format!("{}.sig", binary_asset.name);
let sig_asset = release.assets.iter().find(|a| a.name == sig_name)?;
info!(
current_version = %self.current_version,
new_version = %latest_version,
"New version available"
);
Some(UpgradeInfo {
version: latest_version,
download_url: binary_asset.browser_download_url.clone(),
signature_url: sig_asset.browser_download_url.clone(),
release_notes: release.body.clone(),
})
}
}
fn select_upgrade_from_releases(
releases: &[GitHubRelease],
current_version: &Version,
channel: UpgradeChannel,
) -> Option<UpgradeInfo> {
let mut best: Option<UpgradeInfo> = None;
for release in releases {
let Some(version) = version_from_tag(&release.tag_name) else {
continue;
};
if version <= *current_version {
continue;
}
if channel == UpgradeChannel::Stable && !version.pre.is_empty() {
continue;
}
let Some(binary_asset) = find_platform_asset(&release.assets) else {
continue;
};
let sig_name = format!("{}.sig", binary_asset.name);
let Some(sig_asset) = release.assets.iter().find(|a| a.name == sig_name) else {
continue;
};
let candidate = UpgradeInfo {
version: version.clone(),
download_url: binary_asset.browser_download_url.clone(),
signature_url: sig_asset.browser_download_url.clone(),
release_notes: release.body.clone(),
};
let should_replace = best
.as_ref()
.map_or(true, |b| candidate.version > b.version);
if should_replace {
best = Some(candidate);
}
}
best
}
#[must_use]
pub fn version_from_tag(tag: &str) -> Option<Version> {
let version_str = tag.strip_prefix('v').unwrap_or(tag);
Version::parse(version_str).ok()
}
#[must_use]
pub fn find_platform_asset(assets: &[Asset]) -> Option<&Asset> {
let arch = std::env::consts::ARCH;
let os = std::env::consts::OS;
let patterns = build_platform_patterns(arch, os);
for pattern in &patterns {
if let Some(asset) = assets
.iter()
.find(|a| a.name.contains(pattern) && is_binary_asset(&a.name))
{
return Some(asset);
}
}
None
}
#[allow(clippy::case_sensitive_file_extension_comparisons)]
fn is_binary_asset(name: &str) -> bool {
let lower = name.to_lowercase();
if lower.ends_with(".sig")
|| lower.ends_with(".sha256")
|| lower.ends_with(".md5")
|| lower.ends_with(".txt")
|| lower.ends_with(".md")
|| lower.ends_with(".deb")
|| lower.ends_with(".rpm")
|| lower.ends_with(".msi")
{
return false;
}
if lower.ends_with(".tar.gz") || lower.ends_with(".zip") {
return true;
}
#[cfg(windows)]
if !lower.ends_with(".exe") {
return false;
}
true
}
fn build_platform_patterns(arch: &str, os: &str) -> Vec<String> {
let mut patterns = Vec::new();
let arch_patterns: Vec<&str> = match arch {
"x86_64" => vec!["x86_64", "amd64", "x64"],
"aarch64" => vec!["aarch64", "arm64"],
"x86" => vec!["i686", "i386", "x86"],
_ => vec![arch],
};
let os_patterns: Vec<&str> = match os {
"linux" => vec!["linux", "unknown-linux-gnu", "linux-gnu"],
"macos" => vec!["darwin", "macos", "apple-darwin"],
"windows" => vec!["windows", "pc-windows-msvc", "win64"],
_ => vec![os],
};
for arch_pat in &arch_patterns {
for os_pat in &os_patterns {
patterns.push(format!("{arch_pat}-{os_pat}"));
patterns.push(format!("{os_pat}-{arch_pat}"));
}
}
for arch_pat in &arch_patterns {
patterns.push((*arch_pat).to_string());
}
patterns
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::case_sensitive_file_extension_comparisons
)]
mod tests {
use super::*;
#[test]
fn test_version_newer_available() {
let current = Version::new(1, 0, 0);
let latest = Version::new(1, 1, 0);
assert!(latest > current);
}
#[test]
fn test_version_same() {
let current = Version::new(1, 0, 0);
let latest = Version::new(1, 0, 0);
assert!(latest <= current);
}
#[test]
fn test_version_older_rejected() {
let current = Version::new(1, 1, 0);
let latest = Version::new(1, 0, 0);
assert!(latest <= current);
}
#[test]
fn test_prerelease_version() {
let stable = Version::parse("1.0.0").unwrap();
let beta = Version::parse("1.1.0-beta.1").unwrap();
assert!(beta > stable);
}
#[test]
fn test_stable_channel_filters_beta() {
let monitor = UpgradeMonitor::new(
"WithAutonomi/ant-node".to_string(),
UpgradeChannel::Stable,
24,
);
let beta_version = Version::parse("1.0.0-beta.1").unwrap();
assert!(!monitor.version_matches_channel(&beta_version));
let stable_version = Version::parse("1.0.0").unwrap();
assert!(monitor.version_matches_channel(&stable_version));
}
#[test]
fn test_beta_channel_accepts_beta() {
let monitor = UpgradeMonitor::new(
"WithAutonomi/ant-node".to_string(),
UpgradeChannel::Beta,
24,
);
let beta_version = Version::parse("1.0.0-beta.1").unwrap();
assert!(monitor.version_matches_channel(&beta_version));
}
#[test]
fn test_parse_github_release() {
let json = r#"{
"tag_name": "v1.2.0",
"name": "Release 1.2.0",
"body": "Release notes here",
"prerelease": false,
"assets": [
{
"name": "ant-node-x86_64-unknown-linux-gnu",
"browser_download_url": "https://example.com/binary"
},
{
"name": "ant-node-x86_64-unknown-linux-gnu.sig",
"browser_download_url": "https://example.com/binary.sig"
}
]
}"#;
let release: GitHubRelease = serde_json::from_str(json).unwrap();
assert_eq!(release.tag_name, "v1.2.0");
assert_eq!(release.name, "Release 1.2.0");
assert_eq!(release.body, "Release notes here");
assert!(!release.prerelease);
assert_eq!(release.assets.len(), 2);
}
#[test]
fn test_version_from_tag() {
assert_eq!(version_from_tag("v1.2.3"), Some(Version::new(1, 2, 3)));
assert_eq!(version_from_tag("1.2.3"), Some(Version::new(1, 2, 3)));
assert_eq!(
version_from_tag("v1.0.0-beta.1"),
Some(Version::parse("1.0.0-beta.1").unwrap())
);
assert_eq!(version_from_tag("invalid"), None);
assert_eq!(version_from_tag(""), None);
}
#[test]
fn test_find_platform_asset() {
let assets = vec![
Asset {
name: "ant-node-cli-linux-x64.tar.gz".to_string(),
browser_download_url: "https://example.com/linux".to_string(),
},
Asset {
name: "ant-node-cli-linux-x64.tar.gz.sig".to_string(),
browser_download_url: "https://example.com/linux.sig".to_string(),
},
Asset {
name: "ant-node-cli-macos-arm64.tar.gz".to_string(),
browser_download_url: "https://example.com/macos".to_string(),
},
Asset {
name: "ant-node-cli-macos-arm64.tar.gz.sig".to_string(),
browser_download_url: "https://example.com/macos.sig".to_string(),
},
Asset {
name: "ant-node-cli-windows-x64.zip".to_string(),
browser_download_url: "https://example.com/windows".to_string(),
},
Asset {
name: "ant-node-cli-windows-x64.zip.sig".to_string(),
browser_download_url: "https://example.com/windows.sig".to_string(),
},
];
let asset = find_platform_asset(&assets);
assert!(asset.is_some(), "Should find platform asset");
let asset = asset.unwrap();
assert!(!asset.name.to_lowercase().ends_with(".sig"));
let lower = asset.name.to_lowercase();
assert!(
lower.ends_with(".tar.gz") || lower.ends_with(".zip"),
"Should be an archive format"
);
}
#[test]
fn test_is_binary_asset() {
assert!(is_binary_asset("ant-node-cli-linux-x64.tar.gz"));
assert!(is_binary_asset("ant-node-cli-macos-arm64.tar.gz"));
assert!(is_binary_asset("ant-node-cli-windows-x64.zip"));
assert!(!is_binary_asset("ant-node.sig"));
assert!(!is_binary_asset("ant-node.sha256"));
assert!(!is_binary_asset("ant-node.md5"));
assert!(!is_binary_asset("RELEASE_NOTES.txt"));
assert!(!is_binary_asset("README.md"));
assert!(!is_binary_asset("ant-node.deb"));
assert!(!is_binary_asset("ant-node.rpm"));
assert!(!is_binary_asset("ant-node.msi"));
}
#[test]
fn test_check_interval() {
let monitor = UpgradeMonitor::new("test/repo".to_string(), UpgradeChannel::Stable, 24);
assert_eq!(monitor.check_interval(), Duration::from_secs(24 * 3600));
let monitor2 = UpgradeMonitor::new("test/repo".to_string(), UpgradeChannel::Stable, 6);
assert_eq!(monitor2.check_interval(), Duration::from_secs(6 * 3600));
}
#[test]
fn test_process_release_upgrade_available() {
let monitor = UpgradeMonitor::with_version(
"test/repo".to_string(),
UpgradeChannel::Stable,
24,
Version::new(1, 0, 0),
);
let (friendly_os, archive_ext) = match std::env::consts::OS {
"linux" => ("linux", "tar.gz"),
"macos" => ("macos", "tar.gz"),
"windows" => ("windows", "zip"),
_ => ("unknown", "tar.gz"),
};
let friendly_arch = match std::env::consts::ARCH {
"x86_64" => "x64",
"aarch64" => "arm64",
_ => std::env::consts::ARCH,
};
let archive_name = format!("ant-node-cli-{friendly_os}-{friendly_arch}.{archive_ext}");
let release = GitHubRelease {
tag_name: "v1.1.0".to_string(),
name: "Release 1.1.0".to_string(),
body: "New features".to_string(),
prerelease: false,
assets: vec![
Asset {
name: archive_name.clone(),
browser_download_url: "https://example.com/binary".to_string(),
},
Asset {
name: format!("{archive_name}.sig"),
browser_download_url: "https://example.com/binary.sig".to_string(),
},
],
};
let result = monitor.process_release(&release);
assert!(result.is_some(), "Should find upgrade");
let info = result.unwrap();
assert_eq!(info.version, Version::new(1, 1, 0));
assert_eq!(info.release_notes, "New features");
}
#[test]
fn test_process_release_no_upgrade_same_version() {
let monitor = UpgradeMonitor::with_version(
"test/repo".to_string(),
UpgradeChannel::Stable,
24,
Version::new(1, 0, 0),
);
let release = GitHubRelease {
tag_name: "v1.0.0".to_string(),
name: "Release 1.0.0".to_string(),
body: "Current version".to_string(),
prerelease: false,
assets: vec![],
};
let result = monitor.process_release(&release);
assert!(result.is_none(), "Should not find upgrade for same version");
}
#[test]
fn test_process_release_no_upgrade_older_version() {
let monitor = UpgradeMonitor::with_version(
"test/repo".to_string(),
UpgradeChannel::Stable,
24,
Version::new(1, 1, 0),
);
let release = GitHubRelease {
tag_name: "v1.0.0".to_string(),
name: "Release 1.0.0".to_string(),
body: "Old version".to_string(),
prerelease: false,
assets: vec![],
};
let result = monitor.process_release(&release);
assert!(
result.is_none(),
"Should not find upgrade for older version"
);
}
#[test]
fn test_process_release_beta_filtered() {
let monitor = UpgradeMonitor::with_version(
"test/repo".to_string(),
UpgradeChannel::Stable,
24,
Version::new(1, 0, 0),
);
let release = GitHubRelease {
tag_name: "v1.1.0-beta.1".to_string(),
name: "Beta Release".to_string(),
body: "Beta features".to_string(),
prerelease: true,
assets: vec![],
};
let result = monitor.process_release(&release);
assert!(
result.is_none(),
"Stable channel should filter beta releases"
);
}
#[test]
fn test_monitor_repo() {
let monitor = UpgradeMonitor::new(
"WithAutonomi/ant-node".to_string(),
UpgradeChannel::Stable,
24,
);
assert_eq!(monitor.repo(), "WithAutonomi/ant-node");
}
#[test]
fn test_monitor_current_version() {
let monitor = UpgradeMonitor::with_version(
"test/repo".to_string(),
UpgradeChannel::Stable,
24,
Version::new(2, 3, 4),
);
assert_eq!(*monitor.current_version(), Version::new(2, 3, 4));
}
#[test]
fn test_build_platform_patterns() {
let patterns = build_platform_patterns("x86_64", "linux");
assert!(patterns.iter().any(|p| p.contains("x86_64")));
assert!(patterns.iter().any(|p| p.contains("x64")));
assert!(patterns.iter().any(|p| p.contains("linux")));
let patterns_arm = build_platform_patterns("aarch64", "macos");
assert!(patterns_arm
.iter()
.any(|p| p.contains("aarch64") || p.contains("arm64")));
assert!(patterns_arm
.iter()
.any(|p| p.contains("darwin") || p.contains("macos")));
}
#[test]
fn test_process_release_invalid_tag() {
let monitor = UpgradeMonitor::with_version(
"test/repo".to_string(),
UpgradeChannel::Stable,
24,
Version::new(1, 0, 0),
);
let release = GitHubRelease {
tag_name: "not-a-version".to_string(),
name: "Invalid Release".to_string(),
body: "Invalid".to_string(),
prerelease: false,
assets: vec![],
};
let result = monitor.process_release(&release);
assert!(result.is_none(), "Should gracefully handle invalid tag");
}
#[test]
fn test_select_upgrade_stable_ignores_prerelease() {
let current = Version::new(1, 0, 0);
let arch = std::env::consts::ARCH;
let os = std::env::consts::OS;
#[cfg(windows)]
let bin_name = format!("ant-node-{arch}-{os}.exe");
#[cfg(not(windows))]
let bin_name = format!("ant-node-{arch}-{os}");
let releases = vec![
GitHubRelease {
tag_name: "v1.1.0-beta.1".to_string(),
name: "beta".to_string(),
body: "beta".to_string(),
prerelease: true,
assets: vec![
Asset {
name: bin_name.clone(),
browser_download_url: "https://example.com/beta".to_string(),
},
Asset {
name: format!("{bin_name}.sig"),
browser_download_url: "https://example.com/beta.sig".to_string(),
},
],
},
GitHubRelease {
tag_name: "v1.1.0".to_string(),
name: "stable".to_string(),
body: "stable".to_string(),
prerelease: false,
assets: vec![
Asset {
name: bin_name.clone(),
browser_download_url: "https://example.com/stable".to_string(),
},
Asset {
name: format!("{bin_name}.sig"),
browser_download_url: "https://example.com/stable.sig".to_string(),
},
],
},
];
let upgrade =
select_upgrade_from_releases(&releases, ¤t, UpgradeChannel::Stable).unwrap();
assert_eq!(upgrade.version, Version::new(1, 1, 0));
assert!(upgrade.download_url.contains("stable"));
}
#[test]
fn test_select_upgrade_beta_accepts_prerelease_if_newest() {
let current = Version::new(1, 0, 0);
let arch = std::env::consts::ARCH;
let os = std::env::consts::OS;
#[cfg(windows)]
let bin_name = format!("ant-node-{arch}-{os}.exe");
#[cfg(not(windows))]
let bin_name = format!("ant-node-{arch}-{os}");
let releases = vec![
GitHubRelease {
tag_name: "v1.1.0".to_string(),
name: "stable".to_string(),
body: "stable".to_string(),
prerelease: false,
assets: vec![
Asset {
name: bin_name.clone(),
browser_download_url: "https://example.com/stable".to_string(),
},
Asset {
name: format!("{bin_name}.sig"),
browser_download_url: "https://example.com/stable.sig".to_string(),
},
],
},
GitHubRelease {
tag_name: "v1.2.0-beta.1".to_string(),
name: "beta".to_string(),
body: "beta".to_string(),
prerelease: true,
assets: vec![
Asset {
name: bin_name.clone(),
browser_download_url: "https://example.com/beta".to_string(),
},
Asset {
name: format!("{bin_name}.sig"),
browser_download_url: "https://example.com/beta.sig".to_string(),
},
],
},
];
let upgrade =
select_upgrade_from_releases(&releases, ¤t, UpgradeChannel::Beta).unwrap();
assert_eq!(upgrade.version, Version::parse("1.2.0-beta.1").unwrap());
assert!(upgrade.download_url.contains("beta"));
}
}