Files
MailUI4Agents/deploy/web-comment-only.mjs
JianFeeeee 2f17f62871 feat(deploy): 前端源码更新「仅为注释」时不再卡住部署
## 起因(实测,同一天两次)

`redeploy-gateway.sh` 用 mtime 比对源码与 dist:

    newer=$(find src -type f -newer dist/index.html)
    [ -n "$newer" ] && exit 1

mtime 只说「这个文件被碰过」,不说「它变了什么」。于是共享工作树里**任何人
改一行注释就会拦下部署** —— 那行注释不进 bundle,重建产物与现有 dist
逐字节相同。

2026-10-02 实测被拦两次,都是 `client/electron/src/api/client.ts` 的线程树
说明注释(另一会话的工作树在制品,未提交),每次多花一轮 `npm run build`。

## 为什么不是拆掉那道闸

2026-09-14 踩过它的来历:改了 `src/lib/appearance.ts` 的请求路径却没跑
vite build,部署照样「同步成功」,出去的还是旧 bundle —— 表现为接口 404
(`/api/v1/api/v1/…` 双前缀),而**所有单测都是绿的**。那种失败是沉默的,
所以闸不能拆。

本改动是在闸**前面**加一层精化:先问「差异是否只是注释」,是则放行并说明,
否则维持拦截。拿不准时一律偏向拦截:

    误拦的代价 = 多构建一次(几十秒)
    误放的代价 = 把旧界面打进二进制(接口 404,且没人立刻归因)

## 判定口径(deploy/web-comment-only.mjs)

对每个「比 dist 新」的文件取它相对 **HEAD** 的 diff,去掉 `---`/`+++` 头、
diff 元信息与整行注释后若还剩内容 ⇒ 真改动 ⇒ 拦截。

- **只看未提交差异**(`git diff HEAD --`)。已提交改动早于本次部署决策。
- **未跟踪的新文件**按真改动处理 —— 判不出就别放行。
- 滤的是「整行都是注释」的行;行尾注释(`code(); // 注释`)算真改动(保守)。
- 块注释中间行(` * …`)与结尾也算注释。

## 判据(7 格)

`client/electron/test/web-comment-only.test.mjs`。判据本身必须能区分
「仅注释」与「真代码」,所以每格都给**两侧**对照。其中「只有注释差异」
那格用的 diff **逐字取自当天实测的 git diff**。

**变异验证**:

    去掉注释分支(所有行算真改动)  → 红 7(部署会被那一行注释继续拦)
    isCodeChange 恒 false           → 红 7(把 2026-09-14 的静默失败放回来)

第二条是关键:它证明这套判据不会为了「少拦一次」而牺牲那道沉默失败的闸。

## 真实场景验证(不是只跑单测)

    把 dist/index.html 时间戳改早 → 5 个源文件「比 dist 新」
      ⇒ commentOnly=true,理由写明「差异仅为注释」
    临时往 sse.ts 追加一行真代码
      ⇒ commentOnly=false,理由点名那行 `+const __probe = 1;`
    bash deploy/redeploy-gateway.sh --dry-run
      ⇒ [WARN] 前端源码被更新,但差异**仅为注释** ⇒ 不重建

## 顺带

`find … | head -20`(原 head -3):文件多时不至于只看到前 3 个就下结论。
2026-10-02 13:58:42 +08:00

134 lines
5.2 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.

#!/usr/bin/env node
/**
* 判定「前端源码更新是否只是注释」。
*
* # 为什么需要它
*
* redeploy-gateway.sh 用 mtime 比对源码与 dist:
*
* newer=$(find src -type f -newer dist/index.html)
* [ -n "$newer" ] && exit 1
*
* 而 mtime 只说「这个文件被碰过」,不说「它变了什么」。于是**任何人在共享
* 工作树里改一行注释就会卡住部署** —— 那行注释不进 bundle,重建产物与
* 旧产物逐字节相同。
*
* 实测两次(2026-10-02 同一天):`client/electron/src/api/client.ts` 的
* 线程树说明注释(另一会话的在制品,未提交)把部署拦了两次,每次多花一轮
* `npm run build`。
*
* # 为什么不是「无条件跳过」
*
* mtime 那道闸是有来历的:2026-09-14 改了 `src/lib/appearance.ts` 的请求
* 路径却没跑 vite build,部署照样「同步成功」,出去的还是旧 bundle ——
* 表现为接口 404(`/api/v1/api/v1/…` 双前缀),而**所有单测都是绿的**。
* 那种失败是沉默的,所以闸不能拆。
*
* 本模块只在「差异确为纯注释」时给出放行信号,其余一律维持拦截。
* 拿不准时偏向拦截:误拦的代价是多构建一次(几十秒),误放的代价是
* 把旧界面打进二进制(接口 404,且没人立刻归因)。
*
* # 判定口径
*
* 对每个「比 dist 新」的源码文件,取它相对 **HEAD** 的 diff,去掉
* `---`/`+++` 头与整行注释后若还剩内容 ⇒ 是真改动 ⇒ 维持拦截。
*
* 几个刻意的取舍:
*
* - 只看**未提交**的差异(`git diff HEAD --`)。已提交的改动早于本次
* 部署决策,不该在这里判;否则每次部署都要把整个提交历史重扫一遍。
* - **未跟踪的新文件**按真改动处理。判不出来就拦。
* - 滤掉的是「整行都是注释」的行。行尾注释(`code(); // 注释`)算真改动 ——
* 保守方向,且那种改动本来也该重建。
* - 块注释的中间行(` * …`)与结尾(星号 + 斜杠)也算注释。
*/
/**
* @param {string[]} files 比 dist 新的源码文件(绝对或相对仓库根)
* @param {string} cwd 仓库根
* @param {(cmd: string[]) => {stdout: string, code: number}} run
* @returns {{commentOnly: boolean, changed: string[], untracked: string[], reason: string}}
*/
export function classifySourceChanges(files, cwd, run) {
const rel = files
.map((f) => f.startsWith(cwd) ? f.slice(cwd.length + 1) : f)
.filter(Boolean);
if (rel.length === 0) {
return { commentOnly: true, changed: [], untracked: [], reason: '没有文件更新' };
}
// 未跟踪的新文件:判不出来 ⇒ 当真改动。
const untrackedOut = run(['git', 'ls-files', '--others', '--exclude-standard', '--', ...rel]);
const untracked = splitLines(untrackedOut.stdout);
if (untracked.length > 0) {
return {
commentOnly: false,
changed: untracked,
untracked,
reason: `新增未跟踪文件 ${untracked.join(' ')} —— 判不出是否只是注释,按真改动处理`
};
}
// 取相对 HEAD 的差异(只看工作树里还没提交的)。
const diffOut = run(['git', 'diff', '-U0', 'HEAD', '--', ...rel]);
const significant = splitLines(diffOut.stdout).filter(isCodeChange);
if (significant.length === 0) {
return {
commentOnly: true,
changed: [],
untracked: [],
reason: `差异仅为注释(${rel.join(' ')})—— 不进 bundle,重建产物与现有 dist 相同`
};
}
return {
commentOnly: false,
changed: rel,
untracked: [],
reason: `有非注释改动(${significant.length} 行,如:${significant[0]})`
};
}
/** 判断一行 diff 文本是否算「真代码改动」。 */
export function isCodeChange(line) {
// ---/+++ 文件头
if (line.startsWith('---') || line.startsWith('+++')) return false;
// diff 元信息
if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('@@')) return false;
// 必须是增删行之一
if (!line.startsWith('+') && !line.startsWith('-')) return false;
const body = line.slice(1);
// 空行
if (body.trim() === '') return false;
// 整行注释:// …、* …(块注释中间行)、* / …(块注释结尾)
if (/^\s*(\/\/|\*|\/\*)/.test(body)) return false;
return true;
}
function splitLines(s) {
return String(s || '').split('\n').map((x) => x.trim()).filter(Boolean);
}
// 直接执行时打印判定结果(给 shell 侧一行输出用)。
if (process.argv[1] && process.argv[1].endsWith('web-comment-only.mjs')) {
const { execFileSync } = await import('node:child_process');
const files = process.argv.slice(2);
if (files.length === 0) {
console.log(JSON.stringify({ commentOnly: false, reason: '没有传入文件' }));
process.exit(0);
}
const cwd = process.env.REPO || process.cwd();
const run = (cmd) => {
try {
const stdout = execFileSync(cmd[0], cmd.slice(1), { cwd, encoding: 'utf8' });
return { stdout, code: 0 };
} catch (e) {
return { stdout: (e.stdout || '') + (e.stderr || ''), code: 1 };
}
};
const res = classifySourceChanges(files, cwd, run);
console.log(JSON.stringify(res));
}