Files
easyai-ai-gateway/scripts/release-components.mjs
T
easyai dae5d16a58 refactor(release): 改为 Agent 双阶段人工发布
删除 Gitea Actions、Tag/Main 自动流水线和旧 Runner 配置,取消 Git 操作与发布授权的绑定。\n\n新增本地镜像发布、固定生产部署助手、digest manifest、迁移安全检查、simulation 冒烟及显式回滚流程。\n\n验证:pnpm lint、pnpm test、pnpm build、Go 全量测试、ShellCheck、Compose 配置、人工发布测试和 linux/amd64 完整栈冒烟。
2026-07-22 15:13:40 +08:00

158 lines
4.5 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
function fail(message) {
console.error(`release_components=FAIL ${message}`)
process.exit(1)
}
const base = process.argv[2] || ''
const head = process.argv[3] || 'HEAD'
if (!/^[0-9a-f]{40}$/.test(base)) {
fail('base must be a full lowercase Git SHA')
}
function git(args) {
try {
return execFileSync('git', args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim()
} catch (error) {
const stderr = error.stderr?.toString().trim()
fail(stderr ? `git_error=${JSON.stringify(stderr)}` : 'git command failed')
}
}
const resolvedBase = git(['rev-parse', '--verify', `${base}^{commit}`])
const resolvedHead = git(['rev-parse', '--verify', `${head}^{commit}`])
if (resolvedBase !== base) fail('base did not resolve exactly')
if (!/^[0-9a-f]{40}$/.test(resolvedHead)) fail('head did not resolve to a commit')
try {
execFileSync('git', ['merge-base', '--is-ancestor', base, resolvedHead], {
stdio: 'ignore',
})
} catch {
fail('base is not an ancestor of head')
}
const changed = git([
'diff',
'--name-only',
'--no-renames',
`${base}..${resolvedHead}`,
]).split('\n').filter(Boolean)
const apiPatterns = [
/^apps\/api\//,
/^go\.work(?:\.sum)?$/,
]
const webPatterns = [
/^apps\/web\//,
/^packages\/contracts\//,
/^pnpm-lock\.yaml$/,
/^pnpm-workspace\.yaml$/,
/^nx\.json$/,
/^package\.json$/,
/^docker\/nginx\.conf$/,
]
const sharedRuntimePatterns = [
/^\.dockerignore$/,
/^docker-compose(?:\.[^.]+)?\.ya?ml$/,
]
function dockerfileSections(ref) {
let contents
try {
contents = execFileSync('git', ['show', `${ref}:Dockerfile`], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})
} catch {
return null
}
const sections = new Map([['global', []]])
let section = 'global'
for (const line of contents.split('\n')) {
const match = line.match(/^FROM\s+.+?\s+AS\s+([A-Za-z0-9._-]+)(?:\s|$)/i)
if (match) section = match[1].toLowerCase()
if (!sections.has(section)) sections.set(section, [])
sections.get(section).push(line)
}
return sections
}
function sectionChanged(before, after, name) {
return (before.get(name) || []).join('\n') !== (after.get(name) || []).join('\n')
}
function classifyDockerfile() {
const before = dockerfileSections(base)
const after = dockerfileSections(resolvedHead)
if (!before || !after) return { apiChanged: true, webChanged: true }
const knownApi = new Set(['api-builder', 'api'])
const knownWeb = new Set(['web-builder', 'web'])
let apiChanged = false
let webChanged = false
const beforeGlobal = before.get('global') || []
const afterGlobal = after.get('global') || []
const globalLines = (lines, pattern) => lines.filter((line) => pattern.test(line)).join('\n')
const apiGlobal = /\b(?:GO_VERSION|GO_BUILD_IMAGE|API_RUNTIME_IMAGE)\b/
const webGlobal = /\b(?:NODE_VERSION|NODE_BUILD_IMAGE|WEB_RUNTIME_IMAGE)\b/
if (globalLines(beforeGlobal, apiGlobal) !== globalLines(afterGlobal, apiGlobal)) apiChanged = true
if (globalLines(beforeGlobal, webGlobal) !== globalLines(afterGlobal, webGlobal)) webChanged = true
const sharedGlobal = (lines) => lines.filter((line) => !apiGlobal.test(line) && !webGlobal.test(line)).join('\n')
if (sharedGlobal(beforeGlobal) !== sharedGlobal(afterGlobal)) {
apiChanged = true
webChanged = true
}
const sectionNames = new Set([...before.keys(), ...after.keys()])
sectionNames.delete('global')
for (const name of sectionNames) {
if (!sectionChanged(before, after, name)) continue
if (knownApi.has(name)) apiChanged = true
else if (knownWeb.has(name)) webChanged = true
else {
apiChanged = true
webChanged = true
}
}
return { apiChanged, webChanged }
}
let api = false
let web = false
let migrationsChanged = false
if (changed.includes('Dockerfile')) {
const dockerfile = classifyDockerfile()
api = dockerfile.apiChanged
web = dockerfile.webChanged
}
for (const file of changed) {
if (file === 'Dockerfile') continue
if (/^apps\/api\/migrations\//.test(file)) migrationsChanged = true
if (sharedRuntimePatterns.some((pattern) => pattern.test(file))) {
api = true
web = true
continue
}
if (apiPatterns.some((pattern) => pattern.test(file))) api = true
if (webPatterns.some((pattern) => pattern.test(file))) web = true
}
const components = api && web ? 'all' : api ? 'api' : web ? 'web' : 'none'
console.log(JSON.stringify({
base,
head: resolvedHead,
components,
migrationsChanged,
changedFiles: changed,
}))