/** * 构建产物的**自证**:`dist/BUILD_INFO.json`。 * * # 为什么不是时间戳 * * 原来那条判据是"`dist` 比 `src` 新" —— pi 2026-09-14 指出它有两层代理: * ① 看不见"构建是否成功"(实测:构建失败但碰过 dist 时它照样绿,见 CRITERIA.md §6.7.1); * ② **`dist` 比 `src` 新也不等于 `dist` 是从这份 `src` 构建的**(`checkout`/`cp`/时钟都骗得过 mtime)。 * 换成内容指纹之后,②也消掉了:判据说的是"产物记的源状态 == 当前源状态", * 而不是"谁的时间更晚"。任何"包不是从这份源码来的"都变成一条可读出的不等。 * * # 这个文件同时是"套件自报来源"的构建侧同款 * * pi 的建议:**一条红/一个包都应自带"它对应哪个源码状态"**(`git status --porcelain` 那一族)。 * 消费方: * - `test/build-stamp.test.mjs` 精确比对(不是比新旧); * - `scripts/release-linux.sh` 打完包把同一份信息打进日志(出问题时先看它)。 * * 指纹范围 = **会影响产物的一切**(`src/` + 入口 html + 构建配置 + 生成脚本), * 排除生成物 `background-takeover.generated.css`(它由 `gen:bg` 现生成, * 内容已由生成脚本决定 —— 计入会让"先 build 后 gen"这种无害顺序变化产生假红)。 */ import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; import { dirname, join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; export const ELECTRON_DIR = join(dirname(fileURLToPath(import.meta.url)), '..'); /** 参与指纹的文件/目录(相对 client/electron) */ const TRACKED = [ 'src', 'index.html', 'vite.config.ts', 'tsconfig.json', 'package.json', 'scripts/gen-background-takeover.mjs' ]; /** 生成物:由上面某个文件决定内容,计入会产生假红 */ const EXCLUDE = new Set(['src/background-takeover.generated.css']); function walk(abs, rel, out) { const st = statSync(abs); if (st.isDirectory()) { for (const name of readdirSync(abs).sort()) { walk(join(abs, name), `${rel}/${name}`, out); } return; } const key = rel.split(sep).join('/'); if (EXCLUDE.has(key)) return; out.push(key); } /** * 当前源码状态。 * * 返回 `{ gitRev, gitDirty, srcHash }`: * - `gitRev`:HEAD 短哈希(读不到 git 时给 `''`,此时只看指纹); * - `gitDirty`:工作树是否有未提交改动(**只作信息**:共享工作区常年是脏的, * 判据不拿它当红/绿依据,拿它当"复核时先看这一行"); * - `srcHash`:影响产物的那批文件的**内容**指纹。 */ export function srcState(root = ELECTRON_DIR) { const files = []; for (const t of TRACKED) { const abs = join(root, t); try { walk(abs, t, files); } catch { /* 缺文件就不计(例如还没有 dist 时) */ } } files.sort(); const h = createHash('sha256'); for (const f of files) { h.update(f).update('\0').update(readFileSync(join(root, f))).update('\0'); } let gitRev = ''; let gitDirty = false; const repoRoot = join(root, '..', '..'); try { gitRev = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim(); gitDirty = execFileSync('git', ['status', '--porcelain'], { cwd: repoRoot, encoding: 'utf8' }).trim().length > 0; } catch { /* 不在 git 里(例如解包后的目录):指纹仍然有效 */ } return { gitRev, gitDirty, srcHash: h.digest('hex'), files: files.length }; } /** 写 `dist/BUILD_INFO.json`(构建路径的最后一步) */ export function writeBuildInfo(root = ELECTRON_DIR, buildCmd = process.env.npm_lifecycle_event || '') { const st = srcState(root); const info = { gitRev: st.gitRev, gitDirty: st.gitDirty, srcHash: st.srcHash, srcFiles: st.files, buildCmd, builtAt: new Date().toISOString(), // 说明这份文件是什么、谁在比它(读到此文件的人不用去翻代码) /* * **标签**,不是展示(pi 2026-09-14 §1)。 * * 共享树上永远有人在写别的文件,所以"脏树"是常态 —— 拿它当红会让门禁天天假红。 * 但"从脏树打出来的包"和"从干净树打出来的包"**在门禁上必须可区分**: * 前者含别人的半成品,只能自用,不能给人装。所以给一个是/否: * * releaseCandidate = !gitDirty * * 部署路径可以据此拒绝安装(判据见 build-stamp 的"脏树产物不得自称发布候选")。 */ releaseCandidate: !st.gitDirty, note: '构建自证:test/build-stamp.test.mjs 用 srcHash/gitRev 精确比对,不是比时间戳' }; const out = join(root, 'dist', 'BUILD_INFO.json'); mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, `${JSON.stringify(info, null, 2)}\n`); return info; } // 直接执行时 = 构建的最后一步 if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) { const info = writeBuildInfo(); console.log(`[build-info] dist/BUILD_INFO.json 已写:rev=${info.gitRev || '(无 git)'} srcHash=${info.srcHash.slice(0, 12)} dirty=${info.gitDirty}`); }