#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const cp = require('child_process'); const repoRoot = process.cwd(); const pkgPath = path.join(repoRoot, 'package.json'); const publicDir = path.join(repoRoot, 'public'); if (!fs.existsSync(pkgPath) || !fs.existsSync(publicDir)) process.exit(0); const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); const version = pkg.version; const failures = []; function git(args) { return cp.execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8' }).trim(); } function walkHtmlFiles(dir) { const out = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) out.push(...walkHtmlFiles(full)); else if (entry.isFile() && entry.name.endsWith('.html')) out.push(full); } return out; } function isLocalAsset(rawUrl) { return !/^(?:[a-z]+:|\/\/|#)/i.test(rawUrl); } for (const file of walkHtmlFiles(publicDir)) { const rel = path.relative(repoRoot, file); const content = fs.readFileSync(file, 'utf8'); for (const match of content.matchAll(/]*href=["']([^"']+\.css(?:\?[^"']*)?)["']/gi)) { const url = match[1]; if (isLocalAsset(url) && !new URLSearchParams((url.split('?')[1] || '')).get('v')?.includes(version)) { failures.push(rel + ': stylesheet version does not match package.json'); } } for (const match of content.matchAll(/]*type=["']application\/json["'])[^>]*\bsrc=["']([^"']+\.js(?:\?[^"']*)?)["']/gi)) { const url = match[1]; if (isLocalAsset(url) && !new URLSearchParams((url.split('?')[1] || '')).get('v')?.includes(version)) { failures.push(rel + ': script version does not match package.json'); } } } const staged = git(['diff', '--cached', '--name-only', '--diff-filter=ACMR']).split(/\n+/).filter(Boolean); const codeTouched = staged.some(file => /^(public\/.*\.(html|css|js)|server\.js|package\.json|package-lock\.json)$/.test(file)); const projectMapTouched = staged.includes('PROJECT_MAP.md'); const changelogTouched = staged.includes('CHANGELOG.md'); if (codeTouched && !changelogTouched) failures.push('CHANGELOG.md must be staged when app code, assets, or version files change.'); if (codeTouched && fs.existsSync(path.join(repoRoot, 'PROJECT_MAP.md')) && !projectMapTouched) { failures.push('PROJECT_MAP.md must be staged when app code or frontend state/routes change.'); } const stagedPackageDiff = staged.includes('package.json') ? git(['diff', '--cached', '--', 'package.json']) : ''; if (stagedPackageDiff.includes('"version"') && fs.existsSync(path.join(repoRoot, 'CHANGELOG.md'))) { const changelog = fs.readFileSync(path.join(repoRoot, 'CHANGELOG.md'), 'utf8'); if (!changelog.includes('## [' + version + ']') && !changelog.includes('## [Unreleased]')) { failures.push('CHANGELOG.md must mention the current package.json version or contain an [Unreleased] section.'); } } if (failures.length) { console.error('Release verification failed:'); for (const failure of failures) console.error('- ' + failure); process.exit(1); }