包包baobaolin.com
date
entry
032
topic
infrastructure
rev
—

從乾淨的 worktree 部署,sync --delete 刪掉 32 張沒進 git 的圖

為了只部署 commit 過的內容,我從乾淨的 worktree 部署。代價是 --delete 把只在工作目錄裡的 32 張圖當成該刪的。

我部署公司官網時,會從一個乾淨的 git worktree 跑部署腳本,不從平常寫稿的工作目錄跑。理由很單純:工作目錄裡可能有還沒定稿的東西,而從指定 commit 新開的 worktree 不會帶進工作目錄裡沒追蹤的檔案,建置的就是那個 commit 的原始碼。

2026 年 10 月 11 日那次部署的紀錄裡多了 32 行 delete::24 張 vocus-、5 張 medium-、3 張 threads- 開頭的圖,都是社群貼文用的配圖。它們只存在我的工作目錄,沒有進 git,已經發出去的貼文可能用網址引用它們。我用 aws s3 cp 逐檔補回去,下一次部署(兩份部署紀錄的時間相差約 6 分半)又把同樣 32 張刪掉一次。這篇要談的是 sync --delete 拿來比對的到底是什麼。依序回答四件事:為什麼會刪、怎麼不碰 AWS 重現、有哪些修法、我傾向哪一個。

為什麼會刪

aws s3 sync help 對 --delete 的說明只有兩句:目的地有、來源沒有的檔案,在 sync 時刪除;被篩選條件排除的檔案不會被刪。這裡的「來源」是你給它的那個本機目錄,它不知道 git,也不知道哪些檔案你是故意不 commit 的。

那個站的打包設定會把 repo 裡的 assets/ 整個複製到 dist/assets/,部署腳本最後一步是:

aws s3 sync dist/assets/ "s3://$BUCKET/assets/" --delete \
  --cache-control "public, max-age=31536000, immutable"

這一步本來是為了清掉帶 hash 的舊 JS、CSS。005 談過部署當下就刪舊 bundle 會讓開著的頁面白屏,那篇的修法是不刪;這支腳本的折衷是把刪除留到最後一步。在工作目錄部署時,那 32 張圖在 assets/ 裡,複製進 dist/assets/,S3 上有、本機也有,不會刪。換到乾淨的 worktree,assets/ 裡只有 git 追蹤的檔案,dist/assets/ 少了 32 張,S3 上那 32 張就成了「目的地有、來源沒有」。

換句話說,乾淨的 worktree 解決了「上線的是哪一版」,同時讓「本機有但沒 commit」的檔案在 --delete 眼中等於不存在。這兩件事是同一個動作的兩面。

不碰 AWS 重現

在這個「本機目錄 → 一般 S3 bucket」的情境,--dryrun 讀本機檔案、列出目的地的物件,不會送出上傳或刪除,所以一個只會回 ListObjectsV2 的假 S3 就夠用。二十行 Python:

# fake_s3.py:bucket 裡「已經有」哪些物件,由 keys.txt 決定(每行 key 與大小)
import http.server, urllib.parse, sys
KEYS = sorted(tuple(l.split()) for l in open(sys.argv[2]) if l.strip())
class H(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
        prefix = q.get("prefix", [""])[0]
        items = "".join(
            f"<Contents><Key>{k}</Key><LastModified>2030-01-01T00:00:00.000Z</LastModified>"
            f"<ETag>\"x\"</ETag><Size>{size}</Size><StorageClass>STANDARD</StorageClass></Contents>"
            for k, size in KEYS if k.startswith(prefix))
        body = ('<?xml version="1.0" encoding="UTF-8"?>'
                '<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">'
                f'<Name>site</Name><Prefix>{prefix}</Prefix><IsTruncated>false</IsTruncated>'
                f'{items}</ListBucketResult>').encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/xml")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers(); self.wfile.write(body)
http.server.HTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever()

接著做一個最小的 repo:一個進版控的 app-3f9c.js,兩張沒 commit 的圖,再從它開一個 worktree。假 S3 上三個檔案都有,大小和本機相同、修改時間設在 2030 年,所以不會被判成要上傳。以下用 bash(git commit 需要已設定 user.name 與 user.email)。CLI 照 026 的方式隔離:env -i 清掉環境、設定檔指到 /dev/null、範例金鑰、endpoint 指向本機:

$ mkdir -p demo/repo/assets && cd demo/repo && git init -q
$ echo a > assets/app-3f9c.js && git add . && git commit -qm init
$ echo img > assets/vocus-cover.png; echo img > assets/threads-cover.png
$ git status --short
?? assets/threads-cover.png
?? assets/vocus-cover.png
$ git worktree add -q ../wt HEAD && ls ../wt/assets
app-3f9c.js
$ cd ..
$ printf 'assets/app-3f9c.js 2\nassets/threads-cover.png 4\nassets/vocus-cover.png 4\n' > keys.txt
$ python3 fake_s3.py 9555 keys.txt &      # fake_s3.py 存在 demo/ 底下
$ aws() { env -i PATH="$PATH" HOME="$HOME" \
    AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null \
    AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE \
    AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \
    AWS_DEFAULT_REGION=ap-northeast-1 \
    aws --endpoint-url http://127.0.0.1:9555 "$@"; }

$ aws s3 sync repo/assets/ s3://site/assets/ --delete --dryrun   # 工作目錄
$ aws s3 sync wt/assets/ s3://site/assets/ --delete --dryrun     # 乾淨 worktree
(dryrun) delete: s3://site/assets/threads-cover.png
(dryrun) delete: s3://site/assets/vocus-cover.png
$ aws s3 sync wt/assets/ s3://site/assets/ --delete --dryrun \
    --exclude "vocus-*" --exclude "threads-*"                     # 加上排除

工作目錄那次沒有任何輸出,worktree 那次列出兩筆預計刪除(dry-run,沒有真的刪),加了 --exclude 之後又沒有輸出,和說明文件那句「被排除的檔案不會被刪」一致。

做這個假 S3 時我自己踩了一個坑。第一版沒有把 key 排序,同一個指令在工作目錄跑出的 dry-run 結果,對 threads-cover.png 同時列了預計上傳和預計刪除。aws s3api list-objects-v2 help 寫明一般 bucket 依 key 的字典順序回傳;sync 拿兩邊排好序的清單一路往下比對,順序不對,比對就亂了。換成排序後輸出才正常。

四種修法

  • 把檔案 commit 進去。最直接。代價是社群配圖本來不打算跟網站一起做版本管理,進了 repo 就跟著網站的版本走,以後刪不刪也要另外決定。
  • 用 --exclude 擋住特定前綴。上面已經驗證有效。代價是名單要維護:下次多一種 linkedin- 開頭的圖,只要還放在 assets/、沒有 commit、也沒加進排除名單,一樣會被刪。
  • 不要讓它們進 --delete 管的前綴。非網站用的檔案放到另一個前綴(例如 social/),--delete 只作用在 assets/,兩者不重疊。這是結構上的解法,但已經對外發出去的網址不會跟著搬。
  • 先 dry-run,刪除清單不對就停。不改變放檔案的方式,只在真的刪之前看一眼。

我傾向第四種,因為它擋的是整類問題,不只這 32 張(寫這篇時它還沒進部署腳本,下面是在假 S3 上測過的版本)。這個專案的 Vite 設定打包出來的 JS、CSS,檔名結尾是 - 加 8 個字元的 hash,例如 blog-DlAXBFnW.css(命名規則可以改設定,不是每個專案都一樣)。刪除清單裡只要有不符合這個樣子的檔名,就停下來。過濾用 awk 而不用 grep -v,因為清單合法時 grep -v 沒有輸出、回傳 1,在 set -e 底下整支腳本會提早結束:

set -euo pipefail
src="$1"; dest="$2"; shift 2
case " $* " in *" --quiet "*|*" --only-show-errors "*)
  echo "不支援 --quiet:看不到刪除清單" >&2; exit 1;; esac
plan=$(aws s3 sync "$src" "$dest" --delete --dryrun "$@")
unexpected=$(printf '%s\n' "$plan" | awk '
  /^\(dryrun\) delete: / {
    sub(/^\(dryrun\) delete: /, "")
    if ($0 !~ /-[A-Za-z0-9_-]{8}\.(js|css)$/) print
  }')
if [ -n "$unexpected" ]; then
  echo "sync --delete 會刪掉這些不是舊 bundle 的檔案,先停下來:" >&2
  printf '%s\n' "$unexpected" >&2
  exit 1
fi
aws s3 sync "$src" "$dest" --delete "$@"

這段存成 demo/guard.sh。在假 S3 上多放一個舊 bundle index-Bq3xY9aZ.js 測:先 export -f aws 讓上面的隔離函式傳進子 shell,再跑 bash guard.sh wt/assets/ s3://site/assets/ --dryrun(多傳的 --dryrun 也會帶到最後一行,因為假 S3 不會處理真的刪除)。從 worktree 跑,它列出兩張圖、結束碼 1,沒有往下 sync;從工作目錄跑,刪除清單只有那個舊 bundle,通過檢查;沒有東西要刪時也通過。

這個檢查是用檔名猜的。一個剛好叫 cover-12345678.css 的非 bundle 檔案會被當成舊 bundle 放行,而 hash 長度如果改了設定,樣式也要跟著改。dry-run 和真正的 sync 是兩次各自列出清單,中間有變動它也看不到。它擋的是「沒想到會刪」,不是所有誤刪。

如果只記得一件事

sync --delete 比對的是你給它的那個目錄,不是 git,也不是你記得放過什麼。開頭那 32 張圖被刪了兩次,兩次部署腳本都正常跑完;最直接的訊號是輸出裡那 32 行 delete:,而同一份紀錄裡的 upload 有 1,468 行。讓腳本自己檢查刪除清單,可以少靠人在一千多行裡看到那 32 行。

修訂紀錄

  1. 首次發布