無部署步驟會重新建置的已提交建置產物
一個在 git 中追蹤並嵌入到二進位檔的產生檔案,可以不經重新建置就發布。沒有錯誤,只是一個類別,其後沒有 CSS 規則。
有些建置產物會被刻意提交到儲存庫中。一個編譯後的樣式表、一個生成的 API 客戶端、一個捆綁的翻譯檔案。這種模式一直都行得通,直到你注意到重新生成檔案的指令存在於一個腳本中,而部署路徑執行的卻是另一個。然後新的輸入內容在沒有重新建置的情況下就被發布了,而且沒有任何東西會通知你。
我遇到的情況是:一個由 CSS 框架 CLI 生成的樣式表,被提交到 git,並嵌入到一個 Go 二進位檔中。部署腳本建置了一個容器映像檔,但從未呼叫 CSS CLI。任何在上次本機建置後編寫的新工具類別,都會在沒有相應規則的情況下被發布出去。
隱藏問題的設定
三個決定各自看來合理,但合在一起卻很危險。
首先,提交產生的檔案。這對於建置緩慢或需要執行階段映像檔所沒有的工具鏈的資產來說很常見。你可以在容器中獲得可重現的服務,並減少一個依賴項。
其次,將其嵌入到二進位檔中。在 Go 中,這是 //go:embed,這也是為什麼檔案必須在編譯時期就存在,而不是在啟動時才擷取。
第三,將重新產生的指令放在一個同時也做其他事情的建置腳本中:簽署、公證、上傳桌面產出物。該腳本需要憑證和 VPN,所以它不是 CI 所執行的東西。
現在來追蹤這個檔案。它由腳本 A 產生。它在腳本 B 執行期間被編譯器使用。腳本 B 中沒有任何東西會檢查自上次輸入變更後,腳本 A 是否已執行。
為何失敗是無聲的
其嚴重性完全取決於使用方如何反應一個遺失的條目。
一個遺失了某個方法的生成式 API 用戶端會明確地失敗。呼叫點無法編譯,CI 亮起紅燈,而你可以在合併前修復它。生成式資料庫查詢程式碼和 protobuf 存根也是如此。型別系統正在為你進行檢查。
樣式表則正好相反。CSS 沒有未定義 class 的概念。寫下 class="items-center" 卻沒有匹配的規則時,瀏覽器會將其解析為空。元素會渲染,頁面會載入,但不會出現任何主控台警告。你會得到一個在生產環境中有細微錯誤,但在上次運行 CLI 的機器上卻是正確的版面配置。
當查找回退到使用鍵值時,翻譯包的行為也是一樣的。當一個未知的旗標被讀取為 false 時,功能旗標清單的行為也是一樣的。在每種情況下,遺失的條目都有一個合理的預設值,而一個合理的預設值正是讓這個錯誤變得無聲無息的原因。
所以經驗法則是:如果生成的產物被沒有綱要(schema)的東西所使用,那麼在檢查之前,都應假設缺口存在。
找出哪個路徑會重建它
這大約需要一分鐘,而答案通常令人不悅。
首先確認該檔案確實有被追蹤,而不是被忽略:
git ls-files --error-unmatch path/to/generated.css
git check-ignore -v path/to/generated.css # expect no output
然後對每個參與部署的檔案執行 grep,以尋找產生器指令:
grep -rn "tailwindcss\|protoc\|sqlc\|openapi-generator" \
Dockerfile* .github/workflows/ script/ Makefile cloudbuild.yaml 2>/dev/null
在我的情況下,命中的地方都集中在一處,也就是桌面建置腳本,而在 Dockerfile 和部署腳本中則完全沒有命中。這就是完整的診斷結果。容器建置會複製儲存庫並進行編譯,所以 git 裡有什麼,交付的就是什麼。
還有一個檢查可以抓出相反的錯誤,也就是檔案在部署期間產生,但也被提交了,這會導致每次部署都產生一個虛假的差異比對結果:
git status --porcelain path/to/generated.css # after a local build
如果一次全新的建置在沒有輸入變更的情況下修改了檔案,那麼產生器就不具確定性,而 提交它將會永遠與你作對。
兩種彌補差距的方法
兩者皆為有效方法。請根據執行階段映像檔是否能託管工具鏈來選擇。
將生成作業移至部署路徑。 這是最直接的修復方式。將生成器加入容器建置或產生映像檔的 CI 工作流程中,並停止提交其輸出。代價是建置映像檔會變得更肥大,且需要再多固定一個工具鏈的版本。如果生成器需要憑證或簽署金鑰,那你就無法選擇此選項,而這正是當初差距產生的原因。
持續提交,並驗證差異為空。 如果該產物必須保留在 git 中,那麼就明確訂定不變性:在正常編輯後,生成的檔案不得有任何變更。一個執行生成器並在偵測到 dirty tree 時失敗的檢查,會將一個無聲的差距轉變為紅色的建置失敗:
make generate
git diff --exit-code path/to/generated.css \
|| { echo "generated asset is stale, run make generate and commit"; exit 1; }
將其放入 CI,而非本機掛鉤。趕時間的人會跳過本機掛鉤,而這種人最有可能新增一個新的 class。
當兩者皆不適用時,有個較弱的變通方法值得了解。維護一份寫死的 (hard-coded) token 列表,其中包含產出物 (artifact) 目前支援的 token,並用它來檢查新的原始碼。我將此作為審查步驟:擷取已編譯樣式表 (stylesheet) 中存在的每個 class 名稱,擷取變更所新增的每個 class 屬性,然後對這兩組進行 diff 比較。它在程式碼交付前,發現某個候選 class 不在產生的集合中。這是一個檢查清單項目,而非一道關卡 (gate),但總比相信記憶力來得好。
還有哪些東西具有這種模式
這個模式是「一個產物,是部署時的輸入,卻是其他東西的輸出。」一旦你這樣稱呼它,它就會在許多地方出現。
當框架的 CLI 不在執行期映像檔中時,已編譯的樣式表和 JS 套件。從 OpenAPI 規格產生的 API 客戶端,特別是當規格位於不同的儲存庫時。來自 SQL 產生器的資料庫查詢程式碼。Protobuf 和 gRPC 存根。已編譯的翻譯目錄。嵌入式的遷移套件。Vendored 依賴樹。Terraform 鎖定檔。
那些「大聲」的,也就是缺少一個條目就會破壞編譯的,大多會自行顯現出來。而那些「安靜」的則值得刻意審核:樣式表、翻譯目錄、旗標清單、圖示精靈圖,以及任何在查找失敗時有合理備用方案的東西。
還有第二個值得注意的信號。如果產生的檔案也被嵌入到二進位檔中,消費者就無法在執行期取得新的複本,因此這個差距無法透過清除快取或重新載入設定來掩蓋。嵌入移除了你的逃生口。
常見問題
我應該停止提交產生的檔案嗎? 這樣做可以消除這類錯誤,但會將工具鏈移至映像檔中,並使建置變得更慢、更龐大。對於使用廣泛可用工具在幾秒鐘內即可建置的資產,是的,請在部署期間產生。對於任何需要憑證、原生編譯或緩慢工具鏈的東西,提交檔案再加上 CI 的過時檢查是一個合理的權衡。
pre-commit hook 足夠嗎? 不夠。Hook 是本機的、可跳過的,而且當有人重新 clone 時很容易遺失。如果你喜歡,可以使用 hook 來提升速度,但權威性的檢查必須在沒有人可以繞過的地方執行。
我如何知道一個 class 是真的遺失了,而不是拼錯了?
從產生的成品中提取識別碼,並搜尋確切的 token。這裡會有跳脫字元的問題:一個名為 py-2.5 的工具程式,在編譯後的 CSS 中可能會顯示為 .py-2\.5,而天真地用 grep 搜尋 .py-2.5 會回報它遺失了。比對未跳脫的形式,或兩者都檢查。
如果 diff 檢查在乾淨的 tree 上持續失敗怎麼辦? 那麼表示產生器不是確定性的。常見原因包括嵌入的時間戳、版本橫幅,或依賴於檔案系統的檔案排序。在將檢查設為阻擋性之前,請鎖定版本、移除橫幅,或對輸入進行排序。一個不穩定的關卡會在一週內被停用。
這適用於 lock 檔嗎? 部分適用。Lock 檔也是提交的成品,但套件管理器通常會在安裝過程中驗證它們,所以過時的 lock 檔會明確地導致失敗。Lock 檔的風險正好相反:部署時悄悄地重新產生它們,並發布了沒有人審核過的依賴版本。