fix(update): enforce release channel upgrade rules

Refs #169

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-19 22:38:23 +08:00
co-authored by Codex
parent 5c11b62f93
commit a1ba6c0b61
14 changed files with 660 additions and 117 deletions
+20 -3
View File
@@ -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`;渠道隔离仍以更新器过滤和发布标签规则为准。
+5 -3
View File
@@ -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, &current) 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(&current, &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]))
}
+4
View File
@@ -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
View File
@@ -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 和
归档路径;二进制或静态资源任一替换失败时恢复旧文件。成功后的旧二进制和旧静态目录分别保留为
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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"