mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
feat(#91): add release fidelity evidence gate
This commit is contained in:
86
scripts/release-fidelity/generate.mjs
Normal file
86
scripts/release-fidelity/generate.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
import { readdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
|
||||
const references = [
|
||||
{ viewport: 'desktop', name: 'weekend-between-races' },
|
||||
{ viewport: 'desktop', name: 'weekend-live' },
|
||||
{ viewport: 'mobile', name: 'weekend-between-sessions-mobile' },
|
||||
]
|
||||
|
||||
function option(name, fallback) {
|
||||
const index = process.argv.indexOf(`--${name}`)
|
||||
return index === -1 ? fallback : process.argv[index + 1]
|
||||
}
|
||||
|
||||
async function filesBelow(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
const files = await Promise.all(entries.map(async (entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
return entry.isDirectory() ? filesBelow(path) : [path]
|
||||
}))
|
||||
return files.flat()
|
||||
}
|
||||
|
||||
async function locateMockup(root, name) {
|
||||
const preferred = join(root, `${name}.png`)
|
||||
try {
|
||||
await stat(preferred)
|
||||
return preferred
|
||||
} catch {
|
||||
const matches = (await filesBelow(root)).filter((path) => path.endsWith(`${name}.png`))
|
||||
return matches.length === 1 ? matches[0] : null
|
||||
}
|
||||
}
|
||||
|
||||
function imageData(path) {
|
||||
return readFile(path).then((data) => `data:image/png;base64,${data.toString('base64')}`)
|
||||
}
|
||||
|
||||
export async function generatePacket({ version, evidence, mockups }) {
|
||||
const candidateRoot = join(evidence, 'candidate')
|
||||
const pairs = []
|
||||
const missing = []
|
||||
|
||||
try {
|
||||
await stat(mockups)
|
||||
} catch {
|
||||
throw new Error(`Approved mockups directory is missing: ${mockups}. It is supplied by the product reference integration.`)
|
||||
}
|
||||
|
||||
for (const { viewport, name } of references) {
|
||||
const candidate = join(candidateRoot, viewport, `${name}.png`)
|
||||
const reference = await locateMockup(mockups, name)
|
||||
try {
|
||||
await stat(candidate)
|
||||
} catch {
|
||||
missing.push(`candidate: ${candidate}`)
|
||||
}
|
||||
if (!reference) missing.push(`mockup: ${join(mockups, `${name}.png`)}`)
|
||||
if (reference) pairs.push({ viewport, name, candidate, reference })
|
||||
}
|
||||
|
||||
if (missing.length) {
|
||||
throw new Error(`Release-fidelity packet is incomplete:\n${missing.join('\n')}`)
|
||||
}
|
||||
|
||||
const cards = await Promise.all(pairs.map(async ({ viewport, name, candidate, reference }) => `
|
||||
<section><h2>${viewport}: ${name}</h2><div class="pair">
|
||||
<figure><figcaption>Approved mockup</figcaption><img src="${await imageData(reference)}"></figure>
|
||||
<figure><figcaption>Candidate</figcaption><img src="${await imageData(candidate)}"></figure>
|
||||
</div><p>Reference: <code>${relative(process.cwd(), reference)}</code></p></section>`))
|
||||
const summary = `# Release Fidelity Evidence: ${version}\n\n- Candidate screenshots: candidate/\n- Approved mockups: ${relative(process.cwd(), mockups)}\n- Review: human owner decision required; this packet does not approve the release.\n\nOpen index.html for side-by-side evidence.\n`
|
||||
const html = `<!doctype html><title>${version} release-fidelity review</title><style>body{background:#111;color:#eee;font:16px system-ui;margin:2rem}section{margin:3rem 0}.pair{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}figure{margin:0}img{max-width:100%;border:1px solid #555}figcaption{font-weight:700;margin-bottom:.5rem}code{color:#9fd}@media(max-width:700px){.pair{grid-template-columns:1fr}}</style><h1>${version} Release-Fidelity Review</h1><p>Visual regression is a separate automated gate. This packet is evidence for a subjective owner review and is not approval.</p>${cards.join('')}</html>`
|
||||
await writeFile(join(evidence, 'summary.md'), summary)
|
||||
await writeFile(join(evidence, 'index.html'), html)
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const version = option('version', process.env.RELEASE_FIDELITY_VERSION ?? 'v0.4.0')
|
||||
const output = process.env.RELEASE_FIDELITY_OUTPUT ?? 'release-fidelity'
|
||||
const evidence = resolve(option('evidence', join(output, version)))
|
||||
const mockups = resolve(option('mockups', join('docs/product', version, 'mockups')))
|
||||
generatePacket({ version, evidence, mockups }).then(
|
||||
() => console.log(`Release-fidelity packet: ${join(evidence, 'index.html')}`),
|
||||
(error) => { console.error(error.message); process.exitCode = 1 },
|
||||
)
|
||||
}
|
||||
27
scripts/release-fidelity/generate.test.mjs
Normal file
27
scripts/release-fidelity/generate.test.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { generatePacket } from './generate.mjs'
|
||||
|
||||
test('generates a self-contained side-by-side packet for desktop and mobile', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'boxbox-fidelity-'))
|
||||
const evidence = join(root, 'evidence')
|
||||
const mockups = join(root, 'mockups')
|
||||
const references = [
|
||||
['desktop', 'weekend-between-races'],
|
||||
['desktop', 'weekend-live'],
|
||||
['mobile', 'weekend-between-sessions-mobile'],
|
||||
]
|
||||
for (const [viewport, name] of references) {
|
||||
await mkdir(join(evidence, 'candidate', viewport), { recursive: true })
|
||||
await mkdir(mockups, { recursive: true })
|
||||
await writeFile(join(evidence, 'candidate', viewport, `${name}.png`), 'candidate')
|
||||
await writeFile(join(mockups, `${name}.png`), 'mockup')
|
||||
}
|
||||
|
||||
await generatePacket({ version: 'v-test', evidence, mockups })
|
||||
assert.match(await readFile(join(evidence, 'index.html'), 'utf8'), /Approved mockup/)
|
||||
assert.match(await readFile(join(evidence, 'summary.md'), 'utf8'), /human owner decision required/)
|
||||
})
|
||||
41
scripts/release-fidelity/verify.mjs
Normal file
41
scripts/release-fidelity/verify.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
function option(name, fallback) {
|
||||
const index = process.argv.indexOf(`--${name}`)
|
||||
return index === -1 ? fallback : process.argv[index + 1]
|
||||
}
|
||||
|
||||
function git(...args) {
|
||||
return execFileSync('git', args, { encoding: 'utf8' }).trim()
|
||||
}
|
||||
|
||||
const version = option('version', process.env.RELEASE_FIDELITY_VERSION ?? 'v0.4.0')
|
||||
const output = process.env.RELEASE_FIDELITY_OUTPUT ?? 'release-fidelity'
|
||||
const evidence = resolve(option('evidence', join(output, version)))
|
||||
const signoff = option('signoff', join('docs/release/owner-reviews', `${version}.md`))
|
||||
const required = ['index.html', 'summary.md']
|
||||
|
||||
try {
|
||||
for (const file of required) await readFile(join(evidence, file))
|
||||
git('cat-file', '-e', `HEAD:${signoff}`)
|
||||
const text = await readFile(signoff, 'utf8')
|
||||
const commit = git('rev-parse', 'HEAD')
|
||||
const fields = [
|
||||
['Version', version],
|
||||
['Candidate commit', commit],
|
||||
['Reviewed by', '.+'],
|
||||
['Reviewed on', '\\d{4}-\\d{2}-\\d{2}'],
|
||||
['Decision', 'approved'],
|
||||
]
|
||||
for (const [name, value] of fields) {
|
||||
if (!new RegExp(`^- ${name}: ${value}$`, 'm').test(text)) {
|
||||
throw new Error(`${signoff} must contain "- ${name}: ${value}"`)
|
||||
}
|
||||
}
|
||||
console.log(`Release-fidelity evidence and committed owner approval verified for ${version}.`)
|
||||
} catch (error) {
|
||||
console.error(`Release-fidelity gate blocked: ${error.message}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
Reference in New Issue
Block a user