From a1ba6c0b61270e2d0ac186cffe31be4a99b35cc1 Mon Sep 17 00:00:00 2001 From: Zyy Date: Sat, 19 Sep 2026 22:15:51 +0800 Subject: [PATCH] fix(update): enforce release channel upgrade rules Refs #169 Co-authored-by: Codex --- .agents/skills/release/SKILL.md | 23 +- .../skills/release/references/experimental.md | 13 +- .github/workflows/release.yml | 8 +- .../gateway-host/src/system_update/mod.rs | 46 +- .../gateway-host/src/system_update/release.rs | 194 ++++++-- .../gateway-host/tests/system_update/mod.rs | 424 ++++++++++++++++-- backend/migrations/README.md | 4 + deploy/README.md | 31 +- deploy/compose.yaml | 1 - docs/api.md | 10 +- .../components/SystemUpdateModal/index.vue | 14 +- .../components/SystemUpdateModal/presenter.ts | 2 +- frontend/src/stores/modules/system-update.ts | 3 - release/publish | 4 +- 14 files changed, 660 insertions(+), 117 deletions(-) diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 131afbae3..0c43172b8 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -1,6 +1,6 @@ --- name: release -description: 准备或执行当前仓库的稳定版、实验版发版,维护中文发布说明。用于“发版”“发布实验版/exp 版本”“准备 vX.Y.Z”“推荐版本号”“整理发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing stable or experimental releases; not for deploying/restarting an instance or authoring a release skill. +description: 准备或执行当前仓库的正式版、alpha/beta/rc 预发行版和 exp 实验版发版,维护中文发布说明。用于“发版”“发布 beta/rc/实验版”“准备 vX.Y.Z”“推荐版本号”“整理发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing stable, prerelease, or experimental releases; not for deploying/restarting an instance or authoring a release skill. --- # 发版 @@ -12,6 +12,7 @@ description: 准备或执行当前仓库的稳定版、实验版发版,维护 | 推荐版本号 | 根据本次变更与已有版本给出建议及理由 | | 整理更新日志、写 note、准备发版 | 核对变更、编写说明、检查发布条件;未要求提交时保留本地改动 | | 正式发布、发布指定版本、打版本 tag | 在已授权范围内执行对应步骤;仅打 tag 的请求不自动扩展为完整发版 | +| 发布 alpha、beta、rc | 从 `main` 发布,确定目标正式版本和阶段,核对预发行标记与晋级路径 | | 发布实验版、exp 版本 | 从目标实验分支发布,按下方实验版流程确定版本、文案和渠道隔离 | | 重试失败的发版 | 核对上次执行状态,从未完成的步骤恢复 | @@ -19,7 +20,9 @@ description: 准备或执行当前仓库的稳定版、实验版发版,维护 “准备好了”“可以发版吗”属于准备或检查,不能解释成正式发布;创建本技能也不触发发版。 发行版发布与运行实例升级分开判断,“上线”需结合上下文确定用户指哪一种。 -稳定版从 `main` 发布。实验版从对应的 `experimental/<用途>` 分支发布,版本与 tag 采用 `vX.Y.Z-exp.N`, +稳定版以及面向下一正式版本的 alpha、beta、rc 从 `main` 发布,预发行 tag 使用 `vX.Y.Z-<阶段>.N`。 +发版前读取[版本命名与升级规则](../../../deploy/README.md#版本命名与升级规则),作为命名、阶段晋级和更新边界的权威约定。 +实验版从对应的 `experimental/<用途>` 分支发布,版本与 tag 采用 `vX.Y.Z-exp.N`, 展示标题包含分支用途名;先读 [实验版发布](references/experimental.md) 确定标题与编号,再复用本文的准备、执行与验收步骤。 实验功能是否合入 `main` 是单独的维护决策,发布实验版不包含合并功能的授权。 @@ -38,6 +41,20 @@ description: 准备或执行当前仓库的稳定版、实验版发版,维护 命令从仓库根目录执行;每次以当前脚本和工作流为准,不另写发布脚本或绕过已有质量检查。 用户明确指定其他仓库时先读取其发布流程,不能直接套用这里的路径和命令。 +## 发行通道核对 + +- alpha 是下一正式版本的早期开发阶段,beta 是功能测试阶段,rc 是发布候选,exp 是临时功能实验。 + 预发行采用 `vX.Y.Z-<阶段>.N`,`N` 从 1 开始;正式版不带预发行后缀,不新增未约定的通道名称。 +- 正式版只接收同一大版本内更新的正式版。alpha → beta → rc → 正式版只在相同 `X.Y.Z` 内晋级, + 可以跳过阶段,不能倒退;转正后不自动进入下一轮预发行。exp 仅接收同基线、同一轮实验的后续编号, + 不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。 +- 核对目标工作流的构建类型:正式版及 alpha、beta、rc 为 `release`,exp 为 `experimental`。 + 所有预发行均标记为 Pre-release,不设置 GitHub Latest、不覆盖镜像 `latest`。 + 发布预发行前记录这两个稳定入口,发布后回读确认未改变。 +- 验收更新器时,核对检查更新与执行更新使用相同规则。只有当前构建支持更新且存在允许的更高目标时 + 才能返回 `hasUpdate=true`;被禁止的目标即使版本领先,也不能出现在更新标记、可用版本或发布说明中。 + 检查失败须单独报告。核对上一发行实际包含的更新能力,不能因新包支持在线更新就宣称旧包也能在线安装它。 + ## 准备版本与说明 1. 确认发布类型、对应分支、upstream、目标仓库、工作区和完整待推送范围; @@ -53,7 +70,7 @@ description: 准备或执行当前仓库的稳定版、实验版发版,维护 5. 替换为本次版本说明,首行与目标 tag 一致且正文非空。升级事项应能追溯到实际迁移、配置或兼容变化; 敏感信息脱敏,未合入功能不宣称已发布。已有准确说明仅需翻译时保持其事实和范围。 6. 按贡献规范检查变更与现有 CI 证据,明确通过、失败、跳过和未执行。 - 涉及数据库迁移时,对照同一大版本的上一正式 Release 检查已发布迁移是否保持不变,并验证升级路径; + 涉及数据库迁移时,对照同一大版本的上一正式 Release 及本轮预发行/实验线的已发布版本检查迁移是否保持不变,并验证升级路径; 跨大版本按项目部署约定判断,不默认支持在线升级。区分未发布迁移的整理、已执行这些迁移的源码部署实例, 以及 CI 实际采用的冻结基线。未发版不代表迁移从未被执行,CI 冻结检查失败也不等于正式版数据库升级失败; 按各自证据报告兼容性与检查结果,不因未发版而自动放宽现有冻结规则。 diff --git a/.agents/skills/release/references/experimental.md b/.agents/skills/release/references/experimental.md index 1598db1af..8c335a33a 100644 --- a/.agents/skills/release/references/experimental.md +++ b/.agents/skills/release/references/experimental.md @@ -8,8 +8,8 @@ - 从用户指定或会话已确定的 `experimental/<用途>` 分支发布,核对它的 upstream 和实验功能范围。 使用独立 worktree 时保留其他工作区内容;不把实验代码合入 `main`,也不顺带升级运行实例。 - 版本采用 `vX.Y.Z-exp.N`:`X.Y.Z` 表示本轮采用的正式版基线,`N` 从 1 开始递增。 - 编号在仓库内唯一;多个实验分支共享 Git tag 命名空间,不能分别占用相同版本号。 - 后续只有实际同步了新的正式版基线,才调整 `X.Y.Z`。 + 同一基线的 `exp.N` 只代表同一轮实验,不得用于不同实验分支;开始其他实验时使用不同的实际基线,不能仅接续编号。 + 后续只有实际同步了新的正式版基线,才调整 `X.Y.Z`;调整后属于另一实验线,发布说明需交代手动迁移方式。 - 核对已有 tag、Release 与目标分支源码,先区分重试和新发行。已发布的旧命名版本保留, 不为统一名称移动 tag 或替换原有产物。 - GitHub Release 展示标题采用 `vX.Y.Z-<用途>.M`,`<用途>` 取实验分支名去掉 `experimental/` 前缀后的部分。 @@ -24,9 +24,10 @@ 在目标实验分支读取实际工作流,确认以下行为;新实验分支若缺少支持,先在既有流程中补齐并验证: - `exp.N` 产生 `experimental` 构建类型,所有平台的二进制与镜像使用同一版本和源码提交。 - 本项目实验构建禁用应用内一键更新,用户按部署说明手动升级。 + 应用内更新仅允许同一轮实验,规则见[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则)。 + 发版前核对上一实验版是否已包含该更新器;仍禁用在线更新的旧构建需要先手动升级。 - GitHub Release 标为 Pre-release,且不设为 Latest;Docker 发布版本标签,不覆盖稳定版 `latest`。 - 默认 `stable` 更新渠道忽略实验版;主动配置为 `preview` 的用户可能看到预发行。 + 正式版和 alpha、beta、rc 均不接收实验版更新;环境变量不能改变发行线。 - 复用正式版平台清单、质量检查、完整安装包与多架构镜像,以及部署文件、校验和和现有产物证明。 对照本次清单确认平台,不以“实验版”为由仅发布单架构镜像。 - 部署说明交代与稳定实例并存的目录、配置、端口和数据库要求;需要 Compose 隔离配置时, @@ -45,7 +46,7 @@ 同步主分支修复时说明它对实验用户的影响,不复制整份正式版更新记录。 3. **安装与使用**:给出本版本真实镜像命令、支持平台、部署文件及实验部署文档链接。 交代功能是否默认启用和必要配置;保留工作流实际提供的校验与证明说明。 -4. **使用须知**:只写本次适用的已知限制、数据隔离、迁移与回退边界,以及手动更新方式。 +4. **使用须知**:只写本次适用的已知限制、数据隔离、迁移与回退边界,以及在线更新条件和手动迁移方式。 5. **反馈与致谢**:链接关联 Issue 或已有反馈入口,感谢实际贡献者。没有相应内容的章节可以省略。 用户要求先审核文案时,先展示完整正文;已批准的同一版本文案无需重复确认。 @@ -56,7 +57,7 @@ 按主技能准备说明和其他变更后,在实验分支执行 `release/publish `。 入口创建版本提交和 tag,并从该分支 dispatch 发布工作流;不要从 `main` 发布实验代码。 -发布前记录 GitHub Latest 的版本与 Docker `latest` 摘要;发布后核对二者仍对应稳定版。 +按主技能的「发行通道核对」确认稳定入口未改变,并检查实验线隔离与上一版本的更新能力。 检查展示标题的分支用途与历史续号、它所对应的实际 tag,以及目标 tag 的源码提交、完整产物、校验和、多架构平台和 Pre-release 标记。 同基线的 `X.Y.Z-exp.N` 按 SemVer 排序低于正式 `X.Y.Z`;渠道隔离仍以更新器过滤和发布标签规则为准。 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34f07f735..6fa69a51a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,7 @@ jobs: docker_platforms: ${{ steps.version.outputs.docker_platforms }} previous_tag: ${{ steps.version.outputs.previous_tag }} prerelease: ${{ steps.version.outputs.prerelease }} + build_type: ${{ steps.version.outputs.build_type }} steps: - name: Checkout @@ -57,7 +58,7 @@ jobs: require "yaml" APP_NAME = "codex-proxy-rs" - SEMVER = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\z/ + SEMVER = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:alpha|beta|rc|exp)\.[1-9]\d*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z/ def fail(message) warn(message) @@ -161,6 +162,7 @@ jobs: puts "docker_platforms=#{docker_platforms.join(",")}" puts "previous_tag=#{previous_tag}" puts "prerelease=#{version.split("+", 2).first.include?("-")}" + puts "build_type=#{version.split("+", 2).first.include?("-exp.") ? "experimental" : "release"}" RUBY quality: @@ -221,7 +223,7 @@ jobs: CPR_VERSION: ${{ needs.release-info.outputs.version }} CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }} CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }} - CPR_BUILD_TYPE: release + CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }} - name: Upload binary artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -274,7 +276,7 @@ jobs: CPR_VERSION: ${{ needs.release-info.outputs.version }} CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }} CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }} - CPR_BUILD_TYPE: release + CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }} - name: Upload binary artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/backend/crates/gateway-host/src/system_update/mod.rs b/backend/crates/gateway-host/src/system_update/mod.rs index 66a3bbbe7..62a88187c 100644 --- a/backend/crates/gateway-host/src/system_update/mod.rs +++ b/backend/crates/gateway-host/src/system_update/mod.rs @@ -32,8 +32,8 @@ use self::archive::extract_release; use self::download::{MAX_CHECKSUM_SIZE, MAX_DOWNLOAD_SIZE, download_file, verify_checksum}; use self::process::{environment_value, spawn_replacement}; use self::release::{ - ReleaseCache, confirmed_target, detail_from_release, fetch_latest, select_archive, - versions_cross_major, + ReleaseCache, UpdateChannel, confirmed_target, detail_from_release, fetch_latest, + select_archive, validate_update_target, version_channel, }; use self::state::{ OperationFileLock, UpdateTempDir, finish, operation_id, read_status, set_running, @@ -57,7 +57,6 @@ pub struct SystemUpdateConfig { pub build_time: String, pub deployment_mode: String, pub build_type: String, - pub update_channel: String, pub update_repository: Option, pub github_api_base: String, pub executable_path: Option, @@ -98,8 +97,6 @@ impl Default for SystemUpdateConfig { .to_owned(), deployment_mode, build_type: option_env!("CPR_BUILD_TYPE").unwrap_or("source").to_owned(), - update_channel: environment_value("CPR_UPDATE_CHANNEL") - .unwrap_or_else(|| "stable".to_owned()), update_repository: Some( environment_value("CPR_UPDATE_REPOSITORY") .unwrap_or_else(|| DEFAULT_UPDATE_REPOSITORY.to_owned()), @@ -158,17 +155,18 @@ impl SystemUpdateConfig { "host.system_update.deployment_mode", )); } - if !matches!(self.update_channel.as_str(), "stable" | "preview") { - return Err(ConfigError::InvalidField( - "host.system_update.update_channel", - )); - } Ok(()) } fn update_support_error(&self) -> Option { - if self.build_type != "release" { - return Some("一键更新需要正式构建包".to_owned()); + if !matches!(self.build_type.as_str(), "release" | "experimental") { + return Some("在线更新需要官方发布构建".to_owned()); + } + let Some(channel) = version_channel(&self.version) else { + return Some("当前版本不符合发行命名规范,无法确定更新通道".to_owned()); + }; + if self.build_type == "experimental" && channel != UpdateChannel::Experimental { + return Some("实验构建必须使用 exp.N 版本,无法在线更新".to_owned()); } let Some(repository) = self.update_repository.as_deref() else { return Some("检查更新需要配置 CPR_UPDATE_REPOSITORY".to_owned()); @@ -187,13 +185,12 @@ impl SystemUpdateConfig { fn release_cache_key(&self) -> String { format!( - "{}|{}|{}|{}|{}|{}", + "{}|{}|{}|{}|{}", self.update_repository.as_deref().unwrap_or_default(), self.github_api_base, self.version, self.deployment_mode, self.build_type, - self.update_channel, ) } @@ -249,10 +246,10 @@ 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)); + if let Err(error) = validate_update_target(&self.config.version, &target) { + self.events + .error_terminal(None, Some("preflight"), error.to_string()); + return Err(error); } let repository = self .config @@ -264,11 +261,16 @@ impl ProcessSystemOperations { let release = match fetch_latest( &self.config.github_api_base, repository, - &self.config.update_channel, + &self.config.version, ) .await { - Ok(release) => release, + Ok(Some(release)) => release, + Ok(None) => { + let reason = "当前发行通道没有可用更新"; + self.events.warning_terminal(None, Some("release"), reason); + return Err(conflict(reason)); + } Err(error) => { self.events .error_terminal(None, Some("release"), error.to_string()); @@ -428,7 +430,9 @@ impl SystemOperations for ProcessSystemOperations { git_sha: self.config.git_sha.clone(), build_time: self.config.build_time.clone(), deployment_mode: self.config.deployment_mode.clone(), - update_channel: self.config.update_channel.clone(), + update_channel: version_channel(&self.config.version) + .map(|channel| channel.label().to_owned()) + .unwrap_or_else(|| "unknown".to_owned()), latest_version: detail.latest_version, has_update: detail.has_update, update_cached: detail.cached, diff --git a/backend/crates/gateway-host/src/system_update/release.rs b/backend/crates/gateway-host/src/system_update/release.rs index beaca2cab..a0583de8a 100644 --- a/backend/crates/gateway-host/src/system_update/release.rs +++ b/backend/crates/gateway-host/src/system_update/release.rs @@ -12,6 +12,27 @@ use super::{OperationError, SystemUpdateConfig, conflict, invalid, upstream}; const APP_BINARY_NAME: &str = "codex-proxy-rs"; const CACHE_TTL: Duration = Duration::from_secs(20 * 60); +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum UpdateChannel { + Stable, + Alpha, + Beta, + Rc, + Experimental, +} + +impl UpdateChannel { + pub(crate) fn label(&self) -> &str { + match self { + Self::Stable => "stable", + Self::Alpha => "alpha", + Self::Beta => "beta", + Self::Rc => "rc", + Self::Experimental => "exp", + } + } +} + #[derive(Debug, Clone, Deserialize)] pub(crate) struct GitHubRelease { pub(crate) tag_name: String, @@ -49,6 +70,9 @@ impl ReleaseCache { config: &SystemUpdateConfig, refresh: bool, ) -> Result { + if let Some(reason) = config.update_support_error() { + return Ok(super::base_update_detail(config, Some(reason), None)); + } let repository = config .update_repository .as_deref() @@ -59,9 +83,12 @@ impl ReleaseCache { if !refresh && let Some(detail) = self.cached(&key).await { return Ok(detail); } - match fetch_latest(&config.github_api_base, repository, &config.update_channel).await { + match fetch_latest(&config.github_api_base, repository, &config.version).await { Ok(release) => { - let detail = detail_from_release(config, &release); + let detail = release.as_ref().map_or_else( + || super::base_update_detail(config, config.update_support_error(), None), + |release| detail_from_release(config, release), + ); *self.entry.lock().await = Some(CachedRelease { key, detail: detail.clone(), @@ -69,7 +96,11 @@ impl ReleaseCache { }); Ok(detail) } - Err(error) => self.cached(&key).await.ok_or(error), + Err(error) => { + // 强制检查失败后清掉旧结果,避免后续版本查询再次显示旧的更新标记。 + *self.entry.lock().await = None; + Err(error) + } } } @@ -87,32 +118,62 @@ impl ReleaseCache { pub(crate) async fn fetch_latest( api_base: &str, repository: &str, - update_channel: &str, -) -> Result { + current_version: &str, +) -> Result, OperationError> { validate_api_base(api_base).map_err(conflict)?; validate_repository(repository)?; let base = api_base.trim_end_matches('/'); - // GitHub 的 /releases/latest 永远只返回正式版;preview 渠道需要覆盖 - // prerelease,因此改查列表端点并取最新一条非草稿 Release。 - if update_channel == "preview" { - let releases: Vec = - fetch_release_json(&format!("{base}/{repository}/releases?per_page=10")).await?; - return releases - .into_iter() - .find(|release| !release.draft) - .ok_or_else(|| upstream("no published release available")); - } - fetch_release_json(&format!("{base}/{repository}/releases/latest")).await -} - -async fn fetch_release_json( - url: &str, -) -> Result { + let current = semver::Version::parse(&normalize_version(current_version)) + .map_err(|_| invalid("current version is invalid"))?; let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|error| upstream(format!("failed to create release client: {error}")))?; + + // 所有发行线先过滤再排序,避免更高大版本或其他实验遮住仍可安装的更新。 + let mut latest: Option<(semver::Version, GitHubRelease)> = None; + let mut page = 1; + loop { + let releases: Vec = fetch_release_json( + &client, + &format!("{base}/{repository}/releases?per_page=100&page={page}"), + ) + .await?; + let last_page = releases.len() < 100; + for release in releases { + let Some(version) = eligible_release_version(&release, ¤t) else { + continue; + }; + if latest + .as_ref() + .is_none_or(|(previous, _)| version.cmp_precedence(previous).is_gt()) + { + latest = Some((version, release)); + } + } + if last_page { + return Ok(latest.map(|(_, release)| release)); + } + page += 1; + } +} + +fn eligible_release_version( + release: &GitHubRelease, + current: &semver::Version, +) -> Option { + let version = semver::Version::parse(&normalize_version(&release.tag_name)).ok()?; + (!release.draft + && release.prerelease != version.pre.is_empty() + && update_target_allowed(current, &version)) + .then_some(version) +} + +async fn fetch_release_json( + client: &reqwest::Client, + url: &str, +) -> Result { let response = client .get(url) .header(reqwest::header::ACCEPT, "application/vnd.github+json") @@ -136,24 +197,24 @@ pub(crate) fn detail_from_release( config: &SystemUpdateConfig, release: &GitHubRelease, ) -> SystemUpdateDetail { - let latest = normalize_version(&release.tag_name); - 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); + let unsupported_reason = config.update_support_error(); + if unsupported_reason.is_some() + || validate_update_target(&config.version, &release.tag_name).is_err() + { + return super::base_update_detail(config, unsupported_reason, None); + } SystemUpdateDetail { current_version: config.version.clone(), - latest_version: latest.clone(), - has_update: newer && !cross_major, + latest_version: normalize_version(&release.tag_name), + has_update: true, deployment_mode: config.deployment_mode.clone(), build_type: config.build_type.clone(), release_url: release.html_url.clone(), notes: release.body.clone().or_else(|| release.name.clone()), cached: false, - update_supported: config.update_support_error().is_none(), - unsupported_reason: config.update_support_error(), - warning: (newer && cross_major).then(|| { - format!("检测到新的大版本 v{latest}:跨大版本升级不提供自动更新,请按发布说明手动迁移") - }), + update_supported: true, + unsupported_reason: None, + warning: None, } } @@ -278,17 +339,68 @@ fn normalize_version(version: &str) -> String { version.trim().trim_start_matches('v').to_owned() } -fn version_is_newer(current: &str, latest: &str) -> Option { - let current = semver::Version::parse(&normalize_version(current)).ok()?; - let latest = semver::Version::parse(&normalize_version(latest)).ok()?; - Some(latest > current) +fn channel_for_version(version: &semver::Version) -> Option { + if version.pre.is_empty() { + return Some(UpdateChannel::Stable); + } + let (name, sequence) = version.pre.as_str().split_once('.')?; + if sequence.is_empty() || sequence == "0" || !sequence.bytes().all(|byte| byte.is_ascii_digit()) + { + return None; + } + match name { + "alpha" => Some(UpdateChannel::Alpha), + "beta" => Some(UpdateChannel::Beta), + "rc" => Some(UpdateChannel::Rc), + "exp" => Some(UpdateChannel::Experimental), + _ => None, + } } -/// 自动更新只在同 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) +pub(crate) fn version_channel(version: &str) -> Option { + let version = semver::Version::parse(&normalize_version(version)).ok()?; + channel_for_version(&version) +} + +/// 检查更新与执行更新共用同一规则;构建元数据不构成更新。 +fn update_target_allowed(current: &semver::Version, target: &semver::Version) -> bool { + use UpdateChannel::{Alpha, Beta, Experimental, Rc, Stable}; + + if current.major != target.major || !target.cmp_precedence(current).is_gt() { + return false; + } + let (Some(current_channel), Some(target_channel)) = + (channel_for_version(current), channel_for_version(target)) + else { + return false; + }; + if current_channel == Stable { + return target_channel == Stable; + } + // 预发行只承接本轮目标版本;exp 的相同基线也只代表同一轮实验。 + if (current.minor, current.patch) != (target.minor, target.patch) { + return false; + } + matches!( + (current_channel, target_channel), + (Alpha, Alpha | Beta | Rc | Stable) + | (Beta, Beta | Rc | Stable) + | (Rc, Rc | Stable) + | (Experimental, Experimental) + ) +} + +pub(crate) fn validate_update_target(current: &str, target: &str) -> Result<(), OperationError> { + let current = semver::Version::parse(&normalize_version(current)) + .map_err(|_| conflict("当前版本格式无效"))?; + let target = semver::Version::parse(&normalize_version(target)) + .map_err(|_| invalid("target version is invalid"))?; + if !update_target_allowed(¤t, &target) { + return Err(conflict( + "当前版本不允许更新到此目标:不支持跨通道、跨发行线、跨大版本或降级", + )); + } + Ok(()) } fn repository_character(character: char) -> bool { diff --git a/backend/crates/gateway-host/tests/system_update/mod.rs b/backend/crates/gateway-host/tests/system_update/mod.rs index 8f92048d5..e53c63348 100644 --- a/backend/crates/gateway-host/tests/system_update/mod.rs +++ b/backend/crates/gateway-host/tests/system_update/mod.rs @@ -16,7 +16,7 @@ use gateway_host::system_update::{ }; use sha2::{Digest as _, Sha256}; use tar::{Builder, EntryType, Header}; -use wiremock::matchers::{method, path}; +use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; const TARGET_VERSION: &str = "1.9.9"; @@ -162,40 +162,44 @@ async fn rollback_should_restore_binary_web_and_version_state() { } #[tokio::test] -async fn update_detail_should_fallback_to_cache_when_refresh_fetch_fails() { +async fn update_detail_should_report_refresh_failure_and_clear_previous_update() { let server = MockServer::start().await; let fixture = Fixture::new(); fixture.mount_release_once(&server, TARGET_VERSION).await; let service = fixture.service(&server); service.update_detail(true).await.expect("prime cache"); - assert_eq!( - service - .update_detail(true) - .await - .expect("cached fallback") - .latest_version, - TARGET_VERSION - ); + let detail = service.update_detail(true).await.expect("failure detail"); + assert!(!detail.has_update); + assert!(detail.warning.is_some()); + assert!(!detail.cached); + assert_eq!(detail.latest_version, "1.0.0"); + assert!(detail.notes.is_none()); + let version = service + .version() + .await + .expect("version after failed refresh"); + assert!(!version.has_update); + assert!(version.update_warning.is_some()); } #[tokio::test] -async fn update_detail_should_offer_latest_non_draft_prerelease_on_preview_channel() { +async fn update_detail_should_offer_latest_non_draft_release_in_current_channel() { let server = MockServer::start().await; let fixture = Fixture::new(); Mock::given(method("GET")) .and(path("/repos/owner/repository/releases")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ { - "tag_name": "v1.2.0-beta.2", - "name": "Draft 1.2.0-beta.2", + "tag_name": "v1.1.0-beta.3", + "name": "Draft 1.1.0-beta.3", "prerelease": true, "draft": true, "assets": [], }, { - "tag_name": "v1.1.0-beta.1", - "name": "Release 1.1.0-beta.1", + "tag_name": "v1.1.0-beta.2", + "name": "Release 1.1.0-beta.2", "body": "notes", "html_url": "https://github.com/owner/repository/releases", "prerelease": true, @@ -205,11 +209,11 @@ async fn update_detail_should_offer_latest_non_draft_prerelease_on_preview_chann .mount(&server) .await; let mut config = fixture.config(&format!("{}/repos", server.uri())); - config.update_channel = "preview".to_owned(); + config.version = "1.1.0-beta.1".to_owned(); let service = ProcessSystemOperations::new(CancellationToken::new(), config); let detail = service.update_detail(true).await.expect("detail"); - assert_eq!(detail.latest_version, "1.1.0-beta.1"); + assert_eq!(detail.latest_version, "1.1.0-beta.2"); assert!(detail.has_update); } @@ -227,21 +231,375 @@ async fn update_detail_should_reject_untrusted_github_api_base() { } #[tokio::test] -async fn update_detail_should_check_release_for_source_build_without_enabling_update() { +async fn update_detail_should_withhold_updates_for_source_builds() { let server = MockServer::start().await; let fixture = Fixture::new(); - fixture.mount_release_once(&server, TARGET_VERSION).await; let mut config = fixture.config(&format!("{}/repos", server.uri())); config.build_type = "source".to_owned(); let service = ProcessSystemOperations::new(CancellationToken::new(), config); let detail = service.update_detail(true).await.expect("detail"); - assert_eq!( - (detail.latest_version, detail.update_supported), - (TARGET_VERSION.to_owned(), false) + assert_eq!(detail.latest_version, "1.0.0"); + assert!(!detail.update_supported); + assert!(detail.unsupported_reason.is_some()); + assert!(!detail.has_update); + assert!(detail.notes.is_none()); + assert!(detail.release_url.is_none()); + assert!(!service.version().await.expect("version").has_update); + assert!( + server + .received_requests() + .await + .expect("requests") + .is_empty() ); } +#[tokio::test] +async fn experimental_build_should_ignore_newer_stable_and_other_experimental_releases() { + let server = MockServer::start().await; + let fixture = Fixture::new(); + Mock::given(method("GET")) + .and(path("/repos/owner/repository/releases")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { "tag_name": "v4.0.0", "prerelease": false }, + { "tag_name": "v3.12.0", "prerelease": false }, + { "tag_name": "v3.10.0", "prerelease": false }, + { "tag_name": "v3.11.0-exp.3", "prerelease": true }, + { "tag_name": "v3.10.1-exp.3", "prerelease": true }, + { "tag_name": "v3.11.0-beta.1", "prerelease": true }, + { "tag_name": "v3.10.0-exp.other.3", "prerelease": true }, + { "tag_name": "v3.10.0-exp.3", "prerelease": true, "draft": true }, + { "tag_name": "v3.10.0-exp.4", "prerelease": false }, + { "tag_name": "invalid", "prerelease": true } + ]))) + .expect(1) + .mount(&server) + .await; + let mut config = fixture.config(&format!("{}/repos", server.uri())); + config.version = "3.10.0-exp.2".to_owned(); + config.build_type = "experimental".to_owned(); + let service = ProcessSystemOperations::new(CancellationToken::new(), config); + + let detail = service.update_detail(true).await.expect("detail"); + assert_eq!(detail.latest_version, "3.10.0-exp.2"); + assert!(!detail.has_update); + assert!(detail.update_supported); + assert!(detail.notes.is_none()); + assert!(detail.release_url.is_none()); + assert!(detail.warning.is_none()); + let version = service.version().await.expect("version"); + assert!(!version.has_update); + assert_eq!(version.update_channel, "exp"); + let cached = service.update_detail(false).await.expect("cached detail"); + assert!(cached.cached); + assert!(!cached.has_update); +} + +#[tokio::test] +async fn experimental_update_should_find_highest_same_channel_version_across_pages() { + let server = MockServer::start().await; + let fixture = Fixture::new(); + let mut first_page = vec![ + serde_json::json!({ + "tag_name": "v3.12.0", "prerelease": false + }); + 100 + ]; + first_page[1] = serde_json::json!({ "tag_name": "v3.10.0-exp.3", "prerelease": true }); + Mock::given(method("GET")) + .and(path("/repos/owner/repository/releases")) + .and(query_param("page", "1")) + .and(query_param("per_page", "100")) + .respond_with(ResponseTemplate::new(200).set_body_json(first_page)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/owner/repository/releases")) + .and(query_param("page", "2")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { "tag_name": "v3.10.0-exp.10", "prerelease": true, "body": "exp.10 notes" }, + { "tag_name": "v3.10.0-exp.2", "prerelease": true } + ]))) + .mount(&server) + .await; + let mut config = fixture.config(&format!("{}/repos", server.uri())); + config.version = "3.10.0-exp.2".to_owned(); + config.build_type = "experimental".to_owned(); + let service = ProcessSystemOperations::new(CancellationToken::new(), config); + + let detail = service.update_detail(true).await.expect("detail"); + assert_eq!(detail.latest_version, "3.10.0-exp.10"); + assert_eq!(detail.notes.as_deref(), Some("exp.10 notes")); + assert!(detail.has_update); + assert!(detail.update_supported); + assert!(service.version().await.expect("version").has_update); +} + +#[tokio::test] +async fn experimental_update_should_install_same_channel_release() { + let server = MockServer::start().await; + let fixture = Fixture::new(); + fixture + .mount_release( + &server, + "3.10.0-exp.3", + ArchiveKind::Safe, + ChecksumKind::Valid, + ) + .await; + let mut config = fixture.config(&format!("{}/repos", server.uri())); + config.version = "3.10.0-exp.2".to_owned(); + config.build_type = "experimental".to_owned(); + let service = ProcessSystemOperations::new(CancellationToken::new(), config); + + service + .perform_update(Some("3.10.0-exp.3".to_owned())) + .await + .expect("same-channel update"); + assert_eq!( + fs::read(fixture.executable()).expect("binary"), + b"new-binary" + ); + assert_eq!( + fs::read(fixture.web().join("index.html")).expect("web"), + b"new-web" + ); + assert_eq!( + service + .update_status() + .await + .expect("status") + .operation + .status, + SystemOperationStatus::Succeeded + ); +} + +#[tokio::test] +async fn update_should_reject_cross_channel_targets_before_fetching_or_replacing_files() { + for (build_type, current, target) in [ + ("experimental", "3.10.0-exp.2", "3.12.0"), + ("experimental", "3.10.0-exp.2", "3.10.0"), + ("experimental", "3.10.0-exp.2", "3.10.1-exp.3"), + ("experimental", "3.10.0-exp.2", "3.10.0-exp.1"), + ("release", "3.10.0-beta.2", "3.10.0-alpha.3"), + ("release", "3.10.0-rc.2", "3.11.0-rc.3"), + ("release", "3.10.0-rc.2", "3.11.0"), + ("release", "3.10.0", "3.10.0+new"), + ("release", "3.10.0", "3.9.0"), + ("experimental", "3.10.0-exp.2", "3.11.0-beta.1"), + ("experimental", "3.10.0-exp.2", "3.10.0-exp.other.3"), + ("release", "3.10.0", "3.11.0-exp.1"), + ("release", "3.10.0", "3.11.0-beta.1"), + ] { + let server = MockServer::start().await; + let fixture = Fixture::new(); + let mut config = fixture.config(&format!("{}/repos", server.uri())); + config.version = current.to_owned(); + config.build_type = build_type.to_owned(); + let service = ProcessSystemOperations::new(CancellationToken::new(), config); + + let error = service + .perform_update(Some(target.to_owned())) + .await + .expect_err("cross-channel update"); + assert_eq!(error.kind(), SystemOperationErrorKind::Conflict); + assert!(error.to_string().contains("跨通道")); + assert!( + server + .received_requests() + .await + .expect("requests") + .is_empty() + ); + assert_eq!( + fs::read(fixture.executable()).expect("binary"), + b"old-binary" + ); + assert_eq!( + fs::read(fixture.web().join("index.html")).expect("web"), + b"old-web" + ); + assert!(!fixture.state().exists()); + } +} + +#[tokio::test] +async fn update_checks_should_follow_stage_promotion_and_experimental_isolation() { + let cases = [ + ("3.12.0-alpha.1", "alpha", [true, true, true, true, false]), + ("3.12.0-beta.1", "beta", [false, true, true, true, false]), + ("3.12.0-rc.1", "rc", [false, false, true, true, false]), + ("3.12.0-exp.1", "exp", [false, false, false, false, true]), + ("3.11.0", "stable", [false, false, false, true, false]), + ]; + for (current, channel, expected) in cases { + for (target, allowed) in [ + "3.12.0-alpha.2", + "3.12.0-beta.2", + "3.12.0-rc.2", + "3.12.0", + "3.12.0-exp.2", + ] + .into_iter() + .zip(expected) + { + let server = MockServer::start().await; + let fixture = Fixture::new(); + fixture.mount_release_once(&server, target).await; + let mut config = fixture.config(&format!("{}/repos", server.uri())); + config.version = current.to_owned(); + let service = ProcessSystemOperations::new(CancellationToken::new(), config); + + let detail = service.update_detail(true).await.expect("detail"); + assert_eq!(detail.has_update, allowed, "{current} -> {target}"); + assert_eq!( + detail.latest_version, + if allowed { target } else { current } + ); + assert_eq!(detail.notes.is_some(), allowed); + assert_eq!(detail.release_url.is_some(), allowed); + assert!(detail.warning.is_none()); + let version = service.version().await.expect("version"); + assert_eq!(version.has_update, allowed); + assert_eq!(version.update_channel, channel); + } + } +} + +#[tokio::test] +async fn update_checks_should_ignore_other_cycles_downgrades_and_build_metadata() { + for (current, target) in [ + ("3.12.0-alpha.1", "3.13.0-alpha.2"), + ("3.12.0-beta.1", "3.12.1-rc.1"), + ("3.12.0-rc.1", "3.13.0"), + ("3.12.0-beta.2", "3.12.0-beta.1"), + ("3.12.0-exp.2", "3.12.0-exp.2"), + ("3.12.0", "3.12.0+new-build"), + ("3.12.0-beta.1+aaa", "3.12.0-beta.1+zzz"), + ("3.12.0", "3.11.0"), + ("3.12.0-alpha.1", "3.12.0-preview.2"), + ("3.12.0-alpha.1", "3.12.0-beta"), + ("3.12.0-alpha.1", "3.12.0-beta.0"), + ("3.12.0-alpha.1", "3.12.0-beta.2.extra"), + ] { + let server = MockServer::start().await; + let fixture = Fixture::new(); + fixture.mount_release_once(&server, target).await; + let mut config = fixture.config(&format!("{}/repos", server.uri())); + config.version = current.to_owned(); + let service = ProcessSystemOperations::new(CancellationToken::new(), config); + let detail = service.update_detail(true).await.expect("detail"); + assert!(!detail.has_update, "{current} -> {target}"); + assert_eq!(detail.latest_version, current); + assert!(detail.notes.is_none()); + assert!(detail.release_url.is_none()); + } +} + +#[tokio::test] +async fn update_checks_should_fail_closed_for_unknown_current_channels() { + for (current, build_type) in [ + ("3.12.0-preview.1", "release"), + ("3.12.0-alpha", "release"), + ("3.12.0-exp.0", "experimental"), + ("invalid", "release"), + ("3.12.0", "experimental"), + ("3.12.0-beta.1", "experimental"), + ] { + let server = MockServer::start().await; + let fixture = Fixture::new(); + let mut config = fixture.config(&format!("{}/repos", server.uri())); + config.version = current.to_owned(); + config.build_type = build_type.to_owned(); + let service = ProcessSystemOperations::new(CancellationToken::new(), config); + let detail = service.update_detail(true).await.expect("detail"); + assert!(!detail.has_update); + assert!(!detail.update_supported); + assert!(detail.unsupported_reason.is_some()); + assert!( + server + .received_requests() + .await + .expect("requests") + .is_empty() + ); + } +} + +#[tokio::test] +async fn stable_update_should_select_allowed_version_below_newer_major_releases() { + let server = MockServer::start().await; + let fixture = Fixture::new(); + Mock::given(method("GET")) + .and(path("/repos/owner/repository/releases")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { "tag_name": "v4.0.0", "prerelease": false }, + { "tag_name": "v3.13.0-beta.1", "prerelease": true }, + { "tag_name": "v3.12.1", "prerelease": true }, + { "tag_name": "v3.12.0", "prerelease": false, "draft": true }, + { "tag_name": "v3.11.2", "prerelease": false }, + { "tag_name": "v3.11.10", "prerelease": false, "body": "stable notes" } + ]))) + .mount(&server) + .await; + let mut config = fixture.config(&format!("{}/repos", server.uri())); + config.version = "3.11.0".to_owned(); + let service = ProcessSystemOperations::new(CancellationToken::new(), config); + let detail = service.update_detail(true).await.expect("detail"); + assert!(detail.has_update); + assert_eq!(detail.latest_version, "3.11.10"); + assert_eq!(detail.notes.as_deref(), Some("stable notes")); +} + +#[tokio::test] +async fn prerelease_updates_should_install_later_stages_and_stable_release() { + for (current, target) in [ + ("3.12.0-alpha.1", "3.12.0-beta.1"), + ("3.12.0-beta.1", "3.12.0-rc.1"), + ("3.12.0-rc.1", "3.12.0"), + ("3.12.0-alpha.1", "3.12.0"), + ] { + let server = MockServer::start().await; + let fixture = Fixture::new(); + fixture + .mount_release(&server, target, ArchiveKind::Safe, ChecksumKind::Valid) + .await; + let mut config = fixture.config(&format!("{}/repos", server.uri())); + config.version = current.to_owned(); + let service = ProcessSystemOperations::new(CancellationToken::new(), config); + assert!( + service + .update_detail(true) + .await + .expect("detail") + .has_update + ); + service + .perform_update(Some(target.to_owned())) + .await + .expect("promoted update"); + assert_eq!( + fs::read(fixture.executable()).expect("binary"), + b"new-binary" + ); + assert_eq!( + fs::read(fixture.web().join("index.html")).expect("web"), + b"new-web" + ); + assert_eq!( + service + .update_status() + .await + .expect("status") + .operation + .status, + SystemOperationStatus::Succeeded + ); + } +} + #[tokio::test] async fn update_detail_should_use_cached_release_when_not_refreshed() { let server = MockServer::start().await; @@ -266,9 +624,11 @@ async fn update_detail_should_withhold_cross_major_release() { .update_detail(true) .await .expect("detail"); - assert_eq!(detail.latest_version, CROSS_MAJOR_VERSION); + assert_eq!(detail.latest_version, "1.0.0"); assert!(!detail.has_update); - assert!(detail.warning.is_some()); + assert!(detail.warning.is_none()); + assert!(detail.notes.is_none()); + assert!(detail.release_url.is_none()); } #[tokio::test] @@ -746,7 +1106,6 @@ impl Fixture { build_time: "2026-07-19T00:00:00Z".to_owned(), deployment_mode: "binary".to_owned(), build_type: "release".to_owned(), - update_channel: "stable".to_owned(), update_repository: Some("owner/repository".to_owned()), github_api_base: api_base.to_owned(), executable_path: Some(self.executable()), @@ -810,7 +1169,7 @@ impl Fixture { async fn mount_release_once(&self, server: &MockServer, version: &str) { let assets = Vec::new(); Mock::given(method("GET")) - .and(path("/repos/owner/repository/releases/latest")) + .and(path("/repos/owner/repository/releases")) .respond_with(release_response(version, assets)) .up_to_n_times(1) .mount(server) @@ -902,7 +1261,7 @@ async fn mount_release_json( times: Option, ) { let mock = Mock::given(method("GET")) - .and(path("/repos/owner/repository/releases/latest")) + .and(path("/repos/owner/repository/releases")) .respond_with(release_response(version, assets)); match times { Some(times) => mock.up_to_n_times(times).mount(server).await, @@ -911,13 +1270,18 @@ async fn mount_release_json( } fn release_response(version: &str, assets: Vec) -> ResponseTemplate { - ResponseTemplate::new(200).set_body_json(serde_json::json!({ + let prerelease = !semver::Version::parse(version) + .expect("version") + .pre + .is_empty(); + let release = serde_json::json!({ "tag_name": format!("v{version}"), "name": format!("Release {version}"), "body": "notes", "html_url": "https://github.com/owner/repository/releases/latest", - "prerelease": false, + "prerelease": prerelease, "published_at": "2026-07-19T00:00:00Z", "assets": assets, - })) + }); + ResponseTemplate::new(200).set_body_json(serde_json::json!([release])) } diff --git a/backend/migrations/README.md b/backend/migrations/README.md index 434e90f5c..e71510665 100644 --- a/backend/migrations/README.md +++ b/backend/migrations/README.md @@ -14,6 +14,10 @@ sqlx 在服务启动、监听请求之前按编号执行迁移,并把文件 ch - 同一大版本内的 schema 变更一律新增编号迁移,永远不回改 旧文件。切换大版本时允许把已完成升级的 schema 折叠成新的 `0001` 基线,因为跨大版本 不支持在线升级,目标版本必须使用全新数据目录部署。 +- 支持连续升级的预发行线从首次发布起同样冻结迁移。alpha、beta、rc 到对应正式版必须保留 + 已发布迁移的编号与字节;同一轮 exp 的后续发行也只能追加迁移。发布前以该发行线已发布版本 + 的数据库验证升级路径,不能只与上一正式版比较。不同 exp 实验线之间、exp 与正式版之间 + 不保证迁移历史兼容。 - `.frozen-sha256` 是冻结清单(`sha256sum` 格式)。CI 会校验:清单内文件 字节未变,且目录里每个 `*.sql` 都已入册。新增迁移时在同一提交里执行 `sha256sum 000N_xxx.sql >> .frozen-sha256` 入册。 diff --git a/deploy/README.md b/deploy/README.md index 232074849..d271477e3 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -395,13 +395,40 @@ CPR_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ docker compose -f deploy/compose.yaml build codex-proxy-rs ``` +### 版本命名与升级规则 + +发行通道由当前版本确定,不提供通道切换配置。预发行编号 `N` 从 1 开始递增。 + +| 类型 | 命名示例 | 含义 | 允许在线更新到 | +| --- | --- | --- | --- | +| 正式版 | `3.12.0` | 稳定发布 | 同一大版本内更新的正式版 | +| alpha | `3.12.0-alpha.1` | 下一正式版本的早期开发阶段 | 同一目标版本的更新 alpha、beta、rc、正式版 | +| beta | `3.12.0-beta.1` | 下一正式版本的功能测试阶段 | 同一目标版本的更新 beta、rc、正式版 | +| rc | `3.12.0-rc.1` | 准备转正的发布候选版本 | 同一目标版本的更新 rc、正式版 | +| exp | `3.10.0-exp.1` | 临时功能实验,可能合入正式版或结束维护 | 同一轮实验中编号更高的 exp | + +“同一目标版本”要求 `X.Y.Z` 相同。alpha → beta → rc → 正式版可以跳过中间阶段,不能倒退; +转正后按正式版规则更新,不自动进入下一轮预发行。同一 `X.Y.Z-exp.N` 系列只用于同一轮实验, +不能混用不同实验分支;变更基线即进入另一实验线,需手动迁移。exp 不自动进入正式版、alpha、beta 或 rc。 +从 exp 切换正式版时需先备份,在目标版本的独立数据库中迁移业务数据,不能直接复用或整库还原实验数据库。 + +版本大小遵循 [SemVer](https://semver.org/),构建元数据 `+...` 不参与比较。未知预发行标识不提供在线更新。 +更新器先过滤不允许的目标,再选择版本最高的候选;其他通道或更高大版本领先,不影响当前发行线的更新。 +只有当前官方构建满足在线更新条件且存在允许安装的新版本时,才返回“有可用更新”。否则不显示更新标记、 +其他通道的版本及发布说明,手动检查显示“当前没有可用更新”。检查失败单独报告,不能当作没有更新。 + +alpha、beta、rc、exp 在 GitHub 标记为 Pre-release,不覆盖 GitHub Latest 或镜像 `latest`。 +允许连续升级的发行线从首次发布起遵守[迁移冻结规则](../backend/migrations/README.md#冻结规则), +包括预发行到正式版的晋级;发版前验证对应升级路径,不能仅凭版本号认定数据库兼容。 + ### 管理端在线更新 +官方构建按上述规则在线更新;源码等非官方构建返回不支持原因,不提供可用更新。 + Compose 提供以下在线更新运行参数: - `CPR_UPDATE_REPOSITORY`:只接受 `owner/repository`;默认 `zyycn/codex-proxy-rs`。 - `CPR_GITHUB_API_BASE`:正式环境必须为 `https://api.github.com/repos`。 -- `CPR_UPDATE_CHANNEL`:`stable` 会拒绝 prerelease。 - `CPR_UPDATE_EXE_PATH`、`CPR_WEB_DIST_DIR`:分别指向容器内二进制和前端静态目录; `CPR_WEB_DIST_DIR` 同时供页面服务与更新器使用,相对路径以 `deploy/config.yaml` 所在目录为基准。 - 更新临时目录、状态文件和锁文件默认由 `host.runtime_data_dir` 派生; @@ -409,6 +436,8 @@ Compose 提供以下在线更新运行参数: - `CPR_ENABLE_SELF_RESTART=true`:更新或回滚完成后允许管理端请求重启;Docker 进程退出后由 Compose 的 `restart: unless-stopped` 拉起新进程。 +旧配置中的 `CPR_UPDATE_CHANNEL` 不再参与版本选择,可移除。 + Release 必须提供当前 OS/架构的 `codex-proxy-rs___.tar.gz` 与 `checksums.txt`。服务会在替换前再次查询远端最新版本,校验下载 host、声明大小、SHA-256 和 归档路径;二进制或静态资源任一替换失败时恢复旧文件。成功后的旧二进制和旧静态目录分别保留为 diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 6a55a5959..4a4616175 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -122,7 +122,6 @@ services: CPR_REDIS_URL: redis://redis:6379/ CPR_DEPLOYMENT_MODE: docker CPR_UPDATE_REPOSITORY: zyycn/codex-proxy-rs - CPR_UPDATE_CHANNEL: stable CPR_GITHUB_API_BASE: https://api.github.com/repos CPR_UPDATE_EXE_PATH: /app/bin/codex-proxy-rs CPR_WEB_DIST_DIR: /app/web/dist diff --git a/docs/api.md b/docs/api.md index b386006f9..08faf50ec 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1367,6 +1367,12 @@ Key 已删除或未关联时为 `null`,不影响记录返回,不包含密钥 | `POST` | `/api/admin/system/rollback` | 无 | 回滚到保留的上一版本 | | `POST` | `/api/admin/system/restart` | 无 | 请求进程重启 | -在线更新仅在当前部署模式、Release 资产和进程重启能力都满足要求时可用,且只在同一 major 版本内 -提供:跨大版本目标会以 `40901` 冲突拒绝,需按发布说明重新部署。 +在线更新遵循[版本命名与升级规则](../deploy/README.md#版本命名与升级规则)。版本接口的 +`updateChannel` 由当前版本推导,取值为 `stable`、`alpha`、`beta`、`rc`、`exp`,无法识别时为 `unknown`。 +检查与执行使用同一规则,禁止的通道转换、跨实验线、跨大版本、降级或同版本重装均以 `40901` 拒绝。 +`hasUpdate=true` 仅表示当前构建支持在线更新,且存在允许的更高版本;`latestVersion`、`releaseUrl` 和 +`notes` 对应这个候选。没有候选或当前构建不支持时,`hasUpdate=false`、`latestVersion` 为当前版本, +`releaseUrl` 和 `notes` 为空;不支持原因通过 `updateSupported=false`、`unsupportedReason` 返回。 +强制检查失败时通过 `warning` 返回错误,`hasUpdate=false`,不以旧缓存或“没有更新”掩盖失败。 +普通查询可复用 20 分钟内的结果。下载时仍会校验目标资产、校验和及归档,在线更新成功后按返回值重启。 实例升级和仓库发版见 [部署文档](../deploy/README.md#镜像升级与源码构建)。 diff --git a/frontend/src/layout/components/SystemUpdateModal/index.vue b/frontend/src/layout/components/SystemUpdateModal/index.vue index 9d8918bca..02362e403 100644 --- a/frontend/src/layout/components/SystemUpdateModal/index.vue +++ b/frontend/src/layout/components/SystemUpdateModal/index.vue @@ -97,7 +97,11 @@ function pinUpdateLogsToBottom() { async function handleCheckUpdates(force = true) { try { const data = await checkUpdates(force) - toast.success(data?.hasUpdate ? '发现可用更新' : '当前已是最新版本') + if (data?.warning) { + toast.error(data.warning) + return + } + toast.success(data?.hasUpdate ? '发现可用更新' : '当前没有可用更新') } catch {} } @@ -110,8 +114,12 @@ async function handleUpdateRequest() { preparingUpdate.value = true try { const data = await checkUpdates(true) + if (data?.warning) { + toast.error(data.warning) + return + } if (!data?.hasUpdate) { - toast.success('当前已是最新版本') + toast.success('当前没有可用更新') return } const remoteTargetVersion = normalizeSystemVersion(data.latestVersion) @@ -355,7 +363,7 @@ watch( {{ presentation.restartButtonLabel }} { const restarting = computed(() => phaseKind.value === 'restarting') const hasUpdate = computed(() => Boolean(updateInfo.value?.hasUpdate ?? version.value?.hasUpdate)) - const isReleaseBuild = computed(() => updateInfo.value?.buildType === 'release') const canUpdate = computed( () => hasUpdate.value - && isReleaseBuild.value && Boolean(updateInfo.value?.updateSupported) && !UPDATE_BUSY_PHASES.has(phaseKind.value), ) @@ -336,7 +334,6 @@ export const useSystemUpdateStore = defineStore('system-update', () => { updateStreaming, updateStreamError, hasUpdate, - isReleaseBuild, canUpdate, loadVersion, loadSystem, diff --git a/release/publish b/release/publish index a1a4dd129..c508f3e75 100755 --- a/release/publish +++ b/release/publish @@ -5,7 +5,7 @@ APP_NAME="codex-proxy-rs" VERSION_FILE="release/version.yaml" NOTES_FILE="release/notes.md" TAG_PREFIX="v" -SEMVER='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' +SEMVER='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(alpha|beta|rc|exp)\.[1-9][0-9]*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' SIGNATURE="Co-authored-by: Codex " usage() { @@ -78,7 +78,7 @@ require_command ruby gh auth status >/dev/null 2>&1 || die "gh authentication is required; run gh auth login first" version="${1#v}" -[[ "$version" =~ $SEMVER ]] || die "version must be semver, for example 0.1.4" +[[ "$version" =~ $SEMVER ]] || die "version must be X.Y.Z or X.Y.Z-{alpha,beta,rc,exp}.N (N >= 1), optionally with +build metadata" repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || die "not inside a git repository" cd "$repo_root"