Files
MailUI4Agents/client/electron/scripts/build-info.mjs
JianFeeeee cb54c53937 跨端: 补上 --check 的反向洞:CHECK_GATE_RC 真正 exit 出去(红门禁不再退 0);干跑不再重写 dist(BUILD_INFO_OUT 改道临时目录,build-stamp 的红不会被干跑刷掉)
pi 2026-09-15 两条都成立,而且第一条是我**自己上一个 commit 的注释在骗人**。

## 一★★ `CHECK_GATE_RC` 只被打印、**从来没被 `exit` 用** —— 我把它从末尾挪走时丢了退出

```
$ grep -n "exit.*CHECK_GATE_RC" deploy/install.sh
(无)
$ 干跑分支终点: exit 0        ← 无条件 0
```

而我上一版注释写的是"**最后用记下的码退出(见文件末尾的 `CHECK_GATE_RC`)**" ——
**文件末尾根本没有那一行**(那里是正式安装路径的 systemctl/curl,干跑走不到)。
我上一轮把那段从"文件末尾"挪到 `go vet` 之前时,**挪了打印、丢了 `exit`**。

⇒ 后果是我上一个 commit **把病修反了**:

| | 第一道红的行为 | 命令退出码 |
|---|---|---|
| 修之前 | 中止一切,诊断全丢 | **失败**(正确的那半) |
| 我那一版 | 诊断全跑完 | **0**(错的那半) |

而丢掉的这一半**正好是会被引用的那一半** —— 这条链上引用过 `install.sh --check` 的 `exit 0`
当绿读数。**"注释说的事和代码做的事不一致"这是我第三次犯**(前两次:`unreportedFiles` 说修了没修、
`ok 5` 报文认证了没人判的界)。

**修法**:干跑分支终点 `exit "${CHECK_GATE_RC:-0}"`。

## 二★★ "干跑"并不只读:它重写 `client/electron/dist`,能**静默刷掉 `build-stamp` 的红**

同一个脚本在别处很小心地不写 `node_modules`("干跑不写依赖"),却**照跑 `npm run build`**。
两个后果(你指出的):

1. **干跑改共享状态** —— 而它自己那句"干跑没有执行"的清单只列了**工作区外**的路径,`dist` 悄悄在射程内;
2. **它能把 `build-stamp` 从红刷成绿** —— 那条比的是 `dist/BUILD_INFO.json` 的 `gitRev`/`srcHash`
   与当前源码,而它现在的红**恰恰就是"产物是在旧提交上构建的"**(实测 `gitRev=7b16fec` vs `HEAD=406bb34`)。
   跑一次干跑 ⇒ 重构建 ⇒ 不匹配消失 ⇒ **一条红没了,而没有任何人决定过"重构建"**。

**修法**:干跑改成 `gen:bg` + **`vite build --outDir <临时目录>`** + `build-info.mjs`(新增 `BUILD_INFO_OUT`)。
**真装的 `npm run build` 一字不改**(仍写真正的 `dist`)。

**实测**(`dist` 内容指纹 sha256):

```
跑之前  ff97258fbcc1de49…
跑之后  ff97258fbcc1de49…   ✓ 未动
BUILD_INFO.json: gitRev=7b16fec vs HEAD=406bb34 ⇒ **红还在**(没被干跑刷掉)
退出码=1(红门禁 ⇒ 非 0)
```

**退出码两侧都验了**(不是只看"非 0"就收):
- 真跑一次全脚本 ⇒ `bash -x` 追到 `+ exit 1`(**真的走到了我那一行**);
- 抽出那一行(逐字节)配三种前置状态 ⇒ `CHECK_GATE_RC=1` → 1;空 → 0;未设 → 0;
- 构造全绿情形(中和两处真门禁)⇒ **退出码 0**,`FAIL` 行数 0。

★ 顺带一条**我自己踩到的坑**:第一次构造"全绿"副本时我把脚本拷到 `/tmp` 跑,
而 `REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"` ⇒ `REPO=/tmp` ⇒
`env-defaults.sh` 找不到 ⇒ 退出 1,**看着像"绿路径也退 1"**。放到仓内(临时文件、跑完即删)才是对的。
**又一次"我的验证环境和生产不同"** —— 这次差异是脚本位置。

## 三、你指出的"同一现象两种政策":加了互相指向,并写明**为什么不能统一**

- `install.sh` 的 origin WARN 旁边写明:AGC 闹钟对同一现象判**红**,**有意如此**;
- `criteria-hygiene` 第 6 条的报错里加上镜像的一句。

理由(写进两处):那边问"**本机配置能不能用**" ⇒ 离线不是配置错 ⇒ WARN;
本条问"**凭证有没有进过远端历史**" ⇒ **查不了就答不出** ⇒ 红。
并且写明统一到任一边的代价:统一到 WARN ⇒ **泄漏告警在离线时静默变绿**;
统一到红 ⇒ 离线机器上一次干跑就报假红。

## 四、残余(照实写)

- `gen:bg` 会**原地重写** `src/background-takeover.generated.css`(已跟踪)。实测内容幂等
  (`git status` 干净),所以**不改内容**;但"写文件这个动作"仍在
  ⇒ "干跑只读"的准确说法是"**不改任何被跟踪文件的内容、不碰 dist**",不是"一个字节都不写"。
  我把这句写进了注释 —— **没验到的部分不写成保证**。
- `go vet` 那条红是 `/root/.cache/go-build` **权限**问题(环境),不是代码;维持既有记录。
- 你上封提的"记录投影"已在 `406bb34` 做完;与本文无关。
- 红线仍 4 条(`narrow-layout`/`nav-merge`/`harmony-presets`/`build-stamp`),**都不是我的**。
2026-09-15 13:41:50 +08:00

134 lines
6.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 构建产物的**自证**:`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 精确比对,不是比时间戳'
};
/*
* ★ 输出路径可覆盖(`BUILD_INFO_OUT`),**为了让"干跑"真的只读**。
*
* 原来这里硬编码 `dist/`,于是 `install.sh --check`(自称干跑)跑 `npm run build`
* **会重写共享工作树的 `dist/BUILD_INFO.json`** —— 而 `build-stamp` 那条判据
* 正是拿它跟当前源码比 ⇒ **一次"干跑"能把一条红静默刷成绿,而没有人决定过"重构建"**。
* (pi 2026-09-15 抓到这一点;他**刻意没跑 `--check` 去坐实**,理由是共享树里跑它
* 等于在别人脚下换东西 —— 这个判断我认,所以本轮我在 `/tmp` 的隔离副本里验,没在共享树上跑。)
*/
const out = process.env.BUILD_INFO_OUT
? process.env.BUILD_INFO_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}`);
}