#!/usr/bin/env node import { execFileSync } from 'node:child_process'; import { readFileSync, writeFileSync, renameSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const VERSION = /^\d+\.\d+\.\d+\.\d{8}(?:\.\d+)?$/; const TARGETS = { 'server-main': ['registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server','latest',''], web: ['registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai','latest',''], 'web-no-ssr': ['registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai','latest-no-ssr','-no-ssr'], 'ws-gateway': ['registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway','latest',''], 'model-conversion-worker': ['registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker','latest',''], updater: ['registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater','latest',''], 'service-governance': ['registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance','latest',''], 'memory-service': ['registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory','latest',''], }; const CORE = ['server-main','web','ws-gateway']; const docker = args => execFileSync('docker', args, {encoding:'utf8', stdio:['ignore','pipe','pipe']}); const inspectArgs = ref => ['buildx','imagetools','inspect',ref,'--format','{{json .Manifest}}']; export function promotionPlan(manifest, version, confirmedVersion) { if (!VERSION.test(version ?? '') || version !== confirmedVersion) throw new Error('必须人工指定完整 --version 并用相同 --confirm-version 确认,不能自动选择最新版'); if (manifest.schemaVersion !== 1 || manifest.channel !== 'versioned' || manifest.productVersion !== version || manifest.platform !== 'linux/amd64') throw new Error('候选版本记录与指定版本/平台不一致'); if (typeof manifest.releaseId !== 'string' || !manifest.releaseId.trim() || manifest.releaseId.length > 200 || !Array.isArray(manifest.components)) throw new Error('候选版本记录缺少有效 releaseId/components'); if (!Number.isFinite(Date.parse(manifest.publishedAt)) || ['managedUpdateAllowed','scheduledUpdateAllowed','rollbackSafe'].some(key=>typeof manifest[key]!=='boolean')) throw new Error('候选版本记录缺少有效发布时间或更新兼容性设置'); const seen = new Set(); const images = manifest.components.map(component => { const target = TARGETS[component.key]; if (!target || seen.has(component.key) || component.image !== target[0] || !/^sha256:[a-f0-9]{64}$/.test(component.digest ?? '')) throw new Error(`无效或重复组件: ${component.key}`); seen.add(component.key); const tag = component.tag ?? `${version}${target[2]}`; const base = target[2] && tag.endsWith(target[2]) ? tag.slice(0,-target[2].length) : tag; if (!VERSION.test(base) || (target[2] && !tag.endsWith(target[2]))) throw new Error(`组件 ${component.key} 必须引用固定版本标签`); return {key:component.key, image:component.image, digest:component.digest, source:`${component.image}:${tag}`, stable:`${component.image}:${target[1]}`}; }); for (const key of [...CORE,'web-no-ssr']) if (!seen.has(key)) throw new Error(`候选记录缺少必要组件: ${key}`); return images; } export function promoteStableRelease({manifest, version, confirmedVersion, outputPath, dryRun=false, run=docker}) { const plan = promotionPlan(manifest, version, confirmedVersion); // 所有固定标签先核验完成,再发生任何稳定标签写入。 for (const image of plan) { const descriptor = JSON.parse(run(inspectArgs(image.source))); let platforms = descriptor.manifests?.map(item=>item.platform) ?? [descriptor.platform]; if (!platforms.some(Boolean)) { const verbose = JSON.parse(run(['manifest','inspect','--verbose',image.source])); platforms = (Array.isArray(verbose) ? verbose : [verbose]).map(item=>item.Descriptor?.platform); } if (descriptor.digest !== image.digest || !platforms.some(platform=>platform?.os==='linux' && platform?.architecture==='amd64')) throw new Error(`固定产物核验失败: ${image.key};稳定标签未修改`); } if (dryRun) return {status:'DRY_RUN',version,images:plan}; if (!outputPath) throw new Error('缺少稳定清单输出路径'); const previous = new Map(); for (const image of plan) { try { previous.set(image.stable, JSON.parse(run(inspectArgs(image.stable))).digest); } catch (error) { if (!/manifest unknown|not found|no such manifest/i.test(String(error.stderr ?? error.message))) throw error; previous.set(image.stable,null); } } const changed = []; try { for (const image of plan) { // 先登记尝试;写入成功但回执丢失时也必须尝试恢复原稳定指针。 changed.push(image); run(['buildx','imagetools','create','--prefer-index=false','--tag',image.stable,`${image.image}@${image.digest}`]); const actual = JSON.parse(run(inspectArgs(image.stable))); if (actual.digest !== image.digest) throw new Error(`稳定标签读回不一致: ${image.key}`); } const promoted = {...manifest, channel:'stable', publishedAt:new Date().toISOString(), promotedFromPublishedAt:manifest.publishedAt, components:manifest.components.filter(item=>CORE.includes(item.key))}; const temporary = `${outputPath}.tmp`; writeFileSync(temporary, JSON.stringify(promoted,null,2)+'\n'); renameSync(temporary,outputPath); return {status:'PROMOTED',version,images:plan,manifest:outputPath}; } catch (error) { const recovery = []; for (const image of changed.reverse()) { const oldDigest = previous.get(image.stable); if (!oldDigest) { recovery.push(`${image.key}: 原稳定标签不存在,保留本次标签并记录未完成`); continue; } try { run(['buildx','imagetools','create','--prefer-index=false','--tag',image.stable,`${image.image}@${oldDigest}`]); if (JSON.parse(run(inspectArgs(image.stable))).digest !== oldDigest) throw new Error('恢复读回不一致'); recovery.push(`${image.key}: 已恢复原稳定标签`); } catch { recovery.push(`${image.key}: 恢复失败,需按原 digest 继续恢复`); } } throw new Error(`稳定提升未完成,未提交新的稳定清单:${error.message};${recovery.join(';')}`); } } function main() { const options = {}; const args = process.argv.slice(2); for (let index=0; index