Compare commits

..
Author SHA1 Message Date
easyai03 4df7625052 chore(deploy): 补齐前后端固定镜像测试入口
合入已审查并通过定向验证的剩余改动,保留当前主线与已上线修复。
2026-10-16 09:36:55 +08:00
easyai03 1f12419742 chore(deploy): 集成定向测试入口并同步主线
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s
2026-10-16 09:34:44 +08:00
easyai03 24d7efbb1b chore(release): 登记 3.30.0.20261016 固定版本产物 2026-10-16 03:29:42 +08:00
wangbo 9a55fcfe5b chore(release): 合并结果恢复与转存修复发布清单
纳入已验收固定版本及正式发布元数据。
2026-10-15 20:27:13 +08:00
wangbo 8d60d463ec chore(release): 登记结果恢复与转存修复固定版本 2026-10-15 20:25:30 +08:00
wangbo 3641e91346 feat(catalog): 将官方模型目录迁入公开Git仓库 (#11)
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s
合入已通过定向验收的官方模型目录 Git 维护与消费迁移。
2026-10-15 11:57:20 +08:00
wangbo 9547a027a2 feat(catalog): 将官方模型目录迁入公开Git仓库
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s
Test deployment public URL (Linux) / test-linux (pull_request) Canceled after 0s
Test start.ps1 (Windows) / test-windows (pull_request) Canceled after 0s
2026-10-15 11:53:42 +08:00
easyai03 dad460581e chore(release): 将 3.29.0.20261015 提升为稳定版 2026-10-15 09:56:52 +08:00
easyai03 e78c183888 chore(release): 记录 3.29.0.20261015 固定版本产物 2026-10-15 03:26:33 +08:00
easyai03 dbb6bbce02 fix(release): 按服务核验 Compose 固定镜像并保留线上修复 2026-10-15 03:09:51 +08:00
wangbo 2db18f5804 chore(deploy): 补充前端正式产物的测试验收入口 2026-10-14 12:09:37 +08:00
wangbo ef471e2106 chore(deploy): 提供保留现有配置的后端测试镜像入口 2026-10-14 11:43:55 +08:00
wangbo bf0ac6db54 fix(deploy): 按服务读取镜像定义避免依赖镜像误判 2026-10-14 11:24:13 +08:00
easyai03 9a801ea3d5 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秒通过。未连接或更新生产/测试服务器,未执行真实镜像构建/标签推送。
2026-10-14 08:40:48 +08:00
easyai03 6691ac34e0 fix(release): 补齐前端变体的人工稳定标签映射
Test deployment public URL (Linux) / test-linux (pull_request) Canceled after 0s
Test start.ps1 (Windows) / test-windows (pull_request) Canceled after 0s
2026-10-14 08:39:50 +08:00
easyai03 8c1eadc5ea fix(release): 环境固定版本并拆分人工稳定版提升
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s
Test deployment public URL (Linux) / test-linux (pull_request) Canceled after 0s
Test start.ps1 (Windows) / test-windows (pull_request) Canceled after 0s
2026-10-14 08:35:04 +08:00
wangbo 5fa19eff08 Merge pull request 'fix(release): 同步公开搜索组织范围修复稳定清单' (#8) from codex/search-scope-release-20261014 into main
fix(release): 发布公开搜索组织范围修复正式版本
2026-10-14 01:00:11 +08:00
wangbo 30c5d31787 fix(release): 同步公开搜索组织范围修复稳定清单 2026-10-14 00:59:32 +08:00
wangbo e337f6b7fb Merge pull request 'fix(release): 发布历史搜索递归溢出修复正式清单' (#7) from codex/search-recursion-release-20261014 into main
fix(release): 同步历史搜索递归修复正式稳定清单
2026-10-14 00:48:42 +08:00
wangbo 15a36fd085 fix(release): 发布历史搜索递归溢出修复正式清单 2026-10-14 00:47:14 +08:00
wangbo b77f27f6d5 Merge pull request 'chore(release): 发布 3.29.0.20261013.6 段错误修复清单' (#6) from codex/native-stack-release-20261013 into main
chore(release): 合并主后端段错误修复正式发布清单
2026-10-14 00:05:49 +08:00
wangbo 25e38b4441 chore(release): 发布主后端段错误修复正式镜像清单 2026-10-14 00:04:46 +08:00
easyai03 111ccab266 Merge pull request 'chore(release): 发布 3.29.0.20261013.5 镜像清单' (#5) from codex/runninghub-release-20261013-deploy into main 2026-10-13 20:01:07 +08:00
easyai03 23c9a989d4 chore(release): 更新正式镜像清单至 3.29.0.20261013.5 2026-10-13 19:59:14 +08:00
wangbo e082e24be7 Merge pull request 'chore(release): 发布后端性能修复清单并复用前端镜像' (#4) from codex/expense-index-followup-release-20261013 into main 2026-10-13 18:48:22 +08:00
wangbo ede392c310 chore(release): 更新后端性能修复镜像清单并复用前端产物 2026-10-13 18:47:00 +08:00
wangbo 7114eeee01 chore(release): 发布费用列表保护与查询性能修复清单 2026-10-13 13:24:32 +08:00
wangbo 85399bb185 chore(release): 发布费用列表保护与查询性能修复清单 2026-10-13 13:21:31 +08:00
wangbo 79e6bdf65e chore(release): 发布主站索引与媒体上下文修复清单 2026-10-13 10:54:11 +08:00
easyai03 ffaa01d434 chore(release): 发布 3.29.0.20261013 稳定清单并标注凭据回滚边界 2026-10-13 03:25:18 +08:00
easyai03 4cff2dcc58 feat(deploy): 合并凭据加密迁移及密钥备份恢复 (#2)
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s
完成 API Key 加密、旧部署兼容及迁移支持。

本地验收通过;宁波服务器验收尚未执行。
任务:credential-ningbo-20261012
2026-10-13 00:37:53 +08:00
33 changed files with 31203 additions and 64 deletions
+10 -2
View File
@@ -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=
# 付费通道默认关闭;自建失败不会自动扣费转入其他通道。
+13 -4
View File
@@ -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` 标签。版本号不合法时在修改配置前终止。
@@ -741,3 +746,7 @@ apt remove docker-compose
```bash
cd ~/easyai && cp docker-compose.yml docker-compose.yml.backup && wget -q https://git.51easyai.com/wangbo/easyai/raw/main/docker-compose.yml -O docker-compose.yml && echo "✅ docker-compose.yml 更新完成"
```
## 官方模型目录
官方基础模型定义由本仓库独立维护,无需重建后端即可通过 Git 发布。维护源、公开地址、版本准备、校验和回退流程见 [官方模型目录](model-catalog/official/README.md)。平台后端和独立 AI Gateway 只读消费;不再使用原 OSS/CDN 目录发布流程。
+5
View File
@@ -0,0 +1,5 @@
# 自有测试环境的后端候选验收。追加到既有 COMPOSE_FILE,仅选择主后端镜像。
# EASYAI_TEST_SERVER_IMAGE 必须为已发布并核验的完整版本镜像。
services:
easyai-server:
image: ${EASYAI_TEST_SERVER_IMAGE:?EASYAI_TEST_SERVER_IMAGE is required}
+5
View File
@@ -0,0 +1,5 @@
# 自有测试环境与已发布前端产物配对验收,仅选择前端镜像。
# 不参与生产或客户的默认更新配置。
services:
easyai-web:
image: ${EASYAI_TEST_WEB_IMAGE:?EASYAI_TEST_WEB_IMAGE is required}
+9 -7
View File
@@ -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:-}"
@@ -88,6 +88,8 @@ services:
environment:
# Node.js V8 old-space 堆上限(MiB),可在 .env 中覆盖
- NODE_OPTIONS=--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE:-8192}
# 官方目录由公开 Git 仓库维护,模型数据更新无需重新构建服务镜像。
- CONFIG_OFFICIAL_MODEL_CATALOG_URL=${CONFIG_OFFICIAL_MODEL_CATALOG_URL:-https://git.51easyai.com/wangbo/easyai/raw/branch/main/model-catalog/official/latest.json}
# MongoDB配置,用户管理,不会配置的话保持默认即可
- CONFIG_DB_MONGO_URI=mongodb://172.21.0.3:27017
- CONFIG_DB_MONGO_USERNAME=${MONGO_INITDB_ROOT_USERNAME}
@@ -170,7 +172,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 +226,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 +393,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 +569,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 +601,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:-}"
+1
View File
@@ -0,0 +1 @@
node_modules/
+9
View File
@@ -0,0 +1,9 @@
# 目录维护与消费验收
1. 执行 `yarn format`、`yarn validate`、`yarn test`、`yarn prepare-release --note "变更说明"`、`yarn run check`。
2. 对照当前公开版本逐条核对模型身份和定义,确认新增、修改、下线范围;未知缺失必须停止。
3. PR 合入 `main` 后,免登录读取公开维护源、`latest.json` 及指针中的版本快照;重新计算摘要并核对 Git 提交内容。
4. 使用平台真实 HTTP 和隔离 MongoDB 验证同步、重复同步、恢复停用模型、本地覆盖、主动停用、隐藏、绑定和价格保留。
5. 独立网关用真实公开 HTTP 验证指针、同源 HTTPS、内容摘要和模型解析;自定义源仍可配置。
6. 回退测试恢复历史定义并生成新版本,保留所有已有快照与 Git 历史。
7. 普通应用发布不修改模型定义。迁移不删除客户绑定、价格、权限,也不自动发起付费模型任务。
+8
View File
@@ -0,0 +1,8 @@
# 官方模型目录维护规则
- 唯一维护源为本目录 `catalog.source.json`。平台后端和独立 AI Gateway 都是只读消费者,不在应用仓库另存维护源。
- 内容和版本指针通过 Git 提交发布,公开地址见 README。禁止使用旧 OSS/CDN 上传流程;应用镜像发布不自动授权修改模型定义或生命周期。
- 变更前同步最新主线,检查新增、修改和下线差异;执行格式、Schema、测试和版本一致性检查,将维护源、版本快照和 latest.json 一并提交。
- 恢复历史定义时生成新的回退版本;不覆盖历史快照,不强推改写 Git 历史。
- 正常下线保留原目录身份并设置 offline;缺失模型默认拒绝准备版本。
- 不将客户绑定、价格、权限、账号或密钥放入公开目录。
+50
View File
@@ -0,0 +1,50 @@
# EasyAI 官方模型目录
本目录是官方基础模型定义的唯一维护位置。平台后端与独立 AI Gateway 只读消费,不另存维护源。目录内容不包含客户密钥、账号、平台绑定、价格、权限或运行策略。
- 维护 JSON:`catalog.source.json`
- 公开维护源:[catalog.source.json](https://git.51easyai.com/wangbo/easyai/raw/branch/main/model-catalog/official/catalog.source.json)
- 消费端入口:[latest.json](https://git.51easyai.com/wangbo/easyai/raw/branch/main/model-catalog/official/latest.json)
- Git 历史:[目录提交记录](https://git.51easyai.com/wangbo/easyai/commits/branch/main/model-catalog/official)
公开地址免登录。只有进入 `main` 的 Git 内容才会对外生效;未提交文件、临时 JSON 和普通分支不会替换正式目录。旧 OSS/CDN 上传流程已停用。
## 修改和发布
使用 Node 22.22.0、Yarn 1.22.22,在最新主线创建工作分支:
```bash
cd model-catalog/official
yarn install --frozen-lockfile --ignore-scripts
# 编辑 catalog.source.json
yarn format
yarn validate
yarn test
yarn prepare-release --note "本次模型变更说明"
yarn run check
git diff
```
`prepare-release` 只生成 Git 文件,不上传、不推送,也不切换任何线上对象。它会对照当前 Git 提交的上一版目录,默认拒绝缺少已有模型的候选。新增、能力修改和下线分别审查;正常下线保留原 `catalogKey`,将 `status` 设为 `offline`,不要通过删记录表达退役。确需物理移除时,必须同时声明 `--allow-removals --removal-reason "具体原因"`,原因及移除项会进入版本快照。
确认差异后,将维护源、`latest.json` 和本次 `releases/<版本>/catalog.json` 一起提交,经 PR 合入 `main`:
```bash
git add catalog.source.json latest.json releases/
git commit -m 'feat(catalog): 说明本次模型变更'
git push -u origin HEAD
```
平台后端配置项为 `CONFIG_OFFICIAL_MODEL_CATALOG_URL`,独立网关为 `AI_GATEWAY_OFFICIAL_MODEL_CATALOG_URL`,均使用上面的 `latest.json` 地址。目录同步后还须验收模型状态、身份和本地绑定、价格、权限不变;收录模型不代表上游账号已经可用。
## 回退
使用 `git log -- catalog.source.json` 查看历史,恢复选定提交中的定义,再准备一个新版本:
```bash
git restore --source=<历史提交> -- catalog.source.json
yarn prepare-release --note "恢复指定历史提交的模型定义"
yarn run check
```
审查差异、测试、提交并合入 `main` 后生效。回退也必须审查模型移除;历史中没有的新模型不会被静默删除。保留所有历史版本快照,不覆盖旧文件,不强推或重置公开分支。Git 提交记录用于确定修改人、时间、评审和内容差异;版本快照记录内容摘要及上一版本。
+397
View File
@@ -0,0 +1,397 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { execFileSync } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const Ajv = require("ajv");
const directory = path.dirname(fileURLToPath(import.meta.url));
const repository = path.resolve(directory, "../..");
const defaultSourcePath = path.join(directory, "catalog.source.json");
const schemaPath = path.join(directory, "catalog.schema.json");
export const publicBaseUrl =
"https://git.51easyai.com/wangbo/easyai/raw/branch/main/model-catalog/official";
const args = process.argv.slice(2);
const option = (name) => {
const index = args.indexOf("--" + name);
return index < 0 ? "" : String(args[index + 1] || "");
};
const stableValue = (value) => {
if (Array.isArray(value)) return value.map(stableValue);
if (value && typeof value === "object") {
return Object.keys(value)
.sort()
.reduce((result, key) => {
result[key] = stableValue(value[key]);
return result;
}, {});
}
return value;
};
const checksum = (payload) =>
crypto
.createHash("sha256")
.update(JSON.stringify(stableValue(payload)))
.digest("hex");
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
const serializeJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
function canonicalizeSource(source) {
return {
...source,
baseModels: source.baseModels.map((model) => {
const current = model.capabilities || {};
const capabilityKeys = [
...model.modelTypes,
...Object.keys(current).filter(
(key) => !model.modelTypes.includes(key),
),
];
const capabilities = Object.fromEntries(
capabilityKeys.map((type) => {
const { type: _ignoredType, ...config } = current[type] || {};
return [type, { type, ...config }];
}),
);
return { ...model, capabilities };
}),
};
}
function assertOrderedRanges(value, pathParts = []) {
if (Array.isArray(value)) {
const field = pathParts.at(-1) || "";
if (
(field.endsWith("_range") || field === "thinkingBudgetTokensRange") &&
value.length === 2 &&
value.every((item) => typeof item === "number") &&
value[0] > value[1]
) {
throw new Error(`范围上下限颠倒: ${pathParts.join(".")}`);
}
value.forEach((item, index) =>
assertOrderedRanges(item, [...pathParts, String(index)]),
);
return;
}
if (!value || typeof value !== "object") return;
if (
typeof value.min === "number" &&
typeof value.max === "number" &&
value.min > value.max
) {
throw new Error(`范围上下限颠倒: ${pathParts.join(".")}`);
}
Object.entries(value).forEach(([key, item]) =>
assertOrderedRanges(item, [...pathParts, key]),
);
}
export function validateSource(file = defaultSourcePath) {
const sourceText = fs.readFileSync(file, "utf8");
const source = JSON.parse(sourceText);
const ajv = new Ajv({ allErrors: true, strict: false });
const validate = ajv.compile(readJson(schemaPath));
if (!validate(source)) {
throw new Error(
`官方目录格式无效: ${ajv.errorsText(validate.errors, { separator: "; " })}`,
);
}
const providerCodes = new Set(source.providers.map((item) => item.code));
if (providerCodes.size !== source.providers.length) {
throw new Error("官方目录包含重复厂商 code");
}
const keys = new Set();
const identities = new Set();
for (const model of source.baseModels) {
if (keys.has(model.catalogKey))
throw new Error(`重复 catalogKey: ${model.catalogKey}`);
keys.add(model.catalogKey);
const identity = `${model.alias}\u0000${model.defaultRealModelName}`;
if (identities.has(identity))
throw new Error(
`重复模型身份: ${model.alias} / ${model.defaultRealModelName}`,
);
identities.add(identity);
if (model.providerCode && !providerCodes.has(model.providerCode)) {
throw new Error(
`模型 ${model.catalogKey} 引用了不存在的厂商 ${model.providerCode}`,
);
}
const capabilityKeys = Object.keys(model.capabilities);
const missingCapabilities = model.modelTypes.filter(
(type) => !capabilityKeys.includes(type),
);
const extraCapabilities = capabilityKeys.filter(
(type) => !model.modelTypes.includes(type),
);
if (missingCapabilities.length || extraCapabilities.length) {
throw new Error(
`模型 ${model.catalogKey} 的 modelTypes 与 capabilities 不一致` +
`(缺少: ${missingCapabilities.join(", ") || "无"};` +
`多余: ${extraCapabilities.join(", ") || "无"})`,
);
}
for (const [type, capability] of Object.entries(model.capabilities)) {
if (capability.type !== type) {
throw new Error(
`模型 ${model.catalogKey} 的能力 ${type}.type 必须等于 ${type}`,
);
}
}
const textToSpeech = model.capabilities.text_to_speech;
if (textToSpeech) {
const voices = Array.isArray(textToSpeech.voices)
? textToSpeech.voices
: [];
const voiceIds = voices.map((voice) => voice.id);
if (new Set(voiceIds).size !== voiceIds.length) {
throw new Error(`模型 ${model.catalogKey} 包含重复音色 ID`);
}
if (
textToSpeech.default_voice_id &&
!voiceIds.includes(textToSpeech.default_voice_id)
) {
throw new Error(
`模型 ${model.catalogKey} 的默认音色不在 voices 中: ${textToSpeech.default_voice_id}`,
);
}
}
if (model.catalogKey.startsWith("base:seed:")) {
if (!model.seedKey)
throw new Error(`种子模型 ${model.catalogKey} 必须包含 seedKey`);
} else if (model.seedKey !== undefined) {
throw new Error(`运行时模型 ${model.catalogKey} 不应包含 seedKey`);
}
assertOrderedRanges(model.capabilities, [model.catalogKey, "capabilities"]);
}
const canonicalText = serializeJson(canonicalizeSource(source));
if (sourceText !== canonicalText) {
throw new Error("官方目录格式不规范,请运行 yarn format 后再提交");
}
return source;
}
export function buildRelease(
source,
{ version, note = "", previousVersion = null } = {},
) {
const publishedAt = new Date().toISOString();
const selectedVersion = version || publishedAt.replace(/[-:.]/g, "");
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/.test(selectedVersion))
throw new Error("目录版本格式无效");
const payload = {
schemaVersion: source.schemaVersion,
providers: source.providers,
baseModels: source.baseModels,
providerModelPresets: [],
};
return {
version: selectedVersion,
publishedAt,
generatedAt: publishedAt,
...payload,
checksum: checksum(payload),
count: source.baseModels.length,
ttlSeconds: 21600,
previousVersion,
sourceRepository: "https://git.51easyai.com/wangbo/easyai",
sourcePath: "model-catalog/official/catalog.source.json",
...(note ? { note } : {}),
};
}
export function assertNoMissingModels(
previous,
source,
{ allowRemovals = false, removalReason = "" } = {},
) {
const keys = new Set(source.baseModels.map((model) => model.catalogKey));
const removed = (previous?.baseModels || [])
.filter((model) => !keys.has(model.catalogKey))
.map((model) => ({ catalogKey: model.catalogKey, alias: model.alias }));
if (removed.length && (!allowRemovals || !removalReason.trim())) {
throw new Error(
`维护源遗漏已发布模型:${JSON.stringify(removed)}。下线请保留原身份并设置 status=offline;确需移除时须声明 --allow-removals --removal-reason。`,
);
}
return removed;
}
export function prepareArtifacts(targetDirectory = directory, options = {}) {
const source = validateSource(
path.join(targetDirectory, "catalog.source.json"),
);
const removed = assertNoMissingModels(options.previous, source, options);
const release = buildRelease(source, {
...options,
previousVersion: options.previous?.version || null,
});
if (removed.length)
release.removals = {
models: removed,
reason: options.removalReason.trim(),
};
const releasePath = path.join(
targetDirectory,
"releases",
release.version,
"catalog.json",
);
if (fs.existsSync(releasePath))
throw new Error("该版本快照已存在,请生成新版本,不能覆盖历史文件");
const pointer = {
schemaVersion: 1,
version: release.version,
checksum: release.checksum,
publishedAt: release.publishedAt,
catalogUrl: `${publicBaseUrl}/releases/${release.version}/catalog.json`,
};
fs.mkdirSync(path.dirname(releasePath), { recursive: true });
fs.writeFileSync(releasePath, serializeJson(release));
fs.writeFileSync(
path.join(targetDirectory, "latest.json"),
serializeJson(pointer),
);
return { pointer, release };
}
export function checkArtifacts(targetDirectory = directory) {
const source = validateSource(
path.join(targetDirectory, "catalog.source.json"),
);
const pointer = readJson(path.join(targetDirectory, "latest.json"));
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/.test(pointer.version))
throw new Error("目录版本格式无效");
const release = readJson(
path.join(targetDirectory, "releases", pointer.version, "catalog.json"),
);
const payload = {
schemaVersion: source.schemaVersion,
providers: source.providers,
baseModels: source.baseModels,
providerModelPresets: [],
};
if (
pointer.version !== release.version ||
pointer.checksum !== release.checksum ||
release.checksum !== checksum(payload) ||
release.checksum !==
checksum({
schemaVersion: release.schemaVersion,
providers: release.providers,
baseModels: release.baseModels,
providerModelPresets: release.providerModelPresets || [],
})
) {
throw new Error(
"维护源、最新指针和版本快照不一致,请重新准备版本并一并提交",
);
}
if (
pointer.catalogUrl !==
`${publicBaseUrl}/releases/${release.version}/catalog.json`
)
throw new Error("目录快照必须指向 easyai 仓库公开地址");
return {
version: release.version,
checksum: release.checksum,
baseModelCount: release.baseModels.length,
catalogUrl: pointer.catalogUrl,
};
}
function previousCommittedRelease() {
const git = (file) =>
JSON.parse(
execFileSync("git", ["show", `HEAD:model-catalog/official/${file}`], {
cwd: repository,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 4 * 1024 * 1024,
}),
);
let pointer;
try {
pointer = git("latest.json");
} catch (error) {
// A missing pointer is only allowed for this repository's first catalog commit.
try {
execFileSync(
"git",
["cat-file", "-e", "HEAD:model-catalog/official/latest.json"],
{ cwd: repository, stdio: "ignore" },
);
} catch {
return null;
}
throw error;
}
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/.test(pointer.version))
throw new Error("已提交目录版本格式无效");
return git(`releases/${pointer.version}/catalog.json`);
}
function main() {
const command = args[0] || "validate";
if (command === "format") {
fs.writeFileSync(
defaultSourcePath,
serializeJson(canonicalizeSource(readJson(defaultSourcePath))),
);
validateSource();
console.log(JSON.stringify({ action: "formatted" }));
} else if (command === "validate") {
const source = validateSource();
console.log(
JSON.stringify({
action: "validated",
baseModelCount: source.baseModels.length,
providerCount: source.providers.length,
}),
);
} else if (command === "prepare") {
const result = prepareArtifacts(directory, {
previous: previousCommittedRelease(),
version: option("version") || undefined,
note: option("note"),
allowRemovals: args.includes("--allow-removals"),
removalReason: option("removal-reason"),
});
console.log(
JSON.stringify(
{
action: "prepared-for-git-commit",
version: result.pointer.version,
checksum: result.pointer.checksum,
baseModelCount: result.release.count,
instruction:
"检查 Git 差异后,将维护源、latest.json 和版本快照一并提交并推送;公开 JSON 只来自 Git 已提交内容。",
},
null,
2,
),
);
} else if (command === "check") {
console.log(JSON.stringify(checkArtifacts(), null, 2));
} else {
throw new Error(
"支持 format、validate、prepare、check;目录通过 Git 提交发布,不再使用 OSS/CDN 上传命令。",
);
}
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
try {
main();
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+846
View File
@@ -0,0 +1,846 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EasyAI Official Base Model Catalog Source",
"type": "object",
"required": ["schemaVersion", "providers", "baseModels"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": 1 },
"providers": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/provider" }
},
"baseModels": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/baseModel" }
}
},
"definitions": {
"nonEmptyString": { "type": "string", "minLength": 1 },
"nonNegativeInteger": { "type": "integer", "minimum": 0 },
"positiveInteger": { "type": "integer", "minimum": 1 },
"stringList": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"numberList": {
"type": "array",
"items": { "type": "number" }
},
"integerList": {
"type": "array",
"items": { "type": "integer" },
"uniqueItems": true
},
"numberRange": {
"type": "array",
"items": { "type": "number" },
"minItems": 2,
"maxItems": 2
},
"numberRangeOrMap": {
"oneOf": [
{ "$ref": "#/definitions/numberRange" },
{
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/definitions/numberRange" }
}
]
},
"numberListOrMap": {
"oneOf": [
{ "$ref": "#/definitions/numberList" },
{
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/definitions/numberList" }
}
]
},
"stringListOrMap": {
"oneOf": [
{ "$ref": "#/definitions/stringList" },
{
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/definitions/stringList" }
}
]
},
"capabilityName": {
"enum": [
"audio_generate",
"audio_understanding",
"digital_human_generate",
"image_analysis",
"image_edit",
"image_generate",
"image_to_model",
"image_to_video",
"image_vectorize",
"mesh_edit",
"multiview_to_model",
"omni",
"omni_video",
"speech_to_text",
"text_embedding",
"text_generate",
"text_rerank",
"text_to_model",
"text_to_speech",
"tools_call",
"video_enhance",
"video_generate",
"video_understanding",
"voice_clone"
]
},
"provider": {
"type": "object",
"required": ["code", "name", "displayName"],
"properties": {
"code": { "$ref": "#/definitions/nonEmptyString" },
"name": { "$ref": "#/definitions/nonEmptyString" },
"displayName": { "$ref": "#/definitions/nonEmptyString" },
"iconPath": { "$ref": "#/definitions/nonEmptyString" }
},
"additionalProperties": false
},
"baseModel": {
"type": "object",
"required": [
"catalogKey",
"providerCode",
"displayName",
"alias",
"defaultRealModelName",
"modelTypes",
"capabilities",
"status"
],
"properties": {
"catalogKey": {
"type": "string",
"pattern": "^base:(runtime|seed):.+"
},
"seedKey": { "$ref": "#/definitions/nonEmptyString" },
"providerCode": { "$ref": "#/definitions/nonEmptyString" },
"displayName": { "$ref": "#/definitions/nonEmptyString" },
"alias": { "$ref": "#/definitions/nonEmptyString" },
"defaultRealModelName": { "$ref": "#/definitions/nonEmptyString" },
"modelTypes": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "$ref": "#/definitions/capabilityName" }
},
"iconPath": { "$ref": "#/definitions/nonEmptyString" },
"description": { "$ref": "#/definitions/nonEmptyString" },
"capabilities": { "$ref": "#/definitions/capabilities" },
"status": { "enum": ["active", "deprecated", "offline"] }
},
"additionalProperties": false
},
"languageCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "$ref": "#/definitions/capabilityName" },
"max_context_tokens": { "$ref": "#/definitions/nonNegativeInteger" },
"max_input_tokens": { "$ref": "#/definitions/nonNegativeInteger" },
"max_output_tokens": { "$ref": "#/definitions/nonNegativeInteger" },
"max_thinking_tokens": { "$ref": "#/definitions/nonNegativeInteger" },
"outputTokenLimitScope": { "enum": ["total", "answer_only"] },
"outputTokenParameter": {
"enum": ["max_tokens", "max_completion_tokens"]
},
"defaultOutputTokenBudget": { "const": "candidate_max" },
"supportThinking": { "type": "boolean" },
"supportThinkingModeSwitch": { "type": "boolean" },
"supportStructuredOutput": { "type": "boolean" },
"supportTool": { "type": "boolean" },
"supportWebSearch": { "type": "boolean" },
"thinkingBudgetTokensRange": { "$ref": "#/definitions/numberRange" },
"thinkingEffortLevels": { "$ref": "#/definitions/stringList" },
"supportedApiProtocols": { "$ref": "#/definitions/stringList" }
},
"additionalProperties": false
},
"textEmbeddingCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "text_embedding" },
"dimensions": {
"type": "array",
"items": { "$ref": "#/definitions/positiveInteger" },
"uniqueItems": true
}
},
"additionalProperties": false
},
"textRerankCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "text_rerank" },
"maxDocuments": { "$ref": "#/definitions/positiveInteger" },
"maxRequestTokens": { "$ref": "#/definitions/positiveInteger" },
"maxTokensPerDocument": { "$ref": "#/definitions/positiveInteger" },
"supportInstruct": { "type": "boolean" },
"supportTopN": { "type": "boolean" }
},
"additionalProperties": false
},
"inputParameter": {
"type": "object",
"required": ["type", "parameter"],
"properties": {
"type": { "$ref": "#/definitions/nonEmptyString" },
"parameter": { "$ref": "#/definitions/nonEmptyString" }
},
"additionalProperties": false
},
"imageCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "enum": ["image_generate", "image_edit"] },
"allow_custom_width_height_size": { "type": "boolean" },
"aspect_ratio_allowed": { "$ref": "#/definitions/stringList" },
"aspect_ratio_range": { "$ref": "#/definitions/numberRange" },
"input_format_allowed": { "$ref": "#/definitions/stringList" },
"input_max_file_size_bytes": {
"$ref": "#/definitions/nonNegativeInteger"
},
"input_max_images_count": {
"$ref": "#/definitions/nonNegativeInteger"
},
"input_multiple_images": { "type": "boolean" },
"input_output_max_images_count": {
"$ref": "#/definitions/nonNegativeInteger"
},
"input_parameters": {
"type": "array",
"items": { "$ref": "#/definitions/inputParameter" }
},
"input_size_range": { "$ref": "#/definitions/stringList" },
"output_max_images_count": {
"$ref": "#/definitions/nonNegativeInteger"
},
"output_max_size": { "$ref": "#/definitions/nonNegativeInteger" },
"output_multiple_images": { "type": "boolean" },
"output_resolutions": { "$ref": "#/definitions/stringList" },
"output_size_range": { "$ref": "#/definitions/numberRange" },
"quality_options": { "$ref": "#/definitions/stringList" },
"size_param_format": { "$ref": "#/definitions/nonEmptyString" },
"support_quality_control": { "type": "boolean" },
"width_height_multiple": { "$ref": "#/definitions/positiveInteger" },
"width_height_range": { "$ref": "#/definitions/numberRange" }
},
"additionalProperties": false
},
"promptLengthLimit": {
"type": "object",
"required": ["max"],
"properties": {
"max": { "$ref": "#/definitions/positiveInteger" },
"count_mode": { "$ref": "#/definitions/nonEmptyString" },
"label": { "$ref": "#/definitions/nonEmptyString" }
},
"additionalProperties": false
},
"resolutionRoute": {
"type": "object",
"required": [
"mode",
"generation_resolution",
"target_resolution",
"display_resolution",
"enhance_model",
"fallback_policy",
"billing_mode"
],
"properties": {
"mode": { "$ref": "#/definitions/nonEmptyString" },
"generation_resolution": { "$ref": "#/definitions/nonEmptyString" },
"target_resolution": { "$ref": "#/definitions/nonEmptyString" },
"display_resolution": { "$ref": "#/definitions/nonEmptyString" },
"enhance_model": { "$ref": "#/definitions/nonEmptyString" },
"fallback_policy": { "$ref": "#/definitions/nonEmptyString" },
"billing_mode": { "$ref": "#/definitions/nonEmptyString" }
},
"additionalProperties": false
},
"resolutionRoutes": {
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/definitions/resolutionRoute" }
},
"modeConstraint": {
"type": "object",
"properties": {
"aspect_ratio_allowed": { "$ref": "#/definitions/stringList" },
"forced_aspect_ratio": { "$ref": "#/definitions/nonEmptyString" },
"prompt_required": { "type": "boolean" }
},
"additionalProperties": false
},
"modeConstraints": {
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/definitions/modeConstraint" }
},
"mediaConstraint": {
"type": "object",
"properties": {
"formats": { "$ref": "#/definitions/stringList" },
"codecs": { "$ref": "#/definitions/stringList" },
"max_size_mb": { "type": "number", "minimum": 0 },
"max_count": { "$ref": "#/definitions/nonNegativeInteger" },
"min_dimension": { "$ref": "#/definitions/nonNegativeInteger" },
"max_dimension": { "$ref": "#/definitions/nonNegativeInteger" },
"aspect_ratio_range": { "$ref": "#/definitions/numberRange" },
"duration_range": { "$ref": "#/definitions/numberRange" },
"max_total_duration_seconds": { "type": "number", "minimum": 0 },
"fps_range": { "$ref": "#/definitions/numberRange" },
"requires_visual_reference": { "type": "boolean" }
},
"additionalProperties": false
},
"inputMediaConstraints": {
"type": "object",
"required": ["max_request_size_mb"],
"properties": {
"max_request_size_mb": { "type": "number", "minimum": 0 },
"image": { "$ref": "#/definitions/mediaConstraint" },
"video": { "$ref": "#/definitions/mediaConstraint" },
"audio": { "$ref": "#/definitions/mediaConstraint" }
},
"additionalProperties": false
},
"providerOperations": {
"type": "object",
"properties": {
"context_ir": { "type": "boolean" },
"regeneration": {
"type": "object",
"required": [
"source_resolutions",
"target_resolutions",
"requires_audio",
"source_fps"
],
"properties": {
"source_resolutions": { "$ref": "#/definitions/stringList" },
"target_resolutions": { "$ref": "#/definitions/stringList" },
"requires_audio": { "type": "boolean" },
"source_fps": { "type": "number", "minimum": 0 }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"limitsWithVideo": {
"type": "object",
"properties": {
"max_images_and_elements": {
"$ref": "#/definitions/nonNegativeInteger"
},
"duration_options": { "$ref": "#/definitions/numberList" }
},
"additionalProperties": false
},
"videoDraftCapability": {
"type": "object",
"additionalProperties": false,
"required": [
"enabled",
"contract_version",
"draft_resolution",
"final_resolutions",
"validity_seconds",
"input_modes"
],
"properties": {
"enabled": {
"type": "boolean"
},
"contract_version": {
"enum": [
1,
2
]
},
"draft_resolution": {
"enum": [
"480p",
"720p"
]
},
"final_resolutions": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"items": {
"enum": [
"1080p",
"1440p"
]
}
},
"validity_seconds": {
"type": "integer", "minimum": 0
},
"input_modes": {
"$ref": "#/definitions/stringList"
},
"finalization_protocol": {
"enum": [
"minimax_h3_media_context",
"easyai_result_reference"
]
}
},
"allOf": [
{
"if": {
"properties": {
"contract_version": {
"const": 1
}
}
},
"then": {
"properties": {
"validity_seconds": { "const": 604800 },
"draft_resolution": {
"const": "480p"
},
"final_resolutions": {
"items": {
"const": "1080p"
}
}
}
},
"else": {
"required": [
"finalization_protocol"
],
"properties": {
"draft_resolution": {
"const": "720p"
},
"final_resolutions": {
"items": {
"const": "1440p"
}
}
}
}
}
]
},
"videoCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "enum": ["video_generate", "image_to_video", "omni_video"] },
"draft_mode": { "$ref": "#/definitions/videoDraftCapability" },
"aspect_ratio_allowed": { "$ref": "#/definitions/stringListOrMap" },
"aspect_ratio_range": { "$ref": "#/definitions/numberRange" },
"duration_options": { "$ref": "#/definitions/numberListOrMap" },
"duration_range": { "$ref": "#/definitions/numberRangeOrMap" },
"input_audio": { "type": "boolean" },
"input_first_frame": { "type": "boolean" },
"input_first_last_frame": { "type": "boolean" },
"input_last_frame": { "type": "boolean" },
"input_media_constraints": {
"$ref": "#/definitions/inputMediaConstraints"
},
"input_reference_generate_multiple": { "type": "boolean" },
"input_reference_generate_single": { "type": "boolean" },
"input_size_limit": { "type": "number", "minimum": 0 },
"input_smart_multi_frame": { "type": "boolean" },
"input_width_height_range": { "$ref": "#/definitions/numberRange" },
"limits_with_video": { "$ref": "#/definitions/limitsWithVideo" },
"max_audios": { "$ref": "#/definitions/nonNegativeInteger" },
"max_elements": { "$ref": "#/definitions/nonNegativeInteger" },
"max_file_size_mb": { "type": "number", "minimum": 0 },
"max_images": { "$ref": "#/definitions/nonNegativeInteger" },
"max_images_and_elements": {
"$ref": "#/definitions/nonNegativeInteger"
},
"max_images_for_last_frame": {
"$ref": "#/definitions/nonNegativeInteger"
},
"max_videos": { "$ref": "#/definitions/nonNegativeInteger" },
"mode_constraints": { "$ref": "#/definitions/modeConstraints" },
"output_audio": { "type": "boolean" },
"output_audio_mode": { "$ref": "#/definitions/nonEmptyString" },
"output_bgm": { "type": "boolean" },
"output_resolutions": { "$ref": "#/definitions/stringListOrMap" },
"output_support_return_last_frame": { "type": "boolean" },
"output_video_continuation": { "type": "boolean" },
"prompt_length_limit": { "$ref": "#/definitions/promptLengthLimit" },
"provider_operations": { "$ref": "#/definitions/providerOperations" },
"resolution_routes": { "$ref": "#/definitions/resolutionRoutes" },
"size_allowed": { "$ref": "#/definitions/stringListOrMap" },
"smart_multi_frame_mode": { "$ref": "#/definitions/nonEmptyString" },
"smart_multi_frame_range": { "$ref": "#/definitions/numberRange" },
"support_instruction_edit": { "type": "boolean" },
"support_video_effect_template": { "type": "boolean" },
"support_video_effect_template_list": {
"$ref": "#/definitions/stringList"
},
"supported_modes": { "$ref": "#/definitions/stringList" },
"supports_portrait_asset_reference": { "type": "boolean" },
"supportWebSearch": { "type": "boolean" }
},
"additionalProperties": false
},
"faceLimitRange": {
"type": "object",
"required": ["min", "max"],
"properties": {
"min": { "$ref": "#/definitions/nonNegativeInteger" },
"max": { "$ref": "#/definitions/nonNegativeInteger" }
},
"additionalProperties": false
},
"faceLimitByGeometryQuality": {
"type": "object",
"minProperties": 1,
"additionalProperties": {
"type": "object",
"properties": {
"triangle": { "$ref": "#/definitions/nonNegativeInteger" },
"quad": { "$ref": "#/definitions/nonNegativeInteger" }
},
"additionalProperties": false
}
},
"faceLimitRangeByTopology": {
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/definitions/faceLimitRange" }
},
"crossProviderEdit": {
"type": "object",
"properties": {
"tripo3d_import_supported": { "type": "boolean" }
},
"additionalProperties": false
},
"multiviewInput": {
"type": "object",
"required": ["min_images", "max_images", "views"],
"properties": {
"min_images": { "$ref": "#/definitions/nonNegativeInteger" },
"max_images": { "$ref": "#/definitions/nonNegativeInteger" },
"views": {
"type": "array",
"items": {
"type": "object",
"required": ["type", "order"],
"properties": {
"type": { "$ref": "#/definitions/nonEmptyString" },
"required": { "type": "boolean" },
"order": { "$ref": "#/definitions/nonNegativeInteger" }
},
"additionalProperties": false
}
},
"file_constraints": {
"type": "object",
"properties": {
"allowed_mime_types": { "$ref": "#/definitions/stringList" },
"min_side": { "$ref": "#/definitions/nonNegativeInteger" },
"max_side": { "$ref": "#/definitions/nonNegativeInteger" },
"per_file_max_bytes": {
"$ref": "#/definitions/nonNegativeInteger"
},
"auxiliary_total_max_bytes": {
"$ref": "#/definitions/nonNegativeInteger"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"model3dCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": {
"enum": ["text_to_model", "image_to_model", "multiview_to_model"]
},
"cross_provider_edit": { "$ref": "#/definitions/crossProviderEdit" },
"default_geometry_instruct_mode": {
"$ref": "#/definitions/nonEmptyString"
},
"default_output_format": { "$ref": "#/definitions/nonEmptyString" },
"face_limit_by_geometry_quality": {
"$ref": "#/definitions/faceLimitByGeometryQuality"
},
"face_limit_range": { "$ref": "#/definitions/faceLimitRange" },
"face_limit_range_by_topology": {
"$ref": "#/definitions/faceLimitRangeByTopology"
},
"geometry_instruct_modes": { "$ref": "#/definitions/stringList" },
"geometry_quality_options": { "$ref": "#/definitions/stringList" },
"max_face_limit": { "$ref": "#/definitions/nonNegativeInteger" },
"max_face_limit_quad": { "$ref": "#/definitions/nonNegativeInteger" },
"multiview_input": { "$ref": "#/definitions/multiviewInput" },
"multiview_max_images": { "$ref": "#/definitions/nonNegativeInteger" },
"multiview_min_images": { "$ref": "#/definitions/nonNegativeInteger" },
"multiview_required_slots": { "$ref": "#/definitions/stringList" },
"output_formats": { "$ref": "#/definitions/stringList" },
"pbr_required_when_texture": { "type": "boolean" },
"provider_generation_only": { "type": "boolean" },
"support_export_uv": { "type": "boolean" },
"support_image_autofix": { "type": "boolean" },
"support_micro_detail": { "type": "boolean" },
"support_part_generation": { "type": "boolean" },
"support_pbr": { "type": "boolean" },
"support_public_image_url_only": { "type": "boolean" },
"support_quad": { "type": "boolean" },
"support_smart_low_poly": { "type": "boolean" },
"support_ta_pose": { "type": "boolean" },
"support_texture": { "type": "boolean" },
"support_texture_delight": { "type": "boolean" },
"texture_quality_options": { "$ref": "#/definitions/stringList" }
},
"additionalProperties": false
},
"meshEditCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "mesh_edit" },
"supported_operations": {
"type": "object",
"minProperties": 1,
"additionalProperties": {
"type": "object",
"required": ["supported"],
"properties": {
"supported": { "type": "boolean" },
"supported_formats": { "$ref": "#/definitions/stringList" },
"supported_texture_prompt_modes": {
"$ref": "#/definitions/stringList"
}
},
"additionalProperties": false
}
}
},
"additionalProperties": false
},
"audioGenerateCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "audio_generate" },
"output_formats": { "$ref": "#/definitions/stringList" },
"supports_instrumental": { "type": "boolean" },
"supports_lyrics": { "type": "boolean" },
"supports_lyrics_optimizer": { "type": "boolean" },
"supports_prompt": { "type": "boolean" }
},
"additionalProperties": false
},
"speechToTextCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "speech_to_text" },
"endpoint_path": { "$ref": "#/definitions/nonEmptyString" },
"input_key": { "$ref": "#/definitions/nonEmptyString" },
"max_duration_seconds": { "type": "number", "minimum": 0 },
"max_file_size_mb": { "type": "number", "minimum": 0 },
"mode": { "$ref": "#/definitions/nonEmptyString" },
"poll_interval_ms": { "$ref": "#/definitions/nonNegativeInteger" },
"poll_timeout_ms": { "$ref": "#/definitions/nonNegativeInteger" },
"query_url": { "$ref": "#/definitions/nonEmptyString" },
"resource_id": { "$ref": "#/definitions/nonEmptyString" },
"submit_url": { "$ref": "#/definitions/nonEmptyString" },
"supported_formats": { "$ref": "#/definitions/stringList" },
"supports_diarization": { "type": "boolean" },
"supports_emotion": { "type": "boolean" },
"supports_hotwords": { "type": "boolean" },
"supports_prompt_context": { "type": "boolean" },
"supports_timestamp": { "type": "boolean" }
},
"additionalProperties": false
},
"voice": {
"type": "object",
"required": ["id", "name"],
"properties": {
"accent": { "type": "string" },
"age": { "type": "string" },
"audio": { "type": "string" },
"desc": { "type": "string" },
"gender": { "type": "string" },
"icon": { "type": "string" },
"id": { "$ref": "#/definitions/nonEmptyString" },
"lang": { "type": "string" },
"name": { "$ref": "#/definitions/nonEmptyString" },
"provider": { "$ref": "#/definitions/nonEmptyString" },
"tags": { "$ref": "#/definitions/stringList" }
},
"additionalProperties": false
},
"ttsParamsCapability": {
"type": "object",
"properties": {
"output_audio": { "type": "boolean" },
"voice_modify": { "type": "boolean" },
"emotion": { "type": "boolean" },
"pronunciation_dict": { "type": "boolean" },
"language_boost": { "type": "boolean" },
"language_controls": { "type": "boolean" },
"text_filters": { "type": "boolean" },
"voice_instruction": { "type": "boolean" },
"audio_formats": { "$ref": "#/definitions/stringList" },
"sample_rates": { "$ref": "#/definitions/integerList" },
"opus_sample_rates": { "$ref": "#/definitions/integerList" },
"bit_rates": { "$ref": "#/definitions/integerList" },
"volume_range": {
"type": "object",
"required": ["min", "max", "step"],
"properties": {
"min": { "type": "number", "minimum": 0 },
"max": { "type": "number", "minimum": 0 },
"step": { "type": "number", "exclusiveMinimum": 0 }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"textToSpeechCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "text_to_speech" },
"tts_params": {
"oneOf": [{ "const": false }, { "$ref": "#/definitions/ttsParamsCapability" }]
},
"voice_compatibility_group": {
"$ref": "#/definitions/nonEmptyString"
},
"default_voice_id": { "$ref": "#/definitions/nonEmptyString" },
"resource_id": { "$ref": "#/definitions/nonEmptyString" },
"voices": {
"type": "array",
"items": { "$ref": "#/definitions/voice" }
}
},
"additionalProperties": false
},
"audioInputConstraint": {
"type": "object",
"required": ["formats", "max_duration_seconds", "max_size_mb"],
"properties": {
"formats": { "$ref": "#/definitions/stringList" },
"min_duration_seconds": { "type": "number", "minimum": 0 },
"max_duration_seconds": { "type": "number", "minimum": 0 },
"max_size_mb": { "type": "number", "minimum": 0 }
},
"additionalProperties": false
},
"voiceCloneCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "voice_clone" },
"preview_models": { "$ref": "#/definitions/stringList" },
"prompt_audio": { "$ref": "#/definitions/audioInputConstraint" },
"source_audio": { "$ref": "#/definitions/audioInputConstraint" }
},
"additionalProperties": false
},
"imageVectorizeCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "image_vectorize" },
"async_task": { "type": "boolean" },
"cleanup_levels": { "$ref": "#/definitions/stringList" },
"input_format_allowed": { "$ref": "#/definitions/stringList" },
"max_colors_options": { "$ref": "#/definitions/integerList" },
"output_format_allowed": { "$ref": "#/definitions/stringList" },
"support_base64_input": { "type": "boolean" },
"support_url_input": { "type": "boolean" },
"supports_extra_format_download": { "type": "boolean" },
"supports_preview": { "type": "boolean" }
},
"additionalProperties": false
},
"videoEnhanceCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "video_enhance" },
"async_task": { "type": "boolean" },
"input_resolutions": { "$ref": "#/definitions/stringList" },
"input_video_required": { "type": "boolean" },
"max_file_size_mb": { "type": "number", "minimum": 0 },
"operations": { "$ref": "#/definitions/stringList" },
"output_resolutions": { "$ref": "#/definitions/stringList" },
"preserve_audio": { "type": "boolean" }
},
"additionalProperties": false
},
"digitalHumanCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "digital_human_generate" }
},
"additionalProperties": false
},
"capabilities": {
"type": "object",
"minProperties": 1,
"properties": {
"audio_generate": { "$ref": "#/definitions/audioGenerateCapability" },
"audio_understanding": { "$ref": "#/definitions/languageCapability" },
"digital_human_generate": {
"$ref": "#/definitions/digitalHumanCapability"
},
"image_analysis": { "$ref": "#/definitions/languageCapability" },
"image_edit": { "$ref": "#/definitions/imageCapability" },
"image_generate": { "$ref": "#/definitions/imageCapability" },
"image_to_model": { "$ref": "#/definitions/model3dCapability" },
"image_to_video": { "$ref": "#/definitions/videoCapability" },
"image_vectorize": { "$ref": "#/definitions/imageVectorizeCapability" },
"mesh_edit": { "$ref": "#/definitions/meshEditCapability" },
"multiview_to_model": { "$ref": "#/definitions/model3dCapability" },
"omni": { "$ref": "#/definitions/languageCapability" },
"omni_video": { "$ref": "#/definitions/videoCapability" },
"speech_to_text": { "$ref": "#/definitions/speechToTextCapability" },
"text_embedding": { "$ref": "#/definitions/textEmbeddingCapability" },
"text_generate": { "$ref": "#/definitions/languageCapability" },
"text_rerank": { "$ref": "#/definitions/textRerankCapability" },
"text_to_model": { "$ref": "#/definitions/model3dCapability" },
"text_to_speech": { "$ref": "#/definitions/textToSpeechCapability" },
"tools_call": { "$ref": "#/definitions/languageCapability" },
"video_enhance": { "$ref": "#/definitions/videoEnhanceCapability" },
"video_generate": { "$ref": "#/definitions/videoCapability" },
"video_understanding": { "$ref": "#/definitions/languageCapability" },
"voice_clone": { "$ref": "#/definitions/voiceCloneCapability" }
},
"additionalProperties": false
}
}
}
File diff suppressed because it is too large Load Diff
+197
View File
@@ -0,0 +1,197 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
assertNoMissingModels,
buildRelease,
checkArtifacts,
prepareArtifacts,
publicBaseUrl,
validateSource,
} from "./catalog.mjs";
const directory = path.dirname(new URL(import.meta.url).pathname);
const source = JSON.parse(
fs.readFileSync(path.join(directory, "catalog.source.json"), "utf8"),
);
function fixture(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "easyai-git-catalog-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const data = structuredClone(source);
data.baseModels = data.baseModels.slice(0, 2);
const save = () =>
fs.writeFileSync(
path.join(dir, "catalog.source.json"),
JSON.stringify(data, null, 2) + "\n",
);
save();
return { dir, data, save };
}
test("唯一维护源通过完整 Schema 和规范格式校验", () => {
const data = validateSource();
assert.ok(data.baseModels.length >= 220);
assert.equal(data.providers.length, 27);
});
test("Nano Banana 2.1 保留唯一原身份和图像能力", () => {
const rows = source.baseModels.filter(
(model) => model.defaultRealModelName === "gemini-nano-banana-2.1",
);
assert.equal(rows.length, 1);
assert.equal(
rows[0].catalogKey,
"base:runtime:google-gemini:Nano Banana 2.1:gemini-nano-banana-2.1",
);
assert.equal(rows[0].alias, "Nano Banana 2.1");
assert.equal(rows[0].status, "active");
assert.deepEqual(rows[0].modelTypes, ["image_generate", "image_edit"]);
assert.deepEqual(rows[0].capabilities.image_generate.output_resolutions, [
"1K",
"2K",
"4K",
]);
assert.equal(rows[0].capabilities.image_edit.input_max_images_count, 14);
});
test("重复模型身份拒绝保存", (t) => {
const { dir, data, save } = fixture(t);
data.baseModels.push(structuredClone(data.baseModels[0]));
save();
assert.throws(
() => validateSource(path.join(dir, "catalog.source.json")),
/重复/,
);
});
test("能力字段拼写和类型受 Schema 约束", (t) => {
const { dir, data, save } = fixture(t);
const type = data.baseModels[0].modelTypes[0];
data.baseModels[0].capabilities[type].misspelledCapability = true;
save();
assert.throws(
() => validateSource(path.join(dir, "catalog.source.json")),
/additional properties/,
);
});
test("准备版本时同步生成公开指针和校验和匹配的快照", (t) => {
const { dir } = fixture(t);
const { pointer } = prepareArtifacts(dir, { version: "test-v1" });
assert.equal(
pointer.catalogUrl,
publicBaseUrl + "/releases/test-v1/catalog.json",
);
assert.equal(checkArtifacts(dir).version, "test-v1");
});
test("只改维护源而未重新生成快照会被检查拒绝", (t) => {
const { dir, data, save } = fixture(t);
prepareArtifacts(dir, { version: "test-v1" });
data.baseModels[0].displayName = "修改后的名称";
save();
assert.throws(() => checkArtifacts(dir), /不一致/);
});
test("篡改版本快照会被拒绝", (t) => {
const { dir } = fixture(t);
prepareArtifacts(dir, { version: "test-v1" });
const file = path.join(dir, "releases/test-v1/catalog.json");
const release = JSON.parse(fs.readFileSync(file));
release.baseModels[0].displayName = "篡改";
fs.writeFileSync(file, JSON.stringify(release));
assert.throws(() => checkArtifacts(dir), /不一致/);
});
test("遗漏已发布模型默认拒绝,正常下线保留原身份", (t) => {
const { data } = fixture(t);
const previous = buildRelease(data, { version: "test-v1" });
const reduced = structuredClone(data);
reduced.baseModels.pop();
assert.throws(
() => assertNoMissingModels(previous, reduced),
/遗漏已发布模型/,
);
data.baseModels[0].status = "offline";
assert.deepEqual(assertNoMissingModels(previous, data), []);
});
test("物理移除必须同时声明允许和原因,并记录在新版本中", (t) => {
const { dir, data, save } = fixture(t);
const { release: previous } = prepareArtifacts(dir, { version: "test-v1" });
data.baseModels.pop();
save();
assert.throws(
() => prepareArtifacts(dir, { previous, allowRemovals: true }),
/遗漏/,
);
assert.throws(
() => prepareArtifacts(dir, { previous, removalReason: "迁移" }),
/遗漏/,
);
const { release } = prepareArtifacts(dir, {
previous,
version: "test-v2",
allowRemovals: true,
removalReason: "明确移除",
});
assert.equal(release.removals.models.length, 1);
assert.equal(release.removals.reason, "明确移除");
assert.equal(checkArtifacts(dir).version, "test-v2");
});
test("历史快照不能覆盖且版本不能包含目录穿越", (t) => {
const { dir } = fixture(t);
prepareArtifacts(dir, { version: "test-v1" });
assert.throws(
() => prepareArtifacts(dir, { version: "test-v1" }),
/不能覆盖/,
);
assert.throws(
() => prepareArtifacts(dir, { version: "../outside" }),
/版本格式/,
);
});
test("公开指针不能重新指向原 CDN 或其他仓库", (t) => {
const { dir } = fixture(t);
prepareArtifacts(dir, { version: "test-v1" });
const file = path.join(dir, "latest.json");
const pointer = JSON.parse(fs.readFileSync(file));
pointer.catalogUrl =
"https://aliyuncdn.51easyai.com/model-catalog/official/v1/catalog.json";
fs.writeFileSync(file, JSON.stringify(pointer));
assert.throws(() => checkArtifacts(dir), /easyai 仓库公开地址/);
});
test("恢复 Git 历史定义后生成新回退版本,保留全部快照", (t) => {
const { dir, data, save } = fixture(t);
const original = structuredClone(data);
const first = prepareArtifacts(dir, { version: "test-v1" });
data.baseModels[0].displayName = "新版";
save();
const second = prepareArtifacts(dir, {
previous: first.release,
version: "test-v2",
});
fs.writeFileSync(
path.join(dir, "catalog.source.json"),
JSON.stringify(original, null, 2) + "\n",
);
const third = prepareArtifacts(dir, {
previous: second.release,
version: "test-v3",
note: "恢复 test-v1 的定义",
});
assert.equal(third.release.previousVersion, "test-v2");
assert.equal(third.release.checksum, first.release.checksum);
assert.equal(checkArtifacts(dir).version, "test-v3");
assert.deepEqual(fs.readdirSync(path.join(dir, "releases")).sort(), [
"test-v1",
"test-v2",
"test-v3",
]);
});
+7
View File
@@ -0,0 +1,7 @@
{
"schemaVersion": 1,
"version": "20261015T034833896Z",
"checksum": "b196b44afb78ff349cc1384d0fb97d1c7d33198e8f95bbe0bd6e075dd8354e98",
"publishedAt": "2026-10-15T03:48:33.896Z",
"catalogUrl": "https://git.51easyai.com/wangbo/easyai/raw/branch/main/model-catalog/official/releases/20261015T034833896Z/catalog.json"
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "easyai-official-model-catalog",
"private": true,
"version": "1.0.0",
"type": "module",
"packageManager": "yarn@1.22.22",
"engines": {
"node": ">=22.22.0"
},
"scripts": {
"format": "node catalog.mjs format",
"validate": "node catalog.mjs validate",
"prepare-release": "node catalog.mjs prepare",
"check": "node catalog.mjs check",
"test": "node --test catalog.test.mjs"
},
"dependencies": {
"ajv": "8.17.1"
}
}
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
ajv@8.17.1:
version "8.17.1"
resolved "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6"
integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==
dependencies:
fast-deep-equal "^3.1.3"
fast-uri "^3.0.1"
json-schema-traverse "^1.0.0"
require-from-string "^2.0.2"
fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-uri@^3.0.1:
version "3.1.8"
resolved "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz#f7db8d942e20eead3cfe93b0beeb4eb0169e938b"
integrity sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==
json-schema-traverse@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2"
integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==
require-from-string@^2.0.2:
version "2.0.2"
resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
@@ -0,0 +1,69 @@
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','--format','json',$item.Service)
$executable = $ComposeCommand[0]
$configuration = & $executable @args
if ($LASTEXITCODE -ne 0) { throw '无法读取应用的有效镜像定义;尚未拉取或重建服务' }
try {
# Compose includes dependencies even when a service is selected.
# Keep the configuration in memory; do not log its environment.
$resolved = ($configuration -join "`n") | ConvertFrom-Json -ErrorAction Stop
$actual = $resolved.services.PSObject.Properties[$item.Service].Value.image
} catch { throw '无法读取应用的有效镜像定义;尚未拉取或重建服务' }
if ("$actual".Trim() -ne "$($item.Image):$($item.Version)") {
throw "$($item.Service) 的有效镜像未使用环境中的固定版本;请通过部署仓库的受控迁移恢复镜像定义,尚未拉取或重建服务"
}
}
}
+82
View File
@@ -0,0 +1,82 @@
#!/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 configuration
while read -r service key option image; do
application_selected "$service" || continue
version="$(read_env_value "$file" "$key")"
# --images SERVICE includes dependency images on Compose 5. Read the
# selected service from normalized config instead of comparing the list.
configuration="$(sudo $compose config --format json "$service")" || return 1
actual="$(printf '%s' "$configuration" | python3 -c '
import json, sys
try:
image = json.load(sys.stdin)["services"][sys.argv[1]]["image"]
if not isinstance(image, str): raise ValueError()
print(image)
except (ValueError, KeyError, TypeError):
raise SystemExit("无法读取应用的有效镜像定义;尚未拉取或重建服务")
' "$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}); }
});
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Exercise Bash and PowerShell image verification with real Compose config.
No images are pulled and no containers are created. Fixture settings are fake.
"""
import argparse
import json
import os
from pathlib import Path
import subprocess
import tempfile
import time
parser = argparse.ArgumentParser()
parser.add_argument('--source', type=Path, default=Path(__file__).resolve().parent.parent)
args = parser.parse_args()
source = args.source.resolve()
started = time.monotonic()
version = '1.0.0.20261014'
image = 'registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server'
secret = 'synthetic-config-value-must-not-be-printed'
with tempfile.TemporaryDirectory(prefix='easyai-image-config-') as temporary:
root = Path(temporary)
(root / '.env').write_text(f'SERVER_VERSION={version}\nFIXTURE_SECRET={secret}\n')
binaries = root / 'bin'
binaries.mkdir()
sudo = binaries / 'sudo'
sudo.write_text('#!/bin/sh\nexec "$@"\n')
sudo.chmod(0o755)
env = {**os.environ, 'PATH': str(binaries) + os.pathsep + os.environ['PATH'], 'UPDATE_SERVICES': 'easyai-server'}
reports = []
for correct in (True, False):
selected = f'{image}:${{SERVER_VERSION}}' if correct else f'{image}:latest'
# On the negative case a dependency deliberately has the expected image;
# checking membership in --images output would wrongly approve it.
dependency = 'redis:7-alpine' if correct else f'{image}:${{SERVER_VERSION}}'
(root / 'compose.yml').write_text(f'services:\n dependency:\n image: {dependency}\n easyai-server:\n image: {selected}\n depends_on:\n - dependency\n environment:\n FIXTURE_SECRET: ${{FIXTURE_SECRET}}\n')
config = subprocess.run(['docker','compose','config','--format','json','easyai-server'], cwd=root, env=env, text=True, capture_output=True, check=True)
assert json.loads(config.stdout)['services']['easyai-server']['image'] == (f'{image}:{version}' if correct else f'{image}:latest')
images = subprocess.run(['docker','compose','config','--images','easyai-server'], cwd=root, env=env, text=True, capture_output=True, check=True)
assert len(images.stdout.splitlines()) > 1, 'Compose fixture must include dependency images'
bash = ['bash','-c', '. "$1/scripts/init-security-env.sh"; . "$1/scripts/init-application-version-env.sh"; verify_application_version_images .env "docker compose"', 'fixture', str(source)]
powershell = ['pwsh','-NoProfile','-NonInteractive','-Command', f". '{source}/scripts/Initialize-ApplicationVersions.ps1'; Test-ApplicationVersionImages @([pscustomobject]@{{Service='easyai-server';Image='{image}';Version='{version}'}}) @('docker','compose')"]
for name, command in [('bash', bash), ('powershell', powershell)]:
result = subprocess.run(command, cwd=root, env=env, text=True, errors='backslashreplace', capture_output=True, timeout=30)
assert (result.returncode == 0) == correct, f'{name} incorrect verification: {result.stdout} {result.stderr}'
assert secret not in result.stdout + result.stderr, 'configuration leaked'
reports.append({'shell':name, 'correctImage':correct, 'expectedExitObserved':True})
print(json.dumps({'cases':reports,'seconds':round(time.monotonic()-started,3)}))
+33 -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-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,12 @@ 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[:4] == ['compose', 'config', '--format', 'json']:
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]]
actual = 'registry.cn-shanghai.aliyuncs.com/'+image+':'+('latest' if os.environ.get('UPDATE_TEST_IMAGE_OVERRIDE') == '1' else values[key])
print(json.dumps({'services': {args[-1]: {'image':actual}, 'dependency': {'image':'redis:7-alpine'}}}))
elif args[:3] == ['compose', 'ps', '-q']: print('container-' + args[-1])
sys.exit(0)
''')
@@ -105,6 +111,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 +140,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 +160,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 +182,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 +206,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
+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_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"
@@ -1,3 +1,3 @@
# 独立模型转换;不改变现有在线更新模式与付费选择。
MODEL_CONVERSION_WORKER_VERSION=latest
MODEL_CONVERSION_HUNYUAN_ENABLED=false
@@ -0,0 +1,2 @@
# 只补充缺失或空值,自定义目录地址仍由部署管理员控制。
CONFIG_OFFICIAL_MODEL_CATALOG_URL=https://git.51easyai.com/wangbo/easyai/raw/branch/main/model-catalog/official/latest.json
+28 -18
View File
@@ -1,13 +1,13 @@
{
"schemaVersion": 1,
"channel": "stable",
"releaseId": "3.28.2.20261011.4-86e94f19e538",
"productVersion": "3.28.2.20261011.4",
"releaseId": "3.29.0.20261015-48691f9f4be4",
"productVersion": "3.29.0.20261015",
"platform": "linux/amd64",
"publishedAt": "2026-10-11T09:46:39.697960+00:00",
"publishedAt": "2026-10-15T01:56:24.015Z",
"managedUpdateAllowed": true,
"scheduledUpdateAllowed": true,
"rollbackSafe": true,
"rollbackSafe": false,
"minimumUpdaterVersion": "1.1.0",
"minimumDeploymentSchemaVersion": 2,
"requiredFreeBytes": 8589934592,
@@ -15,23 +15,33 @@
{
"key": "server-main",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server",
"digest": "sha256:2077104a3d9f1cba68eb31911bb00a26d2c154eda2c897fb031c162559379e11",
"buildId": "607c5ee243936bba394579c7bc94ca0b2f62cda6",
"imageSizeBytes": 989020204
},
{
"key": "ws-gateway",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway",
"digest": "sha256:1dd4247cf6a8a21d336876b5d97ecdc3e4ce43199b2f31aff18e0fab47bb4dca",
"buildId": "retained-formal-image",
"imageSizeBytes": 751971874
"tag": "3.29.0.20261015",
"digest": "sha256:df3ced2b5a1d6724a4612cb9373b60e33fc413dc9efd1516014ce26360459b6a",
"buildId": "fa5ba3fd063677ff11b7e9eddce12da476ea10cc",
"imageSizeBytes": 989102531,
"platform": "linux/amd64"
},
{
"key": "web",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"digest": "sha256:279da715ec67c2017c06ca31c8f7e172b703b99c5378404d24198af919c460dc",
"buildId": "f1925755cb481b9c3a609996333d26bf47da5302",
"imageSizeBytes": 347958996
"tag": "3.29.0.20261015",
"digest": "sha256:cba3a7aeabfaecfae59982b134cec3b44438ac8b7c582ece84c0fa5b5447f3da",
"buildId": "917dd4080cea4c99be3d98780093a1acd51c6407",
"imageSizeBytes": 348069167,
"platform": "linux/amd64"
},
{
"key": "ws-gateway",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway",
"tag": "3.25.0.20261009",
"digest": "sha256:1dd4247cf6a8a21d336876b5d97ecdc3e4ce43199b2f31aff18e0fab47bb4dca",
"buildId": "retained-formal-image",
"imageSizeBytes": 751971874,
"platform": "linux/amd64",
"reusedFromTag": "3.25.0.20261009",
"reuseSource": "existing successful production artifact; unchanged runtime source and dependencies"
}
]
],
"recordSource": "2026-10-15 daily fixed-version publication; stable promotion was not executed",
"promotedFromPublishedAt": "2026-10-15T03:26:33.815531+08:00"
}
@@ -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成功生产/文档记录;仅建立版本记录,未执行新的稳定提升"
}
@@ -0,0 +1,61 @@
{
"schemaVersion": 1,
"channel": "versioned",
"releaseId": "3.29.0.20261015.2-248ca4e3a2e5",
"productVersion": "3.29.0.20261015.2",
"platform": "linux/amd64",
"publishedAt": "2026-10-15T12:24:36.871239+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:c53dfbc97881c3073496ad3afc3b4e81d72122a5125258e3b975ea3af1171380",
"buildId": "ae0008ba394043d2d1a233a2ff708c0a96ace6ab",
"imageSizeBytes": 990484390,
"tag": "3.29.0.20261015.2",
"platform": "linux/amd64"
},
{
"key": "web",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"digest": "sha256:de63ed93d3a7b81e52a0cb12db519eadbc33513f74af6cf01d30fcb22547e45a",
"buildId": "343e8cc816b0f9cd7449faa44680035dd47900ec",
"imageSizeBytes": 348011223,
"tag": "3.29.0.20261015.2",
"platform": "linux/amd64"
},
{
"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",
"platform": "linux/amd64"
},
{
"key": "web-no-ssr",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"digest": "sha256:03a8bb6c2f3a5200354a263b8ea12b0422c8f04b4e778d79da9be73746adcf1b",
"buildId": "343e8cc816b0f9cd7449faa44680035dd47900ec",
"imageSizeBytes": 314313406,
"tag": "3.29.0.20261015.2-no-ssr",
"platform": "linux/amd64"
}
],
"recordSource": "由已合并源码构建并发布的固定版本;WebSocket 网关复用未变更的正式产物",
"releaseNotes": "取回已有结果后自动同步画布;浏览器通过预签名上传补存并同步历史与画布,保留原结果、失败记录及结算状态。",
"sourcePullRequests": [
"https://git.51easyai.com/BCAI/comfy-server/pulls/109",
"https://git.51easyai.com/BCAI/comfy-AI2B/pulls/153"
],
"additionalPublishedAliases": [
"registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:3.29.0.20261015.2-hmg-ai"
]
}
@@ -0,0 +1,76 @@
{
"schemaVersion": 1,
"channel": "versioned",
"releaseId": "3.29.0.20261015-48691f9f4be4",
"productVersion": "3.29.0.20261015",
"platform": "linux/amd64",
"publishedAt": "2026-10-15T03:26:33.815531+08: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",
"tag": "3.29.0.20261015",
"digest": "sha256:df3ced2b5a1d6724a4612cb9373b60e33fc413dc9efd1516014ce26360459b6a",
"buildId": "fa5ba3fd063677ff11b7e9eddce12da476ea10cc",
"imageSizeBytes": 989102531,
"platform": "linux/amd64"
},
{
"key": "web",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"tag": "3.29.0.20261015",
"digest": "sha256:cba3a7aeabfaecfae59982b134cec3b44438ac8b7c582ece84c0fa5b5447f3da",
"buildId": "917dd4080cea4c99be3d98780093a1acd51c6407",
"imageSizeBytes": 348069167,
"platform": "linux/amd64"
},
{
"key": "web-no-ssr",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"tag": "3.29.0.20261015-no-ssr",
"digest": "sha256:2b2c1616dd58489f12d1db82daf870e831d52de51804b3c717ce5a6528a52351",
"buildId": "917dd4080cea4c99be3d98780093a1acd51c6407",
"imageSizeBytes": 312779834,
"platform": "linux/amd64"
},
{
"key": "hmg-ai",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"tag": "3.29.0.20261015-hmg-ai",
"digest": "sha256:2b2c1616dd58489f12d1db82daf870e831d52de51804b3c717ce5a6528a52351",
"buildId": "917dd4080cea4c99be3d98780093a1acd51c6407",
"imageSizeBytes": 312779834,
"platform": "linux/amd64"
},
{
"key": "ws-gateway",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway",
"tag": "3.25.0.20261009",
"digest": "sha256:1dd4247cf6a8a21d336876b5d97ecdc3e4ce43199b2f31aff18e0fab47bb4dca",
"buildId": "retained-formal-image",
"imageSizeBytes": 751971874,
"platform": "linux/amd64",
"reusedFromTag": "3.25.0.20261009",
"reuseSource": "existing successful production artifact; unchanged runtime source and dependencies"
},
{
"key": "model-conversion-worker",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker",
"tag": "3.25.0.20261009.2",
"digest": "sha256:c281a3ad7ae2ae9a2e47411fb565e5fa1c35a756c09d1e281d01803ed090932e",
"buildId": "83cefbfdf2bc909d11ace9f020b108145844051a",
"imageSizeBytes": 118327684,
"platform": "linux/amd64",
"amd64Digest": "sha256:085fa49d99a06562ce9634bfdd8567efdfe4e38754e9688de81288d2e2578526",
"reusedFromTag": "3.25.0.20261009.2",
"reuseSource": "existing successful production artifact; unchanged runtime source and dependencies"
}
],
"recordSource": "2026-10-15 daily fixed-version publication; stable promotion was not executed"
}
@@ -0,0 +1,66 @@
{
"schemaVersion": 1,
"channel": "versioned",
"releaseId": "3.30.0.20261016-3e558e56d78e",
"productVersion": "3.30.0.20261016",
"platform": "linux/amd64",
"publishedAt": "2026-10-16T03:29:39.247892+08: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",
"tag": "3.30.0.20261016",
"digest": "sha256:57cac83fb29180fd10591a5960b1e258359f26af5d2d31370674e8e14aaf107e",
"buildId": "e99b636982fc654fdb7f3dcbe08db57bd3b047bc",
"imageSizeBytes": 989161034,
"platform": "linux/amd64"
},
{
"key": "web",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"tag": "3.30.0.20261016",
"digest": "sha256:c2adf0577536f7eed402ce0043f2d708c2294700e8be9a51fbd29112ec1f644d",
"buildId": "b6850f138cd831972fcb35ac8fc456f30fe98609",
"imageSizeBytes": 348127493,
"platform": "linux/amd64"
},
{
"key": "web-no-ssr",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"tag": "3.30.0.20261016-no-ssr",
"digest": "sha256:9240e9689aa9862ebd21035499235e7a3a0817de93274a72dca0b821ea1fbb8d",
"buildId": "b6850f138cd831972fcb35ac8fc456f30fe98609",
"imageSizeBytes": 312836808,
"platform": "linux/amd64"
},
{
"key": "ws-gateway",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway",
"tag": "3.25.0.20261009",
"digest": "sha256:1dd4247cf6a8a21d336876b5d97ecdc3e4ce43199b2f31aff18e0fab47bb4dca",
"buildId": "retained-formal-image",
"imageSizeBytes": 751971874,
"platform": "linux/amd64"
},
{
"key": "model-conversion-worker",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker",
"tag": "3.25.0.20261009.2",
"digest": "sha256:c281a3ad7ae2ae9a2e47411fb565e5fa1c35a756c09d1e281d01803ed090932e",
"buildId": "83cefbfdf2bc909d11ace9f020b108145844051a",
"imageSizeBytes": 118327684,
"platform": "linux/amd64"
}
],
"recordSource": "本轮主分支源码实际构建的固定版本;网关和模型转换Worker复用原固定产物及原构建来源",
"releaseNotes": "消费记录按实际执行主平台筛选并统一明细与导出;生成结果自动恢复并支持浏览器转存兜底;本地素材拖入后先上传持久化;优化3D轮询与聊天审计。画布归档代码交付,当前生产归档开关保持关闭。",
"additionalPublishedAliases": [
"registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:3.30.0.20261016-hmg-ai"
]
}
+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 " 仅拉取并重建指定服务,不重建其依赖;留空保持整组更新。"
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") {
+9 -16
View File
@@ -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