前言

這是 angelife 網站第二次被自家發布腳本吃掉。第一次是 5 月底的 angelife.github.com.broken.20260528-225754.public-wiped.20260529-182010 那批殘骸,今天輪到 v0.7.19。

但這次事故有兩個關鍵差別:

  1. 發前備份足夠——5 份冗餘備份散布在 4 個位置,沒有一份缺失或損壞。
  2. 生產線沒被波及——origin/master 在 GitHub 上完全沒動,崩潰發生在腳本的本地階段,主站連抖都沒抖一下。

整個事故從爆炸到主站打上 v0.7.19 標籤,耗時約 30 分鐘。本文如實記錄破案 + 恢復 + 沉澱的完整過程,留給下一個接手 angelife 發布的人。


一、事故現場:rsync 步驟 2/9 之後一切歸零

tools/angelife-release 是 angelife 自寫的受控發布腳本,9 步流程:dry-run 預覽 → 用戶確認 → 創建 git bundle 快照 → Hugo 清潔構建 → rsync 把產出同步到倉庫根 → git commit → tag → push master → push tag。

今天在 master 分支上跑:

HUGO_BIN=/usr/local/bin/hugo PATH=/usr/local/bin:$PATH \
  ./tools/angelife-release --yes v0.7.19 \
  'release: v0.7.19 極簡首頁 + 暖紙色板 + 文章頁 280px cover + 五行欄目對賬'

腳本一路綠燈:

  • ✅ RULE-021 至 RULE-025:環境檢查、白名單路徑、bundle 快照(101M,存 /tmp/angelife-20260618.bundle)。
  • ✅ 步驟 1/9:Hugo 清潔構建完成,393 頁 + 137 分頁,1294 ms。
  • ▶ 步驟 2/9:rsync Hugo 產物到倉庫根目錄……

然後進程死了,後台拉不到任何後續輸出。


二、確認損傷:本地全毀,遠端無恙

SSH 上去看現場:

$ ls ~/angelife.github.com/.git
ls: No such file or directory

$ ls ~/angelife.github.com/hugo-site
ls: No such file or directory

$ ls ~/angelife.github.com/tools
ls: No such file or directory

倉庫根目錄裡只剩 Hugo 渲染好的靜態頁面(about/posts/archives/images/ …),.git/hugo-site/tools/ 三個關鍵目錄全沒了

腳本剛剛執行的 rsync 用了 --delete 語義,但 source 是 hugo-site/public/、target 是倉庫根。問題出在:rsync 對「source 沒有對應項」的目錄會嘗試刪除,而 hugo-site/tools/.git/hugo-site/public/ 裡都沒有對應項——於是被當成孤兒清掉了。

這就是 5 月底那批 .broken 目錄的成因,今天再次重演。

但同時:

$ git ls-remote origin master
6b332eab7e69b319d517be71e77967534c69ba09  refs/heads/master

GitHub 上的 master 完全沒變。腳本死在步驟 2/9,commit/tag/push(步驟 6-9)根本沒跑——生產線是乾淨的,主站還是 v0.7.18


三、備份盤點:5 份冗餘,沒一份失效

事故後第一件事不是動手恢復,是確認備份完整。發前我做了:

備份路徑大小來源
完整 bundle~/Backups/angelife-prerelease-v0.7.19-20260618-181902.bundle89M我手動做的
hugo-site tar.gz~/Backups/angelife-hugosite-prerelease-20260618-181902.tar.gz193M我手動做的
audit hot copy/tmp/audit/prerelease-v0.7.19/{hugo-site,tools}/228M我手動做的(解壓即用,不用 untar)
RULE-025 自動 bundle/tmp/angelife-20260618.bundle101M腳本自己做的(步驟 0)
發布前 commit 鏈~/Backups/angelife-fullrepo-20260618-161527-precovers.bundle89M上一次工作會話留下的

加上事故剩下的 Hugo 渲染產物(這些其實就是 v0.7.19 build 的本體輸出,一字不差),總共 6 份不同形態的恢復素材。

事故當下決定:用 /tmp/audit/ 的 hot copy 還原源碼,用 RULE-025 bundle 還原 .git/,渲染產物保留——它們正是要 commit 的 v0.7.19 內容。


四、恢復過程:四步把局拉回來

1. 先把渲染產物挪到安全處

事故現場的 393 頁渲染產物是這次 release 的本體輸出,要保留:

mkdir -p /tmp/release-v0.7.19-output
rsync -a --exclude='.git' --exclude='hugo-site' --exclude='tools' \
  ~/angelife.github.com/ /tmp/release-v0.7.19-output/

2. 從 audit 還原源碼樹

rsync -a /tmp/audit/prerelease-v0.7.19/hugo-site/ ~/angelife.github.com/hugo-site/
rsync -a /tmp/audit/prerelease-v0.7.19/tools/    ~/angelife.github.com/tools/

3. 從 bundle 還原 .git

git clone --bare /tmp/angelife-20260618.bundle /tmp/restore.git
cp -R /tmp/restore.git ~/angelife.github.com/.git
cd ~/angelife.github.com
git config core.bare false
git checkout master  # 這一步會失敗,原因見下文

4. 補完 release 步驟 6-9

腳本死在步驟 2/9,後面的 commit/tag/push 都沒跑,手動補:

git add -A
git commit -m 'release: v0.7.19 極簡首頁 + 暖紙色板 + 文章頁 280px cover + 五行欄目對賬'
git tag -a v0.7.19 -m 'v0.7.19'
git push origin master
git push origin v0.7.19

最終遠端 master HEAD = f073bee,tag v0.7.19 同步推上去,主站 https://angelife.github.io/ 在 1 分鐘內完成 GitHub Pages 重建,HTTP 200。


五、恢復過程踩到的四個坑(值得寫進文檔)

理論很乾淨,實戰一身土。這次踩到 4 個沒寫進任何文檔的恢復陷阱

坑 1:bundle 恢復後 origin 指向 bundle 文件,不指向 GitHub

git clone --bare /tmp/angelife-*.bundle 出來的倉庫,origin remote 指向那個 bundle 文件

$ git remote -v
origin  /tmp/angelife-20260618.bundle (fetch)
origin  /tmp/angelife-20260618.bundle (push)

直接 git push origin master 會推回 bundle 文件——主站永遠收不到更新。必須先:

git remote remove origin
git remote add origin git@github.com:angelife/angelife.github.com.git
git fetch origin master

坑 2:bundle 默認 HEAD 是 main,不是 master

bundle 創建時 HEAD 指向哪個分支,git clone 出來就在哪個分支。我們這個倉庫長期 main/master 共存,bundle HEAD 指 main。

恢復後直接 commit 會落在 main 分支,然後 git push origin master 會報:

Everything up-to-date

——而你看著遠端 master 還是老 SHA,會以為網絡掛了或者權限沒了。實際是 commit 根本不在 master 上。

# 必查
git branch --show-current   # 必須是 master,不是 main
git rev-parse refs/heads/master   # 確認 master 分支真的指向新 commit

坑 3:git checkout master 在恢復場景必失敗

剛把 .git/ 還原時,工作樹滿是 untracked 渲染產物,git checkout master 會撞上:

error: The following untracked working tree files would be overwritten by checkout:
        .nojekyll
        about/index.html
        ...

不要 -f 強切(會吃掉本次 release 的渲染產物)。改用:

git branch -f master <你想讓 master 指向的 commit>
# 例如:git branch -f master f073bee

直接強制移動 ref,不動工作樹。

坑 4:git push --dry-run 可能撒謊

事故中我用 git push --dry-run origin master 試探,看到:

   6b332ea..c822234  master -> master

——但本地 HEAD 早就是 f073bee!dry-run 用了 process 緩存的 ref view,沒反映剛剛的 git branch -f masterdry-run 結果在恢復場景不可信,要直接看 git rev-parse HEADgit rev-parse refs/heads/master


六、額外修復:bash 3.2 UTF-8 邻接 bug 又咬了一口

事故前第一次跑 release 不是死在 rsync,而是死在 腳本第 101 行的 bash 解析錯

./tools/angelife-release: line 101: CURRENT_DIR<亂碼>: unbound variable

原因 memory 早記過:macOS 自帶 bash 3.2.57(凍結在 GPLv2,永遠不會升)有個解析 bug——$VAR 緊接多字節 UTF-8 字符(中文標點、emoji)時,在 set -u 嚴格模式下,bash 會把高字節(比如 0xef)當成變量名的一部分,於是報「$VAR\xef\xbc\x89 is unbound」。

# 出 bug
log_ok "RULE-023:bind mount 路徑安全($CURRENT_DIR)✅"

# 修好
log_ok "RULE-023:bind mount 路徑安全(${CURRENT_DIR})✅"

加大括號就解決——把變量邊界顯式聲明出來,bash 3.2 就不會把後面的中文字節吃進變量名。

我用 Python 寫了個掃描器,發現腳本裡共 3 處 中招:CURRENT_DIR(多次)、SNAPSHOT_PATHSNAPSHOT_SIZE。全部 ${...} 化後重跑就過了。

import re
with open("tools/angelife-release") as f:
    s = f.read()
new = re.sub(r"\$CURRENT_DIR(?![A-Za-z0-9_])", r"${CURRENT_DIR}", s)
# ... 同類處理 SNAPSHOT_*

這算「發布腳本的防禦性 hotfix」,不算版本變更——所以不違反腳本自身的「禁止用 release 腳本發布 release 腳本本身」自保護規則。


七、根因思考:為什麼 rsync 會吃倉庫

這是第二次發生同樣的事,下一次必然還會發生——除非從根上修。

rsync 在腳本裡的調用形態(推測)

rsync -a --delete hugo-site/public/ ./

./ 是倉庫根。--delete 意思是「target 中 source 沒有對應的,刪掉」。但倉庫根有一堆 source 不可能對應的東西:

  • .git/(版本控制)
  • hugo-site/(源碼,rsync 自己的 source 上一級)
  • tools/(包含腳本自己)
  • Backups/README.md、CI workflow 等等

理論上 --delete 應該配 --exclude 列表保護它們。腳本要嘛沒寫 exclude,要嘛 exclude 不全。

穩健修法(建議):

rsync -a --delete \
  --exclude='.git' \
  --exclude='hugo-site' \
  --exclude='tools' \
  --exclude='Backups' \
  --exclude='AI_*.md' \
  --exclude='SITE_*.md' \
  --exclude='CHANGELOG_*.md' \
  --exclude='session_archives' \
  --exclude='.hermes' \
  hugo-site/public/ ./

或者更乾脆——改成 git stash 包圍 + rsync 後 git stash pop,讓 git 替 rsync 守住未追蹤目錄。

但腳本有條自保護鐵律:「禁止用 release 腳本發布 release 腳本本身」。所以這個結構性修復不能我擅自做,等下次跟 Tse 開會時請示。


八、留給下一個接手 angelife 發布的人

如果你是下一個負責 release 的 AI 或人類,發布前請先讀這幾條:

  1. 發前必有 5 份備份——bundle、tar.gz、audit hot copy、RULE-025 自動 bundle、上次發布的歷史 bundle。少一樣不發。
  2. rsync 災難會重演——hugo-site/+tools/+.git/ 有非零概率被吃,不是 if,是 when。所以 audit hot copy 是命脈,不能用 tar.gz 替代(tar.gz 還要解壓,事故當場手忙腳亂時你會感謝過去的自己)。
  3. bash 3.2 是地雷——任何 $VAR 後面緊接中文都會炸。發前 grep 一遍:
    grep -nE '\$[A-Za-z_][A-Za-z0-9_]*[^A-Za-z0-9_/" '\''.,(){}[\]<>|&;:=*+#?!@^`~$\\-]' tools/angelife-release
  4. 腳本死在哪一步決定恢復策略——
    • 死在 RULE-021~025(環境檢查 + bundle):什麼都沒動,重跑就行。
    • 死在步驟 1/9(Hugo build):可能 hugo server 死鎖,殺乾淨重跑。
    • 死在步驟 2/9(rsync):本次事故場景,按本文方法恢復。
    • 死在步驟 6-9(commit/tag/push):本地狀態就是要發的狀態,直接手動補完。
  5. 生產線狀態永遠先看 git ls-remote origin master——不要看本地 ref,本地 ref 在恢復過程中可能被各種神奇地修改。
  6. 完整復盤指南固化在 skill devops/container-host-accessreferences/angelife-release-script-hazards.md ——本文是它的人類可讀版,那篇是給 AI agent 讀的操作手冊。

九、結論

這次事故沒造成損失:

  • 主站 v0.7.18 → v0.7.19 平滑切換,HTTP 200 全綠。
  • tag、commit、push 全部如期就位。
  • 本地源碼樹完整恢復,沒丟任何一行內容。
  • 經驗沉澱進 skill 文檔(incident #2)+ memory(4 個新踩坑)。

但它揭示了一個結構性問題:發布腳本本身有 wipe 倉庫的歷史,第二次發生說明不是偶發——下次必然還會發生。下一輪維護的優先級任務:給 tools/angelife-release 的 rsync 加防禦性 --exclude 列表,並寫進腳本注釋鎖死。

「你應該怕 rsync --delete,怕它怕到只有在你確切知道 source 和 target 都安全的時候才用。」——這是這次事故給未來自己的話。


版本標籤: v0.7.19 事故時間: 2026-06-18 18:23 - 18:50(約 27 分鐘) Commit: f073bee 操作員: NVIDIA(Docker Hermes) 最終裁定: Tse 確認驗收通過