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
+145
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
function fail(message) {
|
||||
console.error(`api_release_smoke=FAIL ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) fail(message)
|
||||
}
|
||||
|
||||
const baseURL = (process.env.RELEASE_SMOKE_BASE_URL || '').replace(/\/+$/, '')
|
||||
const webURL = (process.env.RELEASE_SMOKE_WEB_URL || '').replace(/\/+$/, '')
|
||||
const composeProject = process.env.RELEASE_SMOKE_COMPOSE_PROJECT || ''
|
||||
const composeFile = process.env.RELEASE_SMOKE_COMPOSE_FILE || 'docker-compose.yml'
|
||||
const nonce = process.env.RELEASE_SMOKE_NONCE || Date.now().toString(36)
|
||||
const databaseUser = process.env.RELEASE_SMOKE_DATABASE_USER || 'easyai'
|
||||
const databaseName = process.env.RELEASE_SMOKE_DATABASE_NAME || 'easyai_ai_gateway'
|
||||
|
||||
if (!/^http:\/\/127\.0\.0\.1:\d+$/.test(baseURL)) fail('invalid RELEASE_SMOKE_BASE_URL')
|
||||
if (!/^http:\/\/127\.0\.0\.1:\d+$/.test(webURL)) fail('invalid RELEASE_SMOKE_WEB_URL')
|
||||
if (!/^[a-z0-9][a-z0-9_-]{2,62}$/.test(composeProject)) fail('invalid RELEASE_SMOKE_COMPOSE_PROJECT')
|
||||
if (!/^[a-z_][a-z0-9_]{0,62}$/.test(databaseUser)) fail('invalid RELEASE_SMOKE_DATABASE_USER')
|
||||
if (!/^[a-z_][a-z0-9_]{0,62}$/.test(databaseName)) fail('invalid RELEASE_SMOKE_DATABASE_NAME')
|
||||
|
||||
async function request(path, { method = 'GET', token = '', body } = {}) {
|
||||
const response = await fetch(`${baseURL}${path}`, {
|
||||
method,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
})
|
||||
const text = await response.text()
|
||||
let payload
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
fail(`${method} ${path} returned non-JSON HTTP ${response.status}`)
|
||||
}
|
||||
if (!response.ok) fail(`${method} ${path} returned HTTP ${response.status}`)
|
||||
return { response, payload }
|
||||
}
|
||||
|
||||
async function waitForAPI() {
|
||||
let lastError = 'API did not respond'
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(`${baseURL}/api/v1/readyz`, {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
})
|
||||
if (response.ok) return
|
||||
lastError = `readiness returned HTTP ${response.status}`
|
||||
} catch (error) {
|
||||
lastError = error.message
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000))
|
||||
}
|
||||
fail(`API readiness timed out: ${lastError}`)
|
||||
}
|
||||
|
||||
await waitForAPI()
|
||||
|
||||
const health = await request('/api/v1/healthz')
|
||||
assert(health.payload?.ok === true && health.payload?.service === 'easyai-ai-gateway', 'health payload mismatch')
|
||||
|
||||
const ready = await request('/api/v1/readyz')
|
||||
assert(ready.payload?.ok === true, 'readiness payload mismatch')
|
||||
|
||||
const openapi = await request('/api/v1/openapi.json')
|
||||
for (const path of [
|
||||
'/api/v1/healthz',
|
||||
'/api/v1/readyz',
|
||||
'/api/v1/models',
|
||||
'/api/v1/chat/completions',
|
||||
'/api/v1/responses',
|
||||
'/api/v1/images/generations',
|
||||
]) {
|
||||
assert(openapi.payload?.paths?.[path], `OpenAPI is missing ${path}`)
|
||||
}
|
||||
|
||||
const username = `release_smoke_${nonce}`.slice(0, 60)
|
||||
const password = `Release-Smoke-${nonce}-9x!`
|
||||
const registration = await request('/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
body: { username, email: `${username}@example.invalid`, password },
|
||||
})
|
||||
assert(typeof registration.payload?.accessToken === 'string', 'registration did not return an access token')
|
||||
|
||||
const login = await request('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
body: { account: username, password },
|
||||
})
|
||||
const jwt = login.payload?.accessToken
|
||||
assert(typeof jwt === 'string' && jwt.length > 20, 'login did not return an access token')
|
||||
|
||||
try {
|
||||
execFileSync('docker', [
|
||||
'compose', '-p', composeProject, '-f', composeFile,
|
||||
'exec', '-T', 'postgres', 'psql', '-v', 'ON_ERROR_STOP=1',
|
||||
'-U', databaseUser, '-d', databaseName, '-c',
|
||||
`UPDATE gateway_wallet_accounts SET balance = 100000 WHERE gateway_user_id = (SELECT id FROM gateway_users WHERE username = '${username.replaceAll("'", "''")}')`,
|
||||
], { stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
} catch {
|
||||
fail('could not fund the ephemeral smoke wallet')
|
||||
}
|
||||
|
||||
const apiKeyResult = await request('/api/v1/api-keys', {
|
||||
method: 'POST', token: jwt, body: { name: 'release smoke key' },
|
||||
})
|
||||
const apiKey = apiKeyResult.payload?.secret
|
||||
assert(typeof apiKey === 'string' && apiKey.startsWith('sk-gw-'), 'API key creation failed')
|
||||
|
||||
const models = await request('/api/v1/models', { token: apiKey })
|
||||
assert(Array.isArray(models.payload?.items) || Array.isArray(models.payload?.data), 'models response has no items')
|
||||
|
||||
const simulation = { runMode: 'simulation', simulation: true, simulationDurationMs: 5 }
|
||||
const chat = await request('/api/v1/chat/completions', {
|
||||
method: 'POST', token: apiKey,
|
||||
body: { model: 'openai:gpt-4o-mini', messages: [{ role: 'user', content: 'release smoke' }], ...simulation },
|
||||
})
|
||||
assert(/^chatcmpl-/.test(chat.payload?.id || ''), 'chat simulation response mismatch')
|
||||
|
||||
const responses = await request('/api/v1/responses', {
|
||||
method: 'POST', token: apiKey,
|
||||
body: { model: 'openai:gpt-4o-mini', input: 'release smoke', ...simulation },
|
||||
})
|
||||
assert(/^resp/.test(responses.payload?.id || ''), 'responses simulation response mismatch')
|
||||
|
||||
const images = await request('/api/v1/images/generations', {
|
||||
method: 'POST', token: apiKey,
|
||||
body: { model: 'openai:gpt-image-1', prompt: 'release smoke', ...simulation },
|
||||
})
|
||||
assert(Array.isArray(images.payload?.data) && images.payload.data.length > 0, 'image simulation response mismatch')
|
||||
|
||||
const webHealth = await fetch(`${webURL}/api/v1/healthz`, { signal: AbortSignal.timeout(15_000) })
|
||||
assert(webHealth.ok, `web API reverse proxy returned HTTP ${webHealth.status}`)
|
||||
const webApp = await fetch(`${webURL}/`, { signal: AbortSignal.timeout(15_000) })
|
||||
assert(webApp.ok && (await webApp.text()).includes('EasyAI AI Gateway'), 'web application smoke failed')
|
||||
|
||||
console.log('api_release_smoke=PASS health=ok readiness=ok openapi=ok auth=ok chat=ok responses=ok images=ok web=ok')
|
||||
Reference in New Issue
Block a user