建置腳本在開發者電腦上運作正常,推送到雲端 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 仍應掃描外部匯入或動態產生的腳本。
在獨享 Apple Silicon 實體節點上執行下一次建置
選擇租用期限與節點區域,確認 Mac Mini M4、16GB RAM、256GB SSD 及應付 USD 金額後提交訂單。