删除 Gitea Actions、Tag/Main 自动流水线和旧 Runner 配置,取消 Git 操作与发布授权的绑定。\n\n新增本地镜像发布、固定生产部署助手、digest manifest、迁移安全检查、simulation 冒烟及显式回滚流程。\n\n验证:pnpm lint、pnpm test、pnpm build、Go 全量测试、ShellCheck、Compose 配置、人工发布测试和 linux/amd64 完整栈冒烟。
158 lines
4.5 KiB
JavaScript
Executable File
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,
|
|
}))
|