雲端 Mac CI 的 umask 與檔案權限漂移治理

雲端 Mac CI 的 umask 與檔案權限漂移治理

同一份建置指令碼在 SSH 終端機中執行正常,交給雲端 Mac CI 後卻出現 Permission denied,最常見的誤判是「再執行一次 chmod +x 就好」。真正的問題通常橫跨四個層面:任務進入點的 umask、Git 索引儲存的可執行位元、複製與解壓縮工具的權限語意,以及指令碼實際使用的直譯器。只修正工作區中的單一檔案,下次乾淨簽出時仍會再次發生。

先將權限問題分成四類

開始排查前,先記錄失敗的物件與操作類型。無法執行指令碼、無法寫入目錄、金鑰權限過於寬鬆,以及封存檔解壓縮後模式改變,處理方式完全不同。

使用 stat 同時查看八進位模式、擁有者與檔案類型:

target="${1:?path required}"
stat -f 'mode=%Sp octal=%OLp owner=%Su group=%Sg type=%HT path=%N' "$target"
ls -ldeO@ "$target"

第二個命令也能顯示 ACL、檔案旗標與延伸屬性。如果一般權限看似正確卻仍然失敗,不要立刻提升權限;應先確認上層目錄是否可供遍歷、檔案所在的卷宗是否允許執行,以及程序實際使用哪個帳號。

rwx 並非唯一判斷依據。路徑上的每一層目錄都需要具備相應權限,ACL 也可能覆寫一般模式位元。持續使用 sudo 只會讓工作區出現混合擁有者,使後續任務更難重現。

建立最小現場紀錄

至少保留 idumask、目前目錄、目標檔案的 stat 結果與掛載資訊。不要將所有環境變數寫入日誌,其中可能包含權杖。現場紀錄應聚焦於身分、路徑與權限,不應收集憑證內容。

在任務進入點固定 umask

umask 只會影響新建物件,不會回寫既有檔案。常見基準值 022 會使一般檔案預設為 644、目錄預設為 755;對於受控的同群組協作目錄,可以評估使用 002,但不能將它視為通用修正方式。任務進入點應明確設定,並透過新建探針進行驗證:

set -euo pipefail
umask 022

probe_dir="$(mktemp -d)"
probe_file="$probe_dir/probe"
: > "$probe_file"

file_mode="$(stat -f '%OLp' "$probe_file")"
dir_mode="$(stat -f '%OLp' "$probe_dir")"

test "$file_mode" = "644"
test "$dir_mode" = "700"
rm -rf "$probe_dir"

為了保護暫存內容,mktemp -d 通常會建立權限為 700 的目錄,因此不能用它來斷言一般目錄應為 755。如需測試目錄基準值,應在已知的上層目錄中執行 mkdir,再另行檢查。這項差異也是權限測試經常誤報的原因。

讓 Git 儲存正確的執行意圖

Git 主要追蹤檔案是否可執行,不會完整儲存所有 Unix 權限。某個指令碼在一台機器上被手動改成 755,並不代表索引已記錄這項變更。應檢查索引,而不是只查看工作區:

git ls-files --stage |
awk '$1 == "100755" {print $4}' |
sort

需要提交可執行位元時,請使用:

git update-index --chmod=+x scripts/build.sh
git diff --summary
git diff --cached --summary

反過來,如果設定檔或文件意外帶有可執行位元,也應使用 git update-index --chmod=-x 修正。對於 .sh 檔案,還需要檢查第一行。建議使用環境中確定存在的直譯器,例如 #!/bin/zsh#!/usr/bin/env bash,並避免指令碼使用 CRLF 行尾。

在合併閘道中核對指令碼

可以維護一份明確的可執行指令碼清單,再與索引結果進行比較。不要簡單規定「所有 .sh 都必須可執行」,因為由 source 載入的程式庫檔案未必需要可執行位元。規則應表達檔案用途,而不是根據副檔名猜測。

控制複製、壓縮與解壓縮的權限語意

cpdittorsync 與不同封存格式對模式位元、ACL 和延伸屬性的處理方式並不相同。建置快取只應保存可重新產生的資料;交付套件則應在解壓縮後重新驗收權限。

複製工作區時,應先決定是否需要保留中繼資料。如果只需要原始檔案內容,應避免無意間繼承舊有的擁有者、ACL 或延伸屬性。如果必須保留可執行位元,則應在暫存目錄中進行一次往返測試:

src="scripts/build.sh"
tmp="$(mktemp -d)"
ditto "$src" "$tmp/build.sh"

before="$(stat -f '%OLp' "$src")"
after="$(stat -f '%OLp' "$tmp/build.sh")"
test "$before" = "$after"

rm -rf "$tmp"

驗收封存檔時,不能只檢查檔案是否存在。至少要確認進入點指令碼可執行、一般設定檔不可執行,以及敏感檔案不允許群組或其他使用者讀取。解壓縮目錄應為全新目錄,避免舊檔案的模式掩蓋封存檔本身的問題。

將修正轉化為可執行策略

穩定的解決方案不是在失敗後批次執行 chmod -R 777,而是建立少量且可清楚解釋的斷言。以下檢查會拒絕可由其他使用者寫入的檔案,並確認進入點指令碼可執行:

set -euo pipefail

entry="scripts/build.sh"
test -f "$entry"
test -x "$entry"

while IFS= read -r -d '' file; do
  mode="$(stat -f '%OLp' "$file")"
  other_write=$((8#$mode & 2))
  if (( other_write != 0 )); then
    printf 'world-writable file: %s mode=%s\n' "$file" "$mode" >&2
    exit 1
  fi
done < <(find . -type f -not -path './.git/*' -print0)

將策略指令碼放入儲存庫中,供本機預檢與 CI 共用。失敗輸出只應包含路徑、預期模式與實際模式,既方便定位問題,又不會洩漏內容。在 RentMini 的獨享實體節點上執行任務時,也應在每個新工作區中重新執行這些斷言,而不是假設上一輪的環境會維持不變。

最後進行一次乾淨簽出驗收:刪除暫存工作區、重新複製儲存庫,不執行任何人工修正,直接執行權限檢查與最小建置。只有這條路徑通過,權限修正才算真正納入版本控制,而不是停留在某次遠端工作階段中。

常見問題

腳本已執行 chmod +x,為何 CI 仍顯示 Permission denied?

應確認 chmod 套用的是實際執行檔,並檢查所在卷是否允許執行、首行直譯器是否存在,以及後續複製或解壓是否再次移除了可執行位元。

雲端 Mac CI 適合使用哪個 umask?

多數單一使用者建置可從 022 開始;需要同群組共同寫入時可評估 002。任務入口必須明確設定,並以實際產物權限驗證結果。

如何在合併前攔截權限漂移?

同時稽核 Git 索引的可執行位元、腳本首行、敏感檔案權限與封存檔解包結果,任何異常都讓 CI 以非零狀態結束。

按需使用雲端 Mac

為下一次建置準備獨享實體 Mac mini

選擇 M4 配置、租期與節點。每筆訂單對應一台獨享實體設備,實際可用狀態以控制台即時回傳為準。

選擇配置並租用