删除 Gitea Actions、Tag/Main 自动流水线和旧 Runner 配置,取消 Git 操作与发布授权的绑定。\n\n新增本地镜像发布、固定生产部署助手、digest manifest、迁移安全检查、simulation 冒烟及显式回滚流程。\n\n验证:pnpm lint、pnpm test、pnpm build、Go 全量测试、ShellCheck、Compose 配置、人工发布测试和 linux/amd64 完整栈冒烟。
228 lines
9.1 KiB
JavaScript
Executable File
228 lines
9.1 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
import { createHash } from 'node:crypto'
|
|
import { readFileSync, writeFileSync } from 'node:fs'
|
|
|
|
const shaPattern = /^[0-9a-f]{40}$/
|
|
const digestImagePattern = /^[A-Za-z0-9.-]+(?::[0-9]+)?(?:\/[A-Za-z0-9._-]+)+@sha256:[0-9a-f]{64}$/
|
|
const allowedComponents = new Set(['api', 'web'])
|
|
const smokeChecks = [
|
|
'health',
|
|
'readiness',
|
|
'openapi',
|
|
'register',
|
|
'login',
|
|
'apiKey',
|
|
'models',
|
|
'chatCompletions',
|
|
'responses',
|
|
'images',
|
|
'web',
|
|
]
|
|
|
|
function requireExactKeys(value, keys, path) {
|
|
const actual = Object.keys(value).sort()
|
|
const expected = [...keys].sort()
|
|
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
|
fail(`${path} has unexpected or missing fields`)
|
|
}
|
|
}
|
|
|
|
function releaseIntegrity(manifest) {
|
|
const payload = structuredClone(manifest)
|
|
delete payload.integrity
|
|
delete payload.deployment
|
|
return createHash('sha256').update(JSON.stringify(payload)).digest('hex')
|
|
}
|
|
|
|
function fail(message) {
|
|
console.error(`release_manifest=FAIL ${message}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
function load(path) {
|
|
try {
|
|
return JSON.parse(readFileSync(path, 'utf8'))
|
|
} catch (error) {
|
|
fail(`cannot read JSON manifest: ${error.message}`)
|
|
}
|
|
}
|
|
|
|
function validate(manifest) {
|
|
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
fail('manifest root must be an object')
|
|
}
|
|
const rootKeys = [
|
|
'schemaVersion', 'releaseId', 'sourceSha', 'baseReleaseSha', 'sourceRef',
|
|
'createdAt', 'buildCompletedAt', 'targetPlatform', 'components',
|
|
'migrationsChanged', 'images', 'smoke', 'integrity',
|
|
]
|
|
if (manifest.deployment !== undefined) rootKeys.push('deployment')
|
|
requireExactKeys(manifest, rootKeys, 'manifest')
|
|
if (manifest.schemaVersion !== 1) fail('schemaVersion must be 1')
|
|
if (!shaPattern.test(manifest.sourceSha || '')) fail('sourceSha must be a full lowercase Git SHA')
|
|
if (manifest.releaseId !== manifest.sourceSha) fail('releaseId must equal sourceSha')
|
|
if (manifest.sourceRef !== 'refs/heads/main') fail('sourceRef must be refs/heads/main')
|
|
if (manifest.baseReleaseSha !== null && !shaPattern.test(manifest.baseReleaseSha || '')) {
|
|
fail('baseReleaseSha must be null or a full lowercase Git SHA')
|
|
}
|
|
if (manifest.baseReleaseSha === manifest.sourceSha) fail('baseReleaseSha must differ from sourceSha')
|
|
if (manifest.targetPlatform !== 'linux/amd64') fail('targetPlatform must be linux/amd64')
|
|
if (!Number.isFinite(Date.parse(manifest.createdAt))) fail('createdAt must be an ISO timestamp')
|
|
if (!Number.isFinite(Date.parse(manifest.buildCompletedAt))) {
|
|
fail('buildCompletedAt must be an ISO timestamp')
|
|
}
|
|
if (!Array.isArray(manifest.components) || manifest.components.length < 1 || manifest.components.length > 2) {
|
|
fail('components must contain api, web, or both')
|
|
}
|
|
const uniqueComponents = [...new Set(manifest.components)]
|
|
if (uniqueComponents.length !== manifest.components.length ||
|
|
uniqueComponents.some((component) => !allowedComponents.has(component))) {
|
|
fail('components contains an invalid or duplicate value')
|
|
}
|
|
if (typeof manifest.migrationsChanged !== 'boolean') fail('migrationsChanged must be boolean')
|
|
if (manifest.migrationsChanged && !manifest.components.includes('api')) {
|
|
fail('migration changes require the api component')
|
|
}
|
|
if (!manifest.images || typeof manifest.images !== 'object') fail('images must be an object')
|
|
requireExactKeys(manifest.images, ['api', 'web'], 'images')
|
|
for (const component of allowedComponents) {
|
|
if (!digestImagePattern.test(manifest.images[component] || '')) {
|
|
fail(`images.${component} must be a registry digest reference`)
|
|
}
|
|
}
|
|
if (!manifest.smoke || manifest.smoke.status !== 'passed' || manifest.smoke.mode !== 'simulation') {
|
|
fail('smoke must record a passed simulation check')
|
|
}
|
|
requireExactKeys(manifest.smoke, ['status', 'mode', 'completedAt', 'checks'], 'smoke')
|
|
if (!Number.isFinite(Date.parse(manifest.smoke.completedAt))) {
|
|
fail('smoke.completedAt must be an ISO timestamp')
|
|
}
|
|
if (!Array.isArray(manifest.smoke.checks) ||
|
|
manifest.smoke.checks.length !== smokeChecks.length ||
|
|
manifest.smoke.checks.some((check, index) => check !== smokeChecks[index])) {
|
|
fail('smoke.checks is incomplete or out of order')
|
|
}
|
|
if (!manifest.integrity || typeof manifest.integrity !== 'object') {
|
|
fail('integrity must be an object')
|
|
}
|
|
requireExactKeys(manifest.integrity, ['algorithm', 'value'], 'integrity')
|
|
if (manifest.integrity.algorithm !== 'sha256' || !/^[0-9a-f]{64}$/.test(manifest.integrity.value || '')) {
|
|
fail('integrity must contain a SHA-256 value')
|
|
}
|
|
if (releaseIntegrity(manifest) !== manifest.integrity.value) {
|
|
fail('manifest content integrity check failed')
|
|
}
|
|
if (manifest.deployment !== undefined) {
|
|
if (!manifest.deployment || typeof manifest.deployment !== 'object') {
|
|
fail('deployment must be an object')
|
|
}
|
|
requireExactKeys(manifest.deployment, ['status', 'action', 'deployedAt', 'durationSeconds'], 'deployment')
|
|
if (manifest.deployment.status !== 'passed' ||
|
|
!['deploy', 'rollback'].includes(manifest.deployment.action) ||
|
|
!Number.isFinite(Date.parse(manifest.deployment.deployedAt)) ||
|
|
!Number.isInteger(manifest.deployment.durationSeconds) ||
|
|
manifest.deployment.durationSeconds < 0) {
|
|
fail('deployment record is invalid')
|
|
}
|
|
}
|
|
|
|
const forbiddenKey = /(password|secret|token|credential|private.?key)/i
|
|
const visit = (value, path = '') => {
|
|
if (!value || typeof value !== 'object') return
|
|
for (const [key, child] of Object.entries(value)) {
|
|
const childPath = path ? `${path}.${key}` : key
|
|
if (forbiddenKey.test(key)) fail(`forbidden sensitive field: ${childPath}`)
|
|
visit(child, childPath)
|
|
}
|
|
}
|
|
visit(manifest)
|
|
return manifest
|
|
}
|
|
|
|
function requireEnv(name, pattern) {
|
|
const value = process.env[name] || ''
|
|
if (!value || (pattern && !pattern.test(value))) fail(`invalid or missing ${name}`)
|
|
return value
|
|
}
|
|
|
|
const [command = 'validate', path, field] = process.argv.slice(2)
|
|
|
|
if (command === 'create') {
|
|
if (!path) fail('create requires an output path')
|
|
const sourceSha = requireEnv('RELEASE_SOURCE_SHA', shaPattern)
|
|
const baseReleaseSha = process.env.RELEASE_BASE_SHA || null
|
|
if (baseReleaseSha !== null && !shaPattern.test(baseReleaseSha)) fail('invalid RELEASE_BASE_SHA')
|
|
const components = requireEnv('RELEASE_COMPONENTS').split(',').filter(Boolean)
|
|
const migrationsChanged = process.env.RELEASE_MIGRATIONS_CHANGED === 'true'
|
|
const manifest = {
|
|
schemaVersion: 1,
|
|
releaseId: sourceSha,
|
|
sourceSha,
|
|
baseReleaseSha,
|
|
sourceRef: 'refs/heads/main',
|
|
createdAt: new Date().toISOString(),
|
|
buildCompletedAt: requireEnv('RELEASE_BUILD_COMPLETED_AT'),
|
|
targetPlatform: 'linux/amd64',
|
|
components,
|
|
migrationsChanged,
|
|
images: {
|
|
api: requireEnv('RELEASE_API_IMAGE', digestImagePattern),
|
|
web: requireEnv('RELEASE_WEB_IMAGE', digestImagePattern),
|
|
},
|
|
smoke: {
|
|
status: 'passed',
|
|
mode: 'simulation',
|
|
completedAt: requireEnv('RELEASE_SMOKE_COMPLETED_AT'),
|
|
checks: smokeChecks,
|
|
},
|
|
integrity: { algorithm: 'sha256', value: '' },
|
|
}
|
|
manifest.integrity.value = releaseIntegrity(manifest)
|
|
validate(manifest)
|
|
writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx', mode: 0o600 })
|
|
const digest = createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
console.log(`release_manifest=CREATED path=${path} sha256=${digest}`)
|
|
process.exit(0)
|
|
}
|
|
|
|
if (!path) fail(`${command} requires a manifest path`)
|
|
const manifest = validate(load(path))
|
|
|
|
if (command === 'record-deployment') {
|
|
if (!field) fail('record-deployment requires an output path')
|
|
const deployedAt = requireEnv('RELEASE_DEPLOYED_AT')
|
|
const durationSeconds = Number(process.env.RELEASE_DEPLOY_DURATION_SECONDS || '')
|
|
const action = process.env.RELEASE_DEPLOY_ACTION || 'deploy'
|
|
if (!Number.isFinite(Date.parse(deployedAt))) fail('invalid RELEASE_DEPLOYED_AT')
|
|
if (!Number.isInteger(durationSeconds) || durationSeconds < 0) {
|
|
fail('invalid RELEASE_DEPLOY_DURATION_SECONDS')
|
|
}
|
|
if (!['deploy', 'rollback'].includes(action)) fail('invalid RELEASE_DEPLOY_ACTION')
|
|
manifest.deployment = { status: 'passed', action, deployedAt, durationSeconds }
|
|
validate(manifest)
|
|
writeFileSync(field, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx', mode: 0o600 })
|
|
console.log(`release_manifest=RECORDED release=${manifest.releaseId} action=${action}`)
|
|
process.exit(0)
|
|
}
|
|
|
|
if (command === 'validate') {
|
|
const digest = createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
console.log(`release_manifest=PASS release=${manifest.releaseId} sha256=${digest}`)
|
|
process.exit(0)
|
|
}
|
|
|
|
if (command === 'get') {
|
|
if (!field || !/^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)*$/.test(field)) {
|
|
fail('get requires a safe dotted field path')
|
|
}
|
|
let value = manifest
|
|
for (const segment of field.split('.')) value = value?.[segment]
|
|
if (typeof value === 'object') console.log(JSON.stringify(value))
|
|
else if (value === null || value === undefined) console.log('')
|
|
else console.log(String(value))
|
|
process.exit(0)
|
|
}
|
|
|
|
fail(`unknown command: ${command}`)
|