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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user