fix(#91): reject stale fidelity approvals

This commit is contained in:
2026-07-29 23:21:39 -04:00
parent 48030a6d7c
commit deda3bc4ad
3 changed files with 44 additions and 10 deletions

View File

@@ -88,7 +88,7 @@ Do not create the file or use `approved` until the owner has reviewed the packet
npm run release:fidelity:verify npm run release:fidelity:verify
``` ```
The verifier checks that the packet exists, the sign-off exists in `HEAD`, and its full candidate SHA names a commit that is an ancestor of the sign-off commit. It also requires the owner, date, and approved decision fields. It intentionally cannot assess visual fidelity or create approval. The verifier requires the sign-off commit to be `HEAD` and to change only `docs/release/owner-reviews/<version>.md`. Its full candidate SHA must equal `HEAD^`; any code change after approval requires a new owner sign-off. It also requires the owner, date, and approved decision fields. It intentionally cannot assess visual fidelity or create approval.
For a release candidate, `npm run release:fidelity:gate` runs production visual regression first, then capture, packet generation, and owner-evidence verification in that order. It will remain red until the owner has committed the sign-off. For a release candidate, `npm run release:fidelity:gate` runs production visual regression first, then capture, packet generation, and owner-evidence verification in that order. It will remain red until the owner has committed the sign-off.

View File

@@ -24,11 +24,13 @@ try {
const candidate = text.match(/^- Candidate commit: ([0-9a-f]{40})$/mi)?.[1] const candidate = text.match(/^- Candidate commit: ([0-9a-f]{40})$/mi)?.[1]
if (!candidate) throw new Error(`${signoff} must contain a full candidate commit SHA`) if (!candidate) throw new Error(`${signoff} must contain a full candidate commit SHA`)
git('cat-file', '-e', `${candidate}^{commit}`) git('cat-file', '-e', `${candidate}^{commit}`)
const signoffCommit = git('log', '-1', '--format=%H', 'HEAD', '--', signoff) const parent = git('rev-parse', 'HEAD^')
try { const changed = git('diff', '--name-only', 'HEAD^', 'HEAD').split('\n').filter(Boolean)
git('merge-base', '--is-ancestor', candidate, signoffCommit) if (changed.length !== 1 || changed[0] !== signoff) {
} catch { throw new Error(`HEAD must contain only the sign-off file change: ${signoff}`)
throw new Error(`${signoff} must be committed after candidate ${candidate}`) }
if (candidate.toLowerCase() !== parent.toLowerCase()) {
throw new Error(`${signoff} candidate must equal HEAD^ (${parent})`)
} }
const fields = [ const fields = [
['Version', version], ['Version', version],

View File

@@ -11,7 +11,14 @@ function git(directory, ...args) {
return execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim() return execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim()
} }
test('accepts a committed sign-off for an ancestor candidate', async () => { function verify(root, evidence) {
return execFileSync(process.execPath, [verifier, '--version', 'v-test', '--evidence', evidence], {
cwd: root,
encoding: 'utf8',
})
}
async function approvedCandidate({ changeCodeWithSignoff = false } = {}) {
const root = await mkdtemp(join(tmpdir(), 'boxbox-fidelity-verify-')) const root = await mkdtemp(join(tmpdir(), 'boxbox-fidelity-verify-'))
git(root, 'init') git(root, 'init')
git(root, 'config', 'user.email', 'test@example.com') git(root, 'config', 'user.email', 'test@example.com')
@@ -23,16 +30,41 @@ test('accepts a committed sign-off for an ancestor candidate', async () => {
await mkdir(join(root, 'docs/release/owner-reviews'), { recursive: true }) await mkdir(join(root, 'docs/release/owner-reviews'), { recursive: true })
await writeFile(join(root, 'docs/release/owner-reviews/v-test.md'), `- Version: v-test\n- Candidate commit: ${candidate}\n- Reviewed by: Owner\n- Reviewed on: 2026-07-30\n- Decision: approved\n`) await writeFile(join(root, 'docs/release/owner-reviews/v-test.md'), `- Version: v-test\n- Candidate commit: ${candidate}\n- Reviewed by: Owner\n- Reviewed on: 2026-07-30\n- Decision: approved\n`)
if (changeCodeWithSignoff) await writeFile(join(root, 'candidate.txt'), 'changed with approval')
git(root, 'add', 'docs/release/owner-reviews/v-test.md') git(root, 'add', 'docs/release/owner-reviews/v-test.md')
if (changeCodeWithSignoff) git(root, 'add', 'candidate.txt')
git(root, 'commit', '-m', 'owner sign-off') git(root, 'commit', '-m', 'owner sign-off')
const evidence = join(root, 'evidence') const evidence = join(root, 'evidence')
await mkdir(evidence) await mkdir(evidence)
await writeFile(join(evidence, 'index.html'), '') await writeFile(join(evidence, 'index.html'), '')
await writeFile(join(evidence, 'summary.md'), '') await writeFile(join(evidence, 'summary.md'), '')
const output = execFileSync(process.execPath, [verifier, '--version', 'v-test', '--evidence', evidence], { return { candidate, evidence, root }
cwd: root, }
encoding: 'utf8',
function assertBlocked(root, evidence) {
assert.throws(() => verify(root, evidence), (error) => {
assert.match(String(error.stderr), /HEAD must contain only the sign-off file change/)
return true
}) })
}
test('accepts a sign-off-only HEAD for its direct parent candidate', async () => {
const { candidate, evidence, root } = await approvedCandidate()
const output = verify(root, evidence)
assert.match(output, new RegExp(candidate)) assert.match(output, new RegExp(candidate))
}) })
test('rejects code committed after approval', async () => {
const { evidence, root } = await approvedCandidate()
await writeFile(join(root, 'candidate.txt'), 'changed after review')
git(root, 'add', 'candidate.txt')
git(root, 'commit', '-m', 'code after approval')
assertBlocked(root, evidence)
})
test('rejects a sign-off commit that also changes code', async () => {
const { evidence, root } = await approvedCandidate({ changeCodeWithSignoff: true })
assertBlocked(root, evidence)
})