From 8c1eadc5ea9bde54f069d69adcc8c3a5d0647373 Mon Sep 17 00:00:00 2001 From: easyai03 Date: Wed, 14 Oct 2026 08:35:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(release):=20=E7=8E=AF=E5=A2=83=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E7=89=88=E6=9C=AC=E5=B9=B6=E6=8B=86=E5=88=86=E4=BA=BA?= =?UTF-8?q?=E5=B7=A5=E7=A8=B3=E5=AE=9A=E7=89=88=E6=8F=90=E5=8D=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 12 +- README.md | 13 +- docker-compose.yml | 14 +-- scripts/Initialize-ApplicationVersions.ps1 | 62 ++++++++++ scripts/init-application-version-env.sh | 71 +++++++++++ scripts/promote-stable-release.mjs | 116 ++++++++++++++++++ scripts/promote-stable-release.test.mjs | 98 +++++++++++++++ scripts/test-model-conversion-update.py | 37 +++++- scripts/test-update-env-migrations.sh | 4 +- scripts/update-env.d/030-model-conversion.env | 2 +- system-update/releases/3.29.0.20261014.2.json | 56 +++++++++ update.ps1 | 18 +-- update.sh | 25 ++-- 13 files changed, 482 insertions(+), 46 deletions(-) create mode 100644 scripts/Initialize-ApplicationVersions.ps1 create mode 100644 scripts/init-application-version-env.sh create mode 100644 scripts/promote-stable-release.mjs create mode 100644 scripts/promote-stable-release.test.mjs create mode 100644 system-update/releases/3.29.0.20261014.2.json diff --git a/.env.sample b/.env.sample index b697694..9d4deb9 100644 --- a/.env.sample +++ b/.env.sample @@ -259,7 +259,15 @@ STATIC_CDN_OSS_USE_SSL=true STATIC_CDN_FORCE_UPLOAD=false # ========== 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 在线更新 ========== # 新安装默认使用可查询任务模式;存量环境执行 update.sh 后会自动补齐缺失配置。 @@ -294,7 +302,7 @@ EASYAI_DEPLOYMENT_SCHEMA_VERSION=2 # ========== 15.2 3D 模型轻量转换 ========== # update 脚本自动补齐缺失配置并拉取独立容器,无需手动修改线上配置。 # 可填写已发布版本号固定镜像;latest 跟随正式转换镜像。 -MODEL_CONVERSION_WORKER_VERSION=latest +MODEL_CONVERSION_WORKER_VERSION= # 安装/升级时自动生成,API 和 Worker 共用。已有强令牌不会重新生成。 MODEL_CONVERSION_WORKER_TOKEN= # 付费通道默认关闭;自建失败不会自动扣费转入其他通道。 diff --git a/README.md b/README.md index 03bd133..c639a9f 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,10 @@ mv easyai easyai2 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 应用,包含以下功能: - **拉取整个仓库**:执行 `git pull` 获取最新代码(docker-compose.yml、start.sh、.env.*.sample 等全部文件) - 自动补齐缺失的环境配置文件(.env、.env.tools、.env.ASG、.env.AMS,从 .sample 生成且不覆盖已有文件) @@ -190,7 +194,7 @@ update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能: 更新执行器 `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` 路径覆盖不再影响默认远程模式。 @@ -217,7 +221,7 @@ Watchtower 默认使用 `latest`。`start.sh/update.sh` 自动安装宿主机 cr 两个页面入口共用计时和步骤窗口。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`(明确保留某种旧模式);所有迁移每次都会执行并保持幂等。迁移失败会在拉取镜像前终止,避免使用半完成配置启动服务。 @@ -257,12 +261,13 @@ UPDATE_SKIP_REPO_UPDATE=true ./update.sh ```bash 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;原模型始终保留,轻量转换失败可使用原模型。 -`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` 标签。版本号不合法时在修改配置前终止。 diff --git a/docker-compose.yml b/docker-compose.yml index 19596c2..26a214d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: 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 labels: - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}" @@ -58,7 +58,7 @@ services: 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: - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}" - "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}" @@ -170,7 +170,7 @@ services: max-file: "10" 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: - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}" - "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}" @@ -224,7 +224,7 @@ services: ws-gateway: 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: - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}" - "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}" @@ -391,7 +391,7 @@ services: max-size: "100m" max-file: "10" 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 profiles: - managed-update @@ -567,7 +567,7 @@ services: max-file: "10" 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: - "com.centurylinklabs.watchtower.enable=true" - "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}" @@ -599,7 +599,7 @@ services: memory: 128MB 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: - "com.centurylinklabs.watchtower.enable=true" - "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}" diff --git a/scripts/Initialize-ApplicationVersions.ps1 b/scripts/Initialize-ApplicationVersions.ps1 new file mode 100644 index 0000000..1d58672 --- /dev/null +++ b/scripts/Initialize-ApplicationVersions.ps1 @@ -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) 的有效镜像未使用环境中的固定版本;请通过部署仓库的受控迁移恢复镜像定义,尚未拉取或重建服务" + } + } +} diff --git a/scripts/init-application-version-env.sh b/scripts/init-application-version-env.sh new file mode 100644 index 0000000..ca66813 --- /dev/null +++ b/scripts/init-application-version-env.sh @@ -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" +} diff --git a/scripts/promote-stable-release.mjs b/scripts/promote-stable-release.mjs new file mode 100644 index 0000000..6ad5919 --- /dev/null +++ b/scripts/promote-stable-release.mjs @@ -0,0 +1,116 @@ +#!/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 = { + '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({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,并拒绝重复、错误镜像和浮动源标签',()=>{ + 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}); } +}); diff --git a/scripts/test-model-conversion-update.py b/scripts/test-model-conversion-update.py index fed66bd..75a18b3 100644 --- a/scripts/test-model-conversion-update.py +++ b/scripts/test-model-conversion-update.py @@ -37,7 +37,7 @@ def fixture(base): (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 / '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') commands = base / 'commands.jsonl' commands.touch() @@ -57,6 +57,11 @@ if args[:1] == ['inspect']: sys.exit(0) sys.exit(1) 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]) 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' log.write_text('') 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'}) assert result.returncode != 0 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'}) assert result.returncode != 0 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('') 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 @@ -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 not any('model-conversion-worker' in call for call in calls(log) if call[:2] in [['compose', 'pull'], ['compose', 'up']]) 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', 'UPDATE_GATEWAY_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['MODEL_CONVERSION_WORKER_VERSION'] == values['MODEL_CONVERSION_WORKER_VERSION'] 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') 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']]: assert run(args, seed).returncode == 0 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'}) assert result.returncode == 0, result.stdout + result.stderr assert '切换到新版更新脚本' in result.stdout diff --git a/scripts/test-update-env-migrations.sh b/scripts/test-update-env-migrations.sh index 089b3e1..4094ffe 100755 --- a/scripts/test-update-env-migrations.sh +++ b/scripts/test-update-env-migrations.sh @@ -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_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 '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" 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 @@ -57,7 +57,7 @@ read_value() { [ "$(read_value UPDATE_HEALTH_TIMEOUT_MS)" = "300000" ] [ "$(read_value DOCKER_CONFIG_FILE)" = "/root/.docker/config.json" ] [ "$(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" ] cp "$ENV_FILE" "$TMP_DIR/first-run.env" diff --git a/scripts/update-env.d/030-model-conversion.env b/scripts/update-env.d/030-model-conversion.env index bdf7461..e29c038 100644 --- a/scripts/update-env.d/030-model-conversion.env +++ b/scripts/update-env.d/030-model-conversion.env @@ -1,3 +1,3 @@ # 独立模型转换;不改变现有在线更新模式与付费选择。 -MODEL_CONVERSION_WORKER_VERSION=latest + MODEL_CONVERSION_HUNYUAN_ENABLED=false diff --git a/system-update/releases/3.29.0.20261014.2.json b/system-update/releases/3.29.0.20261014.2.json new file mode 100644 index 0000000..3c6d66b --- /dev/null +++ b/system-update/releases/3.29.0.20261014.2.json @@ -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成功生产/文档记录;仅建立版本记录,未执行新的稳定提升" +} diff --git a/update.ps1 b/update.ps1 index cf84b8d..9d8c7ac 100644 --- a/update.ps1 +++ b/update.ps1 @@ -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 " 仅拉取并重建指定服务,不重建其依赖;留空保持整组更新。" 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 " 结束或失败时不等待按 Enter" 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") $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 if (-not (Get-EnvValue $environmentContent "SYSTEM_UPDATE_MODE")) { & docker inspect easyai-server *> $null @@ -222,14 +227,7 @@ Initialize-ServerHttpBindIp -Path (Join-Path $scriptDir ".env") . (Join-Path $scriptDir "scripts\Initialize-SecurityEnv.ps1") Initialize-SecurityEnv -Path (Join-Path $scriptDir ".env") -Mode "upgrade" -if ($env:UPDATE_RELEASE_VERSION) { - $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)" -} +Set-ApplicationVersions -Path $environmentPath -Plan $applicationVersionPlan # 在更新镜像和重建服务前迁移并校验后端公开 API 地址。 . (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;尚未拉取或重建任何服务" } } } +$composeCommand = if ($hasComposeV2) { @("docker","compose") } else { @("docker-compose") } +try { Test-ApplicationVersionImages -Plan $applicationVersionPlan -ComposeCommand $composeCommand } catch { Write-Err $_.Exception.Message } $upArguments = @("up", "-d") if ($updateServices.Count -gt 0) { $upArguments += @("--no-deps") + $updateServices } if ($updateServices.Count -eq 0 -or $updateServices -contains "redis") { diff --git a/update.sh b/update.sh index 1736a30..630a058 100755 --- a/update.sh +++ b/update.sh @@ -20,9 +20,12 @@ if [[ "${1:-}" =~ ^(-h|--help)$ ]]; then echo " UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker'" echo " 仅拉取并重建指定服务;不重建其依赖。留空保持整组更新。" echo " UPDATE_RELEASE_VERSION=固定版本" - echo " 通过脚本固定核心服务和转换 Worker 的镜像版本,不提升 latest。" + echo " 通过脚本将已选应用的固定版本持久化到 .env;latest 仅为人工稳定版。" echo " UPDATE_GATEWAY_VERSION=固定版本 / UPDATE_UPDATER_VERSION=固定版本" 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 " 备份旧部署配置并将本地差异迁移为独立覆盖文件。" echo " UPDATE_RESTORE_REPOSITORY_IMAGES='easyai-web'" @@ -185,6 +188,8 @@ fi # 已安装但没有 updater 的旧部署保留其更新方式;新安装仍默认 managed。 # shellcheck source=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 write_env_value .env SYSTEM_UPDATE_MODE legacy_watchtower fi @@ -200,21 +205,7 @@ init_server_http_bind_ip .env init_security_env .env upgrade bash ./scripts/init-infrastructure-update-env.sh "$SCRIPT_DIR" -if [ -n "${UPDATE_RELEASE_VERSION:-}" ]; then - 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 +apply_application_versions .env # 在更新镜像和重建服务前迁移并校验后端公开 API 地址。 # shellcheck source=scripts/init-public-api-base-url.sh @@ -342,6 +333,8 @@ if [ -n "${UPDATE_SERVICES:-}" ]; then done fi +verify_application_version_images .env "$DOCKER_COMPOSE_CMD" + # 只检查本次会重建的 Redis;--no-deps 更新其他服务不会修改其旧数据位置。 if [ "${#UPDATE_SERVICE_LIST[@]}" -eq 0 ] || printf '%s\n' "${UPDATE_SERVICE_LIST[@]}" | grep -qx 'redis'; then sudo ./scripts/check-redis-persistence.sh