fix(deploy): 还清欠账 redeploy-script-unguarded-steps —— 四类裸步骤接收退出码

出口是这条欠账**自己写的**到期条件("下一次改 `deploy/` 下任一脚本时"):
当天因修 `--dry-run` 落地写入而动了这个脚本,所以一并还。

## ① 前端同步三步

`rm -rf assets` / `rm -f index.html` / `cp -r dist` 原先调用点不接退出码,
而 `run()` 内部是 `eval`(脚本只有 `set -uo pipefail`,无 `-e`)⇒ 失败既不中断也不上报。
`cp` 那条尤其要紧:前端产物没拷进去 ⇒ go:embed 把**旧界面**打进二进制,而所有单测仍绿
(2026-09-14 踩过,见脚本内那段注释)。三处都改成 `|| { bad …; exit 2; }`。

## ② `systemctl stop`(本条欠账原文点名的"stop 失败而状态没人看")

加它的理由比原文**更强一层**:`systemctl start` 对**已在运行**的服务是 **no-op** ⇒
stop 没成功时后面那句 start 什么也不做,**旧进程继续跑旧代码**,而脚本一路走到
后置验证、报"部署成功"。也就是说,"部署脚本跑过了 ≠ 线上跑的是当前代码"这个第 2 类漂移
会被脚本**自己在内部**造出来 —— 与今天线上那件事(09-19 二进制跑了 6 天)同形,只是成因在脚本内。

## ③④ DRY_RUN 分支

am-sandbox 的 `go build`/`install`、以及 `install -d $PREFIX/bin` 与通知脚本 `install`
一并挪进 DRY_RUN 分支(上一提交 7a65b27 只修了后者的"会落地",这里补齐构建/安装两步)。

## 边界(如实记,避免读成"全脚本已无裸步骤")

只覆盖 `redeploy-gateway.sh`。`redeploy-plugin.sh` / `install.sh` 的同类步骤仍未加守卫 ——
那是另一条欠账 `deploy-interrupt-trap-other-scripts` 的范围。

## 验证

`bash -n` 通过;`--dry-run` rc=0 且不落地(只留 0 字节锁);
Go 侧 `TestDebtLedgerMatchesMeasurement` / `TestDebtSummaryReadsAuthoritativeLedger` 通过;
`node test/debt-visibility.test.mjs` 1/1 通过。
This commit is contained in:
2026-09-25 06:22:09 +08:00
parent c6a497f4ed
commit 3c2b7d1385
2 changed files with 26 additions and 7 deletions

View File

@ -126,9 +126,17 @@ if [ "$SYNC_WEB" = 1 ] && [ -d "$REPO/client/electron/dist/assets" ]; then
say "1. 同步前端产物进 go:embed 目录"
# 只清构建产物:placeholder.html 在版本库里(让 go:embed 在新克隆里能编译),
# 删掉它会让 git 看到一个本地删除,下一次 commit -a 就把它从仓库带走。
run "rm -rf '$REPO/server/internal/static/static/assets'"
run "rm -f '$REPO/server/internal/static/static/index.html'"
run "cp -r '$REPO/client/electron/dist/.' '$REPO/server/internal/static/static/'"
# ★ 每一步都要**接收退出码**(还清 `DEBTS.json: redeploy-script-unguarded-steps`)。
# `run()` 内部是 `eval`,调用点不接的话失败既不中断也不上报 —— 实测形状
# (该欠账原文):`mkdir`/`cp` 被拒后仍打出 `[ OK ] 已拷入`,紧接着又打出
# `[FAIL] staging 里没有入口`:**一段输出里两个矛盾信号,且 OK 在前**。
# 这里 `cp` 尤其要紧:前端产物没拷进去,go:embed 会把**旧界面**打进二进制,
# 而所有单测仍是绿的(2026-09-14 就是这么踩的,见下面那段注释)。
# 退出码用 2(环境),与文件头的 0/1/2 约定一致 —— 这不是"检查不过",是"这一步没做成"。
run "rm -rf '$REPO/server/internal/static/static/assets'" || { bad "清不掉旧 assets(权限?)"; exit 2; }
run "rm -f '$REPO/server/internal/static/static/index.html'" || { bad "删不掉旧 index.html"; exit 2; }
run "cp -r '$REPO/client/electron/dist/.' '$REPO/server/internal/static/static/'" \
|| { bad "拷不进前端产物 —— 继续下去会把旧界面嵌进二进制"; exit 2; }
ok "前端产物已同步"
# 运行时脚本必须装在安装根下 —— 单元/drop-in 里引用的是
@ -317,7 +325,17 @@ trap '_restore_on_signal HUP' HUP
# 真原子的三步:**同目录**暂存 → 复制(慢没关系,动的是"还没人用的名字")→ 一次 `mv -f`。
# 对照:`redeploy-plugin.sh` 的 `mv "$STAGING" "$SNAP"` 是**真原子**(两者都在 `$DEST` 下、同 fs),
# 同一个仓库里原先两套"原子切换",一套真、一套名义上的。
run "systemctl stop '$SERVICE'"
run "systemctl stop '$SERVICE'" || {
# ★ stop 失败必须当场停手(欠账 `redeploy-script-unguarded-steps` 点名的形状:
# "其中一次的 stop 失败,而它的状态**没人看**")。
# 为什么这条尤其不能放过:`systemctl start` 对**已在运行**的服务是 no-op ⇒
# stop 没成功时,后面那句 start 什么也不做,于是**旧进程继续跑旧代码**,
# 而脚本一路走到后置验证、报"部署成功" —— 正是本文件头列的第 2 类漂移
# ("部署脚本跑过了 ≠ 线上跑的是当前代码")被脚本**自己在内部**造出来。
# (新加的判据 ⑤b 能事后发现它,但那是补救;源头在这里。)
bad "停不下 $SERVICE —— 继续的话,后面的 start 对运行中的服务是 no-op,会留下'以为换了、其实没换'"
exit 2
}
_SERVICE_STOPPED=1 # ← 从这里开始,"脚本死了但服务停着"就是事故
_NEW="$TARGET.new.$$"
# 复制到**目标同目录**:这一步慢/失败都无所谓,因为 `$_NEW` 还没有任何人用。