#!/usr/bin/env bash # redeploy-gateway.sh —— Gateway 二进制热替换(原子化,带备份与后置验证) # # 为什么需要这个脚本:日常改后端只需要换二进制,跑整个 install.sh 太重 # (它会重装 npm 依赖、重写 systemd 单元、重新生成 env)。而手工 # 「systemctl stop → cp → start」有两个隐患: # # 1. cp 是就地写入,会改坏**正在运行中进程**的可执行映像。 # 即使先 stop 了,中途失败也会留下一个半截二进制且旧的已被覆盖。 # ★ 更正(pi 评审 2026-09-14,已用 strace 实测):**`install(1)` 不是 rename。** # 实测 `strace … install -m 0755 /bin/true /tmp/t`: # openat(/tmp/t, O_WRONLY|O_CREAT|O_EXCL) ← 目标不存在时 # unlinkat(/tmp/t, 0) → openat(… O_CREAT|O_EXCL) ← 目标已存在时**先删旧再建新** # 全程没有 rename/renameat。即"复制"路径:**旧文件在新文件写完整之前就没了**。 # 实证"中途失败留半截":`ulimit -f 1` 下安装 /bin/true ⇒ 退出码 **153**(SIGXFSZ), # 目标变成 **1024 字节的截断 ELF**,原来那 14 字节内容**已被销毁** —— # 正是本段前半句写的那个风险,`install` 并不免疫,它只是不再覆盖旧文件(旧的已先删)。 # 所以真正原子的写法是**目标同目录 + 最后一次 mv**(见第 6 节)。 # # 2. 不备份数据库。SQLite 在 WAL 模式下 cp 会拿到不一致快照 # (主库文件与 -wal 不同步),恢复时可能丢最近写入甚至损坏。 # 必须用 sqlite3 .backup,它走的是官方在线备份 API。 # # 纪律来自 git-release-discipline skill 第五章: # 发布终点不是「推上去了」,而是后置验证清单全绿。任一项不过就回滚, # 不要「先上着再修」。 # # 用法: # bash deploy/redeploy-gateway.sh # 完整流程(含测试) # bash deploy/redeploy-gateway.sh --skip-tests # 跳过测试(急救时用) # bash deploy/redeploy-gateway.sh --dry-run # 只打印将执行的动作 # # 退出码: 0=成功 1=失败或验证不过(已尝试回滚) 2=参数/环境问题 set -uo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PREFIX="${AGENTMAIL_PREFIX:-/opt/agentmail}" # 环境自足:一处给全(理由与四次历史见该文件头注释) # shellcheck source=./lib/env-defaults.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/env-defaults.sh" # 本脚本依赖的外部命令:缺一个就 exit 2 + 人话(见 env-defaults.sh 的 ③b 一节)。 # 为什么必须显式声明:**"命令不在"与"命令在但输出为空"必须分开** —— # 下游把前者读成后者时就会产出假绿(journalctl 那两处就是:工具缺失被读成"无 panic")。 AGENTMAIL_REQUIRE="git go npm node curl systemctl journalctl sqlite3 install flock" agentmail_env_report # ★ root 断言(pi 评审 2026-09-14 指出本脚本缺它,只有 install.sh 有): # 本脚本要往 $PREFIX(默认 /opt/agentmail,系统路径)写 —— 非 root 必然失败在写权限上。 # 而失败时的报错来自 `install`/`cp`,看起来像**工程问题**。 # 与 env-defaults.sh 里那条"别把环境问题报成代码问题"是同一条纪律: # 与其让它晚一点、以晦涩的方式失败,不如在这里一行说清。 # 用退出码 2(环境/权限),与 env-defaults.sh 的口径一致,不冒充判据失败(1)。 [ "$(id -u)" = "0" ] || { printf '\n [FAIL] 环境不足:本脚本要写 %s,需要 root。\n' "$PREFIX" >&2 printf ' 药方:sudo bash deploy/redeploy-gateway.sh\n' >&2 exit 2 } TARGET="$PREFIX/agentmail-gateway" DB="$PREFIX/data/agentmail.db" SERVICE="agentmail-gateway" HEALTH_URL="${AGENTMAIL_HEALTH_URL:-http://127.0.0.1:8180/health}" TS="$(date +%Y%m%d-%H%M%S)" SKIP_TESTS=0 DRY_RUN=0 SYNC_WEB=1 while [ $# -gt 0 ]; do case "$1" in --skip-tests) SKIP_TESTS=1; shift ;; --skip-web) SYNC_WEB=0; shift ;; --dry-run) DRY_RUN=1; shift ;; -h|--help) sed -n '2,30p' "$0"; exit 0 ;; *) echo "未知参数: $1" >&2; exit 2 ;; esac done export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}" say() { printf '\n=== %s\n' "$*"; } ok() { printf ' [ OK ] %s\n' "$*"; } bad() { printf ' [FAIL] %s\n' "$*"; } warn() { printf ' [WARN] %s\n' "$*"; } run() { if [ "$DRY_RUN" = 1 ]; then printf ' [dry-run] %s\n' "$*" return 0 fi printf ' $ %s\n' "$*" eval "$@" } [ -d "$REPO/server" ] || { bad "找不到 $REPO/server"; exit 2; } # $PREFIX 必须**先**判存在:下面建锁要往它里面写文件(首次安装时它可能还没有)。 [ -d "$PREFIX" ] || { bad "找不到 $PREFIX(先跑 deploy/install.sh)"; exit 2; } # ★ **部署锁**(pi 评审 2026-09-14):这是我那张"环境前提"表里原先没有的一类 —— # 不是变量、不是命令、不是空间、不是身份,而是**同时性**。 # 而这台机器上它**不是假设**:`docs/DEV-TOOLING.md` 自己记过"这个工作区是多 agent 共用的, # 另一条会话正在改 deploy/*.sh"。两个部署同时跑会怎样: # 各自 `systemctl stop`(其中一次的 stop 失败,而它的状态**没人看**)→ # 两次写同一个 `$TARGET`(配合"install 非原子"⇒ 真能留下半截)→ # 两次后置验证互相把对方的"验证不过"当成自己的结论 → **谁回滚谁不确定**。 # 放在任何写操作之前(这里)。用 flock,不是"检查文件存在"——后者本身有竞态。 # 判据:两个同时启动 ⇒ 第二个 exit 2 并点名。 _LOCK="$PREFIX/.deploy.lock" exec 9>"$_LOCK" || { bad "无法创建部署锁 $_LOCK"; exit 2; } if ! flock -n 9; then bad "环境不足:另一个部署正在跑($_LOCK 被占用)—— 不要并发部署,等它结束" exit 2 fi # 锁随进程退出自动释放(fd 9 关闭);不需要手工 rm。 say "0. 计划" printf ' 仓库 : %s\n' "$REPO" printf ' 目标 : %s\n' "$TARGET" printf ' 数据库 : %s\n' "$DB" printf ' 服务 : %s\n' "$SERVICE" [ "$DRY_RUN" = 1 ] && printf ' 模式 : DRY-RUN(不落地)\n' || printf ' 模式 : 真实执行\n' # ---------------------------------------------------------------- 1 前端产物 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/'" ok "前端产物已同步" # 运行时脚本必须装在安装根下 —— 单元/drop-in 里引用的是 # /opt/agentmail/bin/service-failure-notify.mjs,不是仓库路径。 # 漏了这一步,故障通知会在"仓库被挪走/改名"时静默失效(2026-09-14 修的就是这个)。 # 前端产物必须比源码新,否则会把旧界面打进二进制。 # 2026-09-14 实测踩过:改了 src/lib/appearance.ts 的请求路径却没跑 vite build, # 部署脚本照样"同步成功",服务出去的还是旧 bundle —— 表现为接口 404 # (/api/v1/api/v1/… 双前缀),而所有单测都是绿的。 if [ -d "$REPO/client/electron/src" ] && [ -d "$REPO/client/electron/dist" ]; then newer=$(find "$REPO/client/electron/src" -type f -newer "$REPO/client/electron/dist/index.html" 2>/dev/null | head -3) if [ -n "$newer" ]; then echo " [FAIL] 前端 dist 比源码旧(先跑:cd client/electron && npm run build)" echo " 更新的文件:$(echo "$newer" | tr '\n' ' ')" exit 1 fi echo " [ OK ] 前端产物比源码新" fi else say "1. 跳过前端同步" [ "$SYNC_WEB" = 0 ] && ok "--skip-web" || warn "client/electron/dist 不存在,先跑 cd client/electron && npm run build" fi # 运行时脚本必须装在安装根下 —— 单元/drop-in 里引用的是 # /opt/agentmail/bin/service-failure-notify.mjs,不是仓库路径。 # 漏了这一步,故障通知会在"仓库被挪走/改名"时静默失效(2026-09-14 修的就是这个)。 # # ★ 这一段**必须在 if/else 之外**(pi 评审 2026-09-14 抓到,实测确认): # 它原先夹在 `if SYNC_WEB…` 分支里(在 `echo " [ OK ] 前端产物比源码新"` 之后、 # `else` 之前),于是 `--skip-web`、或 `client/electron/dist/assets` 不存在时, # **运行时脚本根本不装** —— 而它跟前端产物没有任何关系,只是恰好被写进了同一支。 # 后果是"改了仓库里的通知脚本、用 --skip-web 部署 ⇒ 生产还是旧的那份", # 而判据 ③ 只判"在不在、有没有执行位",不判**是哪一份** ⇒ 全绿。 # (已把 ③ 一并改成比内容。) install -d "$PREFIX/bin" || { bad "建不了 $PREFIX/bin"; exit 2; } if ! install -m 0755 "$REPO/deploy/service-failure-notify.mjs" "$PREFIX/bin/service-failure-notify.mjs"; then bad "装不了故障通知脚本" exit 2 fi ok "故障通知脚本已装到 /opt/agentmail/bin/" # 边界工具 am-sandbox(Landlock)—— 只有**本机真的能建立边界**时才装。 # # 为什么不无条件装:`am-sandbox` 自己 fail closed(套不上边界就拒绝执行命令,退出码 126), # 所以在没有 Landlock 的机器上装它等于给"档位=workspace"发一张空头支票 —— 装一个 # 用不了的工具比不装更坑。这里先真跑一遍边界判据(19 项:界内可写/界外被拒/软链接 # 逃逸/子进程继承/对照),绿了才 install。 # # 不把判据失败当致命错误:它可能只是内核老 —— 那种机器仍然可以部署网关, # 只是沙箱接线会在运行时 fail closed(那一步自己会叫)。 say "1b. 边界工具(Landlock 沙箱)" SANDBOX_STAGE="/tmp/am-sandbox-build-$TS" rm -f "$SANDBOX_STAGE" if ( cd "$REPO/server" && go build -trimpath -o "$SANDBOX_STAGE" ./cmd/am-sandbox ); then if AM_SANDBOX="$SANDBOX_STAGE" bash "$REPO/deploy/check-sandbox.sh" >/tmp/am-sandbox-check.log 2>&1; then install -m 0755 "$SANDBOX_STAGE" "$PREFIX/bin/am-sandbox" ok "边界工具已装(判据 $(grep -c '\[ OK \]' /tmp/am-sandbox-check.log) 项全绿)" else warn "本机建立不起 Landlock 边界:不装 am-sandbox(见 /tmp/am-sandbox-check.log)" fi else warn "am-sandbox 构建失败(不影响网关本身)" fi rm -f "$SANDBOX_STAGE" # ---------------------------------------------------------------- 2 静态检查与测试 say "2. 构建前检查" if [ "$DRY_RUN" = 1 ]; then printf ' [dry-run] go vet ./... && go test ./...\n' else ( cd "$REPO/server" && go vet ./... ) || { bad "go vet 不过,终止"; exit 1; } ok "go vet 通过" if [ "$SKIP_TESTS" = 1 ]; then warn "--skip-tests:跳过测试(急救模式,事后必须补跑)" else ( cd "$REPO/server" && timeout 280 go test ./... -timeout 250s ) \ || { bad "测试不过,终止部署"; exit 1; } ok "go test 全包通过" fi fi # ---------------------------------------------------------------- 3 构建 say "3. 构建二进制" STAGE="/tmp/agentmail-gateway-build-$TS" # 先删再建:go build -o 到已存在的路径时可能拿到 stale 二进制(此坑中过多次) # ★ `-trimpath`:Go 默认把源文件的**绝对路径**编进二进制。2026-09-14 实测:本机 # 历史二进制都是 trimpath 的(里面一处源码路径都没有),19:05 那次不是 —— # 于是生产件 /opt/agentmail/agentmail-gateway 里躺着 57 处 /home/program/agentmail/…。 # 改标准目录部署是为了"运行时不再依赖源码目录";没有 -trimpath 时这条只做到一半: # 依赖确实没了,但**源仓库位置还印在产物上**。判据在 check-deploy-drift(标准目录 ⑤)。 run "rm -f '$STAGE'" if [ "$DRY_RUN" = 1 ]; then printf ' [dry-run] (cd server && go build -trimpath -o %s ./cmd/server)\n' "$STAGE" else ( cd "$REPO/server" && go build -trimpath -o "$STAGE" ./cmd/server ) \ || { bad "构建失败"; exit 1; } ok "构建完成 $(du -h "$STAGE" | cut -f1)" fi # ---------------------------------------------------------------- 4 备份数据库 say "4. 备份数据库(sqlite3 .backup,不能用 cp —— WAL 下 cp 会拿到不一致快照)" DBBAK="" if [ -f "$DB" ]; then DBBAK="/tmp/agentmail-pre-deploy-$TS.db" run "sqlite3 '$DB' \".backup '$DBBAK'\"" || { bad "备份失败,终止部署"; exit 1; } if [ "$DRY_RUN" != 1 ]; then integ="$(sqlite3 "$DBBAK" 'PRAGMA integrity_check;' 2>&1 | head -1)" if [ "$integ" = "ok" ]; then ok "备份完成 $DBBAK($(du -h "$DBBAK" | cut -f1)),integrity_check=ok" else bad "备份完整性异常: $integ"; exit 1 fi fi else warn "数据库不存在(首次部署?): $DB" fi # ---------------------------------------------------------------- 5 旧二进制留档 say "5. 旧二进制留档" BINBAK="" if [ -f "$TARGET" ]; then BINBAK="${TARGET}.bak-${TS}" run "install -m 0755 '$TARGET' '$BINBAK'" ok "旧二进制留档 $BINBAK" else warn "目标不存在(首次安装)" fi # ---------------------------------------------------------------- 6 原子替换 say "6. 停服 → 原子替换 → 起服" # ★ **信号兜底:服务停着而脚本死了,是这套流程最危险的窗口**(pi 评审 2026-09-14)。 # # 原子 `mv` 修的是"半截二进制",**没有修"服务停着没人知道"**: # 第 250 行 stop、第 267 行 start 之间若被外部信号打断(Ctrl-C、宿主杀部署进程、 # 会话被回收、OOM),脚本直接退出 ⇒ **服务留在停止状态,而脚本什么也不说** ⇒ # 后果是"邮件全停 + 无人告知",比半截二进制更难被发现。 # 所以:进入窗口前挂 trap,出了窗口就摘掉;trap 只在"确实还停着"时才动手。 # # 为什么这是第六列的候选(pi 的分类):它既不是变量、命令、空间、身份,也不是 # 并发(那是"同时有两个部署"),而是**"过程被中断"** —— 环境前提表里没有这一类。 _SERVICE_STOPPED=0 _restore_on_signal() { local sig="$1" if [ "$_SERVICE_STOPPED" = "1" ]; then # ⚠️ 用双引号:单引号会让 `$SERVICE` **不展开**、原样打出去(我第一版就是这样, # 探针里实测看到字面量 `$SERVICE`)。这是"消息里带了变量但没展开"的形状 —— # 看的人会以为服务名真的叫 `$SERVICE`。 printf '\n [WARN] 收到 %s 且 %s 仍处于停止状态 —— 正在起回来(否则邮件会一直停)\n' \ "$sig" "$SERVICE" >&2 systemctl start "$SERVICE" 2>/dev/null || \ printf ' [FAIL] 起服务失败:请手工 systemctl start %s\n' "$SERVICE" >&2 printf ' 原子替换未完成;目标二进制可能仍是旧版(原子 mv 保证不会半截)。\n' >&2 fi exit 130 } trap '_restore_on_signal INT' INT trap '_restore_on_signal TERM' TERM trap '_restore_on_signal HUP' HUP # 为什么仍要 stop:SQLite 单写者,且换掉二进制后旧进程还在跑旧代码, # 与新库 schema 可能不一致。下面保证的是「文件替换本身」原子, # 不代表可以热换正在服务的进程。 # # ★ **为什么不能直接 `install "$STAGE" "$TARGET"`**(pi 评审 2026-09-14,实测): # ① `install` 不是 rename(见头部更正)⇒ 中途失败会留下**半截二进制**、旧文件已先被 unlink; # ② 就算换成 `mv` 也不原子:`$STAGE` 在 `/tmp`(tmpfs,设备号 40), # `$TARGET` 在 `/opt/agentmail`(设备号 2049)—— **不同文件系统**, # coreutils 的 `mv` 跨 fs 会退化成 copy+unlink,同样不是原子的。 # 真原子的三步:**同目录**暂存 → 复制(慢没关系,动的是"还没人用的名字")→ 一次 `mv -f`。 # 对照:`redeploy-plugin.sh` 的 `mv "$STAGING" "$SNAP"` 是**真原子**(两者都在 `$DEST` 下、同 fs), # 同一个仓库里原先两套"原子切换",一套真、一套名义上的。 run "systemctl stop '$SERVICE'" _SERVICE_STOPPED=1 # ← 从这里开始,"脚本死了但服务停着"就是事故 _NEW="$TARGET.new.$$" # 复制到**目标同目录**:这一步慢/失败都无所谓,因为 `$_NEW` 还没有任何人用。 if ! run "install -m 0755 '$STAGE' '$_NEW'"; then bad "暂存到目标目录失败($_NEW)" run "rm -f '$_NEW'" run "systemctl start '$SERVICE'" exit 1 fi # 唯一需要原子的那一步:同 fs ⇒ 真 rename,要么全换、要么原样不动。 if ! run "mv -f '$_NEW' '$TARGET'"; then bad "原子替换失败(mv -f $_NEW $TARGET)—— 旧二进制未被动过" run "rm -f '$_NEW'" run "systemctl start '$SERVICE'" exit 1 fi unset _NEW run "systemctl start '$SERVICE'" _SERVICE_STOPPED=0 # ← 服务已回来,危险窗口结束 trap - INT TERM HUP run "sleep 6" # ---------------------------------------------------------------- 7 后置验证 say "7. 后置验证清单(发布终点是这张单子全绿,不是「换上去了」)" if [ "$DRY_RUN" = 1 ]; then cat <<'EOF' [dry-run] 真实执行时逐项校验: [ ] 服务 active [ ] /health 可达 [ ] 近 2 分钟无 panic/fatal [ ] 四个 Agent 桥的 SSE 重新连上 EOF echo echo " DRY-RUN 结束。确认无误后去掉 --dry-run 重跑。" exit 0 fi CHECK_FAIL=0 if systemctl is-active --quiet "$SERVICE"; then ok "服务 $SERVICE active" else bad "服务未 active"; CHECK_FAIL=$((CHECK_FAIL+1)) fi if curl -sf -m 5 "$HEALTH_URL" >/dev/null 2>&1; then ok "健康检查通过 $HEALTH_URL" else bad "健康检查失败 $HEALTH_URL"; CHECK_FAIL=$((CHECK_FAIL+1)) fi # A: 日志读不到不等于没有 panic(pi 评审 2026-09-14 指出这两处是假绿,实测复现)。 # # 原写法 fc="$(journalctl ... 2>/dev/null | grep -icE 'panic|fatal|SIGSEGV' || true)", # journalctl 失败时(无权限读日志、unit 不存在、dbus 不通)错误被 2>/dev/null 吞掉 # => grep 读空输入 => 输出 0、退出 1 => || true => fc="0" => 报"无 panic"。 # 实测复现:journalctl -u 不存在的-unit | grep -icE 'panic' => fc=[0]。 # 下面 sse 那条同形,后果更坏:它把"读不到日志"归因成"插件没连上", # 提示人去查密钥,而问题在日志读不到。 # # 实测:PIPESTATUS 也分不开(命令不存在与"存在但无匹配"都给 1), # 所以不能靠管道状态区分,必须先把日志取出来、成功后再 grep。 # 命令是否存在已由 env-defaults.sh 的 AGENTMAIL_REQUIRE 兜住(缺了提前 exit 2); # 这里处理的是"命令在、但读不到内容"。 # 用法: am_scan_logs [命令,默认 journalctl] # 输出: `clean`(读到了、没命中) | `hit`(读到了、命中) | `unreadable:<码>`(**读不到**) # # ★ 抽成函数是为了**能被样本喂**(pi 建议 2026-09-14),与既有的 `describeEnvError`、 # `judgeRestart` 是同一个做法:把"能被样本喂的部分"抽出来,否则这条分支永远只能靠真部署触发。 # pi 同时指出我原先那次"复现"其实测的是**另一支**:`journalctl -u 不存在的-unit` # 退出码是 **0**(实测确认),走的是"命令在但输出为空";真正的"读不到"(`_jrc != 0`) # 仍然没被走过。所以要靠第 4 个参数(命令)来喂失败。 # # 用 if/else 而不是 `printf | grep`:管道有 SIGPIPE 的边角(读端提前退出时写端可能被信号杀掉), # 而 `grep -q` 会提前退出 —— 这里要的是**确定的**三种输出,不要边角。 am_scan_logs() { local unit="$1" since="$2" pattern="$3" cmd="${4:-journalctl}" out rc out="$("$cmd" -u "$unit" --since "$since" --no-pager 2>/dev/null)"; rc=$? if [ "$rc" != "0" ]; then printf 'unreadable:%s' "$rc"; return; fi if printf '%s' "$out" | grep -qiE "$pattern"; then printf 'hit'; else printf 'clean'; fi } _scan="$(am_scan_logs "$SERVICE" '2 min ago' 'panic|fatal|SIGSEGV')" case "$_scan" in unreadable:*) warn "读不到 $SERVICE 的日志(journalctl 退出码 ${_scan#unreadable:})—— 无法据此判断 panic/fatal" ;; hit) bad "近 2 分钟出现 panic/fatal(见 journalctl -u $SERVICE)"; CHECK_FAIL=$((CHECK_FAIL+1)) ;; *) ok "近 2 分钟无 panic/fatal(已读到日志)" ;; esac # 桥重连:Gateway 重启会掐断所有 SSE,插件应当在几秒内自己回来。 # 一个都没回来通常意味着密钥被撤销(停用 Agent 会撤销密钥)或端口没起。 _scan2="$(am_scan_logs "$SERVICE" '1 min ago' 'Client connected')" case "$_scan2" in unreadable:*) warn "读不到日志(journalctl 退出码 ${_scan2#unreadable:})—— 无法据此判断 SSE 是否重连" ;; hit) ok "已有 SSE 客户端重新连上" ;; # 读到了、但没有:这时才该提示去查密钥。 *) warn "暂未看到 SSE 重连 —— 若插件应当在线,检查密钥是否被撤销(停用会撤销密钥)" ;; esac unset _scan _scan2 2>/dev/null || true echo echo " 仍需人工确认(脚本无法代替):" echo " [ ] 真实发一封邮件端到端跑通(不是只看进程起来了)" echo " [ ] WebUI 能登录且列表正常" echo if [ "$CHECK_FAIL" -gt 0 ]; then say "结论: 验证有 $CHECK_FAIL 项失败 —— 正在回滚,不要「先上着再修」" if [ -n "$BINBAK" ]; then run "systemctl stop '$SERVICE'" _SERVICE_STOPPED=1 run "install -m 0755 '$BINBAK' '$TARGET'" run "systemctl start '$SERVICE'" _SERVICE_STOPPED=0 ok "已回滚到 $BINBAK" else warn "无旧二进制可回滚" fi [ -n "$DBBAK" ] && echo " 如需恢复数据库(先停服务): install -m 0644 '$DBBAK' '$DB'" exit 1 fi say "结论: 自动项全绿" # 构建暂存收尾。这份 24MB 副本只是为了「构建 → install」那一步的原子性,装完就没 # 用了;原先没人删 ⇒ 每次部署在 `$TMPD` 留一份。2026-09-14 实测:7 份 / 162MB, # 而 `$TMPD` 是 9.8G 的 tmpfs —— 占满后**连部署自己的第一步都跑不动** # (go build 报 ENOSPC)。存量由 `deploy/prune-deploy-artifacts.sh` 按窗口收。 # 只在这个**成功分支**上删:失败/回滚时那份二进制正是要留的现场,见上面的回滚分支。 rm -f "$STAGE" ok "构建暂存已收($STAGE)" echo " 回滚命令(留档 24 小时内有效):" [ -n "$BINBAK" ] && echo " install -m 0755 '$BINBAK' '$TARGET' && systemctl restart '$SERVICE'" [ -n "$DBBAK" ] && echo " install -m 0644 '$DBBAK' '$DB' # 先 systemctl stop" exit 0