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

sitemap 檢查說多了一個網址:那頁沒進 git,但前一天就上線了

部署前的檢查停在 stale in sitemap:已 commit 的 sitemap 列著一個 git 裡沒有的頁面。它被當成草稿拿掉了,但那一頁前一天就從工作目錄部署上線。

〈從乾淨的 worktree 部署〉寫過一次:為了只部署 commit 過的內容,我改從乾淨的 worktree 部署,結果 --delete 把只存在工作目錄的 32 張圖刪掉。那次的教訓是:工作目錄裡沒進 git 的東西,可能早就在線上了。

這次是同一件事的另一面。10 月 10 日從乾淨的 worktree 部署公司官網,部署前的 sitemap 檢查停了下來:stale in sitemap: /services/partner-services/。已 commit 的 sitemap 列著一個網址,git 裡卻沒有那一頁。當時的工作階段把它判斷成「別人沒做完的草稿」,從 sitemap 拿掉了。但那一頁前一天就已經上線,而且送過 Google 和 IndexNow。這篇要談的是這個不一致怎麼來的、為什麼這次在乾淨的 worktree 裡才被看到,以及「沒進 git」為什麼不能讀成「沒上線」。依序回答五件事:時間線、工作目錄裡的檢查為什麼通過、順便發現的 lastmod 落後、本機重現,以及我會怎麼改。

時間線

以下時間是 +08:00,取自 git log、檔案的建立時間、工作階段的紀錄和部署 log:

時間發生的事
10/9 21:19–21:20一個工作階段建立了 services/partner-services/index.html(新的「合作服務」頁),本機建置後重產 sitemap:「新增 1、共 378 個 URL」
10/9 21:23我確認後,從工作目錄部署官網,sitemap 檢查「一致(378 個 URL)」;部署後確認那一頁回 200、線上 sitemap 有它,接著重新提交 sitemap、送 IndexNow。這一頁當時一直沒有 commit
10/9 21:46另一批文章的 commit 26c2805 一起 commit 了工作目錄裡的 sitemap:377 → 385 個網址。其中兩個網址在這個 commit 裡沒有對應的頁面:這一頁,和一篇漏 add、1 分鐘後才補進 acefa73 的文章
10/10 19:17另一個工作階段從乾淨的 sparse worktree 部署,檢查停下:stale in sitemap
10/10 19:18它查到那一頁在主工作目錄是未追蹤檔,判斷是別人的草稿,在乾淨的 worktree 重產 sitemap 並 commit(f2e260e,訊息寫「unpublished」)。接著的部署因為 AWS 帳號不對,沒有上傳任何東西
10/11 23:27、23:33從乾淨的 worktree 部署兩次,上傳的 sitemap 是 402 個網址,沒有那一頁
10/13 01:42commit eae52b3 才把那一頁本身 commit 進來,sitemap 也加回去

官網部署腳本對 HTML 只上傳、不刪除(--delete 只作用在 assets/),所以 10 月 11 日那兩次部署之後,那一頁應該還在線上,只是不在 sitemap 裡。這一段是從腳本推的,我沒有那兩天直接抓那一頁的紀錄。

工作目錄裡的檢查為什麼通過

官網的 scripts/gen-sitemap.py 用 os.walk 走過磁碟上的檔案樹,每個有 index.html 的目錄就是一個網址(排除 dist、node_modules 等目錄,以及開頭 4,000 字元內符合它 noindex 規則的頁面)。是否收錄不看 git,git 只用來查 lastmod。--check 模式重新算一次網址集合,和磁碟上的 public/sitemap.xml 比,有新增或移除就結束碼 1;它不比 lastmod。

10 月 9 日那個工作階段在建立頁面之後,先跑檢查得到 MISSING from sitemap,重產之後再跑就一致了。之後只要網址集合沒變,在同一個工作目錄跑幾次都一致,即使其中有未追蹤的頁面:產生與檢查用的是同一套規則、同一個磁碟。

commit 記錄的是索引(index)裡的內容。那次索引裡有 sitemap,沒有那一頁,兩者在 commit 裡就對不上。在工作目錄裡,只要那一頁還在磁碟上,現有的檢查器就看不到這個不一致;10 月 10 日那個乾淨的 worktree 沒有未追蹤的檔案,才把它暴露出來。直接拿已 commit 的 sitemap 和 git ls-tree 列出的頁面比對,也看得到。

檢查本身擋對了:commit 裡的 sitemap 確實和 commit 裡的頁面不一致。出問題的是接下來的判斷。「在 git 裡沒有」只說明它沒被 commit,不說明它沒上線;那一頁是從工作目錄部署出去的,修正方向應該是把頁面補進 commit,而不是把網址從 sitemap 拿掉。

順便發現:lastmod 落在上一次 commit

f2e260e 的 diff 不只拿掉一個網址,還把 7 個網址的 lastmod 改成 2026-10-10。那 7 頁(部落格首頁和 6 篇既有文章)都是在 10 月 10 日 19:17 的 commit 96dc792 裡被改過的。

原因在 lastmod():它用 git log -1 --format=%cs -- <檔案> 取最後一次 commit 的日期,沒有結果或執行失敗才退回檔案修改時間。我的流程是先重產 sitemap、再一起 commit,產生的那一刻,這次改到的頁面還沒 commit,git log 回的是上一次 commit 的日期,要等下一次重產才補上(日期不一定差一天,看兩次 commit 隔多久)。--check 只比網址集合,這種落後不會讓任何檢查失敗。

本機重現

我把官網那支 gen-sitemap.py 原封不動複製進一個玩具 repo:首頁和 blog/a/ 以 10 月 5 日的日期 commit;之後改了 blog/a/,另外放一個沒有 commit 的 services/draft/,重產 sitemap,再把 blog/ 和 public/ 以 10 月 9 日的日期 commit(日期用 GIT_COMMITTER_DATE 和 GIT_AUTHOR_DATE 指定,重現當天是 10 月 15 日):

$ python3 scripts/gen-sitemap.py
  +  /services/draft/
寫入:3 個 URL(新增 1、移除 0)
$ python3 scripts/gen-sitemap.py --check
sitemap 一致(3 個 URL)

# commit 之後,已 commit 的 sitemap:
/                 lastmod 2026-10-05
/blog/a/          lastmod 2026-10-05     ← 這個 commit 改過它,日期卻是上一次的
/services/draft/  lastmod 2026-10-15     ← git 裡沒有,日期是檔案修改時間

$ git clone -q . ../clean && cd ../clean && python3 scripts/gen-sitemap.py --check
stale in sitemap:    /services/draft/       # 節錄
$ echo $?
1

在乾淨的 clone 裡重產,diff 和 f2e260e 同一個形狀:/services/draft/ 那筆消失,/blog/a/ 的 lastmod 從 10-05 變成 10-09。

我會怎麼改

根源是「線上有什麼」和「git 裡有什麼」可以分開走:從工作目錄部署,未追蹤的頁面就能上線;從乾淨的 worktree 部署,它又被當成不存在。只修 sitemap 解決不了這件事,要讓兩者走同一條路。三件事:

  1. 部署前問 git。在工作目錄部署時,網站目錄底下有沒 commit 的變更就停下來;或者一律從乾淨的 worktree 部署。在玩具 repo 裡這樣寫:
    $ cat gate.sh
    dirty=$(git status --porcelain --untracked-files=all -- blog services index.html) || exit 1
    [ -z "$dirty" ] || { echo "工作目錄有沒 commit 的頁面,停止部署:" >&2; echo "$dirty" >&2; exit 1; }
    $ bash gate.sh; echo "exit=$?"
    工作目錄有沒 commit 的頁面,停止部署:
     M blog/a/index.html
    ?? services/draft/index.html
    exit=1
    實際的路徑清單要照網站的目錄結構寫。它擋的是「這次要部署的東西有沒 commit 的部分」;被 .gitignore 排除的檔案不在檢查範圍,已經在線上的頁面也不會因此被清掉。
  2. 檢查報 stale 時,先看線上。curl -s -o /dev/null -w '%{http_code}' https://…/services/partner-services/ 回 200,再看一眼內容,它多半已經上線,先問是不是該把頁面補進 commit;回 404,再查部署紀錄和當初要不要發布,決定補 commit 還是從 sitemap 拿掉。HTTP 狀態碼只是起點,不是判決。
  3. lastmod 改看 git 知道的狀態。產生器只收 git ls-files 列得到的頁面,跟 HEAD 不同的頁面用今天的日期:
    def git_set(*args):
        out = subprocess.run(["git", *args, "-z", "--", "."], cwd=ROOT,
                             capture_output=True, text=True, check=True).stdout
        return {os.path.join(ROOT, f) for f in out.split("\0") if f}
    
    TRACKED = git_set("ls-files")                                   # 目前索引裡的檔案
    CHANGED = git_set("diff", "--name-only", "--relative", "HEAD")  # 內容和 HEAD 不同
    git diff --name-only 預設印相對於 repo 根目錄的路徑,官網的程式在 repo 的 official_website/ 底下,所以要加 --relative。在玩具 repo 和一個「網站在子目錄」的玩具 repo 裡跑過:未追蹤的 services/draft/ 被跳過,/blog/a/ 的 lastmod 變成當天。CHANGED 也包含還沒 git add 的修改,產生器讀的仍是工作目錄的檔案內容,不是索引快照。這一條單獨用會有反效果:如果第 1 條沒做、未追蹤的頁面照樣從工作目錄部署上去,它就會在線上卻不在 sitemap 裡,跟 10 月 11 日的狀況一樣。所以它要和第 1 條一起。另外,「今天」是重產的日期,和 commit 的日期可能不同。

這三件事我都還沒合進官網的 repo。

這個站也一樣

寫這篇時我回頭看了 baobaolin.com 自己的建置。vite.config.mjs 用 readdirSync 收集 posts/ 底下的每一篇,sitemap 由建置產物產生,scripts/deploy.sh 在 repo 目錄裡直接 npm run build,沒有檢查 git 狀態。照程式推,posts/ 裡一份沒 commit 的文章會被建置、部署上去(中文版也會列進 sitemap),和 10 月 9 日那一頁是同一條路。這個站的 sitemap 不 commit,所以不會出現「commit 裡不一致」,但「線上有、git 裡沒有」一樣會發生。

如果只記得一件事

「git 裡沒有」不等於「線上沒有」。開頭那個 stale in sitemap 是對的,它抓到 commit 裡的 sitemap 和頁面不一致;錯在把它讀成「這頁還沒發布」。從工作目錄部署過的東西,只能從線上確認它在不在。

修訂紀錄

  1. 首次發布