Mac mini GitHub Actions Runner:接入与恢复验收
从专用 Mac、可信私有仓库与 Swift Package 出发,分开检查安装、实际调度和登录后恢复;另附无需 Mac 的离线候选筛选练习。
- 专用标准账号、可信私有代码和凭据隔离是前提
- macOS 默认服务是用户 LaunchAgent,恢复需要考虑登录会话
- 路由同时核对标签、访问权限、在线与空闲状态
- 维护先停止接单,恢复后用同一人工测试验收
1. 把常开机器的维护责任想清楚
本篇提供专用 Apple silicon Mac 接入可信私有仓库的安装步骤与验收清单,目标是完成一次无签名 Swift Package 人工测试,并验证后续恢复。读者需要一台可专用于 CI 的 Mac、专用标准账号、仓库管理权限、根目录已有且能正常测试的 Package.swift,以及可到现场解锁和登录的恢复安排。真实安装与调度尚未实测;没有这些条件时,可以先做文末只需 Python 的候选筛选练习。
我会先把目标收窄到可信代码、单个仓库、一个 runner 和无签名测试。常开机器需要有人负责系统升级、断电恢复和磁盘空间。机器价格、耗电、排队时间和实际构建耗时都应按自己的项目记录,这里没有硬件测速,也不能据此承诺比托管 runner 更快或更便宜。
资料核对日期为 2026-10-05。文末的 Python 路由检查器已在 Linux 环境完成离线测试,使用的都是合成状态。本文没有注册真实 runner、取得 token、连接 Mac、安装下载包或执行 macOS 命令;登录、重启、Xcode 构建和 GitHub 调度仍需在读者自己的机器上验收。
| 路径与输入 | 阅读顺序 | 完成时核对什么 |
|---|---|---|
| 第一次安装:专用账号、可信仓库和对应架构下载包 | 可信边界 → 芯片与 Xcode → 下载校验 → 前台注册 → 用户服务 | 保留 checksum、工具链版本、前台连接与 svc.sh status;GitHub 显示 Idle,只有注册记录还不够 |
| 实际调度:默认分支的 mac-mini-ci.yml 和 Package.swift | 固定标签与仓库访问 → 人工触发第一条工作流 | 保存运行链接、提交 SHA 和日志;主机检查、checkout、Swift 测试及 post-job 结束,runner 回到 Idle |
| 锁屏/注销/重启恢复:有现场恢复条件的维护窗口 | 先读“macOS 服务属于登录用户”,再按“重启、升级和故障怎么验收”记录 | 分开记录锁屏测试、注销造成的服务中断和重启后 FileVault 解锁/ci 登录;按既定恢复步骤再跑同一测试,不能仅凭机器开机判断常开可用 |
| 本地练习:Python 与 route_check.py、snapshot.json、test_route_check.py | 直接到“离线练习:解释候选为什么没有被选中” | 合成快照只返回 mini-idle,并解释 busy、缺标签和离线原因;不能据此填前面三行的实机验收 |
2. 先建立可信边界,再给仓库接入权限
GitHub 建议 self-hosted runner 仅用于私有仓库。私有身份本身还不够:能提交工作流、依赖安装脚本或测试代码的人,实质上可能在 runner 的用户权限下执行任意程序。来自 fork 的 PR、外部贡献代码、依赖更新和构建插件都需要审查;不要让它们直接落到这台常驻机器。GitHub:self-hosted runner 安全边界 说明了跨任务残留和凭据泄漏风险。
让管理员先创建专用标准用户,例如 ci,再切换到这个账号安装 runner。不要给它免密 sudo,也不要登录个人 iCloud、密码管理器或工作浏览器。工作目录不能放进个人文档、共享资料或同步盘;还要检查共享磁盘和内网服务是否向这个账号开放。Apple:添加标准用户 可用于核对账号类型。
专用用户只能降低误访问范围,不能把同一台机器变成强隔离沙箱。本文不导入开发者证书、签名私钥和发布 token。后续需要发布时,应单独设计执行环境、短期凭据和临时 keychain 的生命周期;不要因为测试任务成功,就把个人签名钥匙串交给它。
3. 确认芯片、系统和 Xcode
动手前把仓库默认分支、维护负责人、机器用途和恢复方式记下来。本文假设这台机器能够留给 CI 使用,仓库的写入者都是可信维护者,并且你能够到现场解锁。如果它同时处理日常办公或存着无法隔离的个人资料,先改用独立机器或经过验证的隔离环境,再继续注册。
在 ci 用户的终端先做只读盘点。Apple silicon 通常应使用原生 arm64 终端和 osx-arm64 安装包;Intel Mac 使用 x64 包。若 Apple silicon 上显示 x86_64,先检查终端是否经 Rosetta 运行。不要靠手工补 ARM64 标签掩盖架构不一致。系统能启动 runner,也不代表能安装项目要求的 Xcode 和 SDK。
whoami
uname -m
sw_vers
xcode-select --print-path
xcrun swift --version
df -h "$HOME"本例采用完整 Xcode,预期工具目录是 /Applications/Xcode.app/Contents/Developer。只安装 Command Line Tools 时常会看到 /Library/Developer/CommandLineTools;项目若需要 Xcode 工程、模拟器或对应平台 SDK,要先由管理员安装合适版本,并在图形界面完成首次启动、组件安装和许可确认。见 Apple:安装 Command Line Tools。
多版本 Xcode 用工作流里的 DEVELOPER_DIR 选择具体路径,不要在每次 job 中用 sudo 切换全机默认设置。Apple 明确说明这个环境变量可覆盖当前命令的开发工具目录,且不需要超级用户权限。保存 Xcode 与 Swift 的实际输出,后面服务模式也要得到一致结果。见 Apple:选择命令行工具链。
4. 从官方安装页下载并核对文件
在下载前还要确认文件能被专用用户独占管理。用访达查看安装目录和附加磁盘的共享与权限,检查是否被其他日常账号共同使用;不要为了让构建顺利就授予完全磁盘访问权限。需要额外访问某个目录时,先确认项目真的依赖它,再单独决定最小范围。
在可信私有仓库进入 Settings → Actions → Runners → New self-hosted runner,选择 macOS 和实际架构。若没有入口,先请仓库或组织管理员核对权限与 runner 策略。按照页面当前给出的命令下载官方 actions/runner 发布包,逐字核对版本、文件名、架构和 SHA-256;不要复制博客里过期的 token 或校验值。
mkdir -p "$HOME/actions-runner"
cd "$HOME/actions-runner"
# 在这个目录执行 GitHub 安装页当前给出的下载命令。
# 再将下面两个占位值替换为同一官方发布包的信息。
archive='actions-runner-osx-arm64-VERSION.tar.gz'
expected='PASTE_64_HEX_SHA256'
printf '%s %s\n' "$expected" "$archive" | shasum -a 256 -c -看到对应文件的 OK 后再解压;FAILED、文件不存在或校验命令报错都要停下来,重新核对下载来源与内容。SHA-256 校验用于比对字节,信任基础仍是官方页面。上面两个值故意保留占位,不能原样运行成功。下载与校验可对照 GitHub:Runner 官方发布页,完整添加流程见 GitHub:添加 self-hosted runner。
# 只在上一条 checksum 校验已明确成功后执行。
tar xzf "$archive"5. 注册 token 由你在本机输入
下载包和注册目标要来自同一条受信任流程。不要执行搜索结果中的一键安装脚本,也不要把远端脚本直接管道送入 shell。解压后先保留下载来源、版本和校验结果,排查升级问题时就能知道最初安装的具体内容;不需要保存注册 token。
准备好安装目录后,再打开安装页取得注册 token。GitHub 生成的这个 token 一小时有效;它用于注册 runner,不是后续每个 job 的 GITHUB_TOKEN。不要把它写进仓库、文章、共享日志或聊天记录。下面命令省略 token 参数,让你按交互提示在自己的终端输入;OWNER 和 REPO 需要替换。
cd "$HOME/actions-runner"
./config.sh \
--url https://github.com/OWNER/REPO \
--name mac-mini-ci-01 \
--labels mac-mini-ci \
--work _work
./run.sh首次用前台模式查看连接与监听输出,在 GitHub 的 runner 列表确认名称、macOS、ARM64 和自定义标签。注册存在但显示 Offline 时,还不能算安装完成。连通性确认后按 Ctrl+C 停止前台进程,再进入服务安装。不要让同一个安装目录同时跑前台与服务进程。默认标签无需手工重造,初始自定义标签参数见 GitHub:Runner 标签。
6. macOS 服务属于登录用户
注册失败先保留不含 token 的错误信息。若提示认证过期,回到同一个仓库安装页重新取得短期 token;若提示无权添加,处理仓库策略和管理员权限,不要换成长期个人访问令牌盲试。已有同名记录时先确认那台执行器归属,避免误替换正在服务的机器。
在同一个 ci 用户、同一个安装目录执行下面三条命令。macOS 的官方流程不加 sudo;当前 svc.sh 源码会拒绝 root,并把 plist 放进当前用户的 Library/LaunchAgents。Linux 的 systemd 安装示例不适用于这里。具体行为已交叉核对 GitHub:配置 macOS 服务 和 官方服务脚本。
cd "$HOME/actions-runner"
./svc.sh install
./svc.sh start
./svc.sh status
cat .servicestatus 应显示服务已启动以及进程信息;GitHub 页面还需要显示 Idle,才说明连接已建立并且能够等待任务。服务已启动但网络不通时,两个界面的状态可能不同。诊断优先使用安装包自带脚本,不手工搬动 plist,也不将执行用户提升成 root。
Apple 的用户 Agent 随登录会话启动,注销时会结束。安装服务不能保证断电后在未解锁、未登录状态直接恢复接单。先做一次锁屏后运行测试,再做一次有现场恢复条件的重启测试,记录何时需要 FileVault 解锁及 ci 用户登录。不要为省一步登录关闭安全保护或默认开启自动登录。Apple:Launch Daemons 与用户 Agents 是这一会话约束的机制依据。
若需要显示器关闭时仍接任务,由你在当前 macOS 的 Energy 设置里核对防止自动睡眠选项;不同版本和机型的名称会变化。持续唤醒会增加耗电,网络唤醒也不能代替已经在线的 runner。见 Apple:Mac 睡眠与唤醒设置。
7. 用固定标签找到空闲的执行器
终端里能找到的命令,在服务里未必具有相同搜索路径。不要依赖交互 shell 的别名和临时环境变量。为每项必需工具记录实际安装位置,优先使用明确的调用方式;新增工具后在人工工作流里重新输出版本,才能确认服务账号确实在用预期安装。
本例固定使用 self-hosted、macOS、ARM64、mac-mini-ci 四个标签,GitHub 会一起匹配它们。标签名不区分大小写,但标签相同并不授予仓库访问权限,也不能证明硬件确实符合描述。组织级 runner 还要核对所属组和允许使用的仓库。相关规则见 GitHub:在工作流中选用 runner。
网页中的 Idle 表示在线且可接任务,Active 表示正在执行,Offline 表示没有连接。API 常用 status=online/offline 加 busy 布尔值表达:online 与 busy=true 同时出现很正常。一条任务一直 queued,先查标签、访问范围和空闲状态,不要立刻重复注册。见 GitHub:Runner 状态与日志排查。
同一 runner 忙时,新任务需要等候。本例只安装一个 runner 实例;在同一台 Mac 上增加多个实例,会让它们争用 CPU、磁盘和共享缓存,必须先验证资源与目录隔离。没有本项目的测量结果前,不应把核心数直接换算成可并行 job 数。
8. 第一条工作流只做默认分支的人工测试
将下面完整文件保存为 .github/workflows/mac-mini-ci.yml,通过正常审查合入默认分支。确认仓库仍为私有、默认分支提交可信,再到 Actions 选择这个工作流,使用默认分支点击 Run workflow。工作流必须存在于默认分支,人工触发入口才可用;选其他分支时,示例 job 会跳过。见 GitHub:workflow_dispatch 事件。
示例 checkout 固定在 v7.0.1 的完整提交 SHA,只给 contents: read,并关闭凭据持久化。它的 Node 24 运行时要求足够新的 runner,官方 README 给出的下限为 v2.327.1;安装时仍使用当前受支持版本。固定 SHA 后也需要跟踪安全更新并重新审查。见 actions/checkout v7.0.1 README。
name: Mac mini trusted CI
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: mac-mini-ci
cancel-in-progress: false
jobs:
test:
if: >-
github.event_name == 'workflow_dispatch' &&
github.event.repository.private == true &&
github.ref == format('refs/heads/{0}',
github.event.repository.default_branch)
runs-on: [self-hosted, macOS, ARM64, mac-mini-ci]
timeout-minutes: 20
env:
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
defaults:
run:
shell: bash
steps:
- name: Check host and toolchain
run: |
set -euo pipefail
test "$(uname -s)" = Darwin
test "$(uname -m)" = arm64
test -d "$DEVELOPER_DIR"
sw_vers
xcodebuild -version
xcrun swift --version
df -h "$HOME"
- name: Checkout trusted event commit
# actions/checkout v7.0.1; verified 2026-10-05
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.sha }}
persist-credentials: false
fetch-depth: 1
- name: Test Swift package
timeout-minutes: 15
run: |
set -euo pipefail
test -f Package.swift
xcrun swift test先校验主机和工具链,再检出触发时的提交,最后执行仓库自己的 Swift 测试。成功的预期状态是这些步骤与 post-job 全部结束,job 显示绿色,runner 回到 Idle。这里不上传产物、不申请写权限、不进行签名;它也不覆盖需要模拟器、UI 或证书的项目。
job 的分支判断用来减少误触发,不能阻止拥有工作流修改权限的人删掉它。继续保护默认分支、审查 .github/workflows 和依赖变更,并限制能向此 runner 投递任务的人。不要改成 pull_request_target 后检出不可信 PR;一次这样的执行就可能污染后续任务。
9. 并发与超时需要同时设定
测试失败时先读最早出现的具体错误,保留这次运行的提交和工具版本。没有根目录 Package.swift 会在示例的文件检查处停止;需要工作区子目录的项目,应把测试步骤的工作目录明确改到相应位置。不要把所有失败都用继续执行选项吞掉,否则绿色结果会失去判断价值。
上节没有写 queue,等同于 queue: single:同组最多一个 run 执行、一个 pending 等待,后来进入的 run 会替换旧 pending。只关心最新一次构建结果时可以保留这个选择;每次已经批准的构建都需要排队时,在原文件里把 concurrency 块替换为下面四行。组名仍是 mac-mini-ci,不要为每次运行加随机后缀。
concurrency:
group: mac-mini-ci
queue: max
cancel-in-progress: falsequeue: max 允许同组最多积累 100 个 pending;队列满后,额外进入的等待项会取消,所以仍需监控积压。它必须配合 cancel-in-progress: false,或省略该字段,不能与 true 组合。GitHub 在 2026-05-07 更新公告 中发布了扩展队列。切换策略前先等同组旧任务结束,避免 single 和 max 的运行交错。
FIFO 依据任务开始等待该并发组的时间,不依据点下 Run workflow 的先后。不要用两个紧接着的点击来证明顺序;下面先确认 B 已经在等待,再提交 C。同组仍属于同一仓库,不能拿它充当多个仓库共用一台 Mac 的锁。语义以 GitHub 并发文档 为准。
本例给整个 job 二十分钟、测试步骤十五分钟,作为可调整的起始值。它们约束执行时长,不能把还没拿到 runner 的 queued 状态限制在二十分钟内。GitHub 对没有合适 runner 的任务有独立排队规则;长时间等待应主动排查,而非只把 timeout 调大。见 GitHub:Workflow YAML 语法 与 GitHub:系统、架构、调度、更新与网络要求。
先用独立验收文件 .github/workflows/mac-mini-queue-check.yml 验证队列。它固定同一组、同一套 runner 标签,仅记录时间并让 A 等待三分钟,B、C 各等待五秒。这个专用组不会与原 mac-mini-ci 组形成互斥,因此请在原构建结束、runner 空闲时做检查。下面是完整文件;无需 checkout 或仓库写权限。
完整队列验收工作流:mac-mini-queue-check.yml
name: Mac mini queue check
run-name: queue-${{ inputs.case_name }}-${{ github.run_number }}
on:
workflow_dispatch:
inputs:
case_name:
description: Queue acceptance case
required: true
type: choice
default: A
options: [A, B, C]
permissions: {}
concurrency:
group: mac-mini-queue-check
queue: max
cancel-in-progress: false
jobs:
observe:
if: >-
github.event_name == 'workflow_dispatch' &&
github.event.repository.private == true &&
github.ref == format('refs/heads/{0}',
github.event.repository.default_branch)
runs-on: [self-hosted, macOS, ARM64, mac-mini-ci]
timeout-minutes: 5
env:
QUEUE_CASE: ${{ inputs.case_name }}
steps:
- name: Observe queue entry and completion
timeout-minutes: 4
shell: bash
run: |
set -euo pipefail
test "$(uname -s)" = Darwin
test "$(uname -m)" = arm64
printf 'case=%s start=' "$QUEUE_CASE"
date -u +'%Y-%m-%dT%H:%M:%SZ'
case "$QUEUE_CASE" in
A) sleep 180 ;;
B|C) sleep 5 ;;
*) exit 2 ;;
esac
printf 'case=%s finish=' "$QUEUE_CASE"
date -u +'%Y-%m-%dT%H:%M:%SZ'- 审查后合入可信私有仓库的默认分支,确认没有旧的队列验收任务。选 case_name=A 触发,等日志出现 case=A start,且 A 仍在等待。
- 触发 B,打开运行页确认它因同一并发组等待,随后再触发 C。A 结束前,预期 B、C 同时保留为等待项,B 不因 C 到来而取消。
- A 结束后核对运行记录与 start/finish 日志:B 先执行,随后 C 执行,三次均成功。如果 A 太早结束,这一轮不能验证两个 pending 共存,应在空闲时重新做。
- 需要对照时,等三次全部结束,再将这个验收文件的 queue 改为 single,审查合入后按同样顺序重做。预期 C 等待时 B 被替换取消,A 继续执行,A 后只运行 C。每轮只使用一种固定策略。
验收通过后,可保留这个文件作后续回归检查,并按开头四行将实际构建改为 max。本次仅完成 YAML 解析、参数组合核对和 shell 语法检查,没有在真实 Mac 或 GitHub 触发 A/B/C;上面的状态与顺序是待执行的预期结果。队列设置不会修复 runner 离线、标签错误或测试本身失败。
10. 把磁盘清理做成一次可恢复的维护
如果你开始比较性能,至少分开记录排队、下载依赖、编译和测试时间,并区分首次冷构建与缓存命中的后续构建。保持提交、工具链、测试集合和并发负载相同,多跑几次再看分布。一次较快的结果不能说明机器长期稳定,也不能说明增加 runner 实例会提高吞吐。
常驻主机会留下 checkout 工作区、Swift 的 .build、Xcode DerivedData、依赖缓存和日志。第一次跑通先记录磁盘基线,再观察正常构建后的增长。缓存键要反映系统、架构、工具链与锁文件,缓存里不能夹带密钥或个人配置;复用缓存也不能代替对依赖的审查。
cd "$HOME/actions-runner"
df -h "$HOME"
du -sh _work _diag
find _diag -type f -name 'Runner_*.log' -mtime +14 -print
find _diag -type f -name 'Worker_*.log' -mtime +14 -print上面的命令只盘点并列出较旧日志,不删除文件。十四天只是示例保留窗口,需要结合失败排查和自己的存储预算决定。准备清理时先等 Active 任务完成,确认 Idle,再停止服务并复查没有任务接入;清理期间也暂停人工投递。短暂的 Idle 观察不是锁,停服务后仍要检查刚开始的 job 是否已经结束。
./svc.sh stop
./svc.sh status
# 确認 GitHub 为 Offline,且没有残留构建进程,再人工维护。
# 维护完成后恢复,并运行同一条小测试。
./svc.sh start
./svc.sh status先保留失败时段的日志,再按已确认的项目目录处理可重建产物。不要对 HOME 或整个 runner 目录执行一键递归删除,也不要清掉隐藏注册文件、正在使用的缓存或个人 keychain。旧日志可能包含路径和敏感输出,分享前需要检查;保留副本同样要限制访问。
11. 重启、升级和故障怎么验收
清理后第一次运行可能需要重新下载依赖和重新编译,维护窗口要把这段恢复时间算进去。预留多少空间取决于项目、模拟器和产物大小,先按自己的峰值观察设告警线。空间不足时应先停接新任务,避免构建写到一半才失败,留下难以辨认的半成品。
runner 默认会自更新,macOS、Xcode 和项目工具仍由维护者负责。本文不禁用自动更新;若组织采用固定版本,必须有及时升级流程,GitHub 对长期未更新或缺少关键安全更新的 runner 可能停止分配任务。不要为了保持旧环境无限期忽略更新,具体时限见 官方更新规则。
维护前等任务结束并保留版本输出;维护后检查用户会话、svc.sh status、GitHub Idle,再运行一次相同测试。对比前后的 Xcode、Swift、提交 SHA 和日志,不要只看机器可以远程连接。启动恢复依赖人工登录时,把这一点写进值班步骤。
job skipped 先查私有条件和分支;queued 查全部标签、仓库访问与是否忙碌;进程已启动但 Offline 查服务用户、出站 HTTPS、代理和 DNS。网络问题不要用关闭 TLS 校验来掩盖。启动即失败时核对 DEVELOPER_DIR、架构、依赖和磁盘空间;容器型 action 与服务容器要求 Linux,不能靠在 Mac 上安装 Docker 就当作受支持配置。
runner 的连接和更新记录在 _diag 中的 Runner_ 日志,具体任务对应 Worker_ 日志;服务 stdout/stderr 路径可从 .service 指向的 plist 核对。先保留发生故障的那一段,再改变一个条件复测。若怀疑跑过恶意代码,应停止分配任务、由维护者处置凭据与重建环境,不能只删除工作目录后恢复接单。
12. 离线练习:解释候选为什么没有被选中
确认不再使用这台机器时,安排没有活动任务的窗口,按 官方移除流程 处理用户服务与仓库注册。把移除 token 和普通注册 token 区分开,仍由拥有权限的人在本机处理。退役前检查工作区、下载缓存和日志的保留需求,不要把含凭据的完整 runner 目录打包交给下一位使用者。
下方完整的 route_check.py 读取一份人工整理的 JSON 快照,只解释本例固定策略下哪些 runner 可能成为候选。它不联网、不读凭据、不删除文件,也不解析 workflow YAML。trusted 与 allowed 是输入者的声明,程序无法证明代码可信或仓库真的有权限。输出 candidate 只描述这份快照,不代表 GitHub 会选择该机器。
读取快照时先拒绝同一 JSON 对象里的重复字段。Python 官方文档说明,默认 json.loads 遇到重复字段名会保留最后一项。例如把合成输入中的 "trusted": true 换成 "trusted": false, "trusted": true,旧示例仍会返回 mini-idle;同一 runner 中同时出现 "busy": true, "busy": false 也会产生这个误判。下方 parse_snapshot 用 object_pairs_hook 在对象转成字典前检查重复名,冲突顺序反过来、值完全相同、字段名以 Unicode 转义书写,或重复出现在嵌套对象里,都返回退出码二,只在 stderr 说明输入错误,不输出候选报告。不同 runner 各自含有 busy 字段是正常的,不算重复。
接入其他读取入口时也要使用 parse_snapshot,再把结果交给 check;先用普通 json.loads 转成字典后,check 无法找回已丢失的重复字段。这里只消除快照的字段歧义,没有实现完整的严格 JSON 验证器、资源用量限制或可信来源认证,也不能阻止输入者把单个 trusted 值直接写成 true。
把下面三个文件保存在同一目录,使用已有 Python 3 运行。示例应仅返回 mini-idle;mini-busy 被 busy 排除,intel-offline 同时缺 ARM64 且离线。退出码零表示有候选,一表示无候选,二表示输入或调用格式错误。本次二十六项离线测试通过,覆盖标签、访问、分支、状态、非法布尔值、重复 JSON 字段及命令行返回码;普通模式与 Python -O 优化模式均通过。
python3 route_check.py snapshot.json
python3 -m unittest -v test_route_check.py完整源码:route_check.py
"""Offline snapshot triage, not GitHub scheduling or a security sandbox."""
import json
import sys
from pathlib import Path
REQUIRED = frozenset({"self-hosted", "macos", "arm64", "mac-mini-ci"})
def unique_object(pairs):
"""Reject duplicate decoded member names in every JSON object."""
result = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate JSON field: " + repr(key))
result[key] = value
return result
def parse_snapshot(raw):
return json.loads(raw, object_pairs_hook=unique_object)
def check(snapshot):
if not isinstance(snapshot, dict):
raise ValueError("snapshot must be an object")
required = {"private", "trusted", "event", "ref", "default_branch", "runners"}
if not required <= snapshot.keys():
raise ValueError("missing snapshot fields")
for key in ("private", "trusted"):
if type(snapshot[key]) is not bool:
raise ValueError(key + " must be boolean")
for key in ("event", "ref", "default_branch"):
if not isinstance(snapshot[key], str) or not snapshot[key]:
raise ValueError(key + " must be a nonempty string")
if not isinstance(snapshot["runners"], list):
raise ValueError("runners must be an array")
policy = []
if not snapshot["private"]:
policy.append("repository_not_private")
if not snapshot["trusted"]:
policy.append("code_not_trusted")
if snapshot["event"] != "workflow_dispatch":
policy.append("event_not_manual")
expected_ref = "refs/heads/" + snapshot["default_branch"]
if snapshot["ref"] != expected_ref:
policy.append("ref_not_default_branch")
rows = []
seen = set()
for runner in snapshot["runners"]:
if not isinstance(runner, dict):
raise ValueError("runner must be an object")
fields = {"name", "status", "busy", "allowed", "labels"}
if not fields <= runner.keys():
raise ValueError("missing runner fields")
name = runner["name"]
if not isinstance(name, str) or not name or name in seen:
raise ValueError("runner name must be nonempty and unique")
seen.add(name)
if runner["status"] not in ("online", "offline"):
raise ValueError("status must be online or offline")
for key in ("busy", "allowed"):
if type(runner[key]) is not bool:
raise ValueError(key + " must be boolean")
labels = runner["labels"]
if not isinstance(labels, list) or not labels:
raise ValueError("labels must be a nonempty array")
if any(not isinstance(x, str) or not x for x in labels):
raise ValueError("label must be a nonempty string")
missing = sorted(REQUIRED - {x.lower() for x in labels})
reasons = list(policy)
if not runner["allowed"]:
reasons.append("access_not_allowed")
if missing:
reasons.append("missing_labels:" + ",".join(missing))
if runner["status"] != "online":
reasons.append("offline")
if runner["busy"]:
reasons.append("busy")
rows.append({"name": name, "candidate": not reasons, "reasons": reasons})
return {
"scope": "synthetic snapshot only; no scheduler or live API validation",
"candidates": [row["name"] for row in rows if row["candidate"]],
"runners": rows,
}
def main(argv=None):
args = sys.argv[1:] if argv is None else argv
if len(args) != 1:
print("usage: python3 route_check.py snapshot.json", file=sys.stderr)
return 2
try:
raw = Path(args[0]).read_text(encoding="utf-8")
report = check(parse_snapshot(raw))
except (OSError, ValueError, TypeError) as error:
print("input error: " + str(error), file=sys.stderr)
return 2
print(json.dumps(report, indent=2, ensure_ascii=False))
return 0 if report["candidates"] else 1
if __name__ == "__main__":
raise SystemExit(main())合成输入:snapshot.json
{
"private": true,
"trusted": true,
"event": "workflow_dispatch",
"ref": "refs/heads/main",
"default_branch": "main",
"runners": [
{
"name": "mini-idle",
"status": "online",
"busy": false,
"allowed": true,
"labels": ["self-hosted", "macOS", "ARM64", "mac-mini-ci"]
},
{
"name": "mini-busy",
"status": "online",
"busy": true,
"allowed": true,
"labels": ["self-hosted", "macOS", "ARM64", "mac-mini-ci"]
},
{
"name": "intel-offline",
"status": "offline",
"busy": false,
"allowed": true,
"labels": ["self-hosted", "macOS", "X64", "mac-mini-ci"]
}
]
}完整测试:test_route_check.py
import copy
import json
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from io import StringIO
from pathlib import Path
from route_check import check, main, parse_snapshot
BASE = json.loads(Path(__file__).with_name("snapshot.json").read_text())
class RoutingTests(unittest.TestCase):
def setUp(self):
self.data = copy.deepcopy(BASE)
def test_fixture(self):
result = check(self.data)
self.assertEqual(result["candidates"], ["mini-idle"])
self.assertEqual(result["runners"][1]["reasons"], ["busy"])
self.assertEqual(result["runners"][2]["reasons"],
["missing_labels:arm64", "offline"])
def test_labels_case_insensitive(self):
self.data["runners"][0]["labels"] = [
"SELF-HOSTED", "MACOS", "arm64", "MAC-MINI-CI"]
self.assertEqual(check(self.data)["candidates"], ["mini-idle"])
def test_all_labels_required(self):
self.data["runners"][0]["labels"].remove("mac-mini-ci")
self.assertEqual(check(self.data)["candidates"], [])
def test_busy(self):
self.data["runners"][0]["busy"] = True
self.assertEqual(check(self.data)["candidates"], [])
def test_offline(self):
self.data["runners"][0]["status"] = "offline"
self.assertEqual(check(self.data)["candidates"], [])
def test_access(self):
self.data["runners"][0]["allowed"] = False
self.assertEqual(check(self.data)["candidates"], [])
def test_public_repo(self):
self.data["private"] = False
self.assertEqual(check(self.data)["candidates"], [])
def test_untrusted(self):
self.data["trusted"] = False
self.assertEqual(check(self.data)["candidates"], [])
def test_pr_event(self):
self.data["event"] = "pull_request"
self.assertEqual(check(self.data)["candidates"], [])
def test_other_branch(self):
self.data["ref"] = "refs/heads/feature"
self.assertEqual(check(self.data)["candidates"], [])
def test_other_default_branch(self):
self.data["default_branch"] = "trunk"
self.data["ref"] = "refs/heads/trunk"
self.assertEqual(check(self.data)["candidates"], ["mini-idle"])
def test_string_boolean_rejected(self):
self.data["runners"][0]["busy"] = "false"
with self.assertRaises(ValueError):
check(self.data)
def test_invalid_status(self):
self.data["runners"][0]["status"] = "idle"
with self.assertRaises(ValueError):
check(self.data)
def test_missing_field(self):
del self.data["trusted"]
with self.assertRaises(ValueError):
check(self.data)
def test_duplicate_name(self):
self.data["runners"][1]["name"] = "mini-idle"
with self.assertRaises(ValueError):
check(self.data)
def test_empty_runners(self):
self.data["runners"] = []
self.assertEqual(check(self.data)["candidates"], [])
def test_invalid_label(self):
self.data["runners"][0]["labels"] = [False]
with self.assertRaises(ValueError):
check(self.data)
def test_cli_success(self):
with redirect_stdout(StringIO()) as out:
code = main([str(Path(__file__).with_name("snapshot.json"))])
self.assertEqual(code, 0)
self.assertEqual(json.loads(out.getvalue())["candidates"], ["mini-idle"])
def test_cli_no_candidate(self):
self.data["trusted"] = False
with tempfile.TemporaryDirectory() as tmp:
file = Path(tmp) / "snapshot.json"
file.write_text(json.dumps(self.data))
with redirect_stdout(StringIO()):
self.assertEqual(main([str(file)]), 1)
def test_cli_bad_json(self):
with tempfile.TemporaryDirectory() as tmp:
file = Path(tmp) / "bad.json"
file.write_text("{")
with redirect_stderr(StringIO()):
self.assertEqual(main([str(file)]), 2)
def assert_cli_input_error(self, raw, field):
with tempfile.TemporaryDirectory() as tmp:
file = Path(tmp) / "snapshot.json"
file.write_text(raw, encoding="utf-8")
with redirect_stdout(StringIO()) as out, redirect_stderr(StringIO()) as err:
self.assertEqual(main([str(file)]), 2)
self.assertEqual(out.getvalue(), "")
self.assertIn("duplicate JSON field: " + repr(field), err.getvalue())
def test_duplicate_top_level_boolean_rejected(self):
for pair in ('"trusted": false, "trusted": true',
'"trusted": true, "trusted": false'):
with self.subTest(pair=pair):
raw = json.dumps(self.data).replace('"trusted": true', pair, 1)
self.assert_cli_input_error(raw, "trusted")
def test_duplicate_runner_boolean_rejected(self):
for pair in ('"busy": true, "busy": false',
'"busy": false, "busy": true'):
with self.subTest(pair=pair):
raw = json.dumps(self.data).replace('"busy": false', pair, 1)
self.assert_cli_input_error(raw, "busy")
def test_duplicate_same_value_rejected(self):
raw = json.dumps(self.data)
raw = raw.replace('"trusted": true', '"trusted": true, "trusted": true', 1)
self.assert_cli_input_error(raw, "trusted")
def test_escaped_duplicate_name_rejected(self):
raw = json.dumps(self.data)
raw = raw.replace('"busy": false', r'"busy": true, "b\u0075sy": false', 1)
self.assert_cli_input_error(raw, "busy")
def test_duplicate_unchecked_nested_field_rejected(self):
self.data["note"] = {"source": "manual"}
raw = json.dumps(self.data)
raw = raw.replace('"source": "manual"', '"source": "manual", "source": "copy"', 1)
self.assert_cli_input_error(raw, "source")
def test_same_field_in_separate_objects_is_valid(self):
self.assertEqual(parse_snapshot(json.dumps(self.data)), self.data)
self.assertEqual(check(parse_snapshot(json.dumps(self.data)))["candidates"],
["mini-idle"])
if __name__ == "__main__":
unittest.main()这份练习适合先排除标签抄错、布尔值写成字符串等低级问题。它没有模拟 GitHub 并发队列、组织策略、服务端分配和任务执行,二十六项测试也不能验证 Mac 上的服务恢复。最终验收仍要回到真实 runner:可信默认分支的一次人工 job 成功,结束后恢复 Idle,重启后按既定登录步骤再次成功。
13. 本次资料与复核范围
正文中的架构图和状态图均为说明性绘图,候选状态为合成数据。本文包含全部正文、图片、YAML、检查器和测试源码;离线练习按上一节标出的文件名保存到同一目录即可运行。
- GitHub:添加 self-hosted runner:仅可信私有仓库;安装界面选择 OS/架构;注册 token 一小时有效
- GitHub:移除 self-hosted runner:按当前官方界面移除用户服务与仓库注册,删除注册不同于临时停止
- GitHub:self-hosted runner 安全边界:私有仓库也存在不可信 PR 风险;常驻主机可能持久化受攻击
- Apple:添加标准用户:专用标准账号与管理员账号的职责区分
- GitHub:系统、架构、调度、更新与网络要求:全部路由条件、空闲/在线、排队超时、出站 443、更新时限
- Apple:选择命令行工具链:xcode-select --print-path 与 DEVELOPER_DIR 的当前命令语义
- Apple:安装 Command Line Tools:CLT 与完整 Xcode 的安装及适用范围
- GitHub:Runner 官方发布页:核对日 latest 指向 v2.337.0;安装时仍以注册页版本和 checksum 为准
- GitHub:配置 macOS 服务:macOS svc.sh install/start/status/stop 不能套用 Linux sudo 示例
- actions/runner:macOS 服务脚本源码:当前脚本拒绝 root,服务 plist 位于当前 HOME/Library/LaunchAgents
- Apple:Launch Daemons 与用户 Agents:归档机制文档:用户登录启动 agent,注销终止;与当前 runner 源码交叉核对
- Apple:Mac 睡眠与唤醒设置:桌面 Mac 的 Energy 选项;页面按 macOS 版本变化
- GitHub:Runner 状态与日志排查:Idle/Active/Offline、Runner_/Worker_、launchctl 与自动更新日志
- GitHub:Runner 标签:标签不区分大小写;初始配置 --labels;标签不是架构验证
- GitHub:在工作流中选用 runner:多个 runs-on 标签共同匹配,组织 runner 仍受访问范围约束
- GitHub:Workflow YAML 语法:job if、permissions、timeout-minutes 与工作流位置
- GitHub:workflow_dispatch 事件:workflow 文件需位于默认分支才能接收手动事件
- actions/checkout v7.0.1 README:Node 24 runner 下限、只读权限、persist-credentials:false
- actions/checkout v7.0.1 固定提交:发布页 v7.0.1 指向此完整 commit SHA
- GitHub:控制任务并发:同仓库同组互斥;cancel-in-progress:false;默认单 pending 替换行为
公开仓库可以把 PR 直接发到这台 Mac 吗?
本教程不采用这种配置。不可信代码可能持久化控制自托管主机,私有仓库也要审查 PR 与依赖。
svc.sh install 后断电重启就一定在线吗?
不保证。macOS 默认用户级 LaunchAgent 依赖用户登录会话,还需验证 FileVault 解锁和登录恢复。
online 为什么还一直排队?
还要检查 busy、全部 runs-on 标签、runner 组与仓库访问权限;online 只说明已连接。
离线检查器通过是否表示 GitHub 工作流可运行?
不表示。它只检查人工 JSON 快照,没有验证 GitHub 调度、YAML 执行或 Mac 工具链。