fix(host): gate self-update to same-major releases

Cross-major releases are withheld from has_update with an explanatory warning, and perform_update rejects a cross-major target before fetching the release: config and migration contracts are only compatible within a major, so a one-click cross-major update previously produced a binary that fails to start. Same-major updates keep the existing flow.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
zyycn
2026-07-26 19:57:13 +08:00
co-authored by Claude
parent 93ab75e42b
commit cf7ff80f71
3 changed files with 60 additions and 6 deletions
@@ -33,6 +33,7 @@ use self::download::{MAX_CHECKSUM_SIZE, MAX_DOWNLOAD_SIZE, download_file, verify
use self::process::{environment_value, spawn_replacement};
use self::release::{
ReleaseCache, confirmed_target, detail_from_release, fetch_latest, select_archive,
versions_cross_major,
};
use self::state::{
OperationFileLock, UpdateTempDir, finish, operation_id, read_status, set_running,
@@ -226,6 +227,11 @@ impl ProcessSystemOperations {
return Err(conflict(reason));
}
let target = confirmed_target(target_version)?;
if versions_cross_major(&self.config.version, &target).unwrap_or(false) {
let reason = "跨大版本升级不提供自动更新,请按发布说明手动迁移";
self.events.error_terminal(None, Some("preflight"), reason);
return Err(conflict(reason));
}
let repository = self
.config
.update_repository
@@ -121,12 +121,13 @@ pub(crate) fn detail_from_release(
release: &GitHubRelease,
) -> SystemUpdateDetail {
let latest = normalize_version(&release.tag_name);
let available = (config.update_channel != "stable" || !release.prerelease)
let newer = (config.update_channel != "stable" || !release.prerelease)
&& version_is_newer(&config.version, &latest).unwrap_or(false);
let cross_major = versions_cross_major(&config.version, &latest).unwrap_or(false);
SystemUpdateDetail {
current_version: config.version.clone(),
latest_version: latest,
has_update: available,
latest_version: latest.clone(),
has_update: newer && !cross_major,
deployment_mode: config.deployment_mode.clone(),
build_type: config.build_type.clone(),
release_url: release.html_url.clone(),
@@ -134,7 +135,9 @@ pub(crate) fn detail_from_release(
cached: false,
update_supported: config.update_support_error().is_none(),
unsupported_reason: config.update_support_error(),
warning: None,
warning: (newer && cross_major).then(|| {
format!("检测到新的大版本 v{latest}:跨大版本升级不提供自动更新,请按发布说明手动迁移")
}),
}
}
@@ -265,6 +268,13 @@ fn version_is_newer(current: &str, latest: &str) -> Option<bool> {
Some(latest > current)
}
/// 自动更新只在同 major 内提供;跨大版本的配置与数据迁移契约不保证兼容。
pub(crate) fn versions_cross_major(current: &str, latest: &str) -> Option<bool> {
let current = semver::Version::parse(&normalize_version(current)).ok()?;
let latest = semver::Version::parse(&normalize_version(latest)).ok()?;
Some(current.major != latest.major)
}
fn repository_character(character: char) -> bool {
character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
}
@@ -18,7 +18,8 @@ use tar::{Builder, EntryType, Header};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const TARGET_VERSION: &str = "9.9.9";
const TARGET_VERSION: &str = "1.9.9";
const CROSS_MAJOR_VERSION: &str = "2.0.0";
#[tokio::test]
async fn restart_should_not_shutdown_when_replacement_spawn_fails() {
@@ -174,6 +175,43 @@ async fn update_detail_should_use_cached_release_when_not_refreshed() {
assert!(service.update_detail(false).await.is_ok());
}
#[tokio::test]
async fn update_detail_should_withhold_cross_major_release() {
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release_once(&server, CROSS_MAJOR_VERSION)
.await;
let detail = fixture
.service(&server)
.update_detail(true)
.await
.expect("detail");
assert_eq!(detail.latest_version, CROSS_MAJOR_VERSION);
assert!(!detail.has_update);
assert!(detail.warning.is_some());
}
#[tokio::test]
async fn update_should_reject_cross_major_target_before_fetching_release() {
let fixture = Fixture::new();
let service = ProcessSystemOperations::new(
CancellationToken::new(),
fixture.config("http://127.0.0.1:1/repos"),
);
let error = service
.perform_update(Some(CROSS_MAJOR_VERSION.to_owned()))
.await
.expect_err("cross-major target must be rejected");
assert_eq!(error.kind(), SystemOperationErrorKind::Conflict);
assert_eq!(
fs::read(fixture.executable()).expect("binary"),
b"old-binary"
);
}
#[tokio::test]
async fn update_events_should_close_after_terminal_update_log() {
let fixture = Fixture::new();
@@ -397,7 +435,7 @@ async fn update_should_reject_when_confirmed_target_differs_from_remote_latest()
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(&server, "9.9.8", ArchiveKind::Safe, ChecksumKind::Valid)
.mount_release(&server, "1.9.8", ArchiveKind::Safe, ChecksumKind::Valid)
.await;
assert!(