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:
@@ -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 可强制'), '缺少强制备份的提示')
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user