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 的生命周期;不要因为测试任务成功,就把个人签名钥匙串交给它。

可信私有仓库到专用 CI 用户和无签名测试的信任边界,个人凭据应排除
图 1:仓库代码能够触达的账号、文件和网络共同构成安全边界。

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:选择命令行工具链。

主机架构、Runner 包和 Xcode 工具链三层匹配关系
图 2:安装包、标签和 Xcode 的选择要分别验证,标签不会替你安装工具链。

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 .service

status 应显示服务已启动以及进程信息;GitHub 页面还需要显示 Idle,才说明连接已建立并且能够等待任务。服务已启动但网络不通时,两个界面的状态可能不同。诊断优先使用安装包自带脚本,不手工搬动 plist,也不将执行用户提升成 root。

Apple 的用户 Agent 随登录会话启动,注销时会结束。安装服务不能保证断电后在未解锁、未登录状态直接恢复接单。先做一次锁屏后运行测试,再做一次有现场恢复条件的重启测试,记录何时需要 FileVault 解锁及 ci 用户登录。不要为省一步登录关闭安全保护或默认开启自动登录。Apple:Launch Daemons 与用户 Agents 是这一会话约束的机制依据。

若需要显示器关闭时仍接任务,由你在当前 macOS 的 Energy 设置里核对防止自动睡眠选项;不同版本和机型的名称会变化。持续唤醒会增加耗电,网络唤醒也不能代替已经在线的 runner。见 Apple:Mac 睡眠与唤醒设置。

Mac 开机、登录 CI 用户、LaunchAgent 启动和 Runner 在线的顺序
图 3:本教程使用用户级服务。重启后的可用性要把解锁和登录纳入验收。

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 数。

四台合成 Runner 对比全部标签、访问权限、在线和 busy 状态
图 4:合成候选状态。只有第一行同时满足本例路由条件,仍不代表真实调度已发生。

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;一次这样的执行就可能污染后续任务。

人工触发默认分支工作流经过条件检查、标签路由、工具链和 Swift 测试
图 5:触发、路由和执行是三个独立检查点,超时与并发也各有作用范围。

9. 并发与超时需要同时设定

测试失败时先读最早出现的具体错误,保留这次运行的提交和工具版本。没有根目录 Package.swift 会在示例的文件检查处停止;需要工作区子目录的项目,应把测试步骤的工作目录明确改到相应位置。不要把所有失败都用继续执行选项吞掉,否则绿色结果会失去判断价值。

上节没有写 queue,等同于 queue: single:同组最多一个 run 执行、一个 pending 等待,后来进入的 run 会替换旧 pending。只关心最新一次构建结果时可以保留这个选择;每次已经批准的构建都需要排队时,在原文件里把 concurrency 块替换为下面四行。组名仍是 mac-mini-ci,不要为每次运行加随机后缀。

concurrency:
  group: mac-mini-ci
  queue: max
  cancel-in-progress: false

queue: 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'
  1. 审查后合入可信私有仓库的默认分支,确认没有旧的队列验收任务。选 case_name=A 触发,等日志出现 case=A start,且 A 仍在等待。
  2. 触发 B,打开运行页确认它因同一并发组等待,随后再触发 C。A 结束前,预期 B、C 同时保留为等待项,B 不因 C 到来而取消。
  3. A 结束后核对运行记录与 start/finish 日志:B 先执行,随后 C 执行,三次均成功。如果 A 太早结束,这一轮不能验证两个 pending 共存,应在空闲时重新做。
  4. 需要对照时,等三次全部结束,再将这个验收文件的 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。旧日志可能包含路径和敏感输出,分享前需要检查;保留副本同样要限制访问。

维护时等待 Idle、停止服务、确认 Offline、盘点日志缓存并恢复验证
图 6:先停止接单,再盘点和清理;日志与注册信息有不同的保留要求。

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 核对。先保留发生故障的那一段,再改变一个条件复测。若怀疑跑过恶意代码,应停止分配任务、由维护者处置凭据与重建环境,不能只删除工作目录后恢复接单。

Skipped、queued、启动失败和任务失败分别对应的排错检查
图 7:按触发、调度、主机和任务四层缩小范围,避免把所有故障都归因于 runner 离线。

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、检查器和测试源码;离线练习按上一节标出的文件名保存到同一目录即可运行。

公开仓库可以把 PR 直接发到这台 Mac 吗?

本教程不采用这种配置。不可信代码可能持久化控制自托管主机,私有仓库也要审查 PR 与依赖。

svc.sh install 后断电重启就一定在线吗?

不保证。macOS 默认用户级 LaunchAgent 依赖用户登录会话,还需验证 FileVault 解锁和登录恢复。

online 为什么还一直排队?

还要检查 busy、全部 runs-on 标签、runner 组与仓库访问权限;online 只说明已连接。

离线检查器通过是否表示 GitHub 工作流可运行?

不表示。它只检查人工 JSON 快照,没有验证 GitHub 调度、YAML 执行或 Mac 工具链。