許多團隊在 Cloud Mac 或 Mac mini 上掛了 self-hosted GitHub Runner,以為「不排隊、能跑 xcodebuild」就算過關——排隊與 TCO 篇解決的是這一層。
真正的大坑在下一層:共享 workspace。共用 DerivedData、全域依賴快取、一把 PAT 同時給 CI 與 Agent 用——在只有維護者改 workflow 時,這只是少等幾分鐘建置;接上 fork PR、惡意 post step,以及 Claude Code / OpenHands 自動改 .github/workflows 之後,不隔離的自建 runner 形同裸奔:上一個 job 落的密鑰,下一個 job 仍讀得到。
2026 年行業正在對齊的底線也在這裡:一 Job 一 Workspace——每個 job 獨占目錄、結束後清理,並配合 token 輪換。本篇是 Cloud Mac AI Stack · L1 專題第三篇(前置:① 執行引擎 · ② 排隊與取捨):先講 CI/CD 安全大坑從哪來,再給你可抄 runbook。專题目錄見 § L1 連載。
讀本篇前 · L1 專題與 Stack 入口
L0 底座:買還是租 Cloud Mac · 遷雲端 AI 工作站
L1 專題(建議順序讀):① 執行引擎 → ② 排隊與 TCO → ③ 本篇 · CI/CD 安全與一 Job 一 Workspace
同棧常一起出現(L3–L5):Claude Code 工作流 · MCP 安裝 · Ollama 並行排班 · OpenClaw 流水線
對比圖:以前怎麼配 vs 2026 行業標配 · 坑踩在哪
很多團隊從託管 macos-latest 遷到 self-hosted 時,只繼承了「不排隊」(見 L1 ②),卻沒繼承託管機的「每 job 一塊新磁碟」。下面這張總覽表 + 示意圖,標出以前覺得「省事」、現在看是「裸奔」的位置。
以前「省事」= 現在「裸奔」——七條對照一眼看清
以前常見做法 2026 行業標配 踩坑後果
| 維度 | 以前常見做法(2024 前後) | 2026 行業標配 | 踩坑後果 |
|---|---|---|---|
| Job 工作目錄 | 多個 job 共用 _work,不刪 run 目錄 |
一 Job 一 Workspace,job 結束即清 | Job B 讀到 Job A 的 .env、腳本植入物 |
| 建置快取 | 共享 DerivedData / 全域 actions/cache key |
倉庫級 cache key + 定期 prune | 快取投毒;fork PR 掃描全域快取 |
| 憑證 | 一把 PAT 通吃 CI + Agent;secrets 輪換但不刪盤 | CI / MCP 分 token;30–90 天輪換 + 清 workspace | 磁碟上 PAT 副本長期有效 |
| 簽名材料 | 臨時 keychain 落在 $HOME,無 post 刪除 |
job 級 keychain,if: always() 銷毀 |
下一 job 或惡意 step 匯出憑證 |
| 誰改 workflow | 預設只有 2–3 個維護者 | 維護者 + fork PR + Agent 改 CI | 攻擊面從「人」擴到「半自主進程」 |
| Runner 分工 | 一台 Mac 接所有倉庫、所有 PR | 生產 / 預發 / fork 用不同 label | 低信任 workflow 摸到生產簽名環境 |
| 託管 vs 自建錯覺 | 「自建 = 自己的機器,怎麼快怎麼來」 | 自建 = 自己畫安全邊界(託管 VM 自動隔離) | 以為上了 self-hosted 更安全,實則更裸奔 |
以前為什麼能忍? 私有倉庫、無 fork PR、日更 CI 只有內部 job——共享盤只是少等 3–8 分鐘。 2026 為什麼必須改? 同一台 Cloud Mac 上往往還有 Agent、MCP 與多倉庫 job,任何一條舊做法都會把「省事」變成安全事件。
同一台 self-hosted runner(同一 macOS 使用者) Job A · nightly 簽名 │ ├─► 解壓 *.mobileprovision、臨時 keychain ├─► 寫入 ~/Library/Developer/Xcode/DerivedData ← 以前覺得「提速」 └─► PAT 寫入 ~/.netrc(腳本偷懶) ← 以前覺得「省事」 │ │ 未清理 · _work 與快取殘留 ▼ Job B · 白天 fork PR「修文件」 │ └─► post 步驟掃描 _work / DerivedData / .netrc ← 坑:讀到 Job A 殘留 結果:自建 runner 形同裸奔(託管 macos-latest 每 job 會丟整塊 VM 磁碟)
Job A ──► workspace A(_work/.../run-id) ──► if: always() 清理 ──► ✓ Job B ──► workspace B(全新目錄) ──► if: always() 清理 ──► ✓ │ ├─ 敏感快取:帶 repo 前綴的 cache key,或納入 cron prune ├─ PAT:CI 與 MCP 分 token,定期輪換 └─ 高信任 / 低信任 job:不同 runner label(必要時第二台 Cloud Mac) 結果:Fact 層可審計;與「Claude Code 生產 Diff,Runner 生產 Fact」一致
一眼對照 · 以前 vs 現在
- 以前踩坑:把 self-hosted 當「更快的伺服器」,共享一切能共享的
- 現在標配:把 self-hosted 當「你要負責消毒的執行環境」
- 最大誤區:從
macos-latest遷過來時,只遷了 CPU,沒遷隔離
裸奔從哪來:自建 Runner 最常見三類 CI/CD 安全大坑
託管 macos-latest 每次 job 近似一塊新磁碟,所以很多團隊第一次自建 runner 時誤以為「機器是自己的,怎麼省事怎麼來」。self-hosted 的真實代價是:安全邊界要你自己畫——這也是 L1 專題第二篇 裡「不排隊」之外的隱性帳單。
下面三類誤配,我們在 Cloud Mac 客戶現場見過不止一次;任意踩中兩條,就值得立刻上「一 Job 一 Workspace」。
2026 為什麼全員推行一 Job 一 Workspace
不是 GitHub 突然發了新規定,而是攻擊面變大了:執行 workflow 的不只有維護者,還有 fork PR、供應鏈腳本,以及同機上的 AI Agent。下面三條,解釋了為什麼「共享盤提速」在 2026 已經站不住腳。
1. 跨 job 污染:上一個 job 的密鑰,下一個 job 還能讀到
self-hosted runner 的 _work 目錄在 job 之間預設不會自動消毒。job A 解壓的簽名憑證、腳本寫的 .env、post 步驟落下的 .netrc,job B 完全可能透過相對路徑或符號連結讀到。2026 年攻擊面更大:Claude Code、OpenHands 還能在同機改 .github/workflows——你要審計的不只是 diff,還有改 CI 時磁碟上還有什麼殘留。
2. 全域快取投毒:DerivedData / npm 快取變成「公共後門」
共享 ~/Library/Developer/Xcode/DerivedData 或寬口徑 actions/cache key,在內部倉庫裡也許還能忍;一旦接 fork PR,惡意 step 可在 post 掃描全域快取——託管 VM 會銷毀,self-hosted 不清理就是持久攻擊面。iOS 團隊典型翻車:nightly 簽名 job 與白天「文件修復」PR 共用同一 runner。
3. 長期 PAT 落盤:只輪換 secrets 不夠,磁碟上的副本還在
許多 Cloud Mac 棧裡,同一把 GitHub PAT 既給 MCP 拉倉庫,又給 Runner 推製品。Agent 側一旦誤暴露,CI 側也跟著淪陷。只輪換 GitHub UI 裡的 secrets 而不清 workspace,等於只換了鎖芯、沒收回已經寫在磁碟上的副本。
先對齊模型:GitHub Actions 在 runner 上留下什麼
很多人把「workspace」理解成 checkout 出來的 git 目錄——其實只是一部分。一次 macOS job 結束後,磁碟上可能還存在:
| 路徑 / 物件 | 典型內容 | 不清理的風險 |
|---|---|---|
_work/<repo>/<run-id>/ |
checkout、建置產物、測試輸出 | 下一 job 讀取原始碼外的生成物、惡意植入腳本 |
~/.npm、~/Library/Caches |
依賴與工具快取 | 快取投毒、跨倉庫依賴混淆 |
DerivedData、.swiftpm |
Xcode / Swift 建置快取 | 洩露符號、嵌入舊版簽名配置 |
臨時 keychain、*.mobileprovision |
簽名材料 | 高危:下一 job 或惡意 step 匯出憑證 |
環境變數注入檔、.netrc |
CI 腳本寫的憑證 | 明文 PAT 長期留存 |
託管 runner 在 job 結束時會丟棄整塊磁碟;self-hosted 不會。Agent 棧裡更糟:Claude Code 的會話檔案、Ollama 權重、Runner 的 _work 可能共用同一使用者家目錄(同機排班見L2 並行調度)——「一 job 一 workspace」的意思,是 job 開始時假設環境已被 Agent 或其它 job 弄髒,結束時只保留應跨 job 存在的層(通常是沒有)。
行業標配落地:「一 Job 一 Workspace」指什麼
可以拆成四層,從易到難:
- 目錄隔離:每個 job 使用獨立的
RUNNER_TEMP/ run 目錄;禁止腳本寫死/tmp/shared或倉庫外的「團隊公用資料夾」。 - 進程邊界:同一 runner 進程可以串行跑多個 job,但不得在 job 之間共享未清理的全域狀態(例如 export 進
~/.zshrc的 API key)。 - 憑證邊界:簽名用的臨時 keychain 在 job 的
post裡刪除;secrets 只透過環境變數注入,不落盤;若必須落盤,路徑必須在 run 目錄內且隨 job 刪除。 - 維運邊界:為高風險倉庫配置僅跑受信 workflow 的專用 runner(label 分離),與跑 fork PR 的 runner 物理分開——這在 Cloud Mac 上通常意味著第二台節點,而不是同一台 Mac 上賭清理腳本永遠正確。
與「ephemeral runner」的關係
GitHub 企業版提供的 ephemeral self-hosted runner 會在每個 job 後退出進程並換新實例,本質上把「一 job 一 workspace」自動化。若你用的是常駐 runner(Cloud Mac 上最常見),就要用腳本 + workflow 約定手動達到相近效果。
token 輪換:為什麼不能只清目錄
workspace 清理解決「檔案殘留」;token 輪換解決「即使檔案被拷走,拷貝也會過期」。建議至少輪換三類憑證:
- Runner 註冊 token:移除並重新註冊 runner(或按組織策略輪換),避免舊機上的註冊狀態被濫用。
- 用於 CI 的 GitHub PAT / App:scope 最小化(只讀倉庫 vs 寫 packages),與MCP 側 PAT 策略分開管理,避免「一個 token 通吃 Agent + CI」。
- Apple 簽名與第三方 API key:使用短期憑證或每次 job 從 secrets 注入,禁止寫進 runner 使用者主目錄的 plist。
輪換頻率沒有銀彈:私有倉庫、無 fork PR、workflow 僅維護者可改,90 天一輪通常夠用;若接開源 contributor 或 Agent 自動提交 workflow,建議縮短到 30 天,並在每次安全事件後立即移除 runner 重新註冊。
Runbook:把「一 Job 一 Workspace」寫進 workflow
下列片段可直接貼進現有 pipeline,把行業標配落成可審計步驟。假設 macOS self-hosted runner,路徑按 GitHub 預設 _work 佈局。若同一 Cloud Mac 上還跑 Claude Code,優先讓 Runner 使用獨立 macOS 使用者。MCP 連通見 安裝教程。
# .github/workflows/ios-ci.yml 片段 jobs: build: runs-on: [self-hosted, macos, cloud-mac] steps: - uses: actions/checkout@v4 - name: Build iOS run: xcodebuild -scheme App -destination 'platform=iOS Simulator,name=iPhone 16' build - name: Scrub workspace (always) if: always() run: | rm -rf "$RUNNER_TEMP"/* rm -rf "$GITHUB_WORKSPACE"/build security delete-keychain ci_temp.keychain-db 2>/dev/null || true
#!/usr/bin/env bash # /usr/local/bin/runner-prune-work.sh · 每日 03:00 cron set -euo pipefail WORK_ROOT="${HOME}/actions-runner/_work" # 刪除超過 48h 的 run 目錄(按 mtime) find "$WORK_ROOT" -mindepth 3 -maxdepth 3 -type d -mtime +2 -exec rm -rf {} + # 可選:修剪 DerivedData 中超過 7 天的條目 find ~/Library/Developer/Xcode/DerivedData -mindepth 1 -maxdepth 1 -type d -mtime +7 -exec rm -rf {} + 2>/dev/null || true
驗證方式:故意在兩個連續 job 裡列印 ls -la "$GITHUB_WORKSPACE/.." 與關鍵快取路徑,確認第二個 job 看不到第一個 job 寫入的標記檔案(例如在 post 裡 touch /tmp/job-marker-$GITHUB_RUN_ID 並檢查是否殘留)。
常見誤配
在 workflow 裡用 actions/cache 快取未簽名的第三方二進位到全域 cache key,卻未限制倉庫範圍——這相當於在 runner 上建「跨 job、跨倉庫」的共享層。要麼收緊 cache key 與分支,要麼把快取目錄納入 prune 腳本。
Cloud Mac 同機部署:Agent + Runner 的安全分界線
典型 Cloud Mac AI Stack 是:Claude Code(Diff)+ Runner(Fact)+ 可選 Ollama 同機。省排隊、省 git pull,但若共用不可清理的全域目錄,CI 裸奔會牽連 Agent 會話:
- 使用者分離:Runner 用
runner使用者,Agent 用開發者使用者;禁止在同一~/.zshrc混放ANTHROPIC_API_KEY與簽名密鑰。 - Agent 工作區 ≠ CI workspace:Claude Code 的 project 目錄不要指向 Runner 的
_work;Agent 生成的 patch 走 git,不直接寫 CI 快取樹。 - 記憶體爭用 ≠ 可共享磁碟:Ollama 與 Runner 搶記憶體見 並行排班;Swap 高不能成為「繼續共享 DerivedData」的藉口。
- 出口 IP 與 label:能存取內網預發的 runner 不要同時接 fork PR;Agent 自動提交的 workflow 先走低權限 label,人工晉升後再碰生產 runner。
什麼時候可以暫時不做(以及何時必須立刻做)
| 場景 | 可否暫緩 | 說明 |
|---|---|---|
| 私有 monorepo、僅 2–3 維護者、無 fork PR | 短期可 | 仍建議每月 manual prune + 季度 token 輪換 |
| 開源倉庫接受外部 PR 跑 Actions | 不可 | 應專用 runner 或改託管 macOS |
| 已接 Claude Code / OpenHands / MCP 寫倉庫 | 不可 | 預設一 Job 一 Workspace,禁止共享敏感快取 |
| 簽名憑證在 CI 解密 | 不可 | 必須 job 級 keychain + post 刪除 |
上線前檢查清單(可列印)
- 每個 job 有
if: always()的清理 step 或等價宿主機 prune - 臨時 keychain / 簽名檔案不落在
$HOME永久路徑 - 高風險倉庫與低信任 workflow 使用不同 runner label
- Runner 註冊 token 與 CI PAT 有輪換日曆(建議 30–90 天)
- 新 contributor 的首個 workflow PR 走人工 review,而非直接命中生產 runner
- Agent 與 CI 使用不同 PAT / App,禁止一把 token 通吃 MCP 與 Runner
- 與L1 專題第二篇對照:② 解決「慢」,③ 本篇解決「自建 runner 是否裸奔」
L1 專題連載 · 與 Stack 各層怎麼接
本篇收束 L1(Fact 層) 安全線:從「為什麼要有 Runner」→「值不值得自建」→「2026 一 Job 一 Workspace 行業標配怎麼落地」。建議按表順序讀;縱向回 L0,橫向接 L3–L5。
| 篇目 | 主題 | 狀態 |
|---|---|---|
| ① · 執行引擎 | 為什麼 Runner 是 Cloud Mac AI Stack 的 L1(Diff → Fact) | 已發布 |
| ② · 排隊與 TCO | macOS CI 排隊時間 · self-hosted vs macos-latest | 已發布 |
| ③ · 本篇 | 自建 Runner 安全 · 一 Job 一 Workspace 行業標配 | 已發布 |
| ④ · OpenClaw 流水線 | Runner 執行 step · OpenClaw 編排觸發與回執(L1 延伸) | 已發布 |
Stack 縱向銜接(每層一篇入口即可):
- L0 · 底座:買 Mac mini 還是雲電腦 · 遷雲端 AI 工作站
- L2 · Inference:Ollama 私有推理層 · 與 Runner 並行排班
- L3 · Diff:Cloud Mac 上 Claude Code 工作流 · CodeGraph 與漏改
- L4 · Context:MCP 三連通 Hub · 權限最小暴露
- L5 · Workflow:OpenHands Agent 平台
讀完 L1 三連後,若你已在 Cloud Mac 上同時跑 Agent 與 CI,下一層通常是補全 L3 Diff 層決策(Claude Code 為何替代傳統 IDE 的獨立首講,與vs Cursor、工作站文分工不同)——再往後是 L6 端到端閉環地圖(排期中)。
常見問題
一 Job 一 Workspace 會不會讓 CI 更慢?
會冷啟動變慢——這正是當年共享盤的原因。2026 的合理做法是:只刪敏感物與本 job 目錄,可再生快取用帶倉庫前綴的 cache key,而不是永不清理的全域 DerivedData。
自建 runner 算裸奔嗎?什麼情況下算?
若多個 job / 多個倉庫共用同一使用者家目錄裡的簽名材料、.netrc 或寬口徑全域快取,且沒有 if: always() 清理——在接 fork PR 或 Agent 改 workflow 時,就算裸奔。
能不能只收緊 MCP 權限,繼續共享 runner 磁碟?
不夠。MCP 最小權限 管 Agent 調工具;Runner 側仍要管磁碟上已存在的檔案。惡意 fork workflow 不走 MCP,照樣能掃 _work 殘留。
GitHub 官方有沒有一鍵開關?
託管 runner 近似「一鍵」;self-hosted 需組合 workflow post、宿主機 cron、以及可選的 ephemeral 模式。沒有單條 actions/checkout 參數能替代全套邊界。
和 OpenClaw 編排層怎麼分工?
OpenClaw 管觸發順序與回執;Runner 管逐步執行。隔離策略寫在 workflow 與 runner 宿主機,不應假設 OpenClaw 會替你做磁碟清理(見 L1 專題④ · OpenClaw 流水線)。
L1 專題要從哪篇開始讀?
建議 ① 執行引擎 → ② 排隊 → ③ 本篇。完整表見 § L1 連載。
L1 專題已三連 · 下一層 Diff
Fact 層做完,該補 Claude Code 了
L1 解決「CI 在什麼環境裡跑 Fact」。下一步通常是 L3:Cloud Mac 上的 Claude Code 工作流與 IDE 替代邏輯(與 vs Cursor 專題文分工不同)。
閱讀 Claude Code 工作流