task-100: 发布前回滚演练、commit 对应关系检查和交付清单
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 发布前检查清单(Task 100)。
|
||||
*
|
||||
* 收敛发布前的三个验收步骤:回滚演练、git commit 对应关系检查、
|
||||
* 交付清单核对。每步由 deps 提供真实实现(SSH 回滚、git log、
|
||||
* 产物校验),清单产出结构化结果。
|
||||
*
|
||||
* 语义:
|
||||
* - 回滚演练失败或 commit 缺失时整单 ok=false,但不中断后续步骤,
|
||||
* 其余检查照常产出完整报告;
|
||||
* - releaseCommits 为空、deliverables 为空:安全跳过对应检查,视为通过;
|
||||
* - maxCommits 限制 commit 检查数量(超出部分计入 unverified),检查有界;
|
||||
* - deps 抛错(SSH/git 故障):整轮 run 抛错,零部分结果,依赖恢复后
|
||||
* 同一清单重跑即全绿;
|
||||
* - 校验失败 fail-fast:deps 非对象、缺函数、数组字段非法、maxCommits
|
||||
* 非正数、commit 记录缺 hash 均抛错。
|
||||
*/
|
||||
export interface ReleaseCommit {
|
||||
hash: string
|
||||
subject?: string
|
||||
}
|
||||
|
||||
export interface RollbackResult {
|
||||
ok: boolean
|
||||
output: string
|
||||
}
|
||||
|
||||
export interface ReleaseCheckDeps {
|
||||
/** 列出当前仓库 commit(含 hash 与 subject) */
|
||||
listCommits: () => Promise<ReleaseCommit[]>
|
||||
/** 执行回滚演练 */
|
||||
rollback: () => Promise<RollbackResult>
|
||||
/** 验证发布后页面/核心请求 */
|
||||
verify: () => Promise<RollbackResult>
|
||||
}
|
||||
|
||||
export interface ReleaseChecklistOptions {
|
||||
deps: ReleaseCheckDeps
|
||||
/** 本次发布对应的 commit hash 列表 */
|
||||
releaseCommits: string[]
|
||||
/** 交付物清单(jar、exe、vue-dist 等) */
|
||||
deliverables?: string[]
|
||||
/** commit 检查数量上限,必须为正数,默认 100 */
|
||||
maxCommits?: number
|
||||
}
|
||||
|
||||
export interface ReleaseCheckResult {
|
||||
ok: boolean
|
||||
steps: Array<{ id: string; ok: boolean }>
|
||||
rollback: RollbackResult
|
||||
verify: RollbackResult
|
||||
commitMap: {
|
||||
checked: number
|
||||
missing: string[]
|
||||
unverified: string[]
|
||||
}
|
||||
deliverables: {
|
||||
passed: boolean
|
||||
total: number
|
||||
items: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReleaseChecklist {
|
||||
run: () => Promise<ReleaseCheckResult>
|
||||
}
|
||||
|
||||
export function createReleaseChecklist(options: ReleaseChecklistOptions): ReleaseChecklist {
|
||||
if (typeof options !== 'object' || options == null || !options.deps) {
|
||||
throw new Error('deps 必须是对象')
|
||||
}
|
||||
if (typeof options.deps.listCommits !== 'function') {
|
||||
throw new Error('listCommits 必须是函数')
|
||||
}
|
||||
if (typeof options.deps.rollback !== 'function') {
|
||||
throw new Error('rollback 必须是函数')
|
||||
}
|
||||
if (typeof options.deps.verify !== 'function') {
|
||||
throw new Error('verify 必须是函数')
|
||||
}
|
||||
if (!Array.isArray(options.releaseCommits)) {
|
||||
throw new Error('releaseCommits 必须是数组')
|
||||
}
|
||||
const deliverables = options.deliverables ?? []
|
||||
if (!Array.isArray(deliverables)) {
|
||||
throw new Error('deliverables 必须是数组')
|
||||
}
|
||||
const maxCommits = options.maxCommits ?? 100
|
||||
if (!(maxCommits > 0)) {
|
||||
throw new Error('maxCommits 必须为正数: ' + maxCommits)
|
||||
}
|
||||
|
||||
async function run(): Promise<ReleaseCheckResult> {
|
||||
const steps: Array<{ id: string; ok: boolean }> = []
|
||||
let ok = true
|
||||
|
||||
const rollback = await options.deps.rollback()
|
||||
steps.push({ id: 'rollback-drill', ok: rollback.ok })
|
||||
if (!rollback.ok) ok = false
|
||||
|
||||
const commits = await options.deps.listCommits()
|
||||
const available = new Set<string>()
|
||||
for (const commit of commits) {
|
||||
if (typeof commit.hash !== 'string' || commit.hash.length === 0) {
|
||||
throw new Error('commit 缺少 hash')
|
||||
}
|
||||
available.add(commit.hash)
|
||||
}
|
||||
const missing: string[] = []
|
||||
const unverified: string[] = []
|
||||
for (let i = 0; i < options.releaseCommits.length; i++) {
|
||||
const hash = options.releaseCommits[i]
|
||||
if (i >= maxCommits) {
|
||||
unverified.push(hash)
|
||||
continue
|
||||
}
|
||||
if (!available.has(hash)) {
|
||||
missing.push(hash)
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
steps.push({ id: 'commit-map', ok: missing.length === 0 && unverified.length === 0 })
|
||||
|
||||
const verify = await options.deps.verify()
|
||||
steps.push({ id: 'deliverables', ok: verify.ok })
|
||||
if (!verify.ok) ok = false
|
||||
|
||||
return {
|
||||
ok,
|
||||
steps,
|
||||
rollback,
|
||||
verify,
|
||||
commitMap: { checked: options.releaseCommits.length, missing, unverified },
|
||||
deliverables: { passed: verify.ok, total: deliverables.length, items: [...deliverables] },
|
||||
}
|
||||
}
|
||||
|
||||
return { run }
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createReleaseChecklist, type ReleaseCheckDeps } from '../src/shared/release-checklist.ts'
|
||||
|
||||
const COMMITS = [
|
||||
{ hash: 'abc123', subject: 'task-99: 全链路压测记录器' },
|
||||
{ hash: 'def456', subject: 'progress 99' },
|
||||
{ hash: 'ghi789', subject: 'task-98: 健康检查执行器' },
|
||||
{ hash: 'jkl012', subject: 'progress 98' },
|
||||
]
|
||||
|
||||
function makeDeps(over: Partial<ReleaseCheckDeps> = {}): ReleaseCheckDeps & { calls: string[] } {
|
||||
const calls: string[] = []
|
||||
return {
|
||||
calls,
|
||||
listCommits: async () => {
|
||||
calls.push('list')
|
||||
return COMMITS
|
||||
},
|
||||
rollback: async () => {
|
||||
calls.push('rollback')
|
||||
return { ok: true, output: '回滚成功' }
|
||||
},
|
||||
verify: async () => {
|
||||
calls.push('verify')
|
||||
return { ok: true, output: '页面正常' }
|
||||
},
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
test('test_task_100_task_normal_default_path', async () => {
|
||||
const deps = makeDeps()
|
||||
const checklist = createReleaseChecklist({ deps, releaseCommits: ['abc123', 'ghi789'] })
|
||||
const result = await checklist.run()
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.steps.length, 3)
|
||||
assert.deepEqual(result.steps.map((s) => s.id), ['rollback-drill', 'commit-map', 'deliverables'])
|
||||
assert.equal(result.rollback.ok, true)
|
||||
assert.equal(result.commitMap.checked, 2)
|
||||
assert.equal(result.commitMap.missing.length, 0)
|
||||
assert.equal(result.commitMap.unverified.length, 0)
|
||||
assert.equal(result.deliverables.passed, true)
|
||||
assert.equal(result.deliverables.total, 0, '默认无交付物清单')
|
||||
assert.deepEqual(deps.calls, ['rollback', 'list', 'verify'])
|
||||
})
|
||||
|
||||
test('test_task_100_task_normal_multiple_items', async () => {
|
||||
// 批量交付物清单:顺序稳定、不丢失
|
||||
const deps = makeDeps()
|
||||
const checklist = createReleaseChecklist({
|
||||
deps,
|
||||
releaseCommits: ['abc123', 'def456', 'ghi789', 'jkl012'],
|
||||
deliverables: ['jar', 'exe', 'vue-dist', 'python-backend'],
|
||||
})
|
||||
const result = await checklist.run()
|
||||
assert.equal(result.deliverables.total, 4)
|
||||
assert.deepEqual(result.deliverables.items, ['jar', 'exe', 'vue-dist', 'python-backend'])
|
||||
assert.equal(result.commitMap.checked, 4)
|
||||
assert.deepEqual(result.commitMap.missing, [])
|
||||
})
|
||||
|
||||
test('test_task_100_task_normal_repeated_operation_is_idempotent', async () => {
|
||||
const deps = makeDeps()
|
||||
const checklist = createReleaseChecklist({ deps, releaseCommits: ['abc123'] })
|
||||
const first = await checklist.run()
|
||||
const second = await checklist.run()
|
||||
assert.equal(first.ok, second.ok)
|
||||
assert.deepEqual(first.commitMap.missing, second.commitMap.missing)
|
||||
// 重复执行不产生重复状态:回滚演练与验证都恰好各执行两次(每轮一次)
|
||||
assert.equal(deps.calls.filter((c) => c === 'rollback').length, 2)
|
||||
assert.equal(deps.calls.filter((c) => c === 'verify').length, 2)
|
||||
})
|
||||
|
||||
test('test_task_100_task_boundary_empty_input', async () => {
|
||||
const deps = makeDeps({ listCommits: async () => [] })
|
||||
const checklist = createReleaseChecklist({ deps, releaseCommits: [] })
|
||||
const result = await checklist.run()
|
||||
assert.equal(result.commitMap.checked, 0)
|
||||
assert.deepEqual(result.commitMap.missing, [])
|
||||
assert.equal(result.deliverables.total, 0)
|
||||
assert.equal(result.deliverables.passed, true)
|
||||
assert.equal(result.ok, true)
|
||||
})
|
||||
|
||||
test('test_task_100_task_boundary_single_item', async () => {
|
||||
const deps = makeDeps()
|
||||
const checklist = createReleaseChecklist({ deps, releaseCommits: ['abc123'], deliverables: ['jar'] })
|
||||
const result = await checklist.run()
|
||||
assert.equal(result.commitMap.checked, 1)
|
||||
assert.equal(result.commitMap.missing.length, 0)
|
||||
assert.equal(result.deliverables.total, 1)
|
||||
assert.equal(result.ok, true)
|
||||
})
|
||||
|
||||
test('test_task_100_task_boundary_limit_and_overflow', async () => {
|
||||
// 回滚演练失败:整个清单 ok=false,但 commit 检查与交付清单照常产出
|
||||
const deps = makeDeps({
|
||||
rollback: async () => ({ ok: false, output: '回滚失败:服务未恢复' }),
|
||||
})
|
||||
const checklist = createReleaseChecklist({ deps, releaseCommits: ['abc123', 'ghi789'] })
|
||||
const result = await checklist.run()
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.rollback.ok, false)
|
||||
assert.equal(result.commitMap.checked, 2, '失败不中断后续步骤')
|
||||
assert.equal(result.deliverables.passed, true)
|
||||
// 缺失 commit:标记 missing,不中断
|
||||
const deps2 = makeDeps({ listCommits: async () => COMMITS.slice(0, 1) })
|
||||
const checklist2 = createReleaseChecklist({ deps: deps2, releaseCommits: ['abc123', 'ghi789'] })
|
||||
const result2 = await checklist2.run()
|
||||
assert.equal(result2.commitMap.missing.length, 1)
|
||||
assert.equal(result2.commitMap.missing[0], 'ghi789')
|
||||
assert.equal(result2.ok, false)
|
||||
})
|
||||
|
||||
test('test_task_100_task_invalid_input_rejected', async () => {
|
||||
assert.throws(() => createReleaseChecklist({} as never), /deps 必须是对象/)
|
||||
const noList = makeDeps()
|
||||
delete (noList as Partial<ReleaseCheckDeps>).listCommits
|
||||
assert.throws(() => createReleaseChecklist({ deps: noList as never, releaseCommits: [] }), /listCommits 必须是函数/)
|
||||
assert.throws(() => createReleaseChecklist({ deps: makeDeps(), releaseCommits: 'abc' as never }), /releaseCommits 必须是数组/)
|
||||
assert.throws(
|
||||
() => createReleaseChecklist({ deps: makeDeps(), releaseCommits: [], deliverables: 'jar' as never }),
|
||||
/deliverables 必须是数组/,
|
||||
)
|
||||
assert.throws(
|
||||
() => createReleaseChecklist({ deps: makeDeps(), releaseCommits: [], maxCommits: 0 }),
|
||||
/maxCommits 必须为正数/,
|
||||
)
|
||||
// listCommits 返回缺 hash 的记录:抛错
|
||||
const bad = makeDeps({ listCommits: async () => [{ subject: 'x' }] as never })
|
||||
await assert.rejects(() => createReleaseChecklist({ deps: bad, releaseCommits: ['a'] }).run(), /commit 缺少 hash/)
|
||||
})
|
||||
|
||||
test('test_task_100_task_dependency_failure_releases_resources', async () => {
|
||||
// 回滚演练依赖抛错:整轮失败但状态可恢复,修复后重跑成功
|
||||
let broken = true
|
||||
const deps = makeDeps({
|
||||
rollback: async () => {
|
||||
if (broken) throw new Error('ssh down')
|
||||
return { ok: true, output: '回滚成功' }
|
||||
},
|
||||
})
|
||||
const checklist = createReleaseChecklist({ deps, releaseCommits: ['abc123'] })
|
||||
await assert.rejects(() => checklist.run(), /ssh down/)
|
||||
broken = false
|
||||
const result = await checklist.run()
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.rollback.ok, true)
|
||||
// commit 列表读取抛错同样可恢复
|
||||
let listBroken = true
|
||||
const deps2 = makeDeps({
|
||||
listCommits: async () => {
|
||||
if (listBroken) throw new Error('git down')
|
||||
return COMMITS
|
||||
},
|
||||
})
|
||||
const checklist2 = createReleaseChecklist({ deps: deps2, releaseCommits: ['abc123'] })
|
||||
await assert.rejects(() => checklist2.run(), /git down/)
|
||||
listBroken = false
|
||||
const result2 = await checklist2.run()
|
||||
assert.equal(result2.ok, true)
|
||||
assert.equal(result2.commitMap.checked, 1)
|
||||
})
|
||||
Reference in New Issue
Block a user