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 完整栈冒烟。
This commit is contained in:
Executable
+157
@@ -0,0 +1,157 @@
|
||||
#!/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,
|
||||
}))
|
||||
Reference in New Issue
Block a user