雲端 Mac CI 腳本權限與換行治理

CI/CD 實踐 ·約 7 分鐘閱讀

雲端 Mac CI 腳本權限與換行治理

建置腳本在開發者電腦上運作正常,推送到雲端 Mac 後卻出現 Permission denied、bad interpreter,或在同一行結尾冒出難以解釋的 $'\r'。這類故障通常與業務程式碼無關,而是 Git 檔案模式、換行字元與實際直譯器之間沒有形成一致的契約。先保留失敗命令、提交編號與腳本路徑,再依照以下順序檢查,通常比反覆清除快取更快。

先確認故障發生在哪一層

不要一看到「腳本失敗」就立刻加上 chmod +x。Shell 腳本至少涉及四層狀態:工作目錄中的權限、Git 索引記錄的模式、檔案的實際位元組,以及 runner 呼叫腳本的方式。

script="ci/build.sh"

ls -l "$script"
git ls-files -s -- "$script"
file "$script"
head -n 1 "$script" | cat -vet

git ls-files -s 輸出的第一欄若為 100755,表示 Git 已記錄可執行位元;100644 則表示一般檔案。若 cat -vet 在行尾顯示 ^M,代表檔案混入了 CRLF。也要確認第一行是有效的 shebang,例如 #!/bin/zsh 或 #!/usr/bin/env bash。

呼叫方式也必須記錄。bash ci/build.sh 不要求檔案具備可執行位元,但 ./ci/build.sh 會檢查權限並讀取 shebang。若 Xcode 建置階段、任務排程腳本與本機終端機採用不同的呼叫方式,就可能造成「只有 CI 會失敗」的錯覺。

修復目標不是讓某一次任務碰巧通過,而是讓儲存庫明確記錄腳本應由哪個直譯器執行、是否允許直接執行,以及必須採用哪一種換行格式。

透過 Git 索引修正可執行位元

需要直接執行的進入點腳本,應在 Git 中記錄可執行位元;透過 source 載入或明確交給直譯器執行的片段,通常維持 100644 即可。先整理一份進入點清單,避免把所有 .sh 檔案都設成可執行。

git update-index --chmod=+x ci/build.sh
git update-index --chmod=+x ci/test.sh
git diff --summary
git ls-files -s -- ci/build.sh ci/test.sh

如果 git diff --summary 沒有顯示模式變更,請檢查本機儲存庫是否關閉了權限追蹤:

git config --get core.fileMode
git config core.fileMode true

core.fileMode 只會影響目前工作目錄偵測變更的方式,真正會隨提交傳遞的是 Git 索引。不要把 runner 啟動時執行 chmod -R +x . 當成長期方案。這麼做會掩蓋儲存庫中的錯誤,還可能讓設定檔、密鑰資料或一般資料檔案取得多餘權限。

可以在 ci/entrypoints.txt 中逐行登記允許直接執行的腳本。驗收時只需檢查這份清單;新增進入點時也必須同步更新登記檔案,程式碼審查者便能看出權限邊界為何改變。

固定使用 LF,並謹慎正規化既有檔案

儲存庫根目錄中的 .gitattributes 應成為換行規則的唯一依據。針對常見的建置檔案,可以採用以下基準設定:

*.sh       text eol=lf
*.command  text eol=lf
*.zsh      text eol=lf
*.xcconfig text eol=lf
*.pbxproj  text eol=lf
*.png      binary
*.zip      binary

加入規則後,Git 不會自動重寫已提交的檔案。請在乾淨的分支中執行:

git add --renormalize .
git status --short
git diff --check
git diff --cached --stat

先審查差異,再將正規化結果單獨提交,不要與功能修改混在一起。若一次出現大量專案檔案變更,應抽查內容,確認實際上只有換行格式受到調整。壓縮檔、程式碼產生器與外部腳本仍可能重新帶入 CRLF,因此 .gitattributes 不能取代掃描門禁。

檢查結果 常見原因 正確處理方式
100644 且直接執行 索引未記錄可執行權限 使用 git update-index --chmod=+x 修正
行尾出現 ^M 檔案採用 CRLF 正規化為 LF 並提交
bad interpreter shebang 含有 CR 或路徑無效 修正第一行,並確認直譯器存在
本機成功、CI 失敗 呼叫方式或 Shell 不同 輸出呼叫命令並明確選擇 Shell

將直譯器差異變成明確契約

macOS 的預設互動式 Shell,不一定與 CI 實際使用的 Shell 相同。若腳本使用 Bash 陣列、[[ ]] 或行程替換,就不應交由 sh 隱式解譯;依賴 zsh 語法時,也必須直接宣告。

#!/usr/bin/env bash
set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"

不要依賴互動式設定檔提供 PATH、別名或環境變數。CI 進入點應自行設定最小化的 PATH,並透過 command -v xcodebuild、command -v ruby 等方式確認工具是否存在。必要的環境變數應由任務設定注入;腳本只檢查變數名稱,不要輸出敏感值。

也要避免使用 sh script.sh 呼叫帶有 Bash shebang 的檔案,因為明確指定 sh 會略過 shebang。DebugMac 節點上的任務同樣應以儲存庫中的宣告為準,而不是根據某次工作階段剛好可用的 Shell 推斷執行環境。

建立提交前與 CI 雙重門禁

本機掛鉤可以縮短回饋時間,但不能成為唯一防線,因為一般複製儲存庫時不會自動啟用掛鉤。CI 應在安裝相依套件及執行 Xcode 建置之前,先完成輕量檢查。

#!/usr/bin/env bash
set -euo pipefail

while IFS= read -r script; do
  [[ -z "$script" ]] && continue
  mode="$(git ls-files -s -- "$script" | awk '{print $1}')"
  [[ "$mode" == "100755" ]] || {
    printf 'entrypoint is not executable: %s\n' "$script" >&2
    exit 1
  }
done < ci/entrypoints.txt

if git grep -Il $'\r' -- '*.sh' '*.zsh' '*.command' > /tmp/crlf-files; then
  printf 'CRLF detected:\n' >&2
  cat /tmp/crlf-files >&2
  exit 1
fi

門禁還應確認登記檔案中的每個路徑確實存在,並拒絕尚未提交的正規化差異。最終驗收可分成四項:全新簽出儲存庫、直接執行進入點腳本、確認 git status --porcelain 為空,以及在非互動式工作階段完成一次最小建置。如此一來,權限與換行問題會在成本較高的相依套件下載和編譯開始前暴露,也不必依靠臨時修改 runner 環境來維持任務運作。

常見問題

腳本由 bash 執行時,還需要管理可執行權限嗎?

bash script.sh 不依賴可執行權限,但直接執行、建置階段與部分 runner 會檢查權限。真正的入口腳本應在 Git 索引中記錄為 100755。

加入 .gitattributes 後會自動修復既有 CRLF 嗎?

不會。需要執行 git add --renormalize .,檢查差異後提交;CI 仍應掃描外部匯入或動態產生的腳本。

Debug M4

在獨享 Apple Silicon 實體節點上執行下一次建置

選擇租用期限與節點區域,確認 Mac Mini M4、16GB RAM、256GB SSD 及應付 USD 金額後提交訂單。

立即租用雲端 Mac