feat(ops): 添加发布 smoke test
- 新增只读发布 smoke runner 和 dry-run 测试\n- 将生产部署 workflow 从单点版本检查升级为 smoke test\n- 补充 Docker web runtime 脚本复制、package script 和部署文档\n\nCo-Authored-By: GPT-5 Codex <codex@openai.com>
This commit is contained in:
19
.github/workflows/deploy-production.yml
vendored
19
.github/workflows/deploy-production.yml
vendored
@@ -123,26 +123,11 @@ jobs:
|
|||||||
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --remove-orphans
|
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --remove-orphans
|
||||||
|
|
||||||
for attempt in $(seq 1 30); do
|
for attempt in $(seq 1 30); do
|
||||||
if docker compose --env-file .env.production -f docker-compose.prod.yml exec -T web node -e "
|
if docker compose --env-file .env.production -f docker-compose.prod.yml exec -T web node scripts/smoke-test-release.mjs --base-url http://nginx --expected-version "${{ github.sha }}"; then
|
||||||
const expected = process.argv[1];
|
|
||||||
fetch('http://nginx/api/v1/health/version')
|
|
||||||
.then(async (response) => {
|
|
||||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
|
||||||
const payload = await response.json();
|
|
||||||
if (payload.version !== expected) {
|
|
||||||
throw new Error('Expected ' + expected + ', got ' + payload.version);
|
|
||||||
}
|
|
||||||
console.log('Runtime version verified: ' + payload.version);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(error.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
" "${{ github.sha }}"; then
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Runtime version check failed after retries"
|
echo "Release smoke check failed after retries"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ COPY --from=builder /app/turbo.json ./turbo.json
|
|||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/packages/shared ./packages/shared
|
COPY --from=builder /app/packages/shared ./packages/shared
|
||||||
COPY --from=builder /app/apps/web ./apps/web
|
COPY --from=builder /app/apps/web ./apps/web
|
||||||
|
COPY scripts ./scripts
|
||||||
RUN chown -R node:node /app
|
RUN chown -R node:node /app
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
@@ -175,12 +175,13 @@ cp .env.production.example .env.production
|
|||||||
5. 执行 `docker compose --env-file .env.production -f docker-compose.prod.yml pull web server` 拉取本次 SHA 镜像。
|
5. 执行 `docker compose --env-file .env.production -f docker-compose.prod.yml pull web server` 拉取本次 SHA 镜像。
|
||||||
6. 启动数据库与 Redis,执行 `pnpm --filter server db:deploy`。
|
6. 启动数据库与 Redis,执行 `pnpm --filter server db:deploy`。
|
||||||
7. 执行 `docker compose --env-file .env.production -f docker-compose.prod.yml up -d --remove-orphans` 重启服务。
|
7. 执行 `docker compose --env-file .env.production -f docker-compose.prod.yml up -d --remove-orphans` 重启服务。
|
||||||
8. 通过 `/api/v1/health/version` 校验运行中的后端版本是否等于本次 commit SHA。
|
8. 运行发布 smoke test:校验 `/api/v1/health/version`、前端首页、产品页、产品 API、V2.2 读路径和 AI 配置端点,并确认运行中的后端版本等于本次 commit SHA。
|
||||||
|
|
||||||
如果最后一步失败,Actions 会红掉,说明“代码已合并”不等于“线上容器已更新”。本地或服务器也可以手工运行:
|
如果最后一步失败,Actions 会红掉,说明“代码已合并”不等于“线上容器已更新”或关键读路径不可用。本地或服务器也可以手工运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm deploy:check-runtime http://localhost/api/v1/health/version <expected-commit-sha>
|
pnpm deploy:check-runtime http://localhost/api/v1/health/version <expected-commit-sha>
|
||||||
|
pnpm deploy:smoke -- --base-url http://localhost --expected-version <expected-commit-sha>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Nginx 路由
|
## Nginx 路由
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"ops:test": "node --test scripts/*.test.mjs",
|
"ops:test": "node --test scripts/*.test.mjs",
|
||||||
"deploy:verify": "node scripts/verify-production-deploy.mjs",
|
"deploy:verify": "node scripts/verify-production-deploy.mjs",
|
||||||
"deploy:check-runtime": "node scripts/check-runtime-version.mjs",
|
"deploy:check-runtime": "node scripts/check-runtime-version.mjs",
|
||||||
|
"deploy:smoke": "node scripts/smoke-test-release.mjs",
|
||||||
"backup:postgres": "node scripts/backup-postgres.mjs",
|
"backup:postgres": "node scripts/backup-postgres.mjs",
|
||||||
"restore:postgres": "node scripts/restore-postgres.mjs",
|
"restore:postgres": "node scripts/restore-postgres.mjs",
|
||||||
"backup:server-data": "node scripts/backup-server-data.mjs",
|
"backup:server-data": "node scripts/backup-server-data.mjs",
|
||||||
|
|||||||
@@ -82,4 +82,19 @@ describe('production ops scripts', () => {
|
|||||||
assert.match(result.stdout, /ftb_pm_server_data/);
|
assert.match(result.stdout, /ftb_pm_server_data/);
|
||||||
assert.match(result.stdout, /tar -czf/);
|
assert.match(result.stdout, /tar -czf/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prints read-only release smoke checks in dry-run mode', () => {
|
||||||
|
const result = runScript('scripts/smoke-test-release.mjs', [
|
||||||
|
'--dry-run',
|
||||||
|
'--base-url',
|
||||||
|
'http://localhost',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.match(result.stdout, /\/api\/v1\/health\/version/);
|
||||||
|
assert.match(result.stdout, /\/products/);
|
||||||
|
assert.match(result.stdout, /\/api\/v1\/products/);
|
||||||
|
assert.match(result.stdout, /\/api\/v1\/v2\.2\/requirements\?productId=__smoke__/);
|
||||||
|
assert.match(result.stdout, /\/api\/v1\/config\/ai/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
160
scripts/smoke-test-release.mjs
Normal file
160
scripts/smoke-test-release.mjs
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { flag, option, parseArgs } from './ops-utils.mjs';
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
return `Usage: node scripts/smoke-test-release.mjs --base-url <url> [options]
|
||||||
|
|
||||||
|
Read-only release smoke checks:
|
||||||
|
- backend runtime version
|
||||||
|
- frontend root page
|
||||||
|
- frontend products route
|
||||||
|
- products API
|
||||||
|
- V2.2 requirement read path
|
||||||
|
- AI config public endpoint
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--base-url <url> Deployment base URL (default: http://127.0.0.1)
|
||||||
|
--expected-version <sha> Expected /api/v1/health/version payload version
|
||||||
|
--timeout-ms <number> Per-request timeout (default: 5000)
|
||||||
|
--dry-run Print planned checks without making requests
|
||||||
|
--help Show this help
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(value) {
|
||||||
|
const url = new URL(value || 'http://127.0.0.1');
|
||||||
|
url.pathname = url.pathname.replace(/\/+$/, '');
|
||||||
|
url.search = '';
|
||||||
|
url.hash = '';
|
||||||
|
return url.toString().replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSmokeChecks(baseUrl, expectedVersion = '') {
|
||||||
|
const checks = [
|
||||||
|
{
|
||||||
|
name: 'backend runtime version',
|
||||||
|
path: '/api/v1/health/version',
|
||||||
|
kind: 'json',
|
||||||
|
validate(payload) {
|
||||||
|
if (payload?.service !== 'server' || typeof payload.version !== 'string') {
|
||||||
|
throw new Error(`unexpected version payload: ${JSON.stringify(payload)}`);
|
||||||
|
}
|
||||||
|
if (expectedVersion && payload.version !== expectedVersion) {
|
||||||
|
throw new Error(`expected version ${expectedVersion}, got ${payload.version}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: 'frontend root', path: '/', kind: 'text' },
|
||||||
|
{ name: 'frontend products route', path: '/products', kind: 'text' },
|
||||||
|
{
|
||||||
|
name: 'products API',
|
||||||
|
path: '/api/v1/products',
|
||||||
|
kind: 'json',
|
||||||
|
validate(payload) {
|
||||||
|
if (!Array.isArray(payload)) {
|
||||||
|
throw new Error('products API did not return an array');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'V2.2 requirement scoped read',
|
||||||
|
path: '/api/v1/v2.2/requirements?productId=__smoke__&limit=1',
|
||||||
|
kind: 'json',
|
||||||
|
validate(payload) {
|
||||||
|
if (!payload || !Array.isArray(payload.items)) {
|
||||||
|
throw new Error('V2.2 requirements response missing items array');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'AI config public endpoint',
|
||||||
|
path: '/api/v1/config/ai',
|
||||||
|
kind: 'json',
|
||||||
|
validate(payload) {
|
||||||
|
if (!payload || !Array.isArray(payload.providers)) {
|
||||||
|
throw new Error('AI config response missing providers array');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return checks.map((check) => ({
|
||||||
|
...check,
|
||||||
|
url: new URL(check.path, `${baseUrl}/`).toString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTimeout(url, timeoutMs) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCheck(check, timeoutMs) {
|
||||||
|
const response = await fetchWithTimeout(check.url, timeoutMs);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check.kind === 'json') {
|
||||||
|
const payload = await response.json();
|
||||||
|
check.validate?.(payload);
|
||||||
|
} else {
|
||||||
|
await response.text();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function main(argv = process.argv.slice(2)) {
|
||||||
|
const args = parseArgs(argv);
|
||||||
|
if (flag(args, 'help')) {
|
||||||
|
process.stdout.write(usage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = normalizeBaseUrl(option(args, 'base-url', 'http://127.0.0.1'));
|
||||||
|
const expectedVersion = option(args, 'expected-version', '');
|
||||||
|
const timeoutMs = Number.parseInt(option(args, 'timeout-ms', '5000'), 10);
|
||||||
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||||
|
throw new Error('--timeout-ms must be a positive number');
|
||||||
|
}
|
||||||
|
|
||||||
|
const checks = buildSmokeChecks(baseUrl, expectedVersion);
|
||||||
|
if (flag(args, 'dry-run')) {
|
||||||
|
process.stdout.write(`[dry-run] Release smoke target: ${baseUrl}\n`);
|
||||||
|
for (const check of checks) {
|
||||||
|
process.stdout.write(`GET ${check.url} # ${check.name}\n`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(`Release smoke target: ${baseUrl}\n`);
|
||||||
|
const failures = [];
|
||||||
|
for (const check of checks) {
|
||||||
|
try {
|
||||||
|
await runCheck(check, timeoutMs);
|
||||||
|
process.stdout.write(`[pass] ${check.name} ${check.url}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(`[fail] ${check.name} ${check.url}: ${error.message}`);
|
||||||
|
process.stderr.write(`${failures.at(-1)}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
throw new Error(`Release smoke failed: ${failures.length} check(s) failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write('Release smoke test passed.\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(`${error.message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ const checks = [
|
|||||||
'NEXT_PUBLIC_API_URL',
|
'NEXT_PUBLIC_API_URL',
|
||||||
'NEXT_PUBLIC_APP_VERSION',
|
'NEXT_PUBLIC_APP_VERSION',
|
||||||
'ARG APP_VERSION=unknown',
|
'ARG APP_VERSION=unknown',
|
||||||
|
'COPY scripts ./scripts',
|
||||||
'CMD ["pnpm", "--filter", "web", "start"]',
|
'CMD ["pnpm", "--filter", "web", "start"]',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -59,13 +60,22 @@ const checks = [
|
|||||||
'appleboy/ssh-action',
|
'appleboy/ssh-action',
|
||||||
'docker compose --env-file .env.production -f docker-compose.prod.yml pull',
|
'docker compose --env-file .env.production -f docker-compose.prod.yml pull',
|
||||||
'pnpm --filter server db:deploy',
|
'pnpm --filter server db:deploy',
|
||||||
'/api/v1/health/version',
|
'scripts/smoke-test-release.mjs',
|
||||||
|
'--expected-version',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
file: 'scripts/check-runtime-version.mjs',
|
file: 'scripts/check-runtime-version.mjs',
|
||||||
snippets: ['Runtime version verified', '/api/v1/health/version', 'expectedVersion'],
|
snippets: ['Runtime version verified', '/api/v1/health/version', 'expectedVersion'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
file: 'scripts/smoke-test-release.mjs',
|
||||||
|
snippets: [
|
||||||
|
'Release smoke test passed',
|
||||||
|
'/api/v1/v2.2/requirements?productId=__smoke__',
|
||||||
|
'/api/v1/config/ai',
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
file: 'scripts/backup-postgres.mjs',
|
file: 'scripts/backup-postgres.mjs',
|
||||||
snippets: ['pg_dump', '--format=custom', '--dry-run'],
|
snippets: ['pg_dump', '--format=custom', '--dry-run'],
|
||||||
|
|||||||
Reference in New Issue
Block a user