
分析基线：本文基于 `actions/runner` 仓库 `main` 分支和官方设计文档在 2026-05-20 的公开内容整理。

先说结论：

1. GitHub Actions Runner 本质上不是调度器，而是一个「拿到作业描述后在本机执行」的执行代理。
2. 它之所以能运行 workflow，不是因为它自己理解了整套 GitHub Actions 平台规则，而是因为 GitHub 服务端先把 workflow 解析、筛选、编排成了一个可执行的 job message，再交给 runner。
3. runner 真正做的事情可以概括成四段：**注册和鉴权 -> 建立会话并长轮询拿消息 -> 派发 worker 子进程 -> 按 step 类型执行并回传结果**。

## 背景与目标

很多人第一次看 GitHub Actions，会直觉认为 runner 做了两件事：

1. 解析 `.github/workflows/*.yml`。
2. 直接把整条流水线从头跑到尾。

但从 `actions/runner` 的实现看，这两个判断都不准确。

更接近事实的说法是：

1. workflow 的**事件触发、矩阵展开、依赖关系、权限收敛、runner 匹配**主要发生在 GitHub 服务端。
2. runner 收到的已经不是原始 YAML，而是服务端整理好的 `AgentJobRequestMessage`。
3. runner 负责把这份 job message 落到当前机器：准备目录、下载 action、执行脚本或容器、上传日志、上报状态。

所以本文重点回答两个问题：

1. `actions/runner` 的实现原理是什么？
2. 它为什么真的可以把 GitHub 上的一段 workflow 跑起来？

## 一、runner 在整套系统里的定位

先把边界划清楚。

从官方 README 看，runner 的定义很直接：**它是运行 GitHub Actions job 的应用程序**。这句话看起来普通，但它隐含了一个很重要的边界：它运行的是 **job**，不是整个平台。

从 `src/Runner.Worker/JobRunner.cs` 也能看出来，worker 启动后直接处理的是：

1. `message.Resources`
2. `message.Variables`
3. `message.Steps`

也就是说，runner 接手时，服务端已经把一个 job 所需的资源、变量、步骤都准备成结构化消息了。runner 不是拿到 YAML 再自己去做完整编排，而是消费一个已经成型的执行计划。

这也是理解 GitHub Actions 的关键：

1. **GitHub 服务端负责控制面**：触发、调度、权限、编排、消息投递。
2. **runner 负责数据面/执行面**：在具体机器上把 job 跑出来。

## 二、注册与鉴权：runner 为什么能合法接入 GitHub

如果 runner 只是一个本地进程，它首先得回答一个问题：**GitHub 为什么信任这台机器能接作业？**

官方设计文档 `docs/design/auth.md` 给出了比较完整的答案。

### 1. 配置阶段：不是拿你的 GitHub 账号直接跑

自托管 runner 在 `config.sh` / `config.cmd` 阶段，使用的是**短期有效的 registration token**。文档明确写到：

1. 你的个人凭据不会直接被 runner 保存用来访问 GitHub 资源。
2. 配置过程中会在本机生成一对 RSA 公私钥。
3. 公钥会注册到服务端，私钥保存在本地。

这一步的意义是：runner 以后和 GitHub 通信，不需要长期持有你的账号密码，它有自己独立的机器身份。

### 2. 运行阶段：listener 先换取会话级访问能力

`src/Runner.Listener/MessageListener.cs` 的 `CreateSessionAsync()` 会做几件事：

1. 读取本地 runner 配置和凭据。
2. 连接 Runner Server。
3. 创建 `TaskAgentSession`。
4. 把 runner 的版本、机器名、OS 信息带到会话里。

官方认证文档对这一步的解释更清楚：

1. listener 进程启动后，会使用本地私钥向 Token Service 申请 OAuth token。
2. 这个 token 只允许它访问消息队列和 Actions 服务的必要接口。
3. token 不是长期有效，而是跟当前作业或会话生命周期绑定。

所以，runner 不是“装好以后永远有权限”，而是每次启动后重新建立一条受控的会话链路。

### 3. 为什么消息能安全地下发到这台 runner

官方文档写到：服务端下发给 runner 的工作流消息会使用 runner 公钥相关机制进行保护。源码里的 `MessageListener.DecryptMessage()` 则体现出另一层实现细节：

1. runner 从服务端拿到消息后，会检查 session 是否带有加密信息。
2. 如果消息体带有 `IV`，会用 AES 解密消息内容。

把两者合起来理解更稳妥：

1. 配置阶段建立的是**机器身份**。
2. 运行阶段建立的是**会话级安全通道**。
3. 最终下发到本机的 job message 不是明文裸奔，而是 runner 能解、其他机器不能直接消费的消息。

## 三、主循环：runner 是怎么拿到作业的

runner 真正进入“等活干”状态，发生在 `Runner.Listener` 里。

### 1. 入口进程是 Runner.Listener

`src/Runner.Listener/Program.cs` 的主流程很简单：

1. 校验操作系统和目录权限。
2. 解析命令行参数。
3. 把执行转交给 `Runner.ExecuteCommand()`。

也就是说，外部看起来你是在运行 `./run.sh`，但内部真正常驻的是 `Runner.Listener`。

### 2. RunAsync 做的不是执行 step，而是维护一个消息循环

`src/Runner.Listener/Runner.cs` 里的 `RunAsync()` 才是 listener 的核心。

它在创建 session 成功后，会进入一个持续循环：

1. `CreateSessionAsync()` 建立与服务端的会话。
2. 输出 `Listening for Jobs`。
3. 创建 `IJobDispatcher`。
4. 循环调用 `_listener.GetNextMessageAsync()`。
5. 根据消息类型决定是更新、关闭还是派发 job。

这说明 listener 的职责更像一个控制平面代理，而不是具体执行器。

### 3. GetNextMessageAsync 的本质是长轮询

`MessageListener.GetNextMessageAsync()` 调用的是 `_runnerServer.GetAgentMessageAsync(...)`。调用参数里带着：

1. pool id
2. session id
3. last message id
4. 当前 runner 状态
5. runner 版本、OS、架构

这基本就是一个典型的“长轮询消息拉取”模型。

如果拿不到消息，它会：

1. 继续等待或重试。
2. 在错误时做退避重试。
3. 在 session 过期时尝试重建 session。

这也是 runner 可以长期在线的原因：它不是被动等服务端推送 TCP 连接，而是自己稳定地维护一条“我来取消息”的控制链路。

### 4. 新旧两条取 job 路径并存

从 `Runner.cs` 的消息分支可以看到，当前 runner 同时兼容两类 job 获取方式：

1. 旧路径：直接拿到 `PipelineAgentJobRequest`，消息体里就是完整 job。
2. 新路径：先收到一个 `RunnerJobRequestRef`，必要时先 `Acknowledge`，再去 Run Service 拉完整 job message。

这说明 runner 不是一个静态实现，它在逐步从旧的队列模型迁移到 broker/run service 模型。

但不管哪条路径，核心都一样：**先拿到一个 job 引用或 job 描述，再交给 job dispatcher。**

## 四、为什么要再起一个 Runner.Worker 子进程

拿到 job 以后，listener 并不会自己执行，而是交给 `JobDispatcher`。

这是 runner 设计里很关键的一层隔离。

### 1. 一个 runner 同时只处理一个 job

`src/Runner.Listener/JobDispatcher.cs` 里写得很直白：

1. 当前设计按队列一次处理一条消息。
2. 每次只执行一个 job。
3. 服务端在当前 job 没结束前，不应该再给这个 runner 发下一条 job。

所以“一个 runner 并发跑多个 job”不是默认模型。并发一般靠多 runner 实例或 runner scale set，而不是单进程内多 job 复用。

### 2. dispatcher 会先续租 job，再启动 worker

`JobDispatcher.RunAsync()` 在真正拉起 worker 前，会先：

1. 启动 `RenewJobRequestAsync(...)`。
2. 等待第一次续租成功。
3. 只有确认当前 job 仍归这台 runner 所有，才继续派发。

这一步很像“租约确认”。

它解决的是一个分布式系统里的基本问题：**服务端必须知道这份 job 当前确实被某个 runner 持有，而且 runner 还活着。**

### 3. listener 和 worker 分进程，是为了隔离控制面和执行面

继续看 `JobDispatcher.RunAsync()`：

1. 它启动了 `IProcessChannel` 作为 IPC 通道。
2. 再起一个 `Runner.Worker` 子进程，参数是 `spawnclient <pipeOut> <pipeIn>`。
3. 然后通过进程间通道把 `AgentJobRequestMessage` 发给 worker。

这样设计有几个直接好处：

1. listener 可以继续维护会话、接收取消和更新消息，不被具体 step 执行阻塞。
2. worker 崩了，不一定要把整个 listener 一起带崩。
3. 自更新、取消、超时处理会更清晰，因为控制进程和执行进程分开了。

一句话总结：**listener 负责“接活和看场子”，worker 负责“真正干活”。**

## 五、worker 拿到 job 后，到底怎么把 workflow 跑起来

这是最核心的一段。

### 1. worker 收到的不是 YAML，而是标准化的 job message

`src/Runner.Worker/Worker.cs` 启动后，会从 IPC 通道读取一条 `NewJobRequest` 消息，然后反序列化成 `Pipelines.AgentJobRequestMessage`。

随后它会做三件基础工作：

1. 初始化 secret masker。
2. 设置线程文化信息。
3. 调用 `IJobRunner.RunAsync(jobMessage, ...)`。

这里已经很能说明问题了：runner 之所以能跑，是因为它消费的是一个标准 job message，而不是自己从磁盘读取 workflow YAML 重新解释。

### 2. JobRunner 负责把 job 变成执行上下文

`JobRunner.RunAsync()` 是 job 级执行的总控。

它主要做这些事：

1. 连接 job server / run server，用于状态上报、日志回传、结果提交。
2. 创建 `IExecutionContext`，把变量、资源、取消信号都挂进去。
3. 检查并准备工作目录、工具目录、临时目录。
4. 调用 `IJobExtension.InitializeJob()` 把 job 初始化成可执行步骤列表。
5. 把步骤交给 `IStepsRunner.RunAsync()` 顺序执行。
6. 最后做 `FinalizeJob()` 和 `CompleteJobAsync()`。

换句话说，`JobRunner` 干的是“把服务端下发的 job 描述，装配成当前机器上的执行现场”。

### 3. StepsRunner 才是 step 语义的真正落点

`src/Runner.Worker/StepsRunner.cs` 会循环处理 `jobContext.JobSteps`。

每个 step 执行前，它会先做几件 runner 语义层的工作：

1. 注入 `steps`、`env` 等表达式上下文。
2. 合并 job 级和 step 级环境变量。
3. 计算 `if` 条件。
4. 计算 `timeout-minutes`。
5. 处理取消、失败、`continue-on-error` 和 post step。

这一步非常重要，因为它解释了一个常见疑问：**GitHub Actions 的“条件、环境变量、post、超时”这些语义到底是谁实现的？**

答案就是 runner。

服务端把 job 编排好，但真正把 step 级运行语义落到本机的，是 `StepsRunner` 和相关 handler。

### 4. 每个 step 执行前，runner 其实先做了一次 job 级准备

如果问题换成「每个 step 是不是直接开跑」，答案是否定的。

从 `src/Runner.Worker/JobExtension.cs` 可以看到，runner 在真正进入 step 循环前，会先执行一次 `InitializeJob()`。这个阶段会创建一个单独的 timeline 节点，名字就叫 `Set up job`。它不是某个业务 step，而是整个 job 的前置准备。

这一段准备大致是：

1. `Prepare workflow directory`：调用 `PipelineDirectoryManager.PrepareDirectory(...)` 创建或清理 pipeline/workspace 目录。
2. 设置 `runner.workspace` 和 `github.workspace` 上下文。
3. 计算 job 级 `env`、`defaults.run`、job container、service container。
4. 调用 `ActionManager.PrepareActionsAsync(...)`，把当前 job 用到的远程 action 先下载到本地缓存。
5. 按顺序组装出 pre-job steps、job steps、post-job steps。

也就是说，**step 不是裸跑的，它先站在一个已经准备好的 job 执行现场里运行。**

### 5. 但是「准备代码」要分成两类看

这里最容易混淆的是：runner 会不会自动把仓库代码准备好？

答案要拆成两部分：

1. **action 代码会预先准备**。如果 step 里有 `uses: actions/checkout@v4`、`uses: actions/setup-go@v5` 这种远程 action，runner 会在 `PrepareActionsAsync()` 阶段先把这些 action 包下载到本地。
2. **业务仓库代码不会自动 checkout**。`PipelineDirectoryManager.PrepareDirectory()` 做的是创建工作目录、恢复 tracking config、按 `workspace.clean` 策略清理目录；它不是 `git clone`。

换句话说：

1. `github.workspace` 这个目录会先存在。
2. 但这个目录里未必已经有你的仓库代码。
3. 只有 workflow 里显式执行了 `actions/checkout`，或者你自己写了 `git clone`，后面的 `run:` step 才能看到仓库内容。

这也是为什么很多 workflow 的第一步几乎总是：

```yaml
- uses: actions/checkout@v4
```

因为 checkout 本身也是一个普通 action，不是 runner 自动附送的隐藏步骤。

### 6. 只看「step 开跑前」的机器内流程，可以这样理解

```mermaid
flowchart TD
    A[Job message 到达 worker] --> B[JobExtension.InitializeJob]
    B --> C[Prepare workflow directory]
    C --> D[设置 runner.workspace / github.workspace]
    D --> E[计算 job 级 env / defaults / container]
    E --> F[PrepareActionsAsync 下载 uses action]
    F --> G[生成 pre-job / job / post-job step 列表]
    G --> H[StepsRunner 取出下一个 step]
    H --> I[计算 step if / env / timeout]
    I --> J{step 类型}
    J -->|run| K[写临时脚本文件并调用 shell]
    J -->|uses JS action| L[加载 action 定义并调用 node]
    J -->|uses container action| M[准备镜像和挂载后 docker run]
    K --> N[收集 stdout / stderr / exit code]
    L --> N
    M --> N
    N --> O[更新 step 结果与上下文]
    O --> P[进入下一个 step]
```

如果只关心你问的「是不是要先准备代码」，这张图可以读出两个层次：

1. runner **会先准备执行环境**：目录、上下文、环境变量、远程 action、本地 step 列表。
2. runner **不会默认准备业务代码**：仓库代码是否出现，取决于 workflow 里有没有 checkout 或其他拉代码动作。

### 7. 到了每一个具体 step，runner 又会再做一次 step 级准备

虽然 job 级准备只做一次，但每个 step 开始前，`StepsRunner` 还是会做一轮 step 级处理：

1. 把 job 级环境变量合并进 step 的 `env`。
2. 计算 step 的 `if` 条件。
3. 计算 `timeout-minutes`。
4. 注入 `steps`、`github`、`runner` 等表达式上下文。
5. 处理取消信号和 post step 注册。

所以完整理解应该是：

1. **先做一次 job 级准备**，把场地搭好。
2. **再做多次 step 级准备**，每个 step 单独计算条件、环境和执行方式。

## 六、`run:` 和 `uses:` 为什么都能执行

很多人把 workflow step 统一理解成“执行一段命令”，但 runner 里其实分成了几类完全不同的执行模型。

### 1. `run:` 步骤：临时脚本文件 + shell 执行

`src/Runner.Worker/Handlers/ScriptHandler.cs` 负责 `run:` 类型步骤。

它的做法是：

1. 根据 shell 选项决定用 `bash`、`sh`、`pwsh`、`powershell` 等哪种解释器。
2. 把 step 里的脚本内容写入一个临时文件。
3. 拼出命令行参数。
4. 调用 `StepHost.ExecuteAsync()` 执行这个脚本。

所以 `run: echo hello` 的本质，不是 runner 自己实现了一个脚本语言，而是 runner 帮你生成了临时脚本文件，再交给系统 shell。

### 2. JavaScript Action：Node 进程执行

`src/Runner.Worker/Handlers/NodeScriptActionHandler.cs` 负责 Node 类型 action。

它会：

1. 准备 action 输入和环境变量。
2. 注入 `ACTIONS_RUNTIME_URL`、`ACTIONS_RUNTIME_TOKEN`、`ACTIONS_RESULTS_URL` 等运行时信息。
3. 选定 runner 自带的 Node 运行时。
4. 执行 action 指向的 JS 文件。

这说明 JavaScript action 能跑，不是因为机器上恰好有一套对版 Node，而是因为 runner 自己带了执行环境和运行时协议。

### 3. Container Action：Docker 构建/拉取后执行

`src/Runner.Worker/Handlers/ContainerActionHandler.cs` 负责容器 action。

它会：

1. 判断是 `docker://` 镜像还是本地 `Dockerfile`。
2. 需要时先构建镜像。
3. 准备容器网络、挂载目录和环境变量。
4. 把 workspace、temp、file command 目录映射进容器。
5. 再通过 Docker 运行这个 action。

所以“GitHub Actions 就是 Docker”这句话不准确。更准确的说法是：

1. `run:` 默认不是 Docker。
2. JavaScript action 默认也不是 Docker。
3. 只有容器 action 或 job container 场景才强依赖 Docker/容器运行时。

### 4. Composite Action：展开成更多 step

`src/Runner.Worker/ActionRunner.cs` 里，如果发现 action 是 composite 类型，会调用 `PrepareActionsAsync()` 继续展开嵌套步骤。

也就是说，composite action 并不是一种新的底层执行器，它更像一个“步骤模板”，最后仍会落回已有的 script / node / container 执行路径。

## 七、远程 action 为什么能在本机出现

这又是一个常见问题：workflow 写的是 `uses: actions/checkout@v4`，本机原本没有这个目录，runner 为什么就能执行？

答案在 `src/Runner.Worker/ActionManager.cs`。

### 1. runner 会先把远程 action 下载到本地

`PrepareActionsAsync()` 会解析当前 job 里引用到的 action，然后调用 `DownloadRepositoryActionAsync()`。

后者会：

1. 以 `owner/repo@ref` 作为标识构造目标目录。
2. 如果本地已有 watermark，直接复用。
3. 否则下载 action archive。
4. 解压到 staging 目录。
5. 最终落到 runner 的 action 缓存目录。

### 2. 缓存粒度不是工作流级，而是 action 版本级

从实现能看到几个关键点：

1. 缓存路径按 `NameWithOwner + Ref` 组织。
2. 归档下载还可以按 `ResolvedSha` 复用缓存。
3. 某些场景还支持符号链接复用已解压目录。

这就是为什么同一个 runner 重复执行相同版本 action 时，后续会明显更快。

## 八、日志、状态、取消、结果是怎么回到 GitHub 的

如果 runner 只是本地执行，那 GitHub 页面为什么能实时看到：

1. step 日志
2. job 状态
3. cancel 后的终止
4. 最终成功或失败

原因是 runner 在执行过程中一直和服务端保持双向状态同步。

### 1. 日志和状态通过 job server 队列持续上报

`JobRunner.RunAsync()` 会启动 `IJobServerQueue`。这说明 runner 不是等全部结束后再一次性上传结果，而是在执行过程中持续把 timeline、日志、状态上报出去。

### 2. 取消不是本地 Ctrl+C 专属，而是协议里的一类消息

`Worker.cs` 在执行 job 时，会同时监听：

1. job 执行任务本身
2. 来自 IPC 通道的取消/关闭消息

如果 listener 收到取消请求，就可以通过进程间通道把 `CancelRequest` 发给 worker，worker 再取消当前 job token。

所以 GitHub 网页上点 Cancel 能生效，是因为：

1. 服务端发取消信号；
2. listener 收到后通知 worker；
3. worker 终止当前 step 或 job。

### 3. 最终结果不是看进程退出码这么简单

runner 会综合：

1. step 自身结果
2. `continue-on-error`
3. timeout
4. cancellation
5. post-job 执行情况

最后再汇总成 job result 提交给服务端。

这也是为什么 GitHub Actions 页面展示的是完整的 step/job 语义，而不是一串单纯的 Unix 退出码。

## 九、它为什么“可以运行 workflow”：本质上靠哪几层能力拼起来

把前面的实现拆开后，可以把答案压缩成六层。

### 1. 服务端先把 workflow 变成了 runner 能消费的 job

这是最根本的一层。

runner 并不是从零开始解释一份 workflow，而是接收 GitHub 服务端已经编排好的 job message。没有这层，runner 只能算一个脚本执行器，不足以成为 GitHub Actions 的执行节点。

### 2. 注册与会话机制给了 runner 一个可信机器身份

registration token、RSA 密钥、本地凭据、session 创建，这些机制共同保证：

1. 不是任意机器都能冒充某个 runner。
2. 服务端知道 job 应该发给谁。
3. runner 具备受控、短期、可撤销的访问能力。

### 3. 长轮询消息队列把“云上的调度结果”送到了“本地机器”

没有消息循环，workflow 只是 GitHub 后台的一条记录；有了 session + long poll，它才真正有机会落到某台机器上执行。

### 4. worker 子进程把 job 执行和会话维护隔离开了

这层保证了：

1. 控制链路不会被 step 执行拖死。
2. 取消、超时、自更新有独立处理空间。
3. listener 可以稳态在线，worker 可以按 job 生命周期创建和退出。

### 5. handler 体系把 workflow step 语义翻译成操作系统动作

这层是 runner 最像“执行引擎”的地方：

1. `run:` -> shell 脚本
2. Node action -> Node 进程
3. Container action -> Docker 执行
4. Composite action -> 展开成更多 step

也就是说，workflow 并不是直接“跑 YAML”，而是被 runner 翻译成了宿主机能执行的进程、容器和文件操作。

### 6. 结果回传闭环让 GitHub 页面和本地执行保持一致

没有日志和状态回传，runner 最多只是本地工具；正因为它持续把 timeline、日志、结果、取消状态回传给服务端，GitHub UI 才能实时展示一场 workflow run。

## 十、把完整链路串起来

下面用一张图把整条链路串起来：

```mermaid
flowchart LR
    A[GitHub Event<br/>push / pull_request / cron] --> B[GitHub Actions 服务端]
    B --> C[解析 workflow<br/>展开 matrix / needs / permissions]
    C --> D[匹配 runner labels]
    D --> E[向目标 runner 队列投递 job]
    E --> F[Runner.Listener<br/>CreateSession + Long Poll]
    F --> G[拿到 JobRequestMessage]
    G --> H[JobDispatcher<br/>续租 job + 启动 worker]
    H --> I[Runner.Worker]
    I --> J[JobRunner / StepsRunner]
    J --> K[下载 action / 准备 workspace]
    J --> L[执行 run / node / container / composite]
    L --> M[日志、timeline、结果回传]
    M --> N[GitHub Actions UI]
```

如果再压缩成一句话，就是：

> GitHub 负责把「应该跑什么」算清楚，runner 负责把「怎么在这台机器上跑出来」做完整。

### 如果只看「runner 这台机器内部」的执行流程

前一张图偏平台视角。如果只关心「job 到了这台机器之后，runner 内部怎么跑」，更适合看下面这张时序图：

```mermaid
sequenceDiagram
    participant GH as GitHub Actions Service
    participant RL as Runner.Listener
    participant JD as JobDispatcher
    participant RW as Runner.Worker
    participant JR as JobRunner/StepsRunner
    participant SH as Script/Node/Container Handler
    participant OS as OS / Docker / Node

    GH->>RL: 长轮询返回 JobRequestMessage
    RL->>JD: Run(jobMessage)
    JD->>GH: RenewJobRequestAsync(续租 job)
    JD->>RW: 启动 Runner.Worker 子进程
    JD->>RW: IPC 发送 AgentJobRequestMessage
    RW->>JR: 反序列化消息，创建 ExecutionContext
    JR->>JR: 初始化 workspace / temp / variables / steps
    JR->>SH: 按 step 类型选择 handler

    alt run: 脚本步骤
        SH->>OS: 写临时脚本并调用 shell
    else uses: JS Action
        SH->>OS: 调用 runner 自带 node 执行 action
    else uses: Docker Action
        SH->>OS: docker build/pull + docker run
    end

    OS-->>SH: 返回 stdout/stderr/exit code
    SH-->>JR: 汇总 step 结果
    JR-->>GH: 持续上报日志、timeline、job 状态
    GH-->>RL: 可选发送 cancel / shutdown / refresh
    RL->>RW: IPC 转发取消或关闭消息
    RW-->>JD: worker 退出并返回 job 结果
    JD-->>RL: 当前 job 完成
    RL-->>GH: 删除消息 / 保持监听下一条 job
```

可以把这张图按 8 个动作来读：

1. **listener 取消息**：`Runner.Listener` 长轮询 GitHub，拿到一条 job request。
2. **dispatcher 接管**：listener 不自己执行，而是把 job 交给 `JobDispatcher`。
3. **先续租再开工**：dispatcher 先调用 `RenewJobRequestAsync()`，确认这份 job 还归当前 runner 持有。
4. **拉起 worker 子进程**：dispatcher 启动 `Runner.Worker`，并通过 IPC 管道传 job message。
5. **worker 建上下文**：worker 把 job message 反序列化成 `AgentJobRequestMessage`，然后创建 `ExecutionContext`。
6. **按 step 类型挑执行器**：`run:` 走 `ScriptHandler`，JS action 走 `NodeScriptActionHandler`，容器 action 走 `ContainerActionHandler`。
7. **边跑边回传**：日志、timeline、状态不是最后一次性上传，而是执行过程中持续回传给 GitHub。
8. **收尾并回到监听态**：job 完成后，worker 退出；listener 清理这条消息，然后继续等待下一条 job。

如果你只记一件事，可以记这句：

> 在机器内部，runner 不是一个单进程“从头跑到尾”，而是 `Listener 负责控场 + Worker 负责执行 + Handler 负责把 step 翻译成宿主机动作` 的分层模型。

## 十一、几个容易混淆的点

### 1. `GITHUB_TOKEN` 不是 runner listener 自己拿消息的 token

官方认证文档明确区分了两类 token：

1. runner 用来跟 Actions Service 通信的 token；
2. workflow 内给 action/脚本使用的 `GITHUB_TOKEN`。

两者作用域、生命周期、使用对象都不同。

### 2. `actions/checkout` 不是 runner 内建能力

runner 只是把 `actions/checkout@v4` 下载下来再执行。checkout 是一个普通 action，不是 runner 的保留字。

### 3. runner 不是默认容器平台

只有容器 action 或 job container 场景，runner 才会走 Docker 相关逻辑。普通 `run:` 完全可以直接在宿主机 shell 上执行。

## 结论

如果只保留一句话，我会这样总结 GitHub Actions Runner：

**它不是一个“读 YAML 然后运行命令”的小工具，而是 GitHub Actions 控制面在目标机器上的执行代理。**

它之所以能运行 workflow，依赖的是一整套分层协作：

1. GitHub 服务端把 workflow 编排成 job。
2. runner 通过注册、会话、长轮询拿到 job。
3. listener 把 job 派给独立 worker。
4. worker 用 script/node/container/composite 等 handler 把 step 真正落到宿主机执行。
5. 执行状态再持续回传给 GitHub。

从这个角度看，`actions/runner` 最值得关注的不是“它会不会执行 shell”，而是它把**鉴权、消息协议、作业租约、执行隔离、step 语义、日志回传**这些能力拼成了一个完整闭环。

## 参考链接

1. `actions/runner` 仓库：<https://github.com/actions/runner>
2. Runner 认证设计：<https://github.com/actions/runner/blob/main/docs/design/auth.md>
3. Self-hosted runners 文档：<https://docs.github.com/actions/hosting-your-own-runners>
4. Workflow syntax 文档：<https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions>
5. `GITHUB_TOKEN` 文档：<https://docs.github.com/actions/security-for-github-actions/security-guides/automatic-token-authentication>

