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:
2026-07-22 15:13:40 +08:00
parent cbebfd7baa
commit dae5d16a58
32 changed files with 1820 additions and 1990 deletions
+7 -119
View File
@@ -5,10 +5,7 @@ import { lstatSync, readFileSync } from 'node:fs'
import { basename } from 'node:path'
const migrationDirectory = 'apps/api/migrations/'
const baselineFile = 'deploy/ci/production-migration-base'
const base = process.argv[2] || process.env.PRODUCTION_MIGRATION_BASE_SHA || ''
const immutableRef =
process.argv[3] || process.env.MIGRATION_IMMUTABILITY_REF || ''
const base = process.argv[2] || process.env.RELEASE_BASE_SHA || ''
function fail(message) {
console.error(`migration_safety=FAIL ${message}`)
@@ -28,7 +25,7 @@ function git(args, options = {}) {
}
if (!/^[0-9a-f]{40}$/.test(base)) {
fail('PRODUCTION_MIGRATION_BASE_SHA must be a full lowercase commit SHA')
fail('RELEASE_BASE_SHA must be a full lowercase commit SHA')
}
const root = git(['rev-parse', '--show-toplevel']).trim()
@@ -41,34 +38,14 @@ const resolvedBase = git([
`${base}^{commit}`,
]).trim()
if (resolvedBase !== base) {
fail('production migration baseline did not resolve exactly')
fail('release base did not resolve exactly')
}
const ancestry = spawnSync('git', ['merge-base', '--is-ancestor', base, 'HEAD'], {
stdio: 'ignore',
})
if (ancestry.status !== 0) {
fail('production migration baseline is not an ancestor of HEAD')
}
const head = git(['rev-parse', '--verify', 'HEAD^{commit}']).trim()
const baselineTreeEntry = git(['ls-tree', 'HEAD', '--', baselineFile]).trim()
let baselineState
try {
baselineState = lstatSync(baselineFile)
} catch {
fail('production migration baseline file is unavailable in the worktree')
}
if (
!/^100644 blob [0-9a-f]+\t/.test(baselineTreeEntry) ||
!baselineState.isFile() ||
baselineState.isSymbolicLink()
) {
fail('production migration baseline must be a mode 100644 regular file')
}
if (readFileSync(baselineFile, 'utf8').trim() !== base) {
fail('production migration baseline argument does not match the reviewed file')
fail('release base is not an ancestor of HEAD')
}
function changedMigrations(from) {
@@ -252,94 +229,8 @@ const destructiveRules = [
const violations = []
let addedFiles = 0
let immutableBase = ''
const orderingCandidates = new Set()
if (immutableRef) {
if (
!/^[0-9a-f]{40}$/.test(immutableRef) &&
immutableRef !== 'refs/remotes/origin/main'
) {
fail('MIGRATION_IMMUTABILITY_REF must be a full commit SHA or refs/remotes/origin/main')
}
const immutableTip = git([
'rev-parse',
'--verify',
'--end-of-options',
`${immutableRef}^{commit}`,
]).trim()
const baseAtEvent = spawnSync(
'git',
['show', `${immutableTip}:${baselineFile}`],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
)
if (baseAtEvent.status === 0) {
const previousBaselineTreeEntry = git([
'ls-tree',
immutableTip,
'--',
baselineFile,
]).trim()
if (!/^100644 blob [0-9a-f]+\t/.test(previousBaselineTreeEntry)) {
fail('previous production migration baseline must be a mode 100644 regular file')
}
const previousProductionBase = baseAtEvent.stdout.trim()
if (!/^[0-9a-f]{40}$/.test(previousProductionBase)) {
fail('previous production migration baseline is invalid')
}
const baselineAdvance = spawnSync(
'git',
['merge-base', '--is-ancestor', previousProductionBase, base],
{ stdio: 'ignore' },
)
if (baselineAdvance.status !== 0) {
fail('production migration baseline must only move forward')
}
}
const baselineWasAlreadyReviewed = spawnSync(
'git',
['merge-base', '--is-ancestor', base, immutableTip],
{ stdio: 'ignore' },
)
if (baselineWasAlreadyReviewed.status !== 0) {
fail('production migration baseline must belong to the immutable event base history')
}
if (immutableTip === head) {
const parent = spawnSync(
'git',
['rev-parse', '--verify', `${immutableTip}^1^{commit}`],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
)
immutableBase = parent.status === 0 ? parent.stdout.trim() : ''
} else {
const immutableAncestry = spawnSync(
'git',
['merge-base', '--is-ancestor', immutableTip, head],
{ stdio: 'ignore' },
)
if (immutableAncestry.status !== 0) {
fail('migration immutability base is not an ancestor of HEAD')
}
immutableBase = immutableTip
}
if (immutableBase) {
const immutableChanges = changedMigrations(immutableBase)
for (let index = 0; index < immutableChanges.length; index += 2) {
const status = immutableChanges[index]
const file = immutableChanges[index + 1]
if (status === 'A') {
orderingCandidates.add(file)
} else {
violations.push(
`${file}: migration present on main is immutable (status ${status})`,
)
}
}
}
}
function migrationNamesAt(ref) {
const output = git(
['ls-tree', '-r', '-z', '--name-only', ref, '--', migrationDirectory],
@@ -358,14 +249,11 @@ function migrationNamesAt(ref) {
.sort()
}
const orderingBase = immutableBase || base
const existingNames = migrationNamesAt(orderingBase)
const existingNames = migrationNamesAt(base)
const previousMaximumName = existingNames.at(-1) || ''
if (!immutableBase) {
for (let index = 0; index < fields.length; index += 2) {
if (fields[index] === 'A') orderingCandidates.add(fields[index + 1])
}
for (let index = 0; index < fields.length; index += 2) {
if (fields[index] === 'A') orderingCandidates.add(fields[index + 1])
}
for (let index = 0; index < fields.length; index += 2) {