From deda3bc4ad615c5e8dd1a0f5522e2e417b6b1d26 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Wed, 29 Jul 2026 23:21:39 -0400 Subject: [PATCH] fix(#91): reject stale fidelity approvals --- docs/testing.md | 2 +- scripts/release-fidelity/verify.mjs | 12 ++++--- scripts/release-fidelity/verify.test.mjs | 40 +++++++++++++++++++++--- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 626514e..cbdac6e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -88,7 +88,7 @@ Do not create the file or use `approved` until the owner has reviewed the packet 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/.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. diff --git a/scripts/release-fidelity/verify.mjs b/scripts/release-fidelity/verify.mjs index 2ce3953..5dd8f4f 100644 --- a/scripts/release-fidelity/verify.mjs +++ b/scripts/release-fidelity/verify.mjs @@ -24,11 +24,13 @@ try { 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`) git('cat-file', '-e', `${candidate}^{commit}`) - const signoffCommit = git('log', '-1', '--format=%H', 'HEAD', '--', signoff) - try { - git('merge-base', '--is-ancestor', candidate, signoffCommit) - } catch { - throw new Error(`${signoff} must be committed after candidate ${candidate}`) + const parent = git('rev-parse', 'HEAD^') + const changed = git('diff', '--name-only', 'HEAD^', 'HEAD').split('\n').filter(Boolean) + if (changed.length !== 1 || changed[0] !== signoff) { + throw new Error(`HEAD must contain only the sign-off file change: ${signoff}`) + } + if (candidate.toLowerCase() !== parent.toLowerCase()) { + throw new Error(`${signoff} candidate must equal HEAD^ (${parent})`) } const fields = [ ['Version', version], diff --git a/scripts/release-fidelity/verify.test.mjs b/scripts/release-fidelity/verify.test.mjs index 0aec6f3..8fb1511 100644 --- a/scripts/release-fidelity/verify.test.mjs +++ b/scripts/release-fidelity/verify.test.mjs @@ -11,7 +11,14 @@ function git(directory, ...args) { 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-')) git(root, 'init') 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 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') + if (changeCodeWithSignoff) git(root, 'add', 'candidate.txt') git(root, 'commit', '-m', 'owner sign-off') const evidence = join(root, 'evidence') await mkdir(evidence) await writeFile(join(evidence, 'index.html'), '') await writeFile(join(evidence, 'summary.md'), '') - const output = execFileSync(process.execPath, [verifier, '--version', 'v-test', '--evidence', evidence], { - cwd: root, - encoding: 'utf8', + return { candidate, evidence, root } +} + +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)) }) + +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) +})