真机验证时发现:显式指定 --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 之前调用。
144 lines
6.9 KiB
TypeScript
144 lines
6.9 KiB
TypeScript
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, '必须先算改动范围,再决定重建范围')
|
||
},
|
||
)
|