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`),**都不是我的**。
134 lines
6.0 KiB
JavaScript
134 lines
6.0 KiB
JavaScript
/**
|
||
* 构建产物的**自证**:`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}`);
|
||
}
|