Files
order_site/apps/backend/src/deploy-change-detection.test.ts
T
yml2213 ccbcb7cbae fix(deploy): --backend-only/--all 不该连带跳过"改动范围"的计算
真机验证时发现:显式指定 --backend-only 会直接 return,没算 git diff,
于是 DIFF_KNOWN 一直是 false —— "无 migrations 改动就自动跳过备份"的判断随之失效,
一次只想重建后端的部署照样会跑 pg_dump + 传 OSS(生产上约 12 分钟)。

修复:拆成两步
- compute_changed_files:只回答"自上次成功部署以来改了什么"(CHANGED_FILES / DIFF_KNOWN /
  ENV_CHANGED / LAST_DEPLOYED_SHA),在任何分支下都先算;
- detect_changed_services:只决定"重建哪一端",显式参数(--all/--backend-only/--web-only/
  --reset-db)只覆盖重建范围,不覆盖改动范围。
- print_plan 改用 LAST_DEPLOYED_SHA,不再重复读状态文件。

补一条顺序守卫用例:compute_changed_files 必须在 detect_changed_services 之前调用。
2026-10-03 16:42:55 +08:00

144 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
// 这些用例只检查"改动 → 要重建哪一端"的判定与部署脚本的关键分支,
// 不真正跑 docker;分类逻辑本身放在 deploy/lib/classify-changes.sh 里,方便直接 exec。
const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const REPO_ROOT = path.resolve(BACKEND_ROOT, '../..')
const CLASSIFIER = path.join(REPO_ROOT, 'deploy/lib/classify-changes.sh')
const DEPLOY_SCRIPT = path.join(REPO_ROOT, 'deploy/ubuntu-deploy.sh')
const hasDeployScripts = fs.existsSync(CLASSIFIER) && fs.existsSync(DEPLOY_SCRIPT)
function classify(files: string[]): string {
return execFileSync('bash', [CLASSIFIER], {
input: files.join('\n'),
encoding: 'utf8',
}).trim()
}
function withTempGitRepo(fn: (repo: string, git: (...args: string[]) => string) => void): void {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'deploy-classify-'))
const git = (...args: string[]) =>
execFileSync('git', ['-C', repo, '-c', 'user.email=t@t', '-c', 'user.name=t', ...args], {
encoding: 'utf8',
})
try {
git('init', '-q')
git('commit', '-q', '--allow-empty', '-m', 'base')
fn(repo, git)
} finally {
fs.rmSync(repo, { recursive: true, force: true })
}
}
test(
'改动分类:只改后端源码只重建 backend,只改前端源码只重建 web',
{ skip: !hasDeployScripts },
() => {
assert.equal(classify(['apps/backend/src/services/worker-platform/x.ts']), 'backend')
assert.equal(
classify(['apps/backend/src/db/migrations/092_x.sql', 'apps/backend/src/index.ts']),
'backend',
)
assert.equal(classify(['apps/frontend/src/pages/admin/x.tsx']), 'web')
},
)
test('改动分类:进得了镜像的部署文件要带上对应一端', { skip: !hasDeployScripts }, () => {
assert.equal(classify(['deploy/docker/backend.Dockerfile']), 'backend')
assert.equal(classify(['deploy/docker/frontend.Dockerfile']), 'web')
// Caddyfile 会被 COPY 进 web 镜像
assert.equal(classify(['deploy/caddy/Caddyfile']), 'web')
})
test('改动分类:进不了镜像的改动不触发重建', { skip: !hasDeployScripts }, () => {
assert.equal(classify([]), '')
assert.equal(classify(['README.md', 'docs/部署启动与数据库重建.md']), '')
assert.equal(classify(['deploy/ubuntu-deploy.sh', 'deploy/lib/classify-changes.sh']), '')
assert.equal(classify(['deploy/docker/backend-dev.Dockerfile']), '')
assert.equal(classify(['scripts/install-git-hooks.sh', '.gitignore']), '')
// 测试文件不进 dist、生产也不跑 npm test,只改测试不该重建镜像
assert.equal(classify(['apps/backend/src/services/x.test.ts']), '')
assert.equal(classify(['apps/frontend/src/pages/x.test.tsx']), '')
})
test('改动分类:拿不准的路径一律两端重建(保守兜底)', { skip: !hasDeployScripts }, () => {
assert.equal(classify(['docker-compose.yml']), 'backend web')
assert.equal(classify(['apps/backend/src/index.ts', 'apps/frontend/src/main.tsx']), 'backend web')
assert.equal(classify(['some/unknown/path.txt']), 'backend web')
})
test('部署脚本:按改动重建,不再无脑两端全量构建', { skip: !hasDeployScripts }, () => {
const source = fs.readFileSync(DEPLOY_SCRIPT, 'utf8')
assert.ok(source.includes('detect_changed_services'), '缺少改动检测')
assert.ok(source.includes('deploy/lib/classify-changes.sh'), '缺少改动分类脚本调用')
assert.ok(source.includes('两端镜像都不需要重建'), '缺少"无需重建"的短路')
assert.ok(source.includes('ensure_service_running'), '缺少未变化一侧的保活逻辑')
// 旧的写死双端构建必须消失,否则按需重建会被绕过
assert.ok(!source.includes('log "构建后端镜像"'), '旧的写死后端构建仍在')
assert.ok(!source.includes('log "构建 Web 镜像"'), '旧的写死 Web 构建仍在')
})
test('改动分类:中文文件名不会被 git 转义成"漏判 → 两端全建"', { skip: !hasDeployScripts }, () => {
// 真实踩过的坑:docs/部署说明.md 被 git 输出成 "docs/\351\203\250...",
// 匹配不到 docs/* 规则,直接掉进兜底分支把 backend + web 都重建了。
withTempGitRepo((repo, git) => {
fs.mkdirSync(path.join(repo, 'docs'), { recursive: true })
fs.writeFileSync(path.join(repo, 'docs', '部署说明.md'), 'v1')
git('add', '-A')
git('commit', '-q', '-m', 'docs: 中文文件名')
const changed = execFileSync('bash', [CLASSIFIER, '--git-diff', 'HEAD~1', 'HEAD', repo], {
encoding: 'utf8',
})
assert.ok(
changed.includes('docs/部署说明.md'),
`git 改动列表应保留中文原文,实际输出:${changed.trim()}`,
)
assert.equal(classify(changed.split('\n').filter(Boolean)), '')
})
})
test('部署脚本:基础镜像只补缺、部署状态在健康检查之后才落盘', { skip: !hasDeployScripts }, () => {
const source = fs.readFileSync(DEPLOY_SCRIPT, 'utf8')
assert.ok(source.includes('--policy missing'), 'compose pull 应使用 --policy missing')
assert.ok(source.includes('write_deploy_state'), '缺少部署状态落盘')
assert.ok(source.includes('print_timing_summary'), '缺少阶段耗时输出')
assert.ok(source.includes('--git-diff'), '改动列表应通过 classify-changes.sh --git-diff 获取')
const healthIndex = source.indexOf('wait_for_health\n')
const stateIndex = source.indexOf('write_deploy_state\n')
assert.ok(healthIndex > 0 && stateIndex > 0, '找不到健康检查/状态落盘调用')
assert.ok(
healthIndex < stateIndex,
'部署状态必须在健康检查通过后才写入,否则失败的部署会被误判为已上线',
)
})
test(
'部署脚本:无 migrations 改动时自动跳过备份,但算不出改动范围时仍然备份',
{
skip: !hasDeployScripts,
},
() => {
const source = fs.readFileSync(DEPLOY_SCRIPT, 'utf8')
assert.ok(source.includes('自动跳过迁移前备份'), '缺少无迁移改动的自动跳过')
assert.ok(source.includes('DIFF_KNOWN'), '缺少"改动范围是否已知"的判定')
assert.ok(source.includes('migrations_changed'), '缺少 migrations 改动判定')
assert.ok(source.includes('--backup 可强制'), '缺少强制备份的提示')
// --backend-only/--all 这类参数只能覆盖"重建哪一端",不能顺手把"改了什么"一起跳过:
// 否则 DIFF_KNOWN 永远是 false,无 schema 变更也会去跑 pg_dump + 传 OSS(生产 ~12 分钟)。
const computeIndex = source.indexOf('compute_changed_files\n')
const detectIndex = source.indexOf('detect_changed_services\n')
assert.ok(computeIndex > 0 && detectIndex > 0, '找不到改动计算/重建判定的调用')
assert.ok(computeIndex < detectIndex, '必须先算改动范围,再决定重建范围')
},
)