很多团队在 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):L3 决策首讲 · MCP 安装 · Ollama 并行排班 · OpenHands
对比图:以前怎么配 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:权限交出与接入决策 · vs Cursor · 工作站实测
- L4 · Context:MCP 三连通 Hub · 权限最小暴露
- L5 · Workflow:OpenHands Agent 平台
读完 L1 三连后,若你已在 Cloud Mac 上同时跑 Agent 与 CI,下一层通常是补全 L3 Diff 层决策:L3 ① · 权限交出与何时正式接入(与vs Cursor、工作站实测分工不同)——再往后是 L6 端到端闭环地图(排期中)。
常见问题
Q:一 Job 一 Workspace 会不会让 CI 更慢?
会冷启动变慢——这正是当年共享盘的原因。2026 的合理做法是:只删敏感物与本 job 目录,可再生缓存用带仓库前缀的 cache key,而不是永不清理的全局 DerivedData。
Q:自建 runner 算裸奔吗?什么情况下算?
若多个 job / 多个仓库共用同一用户家目录里的签名材料、.netrc 或宽口径全局缓存,且没有 if: always() 清理——在接 fork PR 或 Agent 改 workflow 时,就算裸奔。
Q:能不能只收紧 MCP 权限,继续共享 runner 磁盘?
不够。MCP 最小权限 管 Agent 调工具;Runner 侧仍要管磁盘上已存在的文件。恶意 fork workflow 不走 MCP,照样能扫 _work 残留。
Q:GitHub 官方有没有一键开关?
托管 runner 近似「一键」;self-hosted 需组合 workflow post、宿主机 cron、以及可选的 ephemeral 模式。没有单条 actions/checkout 参数能替代全套边界。
Q:和 OpenClaw 编排层怎么分工?
OpenClaw 管触发顺序与回执;Runner 管逐步执行。隔离策略写在 workflow 与 runner 宿主机,不应假设 OpenClaw 会替你做磁盘清理(见 L1 专题④ · OpenClaw 流水线)。
Q:L1 专题要从哪篇开始读?
建议 ① 执行引擎 → ② 排队 → ③ 本篇。完整表见 § L1 连载。
L1 专题已三连 · 下一层 Diff 决策
Fact 层做完,先过 L3 门禁
L1 解决「CI 在什么环境里跑 Fact」。下一步是 L3 决策首讲:你不是在用 Claude Code,是把代码权限全盘交出——什么时候才该让 Agent 正式接入。
阅读 L3 决策首讲