feat(deploy): 部署改成按需重建,无 schema 变更自动跳过备份

每次部署都无条件重建 backend + web 两个镜像(实测后端 3m35s、前端 2m54s),
并且每次都在迁移前 pg_dump + 传 OSS(生产 492MB dump 那一步约 12 分钟);
只改了后端也要把前端整条链重跑一遍。

改动:
- 新增 deploy/lib/classify-changes.sh:把改动文件映射成"要重建哪一端",
  规则保守——apps/backend/** → backend、apps/frontend/**|deploy/caddy/**|前端 Dockerfile → web、
  文档/部署脚本/测试文件 → 不重建,compose 文件与未知路径 → 两端都重建。
- ubuntu-deploy.sh 用 .deploy-state(上次成功部署的 commit + .env 指纹)算 git diff,
  未变化的一端不 build、也不 --force-recreate,容器保持运行;若它没在跑则用现有镜像拉起。
  两端都要建时合并为一次 compose build(bake 并行)。
- 备份:改动不含 apps/backend/src/db/migrations/** 时自动跳过(--backup 强制、
  --skip-backup 无条件跳过);算不出改动范围(首次使用、force push)时不自动跳过。
- compose pull 改 --policy missing:不再每次重新校验 postgres tag,避免顺手静默升级数据库镜像。
- 新增 --dry-run(打印计划后退出,含基础镜像 digest)、--all、--backend-only/--web-only;
  部署结束打印各阶段耗时,回答"这次怎么比上次慢"不用靠推算。
- .deploy-state 进 .gitignore;README 与 docs/部署启动与数据库重建.md 同步。

验证:新增 7 个用例直接执行分类脚本断言"改动→重建范围",并守住"状态只在健康检查后才落盘"
等关键顺序;全量 595 个单测、typecheck、lint、check:sql 全通过。
This commit is contained in:
yml2213
2026-10-03 16:29:05 +08:00
parent 30ae35d3a5
commit 0b48313617
6 changed files with 555 additions and 20 deletions
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
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()
}
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('部署脚本:基础镜像只补缺、部署状态在健康检查之后才落盘', { 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'), '缺少阶段耗时输出')
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 可强制'), '缺少强制备份的提示')
},
)