雲端 Mac CI 元資料清理與交付關卡實作

雲端 Mac CI 元資料清理與交付關卡實作

雲端 Mac 上的建置已經通過,壓縮檔也能正常解開,但接收方仍可能看到 ._Config.json、.DS_Store,或在上傳階段遇到來源不明的延伸屬性。問題通常不在編譯器,而是 Finder 操作、跨檔案系統複製,以及封存工具處理 macOS 元資料的方式不同。解決方法不是粗暴地清理整個工作區,而是將交付流程拆分為可稽核的暫存、掃描、封存與複驗四個步驟。

先定義哪些內容可以進入交付包

macOS 檔案除了名稱和內容,還可能帶有延伸屬性、資源分支與 Finder 元資料。這些資訊在本機檔案系統中未必會造成問題,但進入 ZIP、共享磁碟或物件儲存空間後,可能轉換成 AppleDouble 伴隨檔案。

應先定義產物規則,而不是一邊打包一邊刪除:

物件 預設策略 原因
.DS_Store 拒絕 只用於描述 Finder 目錄檢視方式
._* 拒絕 通常由資源分支在跨檔案系統轉換時產生
com.apple.quarantine 檢查並定向處理 可能來自下載工具或瀏覽器
com.apple.ResourceFork 依產物類型判斷 一般設定包通常不需要,但特定檔案可能依賴
已簽署的應用程式套件 保留結構並複驗 不加區分地清理會增加簽章異常的風險

清理對象應是獨立的暫存目錄,而不是原始碼工作區。如此一來,即使策略設定有誤,也不會修改快取、相依套件目錄或下次建置需要的輸入。

如果交付內容包含已簽署的應用程式,應先驗證原始產物的簽章。一般文件、設定與日誌可以採用更嚴格的元資料拒絕策略;這兩類物件不應共用同一條遞迴刪除命令。

建立一次性暫存目錄

暫存目錄必須由目前的工作獨占,並在結束時清除。不要重複使用固定的 /tmp/release,因為平行工作可能互相覆寫。

#!/bin/bash
set -euo pipefail

SOURCE="${1:?source path required}"
OUTPUT="${2:?output path required}"
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/bamini-artifact.XXXXXX")"
UNPACK="$(mktemp -d "${TMPDIR:-/tmp}/bamini-unpack.XXXXXX")"

cleanup() {
  rm -rf "$STAGE" "$UNPACK"
}
trap cleanup EXIT

/usr/bin/ditto --norsrc "$SOURCE" "$STAGE/payload"
find "$STAGE/payload" -type f -name '.DS_Store' -delete
find "$STAGE/payload" -type f -name '._*' -delete

mktemp 會讓每次工作取得不同的目錄,trap 則可涵蓋成功、失敗與中斷等結束路徑。此處使用 ditto --norsrc 複製內容,避免主動攜帶資源分支,但後續掃描仍不可省略,因為輸入目錄中可能原本就存在實體化的 ._ 檔案。

如果工作流程需要交付完整的應用程式套件,應先確認複製策略能保留該應用程式所需的內容。更穩妥的方式是將應用程式套件與一般附件分開暫存,並分別套用對應策略。

掃描延伸屬性,而不是全部清除

xattr -cr 很方便,但涵蓋範圍也過於寬泛。它不僅會刪除已知的隔離屬性,也會刪除尚未納入檢查規則的其他屬性。結果是流水線看似「轉綠」,根本原因卻被掩蓋。

可以先輸出屬性名稱,再讓明確禁止的項目中止工作:

ATTR_REPORT="$STAGE/xattr.txt"

if /usr/bin/xattr -lr "$STAGE/payload" >"$ATTR_REPORT" 2>&1; then
  if grep -E 'com\.apple\.(quarantine|ResourceFork)' "$ATTR_REPORT"; then
    echo "disallowed macOS metadata found" >&2
    exit 41
  fi
fi

if find "$STAGE/payload" -type f \( -name '.DS_Store' -o -name '._*' \) -print -quit |
  grep -q .; then
  echo "hidden metadata files found" >&2
  exit 42
fi

這道關卡採用「一經發現即失敗」的策略,適合用來先定位元資料是由哪個步驟引入。確認來源後,再針對下載、複製或產生階段進行修正。若業務需求確實允許某個延伸屬性,應將檔案範圍與屬性名稱寫入允許清單,而不是直接放行整個目錄。

記錄來源步驟

在原始碼簽出後、編譯後與暫存後各掃描一次。首次出現異常的階段,就是應開始排查的邊界。常見來源包括使用圖形介面瀏覽目錄、從非原生檔案系統複製,以及工具先下載再解包。若只在最終封存前掃描,雖然能攔截污染內容,卻無法指出是哪個步驟造成問題。

封存後必須重新開啟檢查

暫存目錄乾淨,不代表封存工具不會重新寫入元資料。產生 ZIP 後,應先檢查成員名稱,再解包到新目錄中複驗。

rm -f "$OUTPUT"
/usr/bin/ditto -c -k --norsrc --keepParent \
  "$STAGE/payload" "$OUTPUT"

/usr/bin/unzip -Z1 "$OUTPUT" >"$STAGE/members.txt"

if grep -E '(^|/)\.DS_Store$|(^|/)\._[^/]+$' "$STAGE/members.txt"; then
  echo "archive contains forbidden metadata files" >&2
  exit 43
fi

/usr/bin/ditto -x -k "$OUTPUT" "$UNPACK"

if find "$UNPACK" -type f \( -name '.DS_Store' -o -name '._*' \) -print -quit |
  grep -q .; then
  echo "extracted artifact failed metadata check" >&2
  exit 44
fi

檢查成員清單速度較快,適合作為第一道關卡;解包複驗則可涵蓋路徑轉換與解壓縮行為。兩者都應執行,因為上傳系統實際接收的是封存檔,而使用者最終操作的是解包後的內容。

如果套件中包含已簽署的應用程式,應找到解包後的 .app,再執行嚴格的簽章驗證。不要只驗證打包前的副本,否則就無法發現封存參數造成的變更。

將關卡接入可重複執行的交付流程

建議將指令碼固定為「輸入目錄、輸出檔案」兩個參數,並讓每次工作產生以下證據:

  1. 暫存前的產物路徑與類型。
  2. 延伸屬性掃描結果。
  3. ZIP 成員清單。
  4. 解包後的隱藏檔案檢查結果。
  5. 存在應用程式套件時的二次簽章驗證結果。
  6. 最終封存檔的 SHA-256 摘要。

摘要可使用 shasum -a 256 artifact.zip 產生。它無法證明兩個 ZIP 的內容在語意上完全一致,卻能確認上傳、下載與交接過程中沒有發生位元組變化。

在 BAMini 的雲端 Mac 上執行這類工作時,應先在主控台確認目前可選的設定,再將指令碼放入儲存庫並交由 CI 呼叫,不要依賴某位成員記得手動清理。關卡失敗後,保留成員清單與屬性報告,並刪除包含專案內容的暫存目錄;下一次工作必須建立新的暫存目錄,避免前一次殘留內容影響判斷。

最終目標不是讓所有延伸屬性消失,而是確保交付包只包含團隊明確核准的內容。暫存隔離負責控制修改範圍,分階段掃描負責定位來源,解包複驗則負責驗證實際交付結果。三者缺一不可,才能防止隱藏元資料在不同複製路徑中反覆出現。

常見問題

可以直接對整個工作區執行 xattr -cr 嗎?

不建議。它會遞迴移除全部延伸屬性,可能破壞需要保留的資源分支,也會掩蓋上游問題。只應處理獨立暫存目錄,並依允許清單移除屬性。

刪除 .DS_Store 後為何壓縮包仍有 ._ 檔案?

._ 檔案是 AppleDouble 旁車檔,不等同於 .DS_Store。封裝時必須停用資源分支複製,並在封裝後檢查成員清單。

已簽章的應用程式應先清理還是先驗證?

先驗證原始應用程式,再複製到暫存目錄;不要對應用程式套件無差別清除屬性。封裝後解包到新目錄並再次嚴格驗證簽章。

獨享實體節點

將下一次建置放到雲端 Mac mini

選擇配置、計費週期與節點區域,使用獨享實體機執行開發、建置與自動化工作。

立即租用 Mac mini