diff --git a/scripts/ci/verify-artifact-manifest.js b/scripts/ci/verify-artifact-manifest.js new file mode 100644 index 00000000..9a84653c --- /dev/null +++ b/scripts/ci/verify-artifact-manifest.js @@ -0,0 +1,115 @@ +#!/usr/bin/env node +// Builds or compares a SHA-256 manifest of every file under the packaged artifact root. With --write the current state becomes the manifest; without it, drift from the committed manifest fails the build. Catches non-deterministic build steps (zip timestamps, random ids in bundles) that survive even fully-pinned dependencies. + +'use strict'; +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const h = require('./lib/app-harness'); + +function parseArgs(argv) { + const out = { app: null, write: false, manifest: null, only: null }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--app') out.app = argv[++i]; + else if (argv[i] === '--write') out.write = true; + else if (argv[i] === '--manifest') out.manifest = argv[++i]; + else if (argv[i] === '--only') out.only = argv[++i]; + } + return out; +} + +// Files that legitimately differ run-to-run (timestamped logs, OS metadata). +// Hashing them would force a manifest churn every build for no signal. +const SKIP = [ + /\.DS_Store$/, + /[\\/]Thumbs\.db$/, + /[\\/]desktop\.ini$/, + /\.log$/, +]; + +function walk(dir, root, out = []) { + let entries = []; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) walk(full, root, out); + else if (e.isFile()) { + const rel = path.relative(root, full).replace(/\\/g, '/'); + if (SKIP.some((rx) => rx.test(rel))) continue; + out.push({ rel, full }); + } + } + return out; +} + +function sha256(file) { + const hash = crypto.createHash('sha256'); + const buf = fs.readFileSync(file); + hash.update(buf); + return { sha: hash.digest('hex'), size: buf.length }; +} + +function defaultManifestPath() { + // The manifest lives in-repo under scripts/ci/ so CI compares to the + // committed snapshot, and we ship one per platform because Mac/Win/Linux + // produce different file sets. + return path.join(h.REPO_ROOT, 'scripts', 'ci', `dist-manifest.${process.platform}.json`); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const appExe = h.packagedAppPath(args.app); + const root = process.platform === 'darwin' + ? path.dirname(path.dirname(path.dirname(appExe))) + : path.dirname(appExe); + const manifestPath = args.manifest || defaultManifestPath(); + + process.stdout.write(`Artifact root: ${root}\n`); + process.stdout.write(`Manifest: ${manifestPath}\n`); + + const files = walk(root, root); + const filtered = args.only ? files.filter((f) => new RegExp(args.only).test(f.rel)) : files; + process.stdout.write(`Hashing ${filtered.length} file(s)...\n`); + + const current = {}; + for (const f of filtered) { + const { sha, size } = sha256(f.full); + current[f.rel] = { sha, size }; + } + + if (args.write) { + fs.writeFileSync(manifestPath, JSON.stringify(current, null, 2)); + process.stdout.write(`\nMANIFEST WRITE: ${Object.keys(current).length} entries -> ${manifestPath}\n`); + process.stdout.write('Commit this file so the next build can compare against it.\n'); + process.exit(0); + } + + let committed; + try { committed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } + catch { process.stderr.write(`\nMANIFEST FAIL: no committed manifest at ${manifestPath}. Run once with --write to seed it.\n`); process.exit(1); } + + const added = []; + const removed = []; + const changed = []; + for (const k of Object.keys(current)) { + if (!committed[k]) added.push(k); + else if (committed[k].sha !== current[k].sha) changed.push({ k, was: committed[k], now: current[k] }); + } + for (const k of Object.keys(committed)) { + if (!current[k]) removed.push(k); + } + + if (added.length === 0 && removed.length === 0 && changed.length === 0) { + process.stdout.write('\nMANIFEST PASS: artifact is byte-identical to the committed manifest.\n'); + process.exit(0); + } + + process.stderr.write(`\nMANIFEST FAIL: artifact diverges from committed manifest.\n`); + if (added.length) { process.stderr.write(` added (${added.length}):\n`); for (const a of added.slice(0, 20)) process.stderr.write(` + ${a}\n`); if (added.length > 20) process.stderr.write(` ...+${added.length - 20} more\n`); } + if (removed.length) { process.stderr.write(` removed (${removed.length}):\n`); for (const r of removed.slice(0, 20)) process.stderr.write(` - ${r}\n`); if (removed.length > 20) process.stderr.write(` ...-${removed.length - 20} more\n`); } + if (changed.length) { process.stderr.write(` changed (${changed.length}):\n`); for (const c of changed.slice(0, 20)) process.stderr.write(` ~ ${c.k}\n was ${c.was.sha.slice(0, 12)}.. (${c.was.size}b), now ${c.now.sha.slice(0, 12)}.. (${c.now.size}b)\n`); if (changed.length > 20) process.stderr.write(` ...~${changed.length - 20} more\n`); } + process.stderr.write('\nIf the divergence is intentional, re-run with --write and commit the new manifest.\n'); + process.exit(1); +} + +main();