From eef09d5af49ca7f45613bb13671afeaf43aba7ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Wed, 8 Jul 2026 16:14:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=E6=B7=BB=E5=8A=A0=E5=8F=91?= =?UTF-8?q?=E5=B8=83=20smoke=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增只读发布 smoke runner 和 dry-run 测试\n- 将生产部署 workflow 从单点版本检查升级为 smoke test\n- 补充 Docker web runtime 脚本复制、package script 和部署文档\n\nCo-Authored-By: GPT-5 Codex --- .github/workflows/deploy-production.yml | 19 +-- Dockerfile.web | 1 + docs/deployment.md | 5 +- package.json | 1 + scripts/ops-scripts.test.mjs | 15 +++ scripts/smoke-test-release.mjs | 160 ++++++++++++++++++++++++ scripts/verify-production-deploy.mjs | 12 +- 7 files changed, 193 insertions(+), 20 deletions(-) create mode 100644 scripts/smoke-test-release.mjs diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 9fa0e97..a041693 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -123,26 +123,11 @@ jobs: docker compose --env-file .env.production -f docker-compose.prod.yml up -d --remove-orphans for attempt in $(seq 1 30); do - if docker compose --env-file .env.production -f docker-compose.prod.yml exec -T web node -e " - 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 + 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 exit 0 fi sleep 2 done - echo "Runtime version check failed after retries" + echo "Release smoke check failed after retries" exit 1 diff --git a/Dockerfile.web b/Dockerfile.web index fe59920..c731c6b 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -57,6 +57,7 @@ COPY --from=builder /app/turbo.json ./turbo.json COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/packages/shared ./packages/shared COPY --from=builder /app/apps/web ./apps/web +COPY scripts ./scripts RUN chown -R node:node /app USER node EXPOSE 3000 diff --git a/docs/deployment.md b/docs/deployment.md index 830ac87..ea3c4a0 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 镜像。 6. 启动数据库与 Redis,执行 `pnpm --filter server db:deploy`。 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 pnpm deploy:check-runtime http://localhost/api/v1/health/version +pnpm deploy:smoke -- --base-url http://localhost --expected-version ``` ## Nginx 路由 diff --git a/package.json b/package.json index 0663a3e..213d37c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "ops:test": "node --test scripts/*.test.mjs", "deploy:verify": "node scripts/verify-production-deploy.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", "restore:postgres": "node scripts/restore-postgres.mjs", "backup:server-data": "node scripts/backup-server-data.mjs", diff --git a/scripts/ops-scripts.test.mjs b/scripts/ops-scripts.test.mjs index e057418..1b08db0 100644 --- a/scripts/ops-scripts.test.mjs +++ b/scripts/ops-scripts.test.mjs @@ -82,4 +82,19 @@ describe('production ops scripts', () => { assert.match(result.stdout, /ftb_pm_server_data/); 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/); + }); }); diff --git a/scripts/smoke-test-release.mjs b/scripts/smoke-test-release.mjs new file mode 100644 index 0000000..e199a4c --- /dev/null +++ b/scripts/smoke-test-release.mjs @@ -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 [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 Deployment base URL (default: http://127.0.0.1) + --expected-version Expected /api/v1/health/version payload version + --timeout-ms 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); + }); +} + diff --git a/scripts/verify-production-deploy.mjs b/scripts/verify-production-deploy.mjs index 510311e..1ff3603 100644 --- a/scripts/verify-production-deploy.mjs +++ b/scripts/verify-production-deploy.mjs @@ -21,6 +21,7 @@ const checks = [ 'NEXT_PUBLIC_API_URL', 'NEXT_PUBLIC_APP_VERSION', 'ARG APP_VERSION=unknown', + 'COPY scripts ./scripts', 'CMD ["pnpm", "--filter", "web", "start"]', ], }, @@ -59,13 +60,22 @@ const checks = [ 'appleboy/ssh-action', 'docker compose --env-file .env.production -f docker-compose.prod.yml pull', 'pnpm --filter server db:deploy', - '/api/v1/health/version', + 'scripts/smoke-test-release.mjs', + '--expected-version', ], }, { file: 'scripts/check-runtime-version.mjs', 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', snippets: ['pg_dump', '--format=custom', '--dry-run'],