fix(release): 指定环境版本并仅人工提升稳定版
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s

日常更新现在将已选应用的固定版本写入服务器 .env,通过仓库 Compose 变量解析镜像。Bash/PowerShell 拒绝浮动版本,并在 pull/up 前检查有效镜像与指定 tag 一致;只更新目标服务,核心单独更新保留其他版本,兼容 VERSION 继承值。Compose 变更仅在仓库维护,服务器仍由更新/迁移脚本同步,不手工编辑。

稳定版独立入口必须提供同一个完整 version/confirm-version;所有固定产物 digest/架构先核验,然后按 digest 补 latest/latest-no-ssr,全部读回后才生成稳定清单。失败尝试恢复旧稳定指针。日常版本记录使用 system-update/releases/<版本>.json;补录现有 .14.2 已验收来源,没有改变现有稳定清单。

验证:稳定提升6项通过;真实 Bash/PowerShell入口(本机隔离命令传输)28.845秒通过,含单后端、核心、独立网关/Updater、浮动/覆盖拒绝、真实 Git pull/旧配置迁移及实际 Compose 解析;环境迁移通过,旧部署迁移0.894秒通过。未连接或更新生产/测试服务器,未执行真实镜像构建/标签推送。
This commit was merged in pull request #9.
This commit is contained in:
2026-10-14 08:40:48 +08:00
13 changed files with 487 additions and 46 deletions
+10 -2
View File
@@ -259,7 +259,15 @@ STATIC_CDN_OSS_USE_SSL=true
STATIC_CDN_FORCE_UPLOAD=false STATIC_CDN_FORCE_UPLOAD=false
# ========== 15. 版本 ========== # ========== 15. 版本 ==========
VERSION=latest VERSION=3.29.0.20261014.2
# 应用镜像只使用完整版本号;latest/latest-no-ssr 仅在人工指定版本后提升为稳定版。
# 组件未单独填写时继承 VERSION;update.sh/update.ps1 会持久化实际选中的组件版本。
SERVER_VERSION=
WEB_VERSION=
WS_GATEWAY_VERSION=
UPDATER_VERSION=
ASG_VERSION=
AMS_VERSION=
# ========== 15.1 在线更新 ========== # ========== 15.1 在线更新 ==========
# 新安装默认使用可查询任务模式;存量环境执行 update.sh 后会自动补齐缺失配置。 # 新安装默认使用可查询任务模式;存量环境执行 update.sh 后会自动补齐缺失配置。
@@ -294,7 +302,7 @@ EASYAI_DEPLOYMENT_SCHEMA_VERSION=2
# ========== 15.2 3D 模型轻量转换 ========== # ========== 15.2 3D 模型轻量转换 ==========
# update 脚本自动补齐缺失配置并拉取独立容器,无需手动修改线上配置。 # update 脚本自动补齐缺失配置并拉取独立容器,无需手动修改线上配置。
# 可填写已发布版本号固定镜像;latest 跟随正式转换镜像。 # 可填写已发布版本号固定镜像;latest 跟随正式转换镜像。
MODEL_CONVERSION_WORKER_VERSION=latest MODEL_CONVERSION_WORKER_VERSION=
# 安装/升级时自动生成,API 和 Worker 共用。已有强令牌不会重新生成。 # 安装/升级时自动生成,API 和 Worker 共用。已有强令牌不会重新生成。
MODEL_CONVERSION_WORKER_TOKEN= MODEL_CONVERSION_WORKER_TOKEN=
# 付费通道默认关闭;自建失败不会自动扣费转入其他通道。 # 付费通道默认关闭;自建失败不会自动扣费转入其他通道。
+9 -4
View File
@@ -175,6 +175,10 @@ mv easyai easyai2
5. 完成上述迁移后,后续若有新的配置文件或版本更新,可直接在新的 `easyai` 目录中执行 `./update.sh` 进行更新。 5. 完成上述迁移后,后续若有新的配置文件或版本更新,可直接在新的 `easyai` 目录中执行 `./update.sh` 进行更新。
日常发布只构建并推送完整版本标签,线上与测试环境均通过 `update.sh` / `update.ps1` 选择指定版本。`UPDATE_RELEASE_VERSION` 统一指定已选组件;`UPDATE_SERVER_VERSION`、`UPDATE_WEB_VERSION`、`UPDATE_GATEWAY_VERSION`、`UPDATE_WORKER_VERSION` 可分别指定已验证版本。脚本持久化 `SERVER_VERSION`、`WEB_VERSION`、`WS_GATEWAY_VERSION`、`MODEL_CONVERSION_WORKER_VERSION` 等到服务器 `.env`,保留非目标的原版本,拉取前核对有效 Compose 确实使用这些固定 tag。配置只能由已提交的部署仓库与迁移脚本更新,不手工修改服务器 `.env` 或 Compose/覆盖文件。
`latest` / `latest-no-ssr` 仅作为人工稳定版指针。只有用户明确指定版本转稳定版后才运行 `node scripts/promote-stable-release.mjs --version <版本> --confirm-version <同一版本>`(先 `--dry-run`),按固定产物 digest 复制稳定别名、读回并生成稳定清单,再审查和提交;日常 deploy 不调用此入口,也不修改稳定清单。该操作不重编业务镜像、不改业务环境版本。版本记录须包括独立 SSR/no-SSR 和实际使用的必要配套产物。
update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能: update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能:
- **拉取整个仓库**:执行 `git pull` 获取最新代码(docker-compose.yml、start.sh、.env.*.sample 等全部文件) - **拉取整个仓库**:执行 `git pull` 获取最新代码(docker-compose.yml、start.sh、.env.*.sample 等全部文件)
- 自动补齐缺失的环境配置文件(.env、.env.tools、.env.ASG、.env.AMS,从 .sample 生成且不覆盖已有文件) - 自动补齐缺失的环境配置文件(.env、.env.tools、.env.ASG、.env.AMS,从 .sample 生成且不覆盖已有文件)
@@ -190,7 +194,7 @@ update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能:
更新执行器 `easyai-updater` 由 Watchtower 默认每 24 小时检查更新,同时由宿主机定时任务每六小时直接检查更新,提供两条独立维护路径。它的任务状态保存在宿主机 `data/update-state` 中,自身更新或意外重启后会继续恢复未完成任务;Watchtower 不再接管三个核心服务,因此页面任务结果不会与后台静默替换相互冲突。 更新执行器 `easyai-updater` 由 Watchtower 默认每 24 小时检查更新,同时由宿主机定时任务每六小时直接检查更新,提供两条独立维护路径。它的任务状态保存在宿主机 `data/update-state` 中,自身更新或意外重启后会继续恢复未完成任务;Watchtower 不再接管三个核心服务,因此页面任务结果不会与后台静默替换相互冲突。
受控更新默认直接获取部署仓库 `main` 分支的 `system-update/release-manifest.json`,无需先在服务器执行 `git pull`。页面检测、手动更新和自动更新使用同一发布清单;任务开始后将清单保存在任务记录中,服务重启恢复时继续使用原目标。发布新正式镜像时必须同步发布清单中的不可变摘要、镜像大小和版本标识,不能只修改官方站运行版本或推送 `latest` 镜像。 受控更新默认直接获取部署仓库 `main` 分支的 `system-update/release-manifest.json`,无需先在服务器执行 `git pull`。页面检测、手动更新和自动更新使用同一发布清单;任务开始后将清单保存在任务记录中,服务重启恢复时继续使用原目标。该清单只表示人工确认的稳定版。日常发布只推完整版本标签并保存 `system-update/releases/<版本>.json`;只有人类明确指定某个版本转稳定版后,才更新稳定清单与 `latest` / `latest-no-ssr`。线上或测试环境的普通版本发布不会自动推广给其他安装。
发布源不可用时会提示暂时无法检查更新,并保留当前服务;不会用历史清单宣称已是最新。`UPDATE_MANIFEST_URL` 可配置私有发布源,留空时使用官方部署仓库。离线部署可显式设置 `UPDATE_MANIFEST_SOURCE=local`,此时读取 Compose 只读挂载的本地清单;需要自行维护该清单。旧部署中的 `UPDATE_MANIFEST_FILE` 路径覆盖不再影响默认远程模式。 发布源不可用时会提示暂时无法检查更新,并保留当前服务;不会用历史清单宣称已是最新。`UPDATE_MANIFEST_URL` 可配置私有发布源,留空时使用官方部署仓库。离线部署可显式设置 `UPDATE_MANIFEST_SOURCE=local`,此时读取 Compose 只读挂载的本地清单;需要自行维护该清单。旧部署中的 `UPDATE_MANIFEST_FILE` 路径覆盖不再影响默认远程模式。
@@ -217,7 +221,7 @@ Watchtower 默认使用 `latest`。`start.sh/update.sh` 自动安装宿主机 cr
两个页面入口共用计时和步骤窗口。updater 保存阶段完成时间到 `data/update-state/jobs`;无 updater 时后端保存任务和单任务锁到 MongoDB。刷新或重新登录后按站点与账号恢复任务查询。请求超时或服务断开不会自动创建另一项更新,旧接口无法读取的阶段会明确提示。后端重启时页面保留进度,提醒在 3–5 分钟后刷新检查版本;只有确认目标版本和服务恢复才显示完成。 两个页面入口共用计时和步骤窗口。updater 保存阶段完成时间到 `data/update-state/jobs`;无 updater 时后端保存任务和单任务锁到 MongoDB。刷新或重新登录后按站点与账号恢复任务查询。请求超时或服务断开不会自动创建另一项更新,旧接口无法读取的阶段会明确提示。后端重启时页面保留进度,提醒在 3–5 分钟后刷新检查版本;只有确认目标版本和服务恢复才显示完成。
正式版本检测默认仍使用原来的 `https://openapi.51easyai.com`。测试验收可在后端设置 `APP_VERSION_CHECK_BASE_URL`,该地址应提供与正式地址相同格式的 `/app/version` 响应;正式发布前必须撤销测试覆盖。候选镜像用版本标签发布,验收后才将相同摘要提升为 `latest`。源码、镜像、环境迁移、三种实际更新验收和回退证据记录在 `system-update/releases/`,其中不得包含令牌或登录凭据。 正式版本检测默认仍使用原来的 `https://openapi.51easyai.com`。测试验收可在后端设置 `APP_VERSION_CHECK_BASE_URL`,该地址应提供与正式地址相同格式的 `/app/version` 响应;正式发布前必须撤销测试覆盖。所有日常镜像使用完整版本标签;验收成功也不会自动提升 `latest`,只有人类明确指定版本转稳定版后才按既有摘要提升。源码、镜像、环境迁移、三种实际更新验收和回退证据记录在 `system-update/releases/`,其中不得包含令牌或登录凭据。
以后新增必需环境变量时,在 `scripts/update-env.d/` 增加新的编号 `.env` 文件即可,不需要继续把写入逻辑堆进 `update.sh`。迁移文件支持 `KEY=value`(仅在缺失或空值时写入)、`KEY+=item`(确保逗号列表包含该项),以及 `@skip-if KEY=value`(明确保留某种旧模式);所有迁移每次都会执行并保持幂等。迁移失败会在拉取镜像前终止,避免使用半完成配置启动服务。 以后新增必需环境变量时,在 `scripts/update-env.d/` 增加新的编号 `.env` 文件即可,不需要继续把写入逻辑堆进 `update.sh`。迁移文件支持 `KEY=value`(仅在缺失或空值时写入)、`KEY+=item`(确保逗号列表包含该项),以及 `@skip-if KEY=value`(明确保留某种旧模式);所有迁移每次都会执行并保持幂等。迁移失败会在拉取镜像前终止,避免使用半完成配置启动服务。
@@ -257,12 +261,13 @@ UPDATE_SKIP_REPO_UPDATE=true ./update.sh
```bash ```bash
UPDATE_MODE=full UPDATE_NON_INTERACTIVE=1 \ UPDATE_MODE=full UPDATE_NON_INTERACTIVE=1 \
UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker' ./update.sh UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker' \
UPDATE_RELEASE_VERSION="<指定完整版本号>" ./update.sh
``` ```
脚本拉取部署仓库后会切换到新版脚本,补齐缺失的转换配置;已有令牌、端口、数据卷和显式付费选择保持原值。指定 `UPDATE_SERVICES` 时只重建这些服务,不重建数据库等依赖;服务名称有误会在拉取镜像前终止。普通更新不指定该变量时保留原来的整组更新行为。转换容器只加入业务网络,不开放主机端口,不连接沙箱网络,不挂载 Docker socket,也不读取整个生产 `.env`。默认限额为 2 CPU、4 GiB、并发 1、单任务 300 秒、临时空间 2 GiB;原模型始终保留,轻量转换失败可使用原模型。 脚本拉取部署仓库后会切换到新版脚本,补齐缺失的转换配置;已有令牌、端口、数据卷和显式付费选择保持原值。指定 `UPDATE_SERVICES` 时只重建这些服务,不重建数据库等依赖;服务名称有误会在拉取镜像前终止。普通更新不指定该变量时保留原来的整组更新行为。转换容器只加入业务网络,不开放主机端口,不连接沙箱网络,不挂载 Docker socket,也不读取整个生产 `.env`。默认限额为 2 CPU、4 GiB、并发 1、单任务 300 秒、临时空间 2 GiB;原模型始终保留,轻量转换失败可使用原模型。
`MODEL_CONVERSION_WORKER_VERSION` 可以固定已发布的版本;默认 `latest`。混元付费通道默认关闭,自建转换失败不自动切换到付费服务。已有 updater 的页面更新仍按稳定清单管理三个核心服务;转换 Worker 的首次安装和配置升级使用上述 `update` 脚本完成。 `MODEL_CONVERSION_WORKER_VERSION` 使用已发布的固定版本,未独立填写时继承 `VERSION`;更新脚本将目标组件版本持久化到 `.env`,应用更新不接受 `latest` 或其他浮动标签。混元付费通道默认关闭,自建转换失败不自动切换到付费服务。已有 updater 的页面更新仍按稳定清单管理三个核心服务;转换 Worker 的首次安装和配置升级使用上述 `update` 脚本完成。
测试候选版本时使用 `UPDATE_RELEASE_VERSION=3.23.0.20261003`,由更新脚本固定核心服务和转换 Worker 的版本,无需改动远端 `.env` 或移动 `latest` 标签。版本号不合法时在修改配置前终止。 测试候选版本时使用 `UPDATE_RELEASE_VERSION=3.23.0.20261003`,由更新脚本固定核心服务和转换 Worker 的版本,无需改动远端 `.env` 或移动 `latest` 标签。版本号不合法时在修改配置前终止。
+7 -7
View File
@@ -5,7 +5,7 @@ services:
easyai-web: easyai-web:
container_name: easyai-web container_name: easyai-web
#更新修改冒号后面的版本号 #更新修改冒号后面的版本号
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:${VERSION} image: registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:${WEB_VERSION:-${VERSION}}
#端口冲突时,只需要修改前面的端口比如修改为,3011:3010 #端口冲突时,只需要修改前面的端口比如修改为,3011:3010
labels: labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}" - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
@@ -58,7 +58,7 @@ services:
easyai-server: easyai-server:
container_name: easyai-server container_name: easyai-server
# 阿里云镜像地址 # 阿里云镜像地址
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server:${VERSION} # 阿里云 image: registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server:${SERVER_VERSION:-${VERSION}} # 阿里云
labels: labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}" - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}" - "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
@@ -170,7 +170,7 @@ services:
max-file: "10" max-file: "10"
model-conversion-worker: model-conversion-worker:
container_name: easyai-model-conversion-worker container_name: easyai-model-conversion-worker
image: registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker:${MODEL_CONVERSION_WORKER_VERSION:-latest} image: registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker:${MODEL_CONVERSION_WORKER_VERSION:-${VERSION}}
labels: labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}" - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}" - "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
@@ -224,7 +224,7 @@ services:
ws-gateway: ws-gateway:
container_name: easyai-wsgateway # websocket网关 container_name: easyai-wsgateway # websocket网关
# 阿里云镜像地址 # 阿里云镜像地址
image: registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway:${WS_GATEWAY_VERSION:-latest} # 阿里云 image: registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway:${WS_GATEWAY_VERSION:-${VERSION}} # 阿里云
labels: labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}" - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}" - "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
@@ -391,7 +391,7 @@ services:
max-size: "100m" max-size: "100m"
max-file: "10" max-file: "10"
easyai-updater: easyai-updater:
image: registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater:${UPDATER_VERSION:-latest} image: registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater:${UPDATER_VERSION:-${VERSION}}
container_name: easyai-updater container_name: easyai-updater
profiles: profiles:
- managed-update - managed-update
@@ -567,7 +567,7 @@ services:
max-file: "10" max-file: "10"
easyai-asg: easyai-asg:
container_name: easyai-asg container_name: easyai-asg
image: registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance:${ASG_VERSION:-latest} image: registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance:${ASG_VERSION:-${VERSION}}
labels: labels:
- "com.centurylinklabs.watchtower.enable=true" - "com.centurylinklabs.watchtower.enable=true"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}" - "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
@@ -599,7 +599,7 @@ services:
memory: 128MB memory: 128MB
agent-memory: agent-memory:
container_name: agent-memory container_name: agent-memory
image: registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory:${AMS_VERSION:-latest} image: registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory:${AMS_VERSION:-${VERSION}}
labels: labels:
- "com.centurylinklabs.watchtower.enable=true" - "com.centurylinklabs.watchtower.enable=true"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}" - "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
@@ -0,0 +1,62 @@
function Get-ApplicationVersionPlan {
param([string]$Path)
$content = Get-Content $Path -Raw -Encoding UTF8
$selection = [string]$env:UPDATE_SERVICES
$selected = @($selection.Trim() -split '\s+' | Where-Object { $_ })
$mapping = @(
@('easyai-server','SERVER_VERSION','UPDATE_SERVER_VERSION','registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server'),
@('easyai-web','WEB_VERSION','UPDATE_WEB_VERSION','registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai'),
@('ws-gateway','WS_GATEWAY_VERSION','UPDATE_GATEWAY_VERSION','registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway'),
@('model-conversion-worker','MODEL_CONVERSION_WORKER_VERSION','UPDATE_WORKER_VERSION','registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker'),
@('easyai-updater','UPDATER_VERSION','UPDATE_UPDATER_VERSION','registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater'),
@('easyai-asg','ASG_VERSION','UPDATE_ASG_VERSION','registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance'),
@('agent-memory','AMS_VERSION','UPDATE_AMS_VERSION','registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory')
)
$plan = @()
foreach ($item in $mapping) {
$service,$key,$option,$image = $item
$value = [Environment]::GetEnvironmentVariable($option)
$isSelected = $selected.Count -eq 0 -or $service -in $selected
if ($value -and -not $isSelected) { throw "$option 要求在 UPDATE_SERVICES 中指定 $service" }
if (-not $isSelected) { continue }
if (-not $value) { $value = $env:UPDATE_RELEASE_VERSION }
if (-not $value) { $value = Get-EnvValue $content $key }
if (-not $value) { $value = Get-EnvValue $content 'VERSION' }
$pattern = '^\d+\.\d+\.\d+\.\d{8}(\.\d+)?$'
if ($service -eq 'easyai-web') { $pattern = '^\d+\.\d+\.\d+\.\d{8}(\.\d+)?(-no-ssr|-hmg-ai|-easyai|-chinacuc)?$' }
if (-not $value -or $value -notmatch $pattern) { throw "$service 缺少固定版本;请指定 $option / UPDATE_RELEASE_VERSION,latest/dev 等浮动标签不能用于应用更新" }
$plan += [PSCustomObject]@{ Service=$service; Key=$key; Version=$value; Image=$image }
}
if ($env:UPDATE_RELEASE_VERSION -and ($selected.Count -eq 0 -or ('easyai-server' -in $selected -and 'easyai-web' -in $selected))) {
$oldDefault = Get-EnvValue $content 'VERSION'
foreach ($item in $mapping) {
$service,$key,$option,$image = $item
if ($selected.Count -gt 0 -and $service -notin $selected -and -not (Get-EnvValue $content $key) -and $oldDefault) {
$plan += [PSCustomObject]@{ Service=''; Key=$key; Version=$oldDefault; Image='' }
}
}
$plan += [PSCustomObject]@{ Service=''; Key='VERSION'; Version=$env:UPDATE_RELEASE_VERSION; Image='' }
}
return $plan
}
function Set-ApplicationVersions {
param([string]$Path, [object[]]$Plan)
$content = Get-Content $Path -Raw -Encoding UTF8
foreach ($item in $Plan) { $content = Set-EnvValue $content $item.Key $item.Version }
[System.IO.File]::WriteAllText($Path, $content, [System.Text.UTF8Encoding]::new($false))
foreach ($item in $Plan) { Write-Host "📌 已持久化应用版本: $($item.Key)=$($item.Version)" }
}
function Test-ApplicationVersionImages {
param([object[]]$Plan, [string[]]$ComposeCommand)
foreach ($item in $Plan) {
if (-not $item.Service) { continue }
$args = @($ComposeCommand | Select-Object -Skip 1) + @('config','--images',$item.Service)
$executable = $ComposeCommand[0]
$actual = & $executable @args
if ($LASTEXITCODE -ne 0 -or "$actual".Trim() -ne "$($item.Image):$($item.Version)") {
throw "$($item.Service) 的有效镜像未使用环境中的固定版本;请通过部署仓库的受控迁移恢复镜像定义,尚未拉取或重建服务"
}
}
}
+71
View File
@@ -0,0 +1,71 @@
#!/bin/bash
# 仅规划/持久化已选应用的固定版本;由 update.sh 调用,不编辑 Compose。
APPLICATION_VERSION_MAP='easyai-server SERVER_VERSION UPDATE_SERVER_VERSION registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server
easyai-web WEB_VERSION UPDATE_WEB_VERSION registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai
ws-gateway WS_GATEWAY_VERSION UPDATE_GATEWAY_VERSION registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway
model-conversion-worker MODEL_CONVERSION_WORKER_VERSION UPDATE_WORKER_VERSION registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker
easyai-updater UPDATER_VERSION UPDATE_UPDATER_VERSION registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater
easyai-asg ASG_VERSION UPDATE_ASG_VERSION registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance
agent-memory AMS_VERSION UPDATE_AMS_VERSION registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory'
application_selected() {
[ -z "${UPDATE_SERVICES:-}" ] || [[ " ${UPDATE_SERVICES} " == *" $1 "* ]]
}
plan_application_versions() {
local file="$1" service key option image value suffix base old_default
APPLICATION_VERSION_WRITES=()
while read -r service key option image; do
value="${!option:-}"
if [ -n "$value" ] && ! application_selected "$service"; then
echo "❌ $option 要求在 UPDATE_SERVICES 中指定 $service;尚未修改环境或容器" >&2
return 1
fi
application_selected "$service" || continue
if [ -z "$value" ] && [ -n "${UPDATE_RELEASE_VERSION:-}" ]; then value="$UPDATE_RELEASE_VERSION"; fi
if [ -z "$value" ]; then value="$(read_env_value "$file" "$key")"; fi
if [ -z "$value" ]; then value="$(read_env_value "$file" VERSION)"; fi
suffix=''
if [ "$service" = easyai-web ]; then suffix='(-no-ssr|-hmg-ai|-easyai|-chinacuc)?'; fi
if ! [[ "$value" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]{8}(\.[0-9]+)?${suffix}$ ]]; then
echo "❌ $service 缺少固定版本:请指定 $option 或 UPDATE_RELEASE_VERSION,或在 .env 保留有效 $key;latest/dev 等浮动标签不能用于应用更新" >&2
return 1
fi
APPLICATION_VERSION_WRITES+=("$key=$value")
done <<< "$APPLICATION_VERSION_MAP"
if [ -n "${UPDATE_RELEASE_VERSION:-}" ] && application_selected easyai-server && application_selected easyai-web; then
# VERSION 仍保留兼容入口;改变它前先冻结未选中服务的旧继承值。
old_default="$(read_env_value "$file" VERSION)"
while read -r service key option image; do
application_selected "$service" && continue
value="$(read_env_value "$file" "$key")"
if [ -z "$value" ] && [ -n "$old_default" ]; then APPLICATION_VERSION_WRITES+=("$key=$old_default"); fi
done <<< "$APPLICATION_VERSION_MAP"
APPLICATION_VERSION_WRITES+=("VERSION=$UPDATE_RELEASE_VERSION")
fi
}
apply_application_versions() {
local file="$1" entry temporary
temporary="$(mktemp "${file}.versions.XXXXXX")"
cp "$file" "$temporary"
chmod 600 "$temporary"
for entry in "${APPLICATION_VERSION_WRITES[@]}"; do
write_env_value "$temporary" "${entry%%=*}" "${entry#*=}"
done
mv "$temporary" "$file"
for entry in "${APPLICATION_VERSION_WRITES[@]}"; do echo "📌 已持久化应用版本: $entry"; done
}
verify_application_version_images() {
local file="$1" compose="$2" service key option image version actual
while read -r service key option image; do
application_selected "$service" || continue
version="$(read_env_value "$file" "$key")"
actual="$(sudo $compose config --images "$service")" || return 1
if [ "$actual" != "$image:$version" ]; then
echo "❌ $service 的有效镜像未使用 .env 中的固定版本 $key=$version;请通过部署仓库的受控迁移恢复镜像定义,尚未拉取或重建服务" >&2
return 1
fi
done <<< "$APPLICATION_VERSION_MAP"
}
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const VERSION = /^\d+\.\d+\.\d+\.\d{8}(?:\.\d+)?$/;
const TARGETS = {
'hmg-ai': ['registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai','latest-hmg-ai','-hmg-ai'],
easyai: ['registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai','latest-easyai','-easyai'],
chinacuc: ['registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai','latest-chinacuc','-chinacuc'],
'server-main': ['registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server','latest',''],
web: ['registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai','latest',''],
'web-no-ssr': ['registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai','latest-no-ssr','-no-ssr'],
'ws-gateway': ['registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway','latest',''],
'model-conversion-worker': ['registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker','latest',''],
updater: ['registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater','latest',''],
'service-governance': ['registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance','latest',''],
'memory-service': ['registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory','latest',''],
};
const CORE = ['server-main','web','ws-gateway'];
const docker = args => execFileSync('docker', args, {encoding:'utf8', stdio:['ignore','pipe','pipe']});
const inspectArgs = ref => ['buildx','imagetools','inspect',ref,'--format','{{json .Manifest}}'];
export function promotionPlan(manifest, version, confirmedVersion) {
if (!VERSION.test(version ?? '') || version !== confirmedVersion) throw new Error('必须人工指定完整 --version 并用相同 --confirm-version 确认,不能自动选择最新版');
if (manifest.schemaVersion !== 1 || manifest.channel !== 'versioned' || manifest.productVersion !== version || manifest.platform !== 'linux/amd64') throw new Error('候选版本记录与指定版本/平台不一致');
if (typeof manifest.releaseId !== 'string' || !manifest.releaseId.trim() || manifest.releaseId.length > 200 || !Array.isArray(manifest.components)) throw new Error('候选版本记录缺少有效 releaseId/components');
if (!Number.isFinite(Date.parse(manifest.publishedAt)) || ['managedUpdateAllowed','scheduledUpdateAllowed','rollbackSafe'].some(key=>typeof manifest[key]!=='boolean')) throw new Error('候选版本记录缺少有效发布时间或更新兼容性设置');
const seen = new Set();
const images = manifest.components.map(component => {
const target = TARGETS[component.key];
if (!target || seen.has(component.key) || component.image !== target[0] || !/^sha256:[a-f0-9]{64}$/.test(component.digest ?? '')) throw new Error(`无效或重复组件: ${component.key}`);
seen.add(component.key);
const tag = component.tag ?? `${version}${target[2]}`;
const base = target[2] && tag.endsWith(target[2]) ? tag.slice(0,-target[2].length) : tag;
if (!VERSION.test(base) || (target[2] && !tag.endsWith(target[2]))) throw new Error(`组件 ${component.key} 必须引用固定版本标签`);
return {key:component.key, image:component.image, digest:component.digest, source:`${component.image}:${tag}`, stable:`${component.image}:${target[1]}`};
});
for (const key of [...CORE,'web-no-ssr']) if (!seen.has(key)) throw new Error(`候选记录缺少必要组件: ${key}`);
return images;
}
export function promoteStableRelease({manifest, version, confirmedVersion, outputPath, dryRun=false, run=docker}) {
const plan = promotionPlan(manifest, version, confirmedVersion);
// 所有固定标签先核验完成,再发生任何稳定标签写入。
for (const image of plan) {
const descriptor = JSON.parse(run(inspectArgs(image.source)));
let platforms = descriptor.manifests?.map(item=>item.platform) ?? [descriptor.platform];
if (!platforms.some(Boolean)) {
const verbose = JSON.parse(run(['manifest','inspect','--verbose',image.source]));
platforms = (Array.isArray(verbose) ? verbose : [verbose]).map(item=>item.Descriptor?.platform);
}
if (descriptor.digest !== image.digest || !platforms.some(platform=>platform?.os==='linux' && platform?.architecture==='amd64')) throw new Error(`固定产物核验失败: ${image.key};稳定标签未修改`);
}
if (dryRun) return {status:'DRY_RUN',version,images:plan};
if (!outputPath) throw new Error('缺少稳定清单输出路径');
const previous = new Map();
for (const image of plan) {
try { previous.set(image.stable, JSON.parse(run(inspectArgs(image.stable))).digest); }
catch (error) {
if (!/manifest unknown|not found|no such manifest/i.test(String(error.stderr ?? error.message))) throw error;
previous.set(image.stable,null);
}
}
const changed = [];
try {
for (const image of plan) {
// 先登记尝试;写入成功但回执丢失时也必须尝试恢复原稳定指针。
changed.push(image);
run(['buildx','imagetools','create','--prefer-index=false','--tag',image.stable,`${image.image}@${image.digest}`]);
const actual = JSON.parse(run(inspectArgs(image.stable)));
if (actual.digest !== image.digest) throw new Error(`稳定标签读回不一致: ${image.key}`);
}
const promoted = {...manifest, channel:'stable', publishedAt:new Date().toISOString(),
promotedFromPublishedAt:manifest.publishedAt, components:manifest.components.filter(item=>CORE.includes(item.key))};
const temporary = `${outputPath}.tmp`;
writeFileSync(temporary, JSON.stringify(promoted,null,2)+'\n');
renameSync(temporary,outputPath);
return {status:'PROMOTED',version,images:plan,manifest:outputPath};
} catch (error) {
const recovery = [];
for (const image of changed.reverse()) {
const oldDigest = previous.get(image.stable);
if (!oldDigest) { recovery.push(`${image.key}: 原稳定标签不存在,保留本次标签并记录未完成`); continue; }
try {
run(['buildx','imagetools','create','--prefer-index=false','--tag',image.stable,`${image.image}@${oldDigest}`]);
if (JSON.parse(run(inspectArgs(image.stable))).digest !== oldDigest) throw new Error('恢复读回不一致');
recovery.push(`${image.key}: 已恢复原稳定标签`);
} catch { recovery.push(`${image.key}: 恢复失败,需按原 digest 继续恢复`); }
}
throw new Error(`稳定提升未完成,未提交新的稳定清单:${error.message};${recovery.join(';')}`);
}
}
function main() {
const options = {};
const args = process.argv.slice(2);
for (let index=0; index<args.length; index++) {
const key=args[index];
if (key==='--dry-run') options.dryRun=true;
else if (['--version','--confirm-version','--manifest','--output'].includes(key)) {
const value=args[++index];
if (!value || value.startsWith('--')) throw new Error(`${key} 缺少参数`);
options[key.slice(2)]=value;
} else throw new Error(`未知选项: ${key}`);
}
// 在读取候选文件、调用 Docker 前先拒绝未确认或浮动版本。
if (!VERSION.test(options.version ?? '') || options.version!==options['confirm-version']) throw new Error('用法: node scripts/promote-stable-release.mjs --version X.Y.Z.YYYYMMDD[.N] --confirm-version 同一版本 [--dry-run];仅在用户明确指定版本转稳定版后执行');
const manifestPath=resolve(options.manifest ?? `${ROOT}/system-update/releases/${options.version}.json`);
const manifest=JSON.parse(readFileSync(manifestPath,'utf8'));
console.log(JSON.stringify(promoteStableRelease({manifest,version:options.version,confirmedVersion:options['confirm-version'],
outputPath:resolve(options.output ?? `${ROOT}/system-update/release-manifest.json`),dryRun:options.dryRun}),null,2));
}
if (process.argv[1] && import.meta.url===pathToFileURL(resolve(process.argv[1])).href) {
try { main(); } catch (error) { console.error(error.message); process.exitCode=1; }
}
+100
View File
@@ -0,0 +1,100 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promotionPlan, promoteStableRelease } from './promote-stable-release.mjs';
const version='3.29.0.20261014.2';
const manifest={schemaVersion:1,channel:'versioned',productVersion:version,platform:'linux/amd64',releaseId:'release-id',managedUpdateAllowed:true,scheduledUpdateAllowed:true,rollbackSafe:false,publishedAt:'2026-10-14T00:00:00Z',
components:[
['server-main','comfy-ai/comfy-server',''],['web','comfy-ai/one-ai',''],
['web-no-ssr','comfy-ai/one-ai','-no-ssr'],['ws-gateway','easyaigc/wsgateway',''],
['model-conversion-worker','easyaigc/model-conversion-worker',''],
].map(([key,image,suffix],index)=>({key,image:'registry.cn-shanghai.aliyuncs.com/'+image,tag:version+suffix,digest:'sha256:'+String(index+1).repeat(64),buildId:'source-sha'}))};
function fixture() {
const plan=promotionPlan(manifest,version,version);
const tags=new Map(plan.flatMap(image=>[[image.source,image.digest],[image.stable,'sha256:'+'9'.repeat(64)]]));
const calls=[];
const run=args=>{
calls.push(args);
if (args[0]==='manifest') return JSON.stringify({Descriptor:{platform:{os:'linux',architecture:'amd64'}}});
if (args[2]==='inspect') {
const ref=args[3];
// 真实单平台 imagetools 输出没有 platform,需读取 verbose manifest。
return JSON.stringify({digest:tags.get(ref),...(ref.includes('comfy-server') ? {} : {manifests:[{platform:{os:'linux',architecture:'amd64'}}]})});
}
assert.equal(args[2],'create');
assert.ok(args.includes('--prefer-index=false'));
const ref=args[args.indexOf('--tag')+1];
const digest=args.at(-1).split('@')[1];
tags.set(ref,digest);
return '';
};
return {plan,tags,calls,run};
}
test('未明确确认同一个版本及浮动版本都不调用Docker',()=>{
let called=false;
for (const [candidate,confirm] of [[version,undefined],[version,'3.29.0.20261014.3'],['latest','latest']]) {
assert.throws(()=>promoteStableRelease({manifest,version:candidate,confirmedVersion:confirm,run:()=>{called=true;}}),/人工指定/);
}
assert.equal(called,false);
});
test('候选必须包括SSR/noSSR,并拒绝重复、错误镜像和浮动源标签',()=>{
const variant = {...manifest.components[1],key:'hmg-ai',tag:version+'-hmg-ai'};
assert.equal(promotionPlan({...manifest,components:[...manifest.components,variant]},version,version).at(-1).stable,variant.image+':latest-hmg-ai');
for (const components of [manifest.components.filter(item=>item.key!=='web-no-ssr'),
[...manifest.components,manifest.components[0]],
manifest.components.map((item,index)=>index?item:{...item,image:'wrong/image'}),
manifest.components.map((item,index)=>index?item:{...item,tag:'latest'})]) {
assert.throws(()=>promotionPlan({...manifest,components},version,version));
}
});
test('全部固定产物先核验;任一digest不匹配时稳定标签和清单保持',()=>{
const f=fixture();
f.tags.set(f.plan.at(-1).source,'sha256:'+'8'.repeat(64));
assert.throws(()=>promoteStableRelease({manifest,version,confirmedVersion:version,outputPath:'unused',run:f.run}),/核验失败/);
assert.ok(f.calls.every(args=>args[2]!=='create'));
assert.ok(f.plan.every(image=>f.tags.get(image.stable)==='sha256:'+'9'.repeat(64)));
});
test('dry-run核验固定版本且不写任何稳定标签或文件',()=>{
const f=fixture();
assert.equal(promoteStableRelease({manifest,version,confirmedVersion:version,dryRun:true,run:f.run}).status,'DRY_RUN');
assert.ok(f.calls.every(args=>args[2]!=='create'));
});
test('人工提升只按已核验digest补稳定标签,两种前端分别保留;不构建业务或改源版本',()=>{
const dir=mkdtempSync(join(tmpdir(),'easyai-promote-'));
try {
const path=join(dir,'stable.json');
const f=fixture();
const result=promoteStableRelease({manifest,version,confirmedVersion:version,outputPath:path,run:f.run});
assert.equal(result.status,'PROMOTED');
assert.ok(f.plan.every(image=>f.tags.get(image.stable)===image.digest));
assert.ok(f.plan.every(image=>f.tags.get(image.source)===image.digest));
assert.equal(JSON.parse(readFileSync(path)).channel,'stable');
assert.deepEqual(JSON.parse(readFileSync(path)).components.map(item=>item.key),['server-main','web','ws-gateway']);
assert.ok(f.calls.every(args=>args[0]!=='build' && args[0]!=='push'));
} finally { rmSync(dir,{recursive:true,force:true}); }
});
test('稳定别名写入后回执失败时恢复原指针,稳定清单不提前写入',()=>{
const dir=mkdtempSync(join(tmpdir(),'easyai-promote-fail-'));
try {
const path=join(dir,'stable.json'); writeFileSync(path,'old stable manifest');
const f=fixture(); let once=true;
const run=args=>{
const output=f.run(args);
if (args[2]==='create' && args.includes(f.plan[2].stable) && once) { once=false; throw new Error('write succeeded but response lost'); }
return output;
};
assert.throws(()=>promoteStableRelease({manifest,version,confirmedVersion:version,outputPath:path,run}),/未完成.*已恢复/);
assert.ok(f.plan.every(image=>f.tags.get(image.stable)==='sha256:'+'9'.repeat(64)));
assert.equal(readFileSync(path,'utf8'),'old stable manifest');
} finally { rmSync(dir,{recursive:true,force:true}); }
});
+32 -5
View File
@@ -37,7 +37,7 @@ def fixture(base):
(root / 'scripts/install-infrastructure-update-task.sh').write_text('#!/bin/sh\nexit 0\n') (root / 'scripts/install-infrastructure-update-task.sh').write_text('#!/bin/sh\nexit 0\n')
(root / 'scripts/Install-InfrastructureUpdateTask.ps1').write_text('# Scheduler transport is verified separately.\n') (root / 'scripts/Install-InfrastructureUpdateTask.ps1').write_text('# Scheduler transport is verified separately.\n')
(root / 'data').mkdir() (root / 'data').mkdir()
(root / '.env').write_text('SYSTEM_UPDATE_MODE=legacy_watchtower\nNUXT_PUBLIC_BASE_APIURL=http://127.0.0.1:3001\nCONFIG_JWT_SECRET=existing-jwt-secret-that-is-long-enough\nWS_AUTH_WS_TICKET_SECRET=existing-ticket-secret-that-is-long-enough\n') (root / '.env').write_text('VERSION=3.23.0.20261003\nMODEL_CONVERSION_WORKER_VERSION=3.23.0.20261003\nSYSTEM_UPDATE_MODE=legacy_watchtower\nNUXT_PUBLIC_BASE_APIURL=http://127.0.0.1:3001\nCONFIG_JWT_SECRET=existing-jwt-secret-that-is-long-enough\nWS_AUTH_WS_TICKET_SECRET=existing-ticket-secret-that-is-long-enough\n')
(root / '.gitignore').write_text('.env\n*.log\n') (root / '.gitignore').write_text('.env\n*.log\n')
commands = base / 'commands.jsonl' commands = base / 'commands.jsonl'
commands.touch() commands.touch()
@@ -57,6 +57,11 @@ if args[:1] == ['inspect']:
sys.exit(0) sys.exit(0)
sys.exit(1) sys.exit(1)
if args[:3] == ['compose', 'config', '--services']: print('easyai-server\\neasyai-web\\nws-gateway\\neasyai-updater\\nmodel-conversion-worker\\nmongo\\nredis') if args[:3] == ['compose', 'config', '--services']: print('easyai-server\\neasyai-web\\nws-gateway\\neasyai-updater\\nmodel-conversion-worker\\nmongo\\nredis')
elif args[:3] == ['compose', 'config', '--images']:
names = {'easyai-server':('SERVER_VERSION','comfy-ai/comfy-server'), 'easyai-web':('WEB_VERSION','comfy-ai/one-ai'), 'ws-gateway':('WS_GATEWAY_VERSION','easyaigc/wsgateway'), 'easyai-updater':('UPDATER_VERSION','easyaigc/easyai-updater'), 'model-conversion-worker':('MODEL_CONVERSION_WORKER_VERSION','easyaigc/model-conversion-worker')}
values = dict(line.split('=',1) for line in open('.env').read().splitlines() if '=' in line and not line.startswith('#'))
key,image = names[args[-1]]
print('registry.cn-shanghai.aliyuncs.com/'+image+':'+('latest' if os.environ.get('UPDATE_TEST_IMAGE_OVERRIDE') == '1' else values[key]))
elif args[:3] == ['compose', 'ps', '-q']: print('container-' + args[-1]) elif args[:3] == ['compose', 'ps', '-q']: print('container-' + args[-1])
sys.exit(0) sys.exit(0)
''') ''')
@@ -105,6 +110,19 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
assert original == (root / '.env').read_bytes(), 'repeat changes deployment settings' assert original == (root / '.env').read_bytes(), 'repeat changes deployment settings'
log.write_text('') log.write_text('')
original = (root / '.env').read_bytes() original = (root / '.env').read_bytes()
(root / '.env').write_text((root / '.env').read_text().replace('VERSION=3.23.0.20261003', 'VERSION=latest'))
rejected_env = (root / '.env').read_bytes()
result = run(command, root, env)
assert result.returncode != 0 and '固定版本' in result.stdout + result.stderr
assert rejected_env == (root / '.env').read_bytes()
assert not any(c[:2] in [['compose','pull'],['compose','up']] for c in calls(log))
(root / '.env').write_bytes(original)
log.write_text('')
result = run(command, root, {**env, 'UPDATE_TEST_IMAGE_OVERRIDE':'1'})
assert result.returncode != 0 and '有效镜像' in result.stdout + result.stderr
assert not any(c[:2] in [['compose','pull'],['compose','up']] for c in calls(log))
log.write_text('')
original = (root / '.env').read_bytes()
result = run(command, root, {**env, 'UPDATE_RELEASE_VERSION': 'latest;unsafe'}) result = run(command, root, {**env, 'UPDATE_RELEASE_VERSION': 'latest;unsafe'})
assert result.returncode != 0 assert result.returncode != 0
assert original == (root / '.env').read_bytes() assert original == (root / '.env').read_bytes()
@@ -121,7 +139,16 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
result = run(command, root, {**env, 'UPDATE_TEST_UNHEALTHY': '1', 'UPDATE_HEALTH_TIMEOUT_SECONDS': '1'}) result = run(command, root, {**env, 'UPDATE_TEST_UNHEALTHY': '1', 'UPDATE_HEALTH_TIMEOUT_SECONDS': '1'})
assert result.returncode != 0 assert result.returncode != 0
assert '应用更新成功' not in result.stdout assert '应用更新成功' not in result.stdout
if name == 'bash': if name in ('bash','powershell'):
log.write_text('')
before = dict(line.split('=',1) for line in (root / '.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
result = run(command, root, {**env, 'UPDATE_SERVICES':'easyai-server', 'UPDATE_RELEASE_VERSION':'3.25.0.20261009.2'})
assert result.returncode == 0, result.stdout + result.stderr
after = dict(line.split('=',1) for line in (root / '.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
assert after['SERVER_VERSION'] == '3.25.0.20261009.2'
assert after['VERSION'] == before['VERSION'] and after['WEB_VERSION'] == before['WEB_VERSION']
assert after['MODEL_CONVERSION_WORKER_VERSION'] == before['MODEL_CONVERSION_WORKER_VERSION']
assert ['compose','up','-d','--no-deps','easyai-server'] in calls(log)
log.write_text('') log.write_text('')
result = run(command, root, {**env, 'UPDATE_SERVICES': 'easyai-server easyai-web', 'UPDATE_RELEASE_VERSION': '3.25.0.20261009.3'}) result = run(command, root, {**env, 'UPDATE_SERVICES': 'easyai-server easyai-web', 'UPDATE_RELEASE_VERSION': '3.25.0.20261009.3'})
assert result.returncode == 0, result.stdout + result.stderr assert result.returncode == 0, result.stdout + result.stderr
@@ -132,7 +159,7 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
assert ['compose', 'up', '-d', '--no-deps', 'easyai-server', 'easyai-web'] in calls(log) assert ['compose', 'up', '-d', '--no-deps', 'easyai-server', 'easyai-web'] in calls(log)
assert not any('model-conversion-worker' in call for call in calls(log) if call[:2] in [['compose', 'pull'], ['compose', 'up']]) assert not any('model-conversion-worker' in call for call in calls(log) if call[:2] in [['compose', 'pull'], ['compose', 'up']])
values = core_values values = core_values
print('bash: core-only release preserves non-target Worker pin passed') print(f'{name}: core-only release preserves non-target Worker pin passed')
companion_env = {**env, 'UPDATE_SERVICES': 'ws-gateway easyai-updater', companion_env = {**env, 'UPDATE_SERVICES': 'ws-gateway easyai-updater',
'UPDATE_GATEWAY_VERSION': '3.25.0.20261009', 'UPDATE_GATEWAY_VERSION': '3.25.0.20261009',
'UPDATE_UPDATER_VERSION': '3.25.0.20261009'} 'UPDATE_UPDATER_VERSION': '3.25.0.20261009'}
@@ -154,7 +181,7 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
assert new_values['VERSION'] == values['VERSION'] assert new_values['VERSION'] == values['VERSION']
assert new_values['MODEL_CONVERSION_WORKER_VERSION'] == values['MODEL_CONVERSION_WORKER_VERSION'] assert new_values['MODEL_CONVERSION_WORKER_VERSION'] == values['MODEL_CONVERSION_WORKER_VERSION']
assert new_values['CONFIG_JWT_SECRET'] == values['CONFIG_JWT_SECRET'] assert new_values['CONFIG_JWT_SECRET'] == values['CONFIG_JWT_SECRET']
print('bash: independent companion pins preserve non-target versions and reject invalid scope passed') print(f'{name}: independent companion pins preserve non-target versions and reject invalid scope passed')
print(f'{name}: selected services, stable secret, invalid service and unhealthy Worker passed') print(f'{name}: selected services, stable secret, invalid service and unhealthy Worker passed')
case = base / 'self-update' case = base / 'self-update'
@@ -178,7 +205,7 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
for args in [['git', 'add', 'update.sh'], ['git', 'commit', '-qm', 'test: 验证更新脚本自更新'], ['git', 'push', '-q', 'origin', 'HEAD']]: for args in [['git', 'add', 'update.sh'], ['git', 'commit', '-qm', 'test: 验证更新脚本自更新'], ['git', 'push', '-q', 'origin', 'HEAD']]:
assert run(args, seed).returncode == 0 assert run(args, seed).returncode == 0
compose_path = root / 'docker-compose.yml' compose_path = root / 'docker-compose.yml'
compose_path.write_text(compose_path.read_text().replace('comfy-ai/one-ai:${VERSION}', 'comfy-ai/one-ai@sha256:' + 'a' * 64)) compose_path.write_text(compose_path.read_text().replace('comfy-ai/one-ai:${WEB_VERSION:-${VERSION}}', 'comfy-ai/one-ai@sha256:' + 'a' * 64))
result = run(['bash', 'update.sh'], root, {**env, 'UPDATE_MODE': 'full', 'UPDATE_ADOPT_LEGACY_CONFIG': '1', 'UPDATE_RESTORE_REPOSITORY_IMAGES': 'easyai-web'}) result = run(['bash', 'update.sh'], root, {**env, 'UPDATE_MODE': 'full', 'UPDATE_ADOPT_LEGACY_CONFIG': '1', 'UPDATE_RESTORE_REPOSITORY_IMAGES': 'easyai-web'})
assert result.returncode == 0, result.stdout + result.stderr assert result.returncode == 0, result.stdout + result.stderr
assert '切换到新版更新脚本' in result.stdout assert '切换到新版更新脚本' in result.stdout
+2 -2
View File
@@ -27,7 +27,7 @@ grep -qx 'WATCHTOWER_CLEANUP=' "$LEGACY_ENV_FILE"
grep -qx 'WATCHTOWER_HTTP_API_PERIODIC_POLLS=false' "$LEGACY_ENV_FILE" grep -qx 'WATCHTOWER_HTTP_API_PERIODIC_POLLS=false' "$LEGACY_ENV_FILE"
grep -qx 'WATCHTOWER_UPDATE_TARGET=easyai-server easyai-wsgateway easyai-web' "$LEGACY_ENV_FILE" grep -qx 'WATCHTOWER_UPDATE_TARGET=easyai-server easyai-wsgateway easyai-web' "$LEGACY_ENV_FILE"
grep -qx 'WATCHTOWER_IMAGE=registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:latest' "$LEGACY_ENV_FILE" grep -qx 'WATCHTOWER_IMAGE=registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:latest' "$LEGACY_ENV_FILE"
grep -qx 'MODEL_CONVERSION_WORKER_VERSION=latest' "$LEGACY_ENV_FILE" ! grep -q '^MODEL_CONVERSION_WORKER_VERSION=' "$LEGACY_ENV_FILE"
grep -qx 'MODEL_CONVERSION_HUNYUAN_ENABLED=false' "$LEGACY_ENV_FILE" grep -qx 'MODEL_CONVERSION_HUNYUAN_ENABLED=false' "$LEGACY_ENV_FILE"
cp "$LEGACY_ENV_FILE" "$TMP_DIR/legacy-first-run.env" cp "$LEGACY_ENV_FILE" "$TMP_DIR/legacy-first-run.env"
bash "$PROJECT_ROOT/scripts/apply-update-env-migrations.sh" "$LEGACY_ENV_FILE" "$PROJECT_ROOT" >/dev/null bash "$PROJECT_ROOT/scripts/apply-update-env-migrations.sh" "$LEGACY_ENV_FILE" "$PROJECT_ROOT" >/dev/null
@@ -57,7 +57,7 @@ read_value() {
[ "$(read_value UPDATE_HEALTH_TIMEOUT_MS)" = "300000" ] [ "$(read_value UPDATE_HEALTH_TIMEOUT_MS)" = "300000" ]
[ "$(read_value DOCKER_CONFIG_FILE)" = "/root/.docker/config.json" ] [ "$(read_value DOCKER_CONFIG_FILE)" = "/root/.docker/config.json" ]
[ "$(read_value EASYAI_DEPLOYMENT_SCHEMA_VERSION)" = "2" ] [ "$(read_value EASYAI_DEPLOYMENT_SCHEMA_VERSION)" = "2" ]
[ "$(read_value MODEL_CONVERSION_WORKER_VERSION)" = "latest" ] [ -z "$(read_value MODEL_CONVERSION_WORKER_VERSION)" ]
[ "$(read_value MODEL_CONVERSION_HUNYUAN_ENABLED)" = "false" ] [ "$(read_value MODEL_CONVERSION_HUNYUAN_ENABLED)" = "false" ]
cp "$ENV_FILE" "$TMP_DIR/first-run.env" cp "$ENV_FILE" "$TMP_DIR/first-run.env"
@@ -1,3 +1,3 @@
# 独立模型转换;不改变现有在线更新模式与付费选择。 # 独立模型转换;不改变现有在线更新模式与付费选择。
MODEL_CONVERSION_WORKER_VERSION=latest
MODEL_CONVERSION_HUNYUAN_ENABLED=false MODEL_CONVERSION_HUNYUAN_ENABLED=false
@@ -0,0 +1,56 @@
{
"schemaVersion": 1,
"channel": "versioned",
"releaseId": "3.29.0.20261014.2-610c1e33ae02",
"productVersion": "3.29.0.20261014.2",
"platform": "linux/amd64",
"publishedAt": "2026-10-13T16:59:32.229224+00:00",
"managedUpdateAllowed": true,
"scheduledUpdateAllowed": true,
"rollbackSafe": false,
"minimumUpdaterVersion": "1.1.0",
"minimumDeploymentSchemaVersion": 2,
"requiredFreeBytes": 8589934592,
"components": [
{
"key": "server-main",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server",
"digest": "sha256:a146cd64452f830ba589497be77edce10014a64de6d61abeb32533652dfc7cdc",
"buildId": "b7d82b76bd49c02aacad50bfe778dc256a41c4c0",
"imageSizeBytes": 990412653,
"tag": "3.29.0.20261014.2"
},
{
"key": "ws-gateway",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway",
"digest": "sha256:1dd4247cf6a8a21d336876b5d97ecdc3e4ce43199b2f31aff18e0fab47bb4dca",
"buildId": "retained-formal-image",
"imageSizeBytes": 751971874,
"tag": "3.25.0.20261009"
},
{
"key": "web",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"digest": "sha256:0140956e880d0c9eec0da1ebd1e3bcba20312d96b0161b5978c8c13f16335e5b",
"buildId": "c685301b169bdcd662a97769a33bf69e3668f4c3",
"imageSizeBytes": 348004004,
"tag": "3.29.0.20261014.2"
},
{
"key": "web-no-ssr",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"tag": "3.29.0.20261014.2-no-ssr",
"digest": "sha256:cbc47061109e21a4b9d668dc3cefe3fda8cb3058124ebab333dafd087a4aa49b",
"buildId": "c685301b169bdcd662a97769a33bf69e3668f4c3",
"imageSizeBytes": 312754106
},
{
"key": "model-conversion-worker",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker",
"tag": "3.25.0.20261009.2",
"digest": "sha256:c281a3ad7ae2ae9a2e47411fb565e5fa1c35a756c09d1e281d01803ed090932e",
"buildId": "retained-formal-image"
}
],
"recordSource": "既有3.29.0.20261014.2成功生产/文档记录;仅建立版本记录,未执行新的稳定提升"
}
+9 -9
View File
@@ -59,7 +59,10 @@ if ($args -contains "-h" -or $args -contains "--help") {
Write-Host " UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker'" Write-Host " UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker'"
Write-Host " 仅拉取并重建指定服务,不重建其依赖;留空保持整组更新。" Write-Host " 仅拉取并重建指定服务,不重建其依赖;留空保持整组更新。"
Write-Host " UPDATE_RELEASE_VERSION=固定版本" Write-Host " UPDATE_RELEASE_VERSION=固定版本"
Write-Host " 通过脚本固定核心服务和转换 Worker 的镜像版本,不提升 latest。" Write-Host " 将已选应用的固定版本持久化到 .env;latest 仅为人工稳定版。"
Write-Host " UPDATE_SERVER_VERSION / UPDATE_WEB_VERSION / UPDATE_WORKER_VERSION=固定版本"
Write-Host " UPDATE_GATEWAY_VERSION / UPDATE_UPDATER_VERSION / UPDATE_ASG_VERSION / UPDATE_AMS_VERSION=固定版本"
Write-Host " 独立参数优先,只改变选中应用的版本。"
Write-Host " UPDATE_NO_WAIT=1" Write-Host " UPDATE_NO_WAIT=1"
Write-Host " 结束或失败时不等待按 Enter" Write-Host " 结束或失败时不等待按 Enter"
Write-Host " UPDATE_HEALTH_TIMEOUT_SECONDS=秒数" Write-Host " UPDATE_HEALTH_TIMEOUT_SECONDS=秒数"
@@ -201,6 +204,8 @@ if (-not (Test-Path ".env")) { Write-Err "未找到 .env,请先执行 start.ps
# 每次更新都执行版本化、幂等的环境配置迁移;已有非空自定义值保持不变。 # 每次更新都执行版本化、幂等的环境配置迁移;已有非空自定义值保持不变。
. (Join-Path $scriptDir "scripts\Initialize-SecurityEnv.ps1") . (Join-Path $scriptDir "scripts\Initialize-SecurityEnv.ps1")
$environmentPath = Join-Path $scriptDir ".env" $environmentPath = Join-Path $scriptDir ".env"
. (Join-Path $scriptDir "scripts/Initialize-ApplicationVersions.ps1")
try { $applicationVersionPlan = @(Get-ApplicationVersionPlan -Path $environmentPath) } catch { Write-Err $_.Exception.Message }
$environmentContent = Get-Content $environmentPath -Raw -Encoding UTF8 $environmentContent = Get-Content $environmentPath -Raw -Encoding UTF8
if (-not (Get-EnvValue $environmentContent "SYSTEM_UPDATE_MODE")) { if (-not (Get-EnvValue $environmentContent "SYSTEM_UPDATE_MODE")) {
& docker inspect easyai-server *> $null & docker inspect easyai-server *> $null
@@ -222,14 +227,7 @@ Initialize-ServerHttpBindIp -Path (Join-Path $scriptDir ".env")
. (Join-Path $scriptDir "scripts\Initialize-SecurityEnv.ps1") . (Join-Path $scriptDir "scripts\Initialize-SecurityEnv.ps1")
Initialize-SecurityEnv -Path (Join-Path $scriptDir ".env") -Mode "upgrade" Initialize-SecurityEnv -Path (Join-Path $scriptDir ".env") -Mode "upgrade"
if ($env:UPDATE_RELEASE_VERSION) { Set-ApplicationVersions -Path $environmentPath -Plan $applicationVersionPlan
$environmentPath = Join-Path $scriptDir ".env"
$environmentContent = Get-Content -Raw -Encoding UTF8 $environmentPath
$environmentContent = Set-EnvValue $environmentContent "VERSION" $env:UPDATE_RELEASE_VERSION
$environmentContent = Set-EnvValue $environmentContent "MODEL_CONVERSION_WORKER_VERSION" $env:UPDATE_RELEASE_VERSION
[System.IO.File]::WriteAllText($environmentPath, $environmentContent, (New-Object System.Text.UTF8Encoding $false))
Write-Step "📌 已固定本次发布版本: $($env:UPDATE_RELEASE_VERSION)"
}
# 在更新镜像和重建服务前迁移并校验后端公开 API 地址。 # 在更新镜像和重建服务前迁移并校验后端公开 API 地址。
. (Join-Path $scriptDir "scripts\Initialize-PublicApiBaseUrl.ps1") . (Join-Path $scriptDir "scripts\Initialize-PublicApiBaseUrl.ps1")
@@ -372,6 +370,8 @@ if (-not [string]::IsNullOrWhiteSpace($env:UPDATE_SERVICES)) {
if ($availableServices -notcontains $service) { Write-Err "更新服务不存在: $service;尚未拉取或重建任何服务" } if ($availableServices -notcontains $service) { Write-Err "更新服务不存在: $service;尚未拉取或重建任何服务" }
} }
} }
$composeCommand = if ($hasComposeV2) { @("docker","compose") } else { @("docker-compose") }
try { Test-ApplicationVersionImages -Plan $applicationVersionPlan -ComposeCommand $composeCommand } catch { Write-Err $_.Exception.Message }
$upArguments = @("up", "-d") $upArguments = @("up", "-d")
if ($updateServices.Count -gt 0) { $upArguments += @("--no-deps") + $updateServices } if ($updateServices.Count -gt 0) { $upArguments += @("--no-deps") + $updateServices }
if ($updateServices.Count -eq 0 -or $updateServices -contains "redis") { if ($updateServices.Count -eq 0 -or $updateServices -contains "redis") {
+9 -16
View File
@@ -20,9 +20,12 @@ if [[ "${1:-}" =~ ^(-h|--help)$ ]]; then
echo " UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker'" echo " UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker'"
echo " 仅拉取并重建指定服务;不重建其依赖。留空保持整组更新。" echo " 仅拉取并重建指定服务;不重建其依赖。留空保持整组更新。"
echo " UPDATE_RELEASE_VERSION=固定版本" echo " UPDATE_RELEASE_VERSION=固定版本"
echo " 通过脚本固定核心服务和转换 Worker 的镜像版本,不提升 latest。" echo " 通过脚本将已选应用的固定版本持久化到 .env;latest 仅为人工稳定版。"
echo " UPDATE_GATEWAY_VERSION=固定版本 / UPDATE_UPDATER_VERSION=固定版本" echo " UPDATE_GATEWAY_VERSION=固定版本 / UPDATE_UPDATER_VERSION=固定版本"
echo " 仅对已选中的网关或更新服务固定已核验镜像。" echo " 仅对已选中的网关或更新服务固定已核验镜像。"
echo " UPDATE_SERVER_VERSION / UPDATE_WEB_VERSION / UPDATE_WORKER_VERSION=固定版本"
echo " 单独指定组件版本;不改变未选中服务的版本。"
echo " UPDATE_ASG_VERSION / UPDATE_AMS_VERSION=固定版本"
echo " UPDATE_ADOPT_LEGACY_CONFIG=1" echo " UPDATE_ADOPT_LEGACY_CONFIG=1"
echo " 备份旧部署配置并将本地差异迁移为独立覆盖文件。" echo " 备份旧部署配置并将本地差异迁移为独立覆盖文件。"
echo " UPDATE_RESTORE_REPOSITORY_IMAGES='easyai-web'" echo " UPDATE_RESTORE_REPOSITORY_IMAGES='easyai-web'"
@@ -185,6 +188,8 @@ fi
# 已安装但没有 updater 的旧部署保留其更新方式;新安装仍默认 managed。 # 已安装但没有 updater 的旧部署保留其更新方式;新安装仍默认 managed。
# shellcheck source=scripts/init-security-env.sh # shellcheck source=scripts/init-security-env.sh
. ./scripts/init-security-env.sh . ./scripts/init-security-env.sh
. ./scripts/init-application-version-env.sh
plan_application_versions .env
if [ -z "$(read_env_value .env SYSTEM_UPDATE_MODE)" ] && docker inspect easyai-server >/dev/null 2>&1 && ! docker inspect easyai-updater >/dev/null 2>&1; then if [ -z "$(read_env_value .env SYSTEM_UPDATE_MODE)" ] && docker inspect easyai-server >/dev/null 2>&1 && ! docker inspect easyai-updater >/dev/null 2>&1; then
write_env_value .env SYSTEM_UPDATE_MODE legacy_watchtower write_env_value .env SYSTEM_UPDATE_MODE legacy_watchtower
fi fi
@@ -200,21 +205,7 @@ init_server_http_bind_ip .env
init_security_env .env upgrade init_security_env .env upgrade
bash ./scripts/init-infrastructure-update-env.sh "$SCRIPT_DIR" bash ./scripts/init-infrastructure-update-env.sh "$SCRIPT_DIR"
if [ -n "${UPDATE_RELEASE_VERSION:-}" ]; then apply_application_versions .env
write_env_value .env VERSION "$UPDATE_RELEASE_VERSION"
# 定向发布主服务时保留未参与的 Worker 固定版本。
if [ -z "${UPDATE_SERVICES:-}" ] || [[ " ${UPDATE_SERVICES} " == *" model-conversion-worker "* ]]; then
write_env_value .env MODEL_CONVERSION_WORKER_VERSION "$UPDATE_RELEASE_VERSION"
fi
echo "📌 已固定本次发布版本: $UPDATE_RELEASE_VERSION"
fi
if [ -n "${UPDATE_GATEWAY_VERSION:-}" ]; then
write_env_value .env WS_GATEWAY_VERSION "$UPDATE_GATEWAY_VERSION"
fi
if [ -n "${UPDATE_UPDATER_VERSION:-}" ]; then
write_env_value .env UPDATER_VERSION "$UPDATE_UPDATER_VERSION"
fi
# 在更新镜像和重建服务前迁移并校验后端公开 API 地址。 # 在更新镜像和重建服务前迁移并校验后端公开 API 地址。
# shellcheck source=scripts/init-public-api-base-url.sh # shellcheck source=scripts/init-public-api-base-url.sh
@@ -342,6 +333,8 @@ if [ -n "${UPDATE_SERVICES:-}" ]; then
done done
fi fi
verify_application_version_images .env "$DOCKER_COMPOSE_CMD"
# 只检查本次会重建的 Redis;--no-deps 更新其他服务不会修改其旧数据位置。 # 只检查本次会重建的 Redis;--no-deps 更新其他服务不会修改其旧数据位置。
if [ "${#UPDATE_SERVICE_LIST[@]}" -eq 0 ] || printf '%s\n' "${UPDATE_SERVICE_LIST[@]}" | grep -qx 'redis'; then if [ "${#UPDATE_SERVICE_LIST[@]}" -eq 0 ] || printf '%s\n' "${UPDATE_SERVICE_LIST[@]}" | grep -qx 'redis'; then
sudo ./scripts/check-redis-persistence.sh sudo ./scripts/check-redis-persistence.sh