From cf7ff80f717b89b88a948b0ac82fa925d13a970b Mon Sep 17 00:00:00 2001 From: zyycn Date: Sun, 26 Jul 2026 19:57:13 +0800 Subject: [PATCH] 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 --- .../gateway-host/src/system_update/mod.rs | 6 +++ .../gateway-host/src/system_update/release.rs | 18 ++++++-- .../gateway-host/tests/system_update/mod.rs | 42 ++++++++++++++++++- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/backend/crates/gateway-host/src/system_update/mod.rs b/backend/crates/gateway-host/src/system_update/mod.rs index aa456ebb0..ddd3ae423 100644 --- a/backend/crates/gateway-host/src/system_update/mod.rs +++ b/backend/crates/gateway-host/src/system_update/mod.rs @@ -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 diff --git a/backend/crates/gateway-host/src/system_update/release.rs b/backend/crates/gateway-host/src/system_update/release.rs index ebf736d16..da0d44017 100644 --- a/backend/crates/gateway-host/src/system_update/release.rs +++ b/backend/crates/gateway-host/src/system_update/release.rs @@ -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 { Some(latest > current) } +/// 自动更新只在同 major 内提供;跨大版本的配置与数据迁移契约不保证兼容。 +pub(crate) fn versions_cross_major(current: &str, latest: &str) -> Option { + 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, '-' | '_' | '.') } diff --git a/backend/crates/gateway-host/tests/system_update/mod.rs b/backend/crates/gateway-host/tests/system_update/mod.rs index 0540c6833..1d2afa2d2 100644 --- a/backend/crates/gateway-host/tests/system_update/mod.rs +++ b/backend/crates/gateway-host/tests/system_update/mod.rs @@ -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!(