ᕕ( ᐛ )ᕗ Jimyag's Blog

GitHub Actions Runner 实现原理:它为什么能运行工作流

· 1594 words · ~ 8 min read

分析基线:本文基于 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.csCreateSessionAsync() 会做几件事:

  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. 注入 stepsenv 等表达式上下文。
  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.workspacegithub.workspace 上下文。
  3. 计算 job 级 envdefaults.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@v4uses: actions/setup-go@v5 这种远程 action,runner 会在 PrepareActionsAsync() 阶段先把这些 action 包下载到本地。
  2. 业务仓库代码不会自动 checkoutPipelineDirectoryManager.PrepareDirectory() 做的是创建工作目录、恢复 tracking config、按 workspace.clean 策略清理目录;它不是 git clone

换句话说:

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

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

1
- uses: actions/checkout@v4

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

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

  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. 注入 stepsgithubrunner 等表达式上下文。
  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 选项决定用 bashshpwshpowershell 等哪种解释器。
  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_URLACTIONS_RUNTIME_TOKENACTIONS_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。

十、把完整链路串起来

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

  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 内部怎么跑」,更适合看下面这张时序图:

  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

#Github-Actions #Runner #Ci #Workflow #Source-Code

Table of Contents