mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(update): enforce release channel upgrade rules
Refs #169 Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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 冻结检查失败也不等于正式版数据库升级失败;
|
||||
按各自证据报告兼容性与检查结果,不因未发版而自动放宽现有冻结规则。
|
||||
|
||||
@@ -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 <X.Y.Z-exp.N>`。
|
||||
入口创建版本提交和 tag,并从该分支 dispatch 发布工作流;不要从 `main` 发布实验代码。
|
||||
|
||||
发布前记录 GitHub Latest 的版本与 Docker `latest` 摘要;发布后核对二者仍对应稳定版。
|
||||
按主技能的「发行通道核对」确认稳定入口未改变,并检查实验线隔离与上一版本的更新能力。
|
||||
检查展示标题的分支用途与历史续号、它所对应的实际 tag,以及目标 tag 的源码提交、完整产物、校验和、多架构平台和 Pre-release 标记。
|
||||
同基线的 `X.Y.Z-exp.N` 按 SemVer 排序低于正式 `X.Y.Z`;渠道隔离仍以更新器过滤和发布标签规则为准。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>,
|
||||
pub github_api_base: String,
|
||||
pub executable_path: Option<PathBuf>,
|
||||
@@ -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<String> {
|
||||
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,
|
||||
|
||||
@@ -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<SystemUpdateDetail, OperationError> {
|
||||
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<GitHubRelease, OperationError> {
|
||||
current_version: &str,
|
||||
) -> Result<Option<GitHubRelease>, 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<GitHubRelease> =
|
||||
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<T: serde::de::DeserializeOwned>(
|
||||
url: &str,
|
||||
) -> Result<T, OperationError> {
|
||||
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<GitHubRelease> = 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<semver::Version> {
|
||||
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<T: serde::de::DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
) -> Result<T, OperationError> {
|
||||
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<bool> {
|
||||
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<UpdateChannel> {
|
||||
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<bool> {
|
||||
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<UpdateChannel> {
|
||||
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 {
|
||||
|
||||
@@ -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<u64>,
|
||||
) {
|
||||
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<serde_json::Value>) -> 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]))
|
||||
}
|
||||
|
||||
@@ -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` 入册。
|
||||
|
||||
+30
-1
@@ -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_<version>_<os>_<arch>.tar.gz` 与
|
||||
`checksums.txt`。服务会在替换前再次查询远端最新版本,校验下载 host、声明大小、SHA-256 和
|
||||
归档路径;二进制或静态资源任一替换失败时恢复旧文件。成功后的旧二进制和旧静态目录分别保留为
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
-2
@@ -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#镜像升级与源码构建)。
|
||||
|
||||
@@ -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 }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-else
|
||||
v-else-if="hasUpdate || updating"
|
||||
variant="primary"
|
||||
:loading="preparingUpdate || updating"
|
||||
:disabled="!canUpdate || preparingUpdate"
|
||||
|
||||
@@ -61,7 +61,7 @@ function resolveStatus(input: SystemUpdatePresentationInput) {
|
||||
}
|
||||
if (input.updateSuccess || input.hasUpdate || input.updateInfo) {
|
||||
return {
|
||||
label: input.updateSuccess ? '已更新' : input.hasUpdate ? '有新版本' : '已是最新',
|
||||
label: input.updateSuccess ? '已更新' : input.hasUpdate ? '有可用更新' : '无可用更新',
|
||||
icon: input.hasUpdate ? ArrowUpCircle : CheckCircle2,
|
||||
badge: 'bg-cp-success-container text-cp-success-on-container',
|
||||
iconClass: 'text-cp-success',
|
||||
|
||||
@@ -84,11 +84,9 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
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,
|
||||
|
||||
+2
-2
@@ -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 <noreply@openai.com>"
|
||||
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user