OFFSET偏移
← 文章目录OFFSET / FIELD NOTESNO. 107

GitHub Actions CI 配置

从本地质量门设计 CI,并用稳定的检查状态接入仓库合并规则。

所在专题GitHub Actions 与 CI ↗
约 32 分钟阅读开始阅读

前置阅读:04 Actions 基础与执行模型。本文假设已经理解 Workflow、Event、Job、Runner、Step、Matrix、Cache 和 Artifact 的执行原语,重点回答“如何把本地质量门设计成可维护的 CI”。

持续集成(Continuous Integration,CI)不是“把命令搬进 YAML”这么简单。一个好的 CI 契约要说明:什么变化触发它、在哪些环境运行、哪些检查决定成功、失败时留下什么证据,以及哪些结果会阻止合并。配置应当从项目已经认可的本地命令出发,再逐步增加并行、缓存和产物,而不是先堆出复杂的工作流。

1. 从本地质量门到 CI 契约#

1.1 先定义本地质量门

在写 workflow 之前,先把项目的质量门写成别人可以复制的命令。最小集合通常包括:

  1. 安装依赖:使用锁文件和项目声明的运行时版本。
  2. 静态检查:格式化检查、lint、类型检查或 Nix 求值检查。
  3. 测试:单元测试、集成测试,或项目能可靠自动化的最小测试集。
  4. 构建/打包:确认最终产物能够生成,而不是只确认源码可以解析。
  5. 契约检查:验证导出的文件、包元数据、CLI 帮助或 ABI 等对消费者可见的接口。

CI 应尽量调用与本地相同的命令。例如项目本地规定 npm test,workflow 就调用 npm test,不要在 CI 中另写一套隐藏脚本。这样失败信息能在开发者机器上复现,CI 也不会成为唯一的质量来源。

1.2 把质量门写成 CI 契约

契约部分 要回答的问题 配置位置 设计原则
触发范围 哪一次 push、PR 或手动运行需要检查? on 先覆盖合并路径,再控制无效重复运行
运行环境 哪个操作系统、运行时和工具版本? runs-on、with 固定关键版本,避免隐式依赖 runner 镜像
检查集合 哪些命令必须成功? jobs、steps 每个命令本地可复现,失败即非零退出
证据 失败后要保留什么? upload-artifact 只上传诊断和有价值的构建产物
合并门槛 哪一个状态会阻止合并? Ruleset / branch protection 使用稳定的 check 名称
资源策略 如何避免过时运行浪费并发? concurrency、matrix 取消同一分支的陈旧运行,保留必要维度

1.3 一个普通项目的基础 push/PR CI

下面的例子假设仓库是 Node.js 项目,提交了 package-lock.json,并且 package.json 有 test 脚本;将 Action 的占位符替换为实际发布版本对应的完整 commit SHA 后即可运行。这里没有加入缓存、矩阵或产物,刻意保持单 job,适合作为第一版 CI。

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  test:
    name: Test
    runs-on: ubuntu-latest
    steps:
      - name: Check out source
        uses: actions/checkout@<full-commit-sha>

      - name: Set up Node.js
        uses: actions/setup-node@<full-commit-sha>
        with:
          node-version: '22.x' # 教学版本选择,按项目支持范围定期更新

      - name: Install dependencies
        run: npm ci

      - name: Run lint when defined
        run: npm run lint --if-present

      - name: Run tests
        run: npm test

关键字段逐行看:

  • name 是 workflow 在 Actions 页面和检查列表中的显示名;改名会让人难以追踪历史,也可能影响已选择的 required check。
  • on.push.branches: [main] 只在代码实际进入 main 后做一次验证;不为每个没有 PR 的临时分支额外重复运行。
  • on.pull_request.branches: [main] 让准备合入 main 的 PR 在合并前运行。PR 事件的 branches 匹配的是目标(base)分支,不是 head 分支。
  • permissions.contents: read 是这个只读 CI 所需的最小仓库权限;不在本文为测试任务添加写权限。
  • jobs.test 是稳定的 job ID;name: Test 是人和 Ruleset 看到的可读名称。
  • runs-on 选择 GitHub-hosted runner。这里的 ubuntu-latest 方便示例运行,但关键项目可以改成明确的系统标签并定期验证。
  • uses 引用可复用 Action;<full-commit-sha> 是必须替换的占位符,不是可以直接提交的伪 SHA。
  • node-version 固定 Node 主版本,避免 runner 镜像升级让同一提交得到完全不同的运行时。
  • npm ci 根据 lockfile 安装干净依赖,比在 CI 中执行会改写锁文件的 npm install 更适合质量门。
  • npm run lint --if-present 允许尚未定义 lint 脚本的简单仓库复用模板;项目一旦把 lint 作为契约,应去掉 --if-present,让缺失脚本直接失败。
  • npm test 是真正的合并门槛;测试命令必须以非零退出码表示失败。

如果项目已有统一入口,例如 make check、just ci 或 nix flake check,优先把多个本地质量门封装在那个入口,再在 CI 中调用一个命令。这样可以减少 YAML 逻辑,让开发者与 CI 使用同一契约。

2. 触发器、分支和路径过滤#

官方语法以 on、jobs 和 steps 的 Workflow syntax 为准。触发器设计先问“哪些事件必须阻止合并”,再问“哪些运行可以省掉”。常见选择如下:

触发器 适用目的 注意点
pull_request 检查进入目标分支的变更 以 PR 的 base 分支和合并结果为中心
push 验证已经进入默认分支的提交,或没有 PR 的分支 与 pull_request 同时覆盖所有分支可能重复运行
workflow_dispatch 手动复现、临时验证或重新上传诊断 不应取代自动合并门槛
schedule 夜间或周期性检查 更适合漂移检测、依赖更新等非每次 PR 任务

2.1 分支过滤

  • 默认分支的 push 通常足以提供合并后的回归信号。
  • PR 的 branches 只筛选目标分支,例如 main、release/**。
  • 不要把 head 分支名称误写到 PR 的 branches;需要限制来源分支时应使用 job 条件或仓库规则,并明确这会增加维护成本。
  • 需要同时支持多个长期维护分支时,用显式列表或 glob,并在分支创建/删除时同步检查 Ruleset。

2.2 路径过滤

当仓库是 monorepo,或某些文件确实不影响某项检查时,可以用 paths:

on:
  pull_request:
    branches: [main]
    paths:
      - 'src/**'
      - 'package.json'
      - 'package-lock.json'
      - '.github/workflows/**'

paths 与 paths-ignore 不能在同一个事件下同时使用。包含和排除模式的顺序也有意义:否定模式 ! 可以排除之前包含的路径,而之后的正模式可能重新包含路径。因此,路径列表应覆盖依赖声明、构建脚本和 workflow 本身;漏掉它们会让源码变化没有触发应有检查。

2.3 Required check 的 Pending 陷阱

如果一个 workflow 被 Ruleset 标记为 required,而该 workflow 因 paths 或 branches 过滤器没有匹配到文件,GitHub 可能不会创建成功的 check run。PR 页面会看到该 required check 处于 Pending,于是合并被阻止,即使这次变更理论上不需要运行它。这个行为与 job 内部一个已成功的 if 分支不同,不能把两者混为一谈。

有三种处理策略:

  1. 把 required 的入口 workflow 设计成不使用路径过滤;在 job 内判断变更范围,并对“不相关”情况明确成功退出。
  2. 把路径过滤的 workflow 保持为 advisory,不把它的 check 设为 required。
  3. 使用一个总是触发的轻量 gate 作为 required check;它根据依赖 job 结果汇总状态,汇总 job 通过 needs 等待路径特定的检查,再判定所需检查是否成功。

路径过滤应当节省成本,而不是让合并依赖一个偶尔不存在的状态。对不确定的仓库,先使用无路径过滤的基础 workflow,观察运行量后再做拆分。

3. 从单 job 到 matrix:只并行有价值的维度#

单 job 的优点是启动快、日志集中、required check 名称简单。只有当“多个环境都必须通过”或“失败定位需要隔离”时,才增加 matrix。常见维度包括语言运行时版本、操作系统、数据库版本和构建特性。

3.1 Matrix 的基本决策

  • 每增加一个维度,通常都会增加 runner 启动、依赖安装和队列消耗;先确认该维度会捕获真实风险。
  • fail-fast: false 适合兼容性矩阵:一个版本失败时,其余版本仍运行,便于一次看到完整兼容性结果。
  • max-parallel 用于限制并发峰值;它控制队列压力,不会减少总任务数。
  • 使用 include 表达少数特殊组合,使用 exclude 删除明确不支持的组合;不要为一个特殊组合复制整套 workflow。
  • Matrix job 的检查名称可能带有维度值;把它们直接选作 required check 会让新增/删除维度变成治理变更。

官方说明见 Running variations of jobs with matrix。

3.2 展示 matrix、cache、artifact 和 concurrency

下面的完整例子假设仓库有包含测试依赖的 requirements.txt、pytest 测试和一个可生成 test-results.xml 的测试命令。它使用 Python 版本矩阵,将 pip 下载缓存与测试报告产物区分开,并取消同一 ref 上已经过时的运行。所有 Action 引用仍然使用 <full-commit-sha> 占位符;替换后才是可执行配置。

name: Matrix CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: matrix-ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    name: Test Python ${{ matrix.python-version }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python-version: ['3.11', '3.12', '3.13']
    steps:
      - name: Check out source
        uses: actions/checkout@<full-commit-sha>

      - name: Set up Python
        uses: actions/setup-python@<full-commit-sha>
        with:
          python-version: ${{ matrix.python-version }}

      - name: Resolve pip cache directory
        id: pip-cache
        shell: bash
        run: echo "dir=$(python -m pip cache dir)" >> "$GITHUB_OUTPUT"

      - name: Cache pip downloads
        uses: actions/cache@<full-commit-sha>
        with:
          path: ${{ steps.pip-cache.outputs.dir }}
          key: pip-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/requirements*.txt', '**/pyproject.toml', '**/poetry.lock') }}
          restore-keys: |
            pip-${{ runner.os }}-${{ matrix.python-version }}-

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          python -m pip install -r requirements.txt

      - name: Run tests
        run: python -m pytest --junitxml=test-results.xml

      - name: Upload test report
        if: ${{ always() }}
        uses: actions/upload-artifact@<full-commit-sha>
        with:
          name: test-report-py${{ matrix.python-version }}
          path: test-results.xml
          if-no-files-found: warn
          retention-days: 14

  required:
    name: Required CI
    if: ${{ always() }}
    needs: [test]
    runs-on: ubuntu-latest
    steps:
      - name: Require matrix success
        run: test "${{ needs.test.result }}" = success

这份 YAML 的关键字段和取舍如下:

  • workflow_dispatch 提供手动重跑入口,但不改变 push/PR 的自动覆盖。
  • concurrency.group 以 workflow 和 ref 区分运行;同一 PR 的新提交可以取消旧运行,不会取消另一条分支的检查。
  • cancel-in-progress: true 适合 PR CI,因为旧提交不再可能成为最终合并内容;发布或不可中断的迁移工作不应盲目复用此设置。
  • strategy.matrix.python-version 生成三个独立 job,任何一个不兼容版本都会反馈到 PR。
  • fail-fast: false 保留全部版本的结果,避免只看到第一个失败。
  • id: pip-cache 让后续步骤通过 steps.pip-cache.outputs.dir 使用动态缓存目录,避免硬编码用户目录。
  • actions/cache 缓存下载内容,不把缓存当作测试成功的证据;缓存命中失败时仍应可以完整安装依赖。
  • key 包含操作系统、Python 版本和锁文件相关哈希,使不同运行时不会错误共用依赖缓存。
  • restore-keys 允许使用同一系统和运行时的旧缓存作为近似起点;锁文件变化后仍会以新 key 保存独立缓存。
  • python -m pytest --junitxml=... 同时运行测试并生成可上传的诊断文件。
  • if: ${{ always() }} 确保测试失败时仍尝试上传报告;if-no-files-found: warn 避免“测试尚未生成报告”掩盖原始失败。
  • retention-days 控制报告保留时间;诊断产物不应无限增长,具体期限按项目当前策略调整。
  • needs: [test] 让 required 等待所有 matrix 实例完成;对 matrix job 来说,needs.test.result 只有在整个矩阵成功时才是 success。
  • Required CI 是稳定汇总 check 的候选名称。Ruleset 应要求这个汇总结果,而不是把每一个带版本后缀的矩阵实例当成长期 API。

3.3 选择单 job、矩阵或拆分 workflow

可以用下面的顺序做决定:

  1. 一个环境就足以表达契约:使用单 job。
  2. 多个运行时必须同时通过,且步骤基本相同:使用 matrix。
  3. 不同系统需要完全不同安装或权限:拆成不同 job,必要时拆成不同 workflow。
  4. 只有夜间才需要的昂贵兼容性:移到 schedule 或手动 workflow,不阻塞普通 PR。

不要为了“看起来专业”把六个小检查强行拆成六个 runner。矩阵并行节省墙钟时间,但会提高 runner 数、依赖下载量和排障噪声;以实际队列时间和失败率决定是否值得。

4. 依赖缓存与 Artifact:分别管理速度和证据#

缓存(cache)和 Artifact 解决的是不同问题:缓存为了让后续运行更快,Artifact 为了让某次运行的结果可下载、可审查。官方参考分别是 Caching dependencies to speed up workflows 和 Storing workflow data as artifacts。

4.1 设计缓存

  • 缓存依赖下载目录、编译器下载内容或明确可重建的中间结果,不缓存秘密和不可解释的最终状态。
  • key 至少包含操作系统、运行时/工具链版本和 lockfile 哈希。
  • 缓存 miss 必须是正常路径;第一次运行、缓存淘汰或 key 变更都不能使 CI 失败。
  • 不要把 restore-keys 当作正确性保证。恢复旧缓存后仍要让包管理器按锁文件校验并补齐依赖。
  • 缓存不是构建产物发布渠道。需要下载、审查或供下游 job 使用的文件应作为 artifact 上传。
  • Nix 项目要先理解其 substituter、store 和项目缓存策略;直接把整个 /nix/store 当作普通目录缓存通常既昂贵又不稳定。

4.2 设计 Artifact

  • 适合上传测试报告、覆盖率、日志、可审查的压缩包和构建结果。
  • 使用包含版本或矩阵维度的稳定名称,避免不同 job 互相覆盖。
  • 失败路径的日志可用 if: always() 上传;但不要把所有临时目录都打包。
  • 设置有限保留期,项目若需要长期发布,应使用正式 Release 或包仓库,而不是把短期 artifact 当永久存储。
  • 上传前确认产物不包含 token、配置文件、私钥或用户数据;凭据和第三方 Action 的更深安全边界留给 06 Actions 安全与排障。

5. Required Checks 与 Rulesets 的接入#

CI 通过不等于仓库一定阻止坏提交合入。合并门槛要在仓库的 Ruleset 或 branch protection 中明确要求 Pull Request、审查和稳定的 status check。平台 UI 和命名会变化,配置时应在目标仓库的规则页面确认实际 check 名称。

5.1 先选稳定的 check 名

  • 给 workflow 和 job 都写清晰的 name,不要依赖自动生成的默认名称。
  • 不要频繁重命名 workflow、job 或矩阵维度;这些名称就是治理配置依赖的接口。
  • Matrix 会产生多个带维度的 check。若只需要一个合并门,使用 needs 加 if: always() 的汇总 job,例如上例的 Required CI。
  • 汇总 job 必须真实检查依赖结果;仅仅无条件退出 0 会把失败伪装成成功。
  • 在 Ruleset 中选择 check 前,先在一个真实 PR 的 Checks 页面确认完整名称、来源 workflow 和默认分支是否一致。

5.2 Ruleset 与 workflow 的边界

建议让规则负责“是否允许合并”,让 workflow 负责“怎样验证”:

责任 Ruleset / branch protection Workflow
谁可以合并 是 否
是否必须 PR 和审查 是 否
哪些分支受保护 是 on 只决定运行范围
命令、运行时和测试 否 是
status check 是否 required 是 job 产出 status
失败日志与测试报告 否 是

规则只要求一个不存在或已改名的 check,会把正常 PR 变成无法合并的状态。因此每次修改 job ID、name、matrix 维度或 workflow 文件名时,都应同时审查 Ruleset。反过来,新增 required check 也应先确认默认分支和历史 PR 的运行都能产生该 check。

5.3 路径过滤与 required 的组合

路径过滤特别容易和 required 发生冲突。推荐的低风险方案是:

  1. 一个无路径过滤、运行迅速的 Required CI 入口始终触发。
  2. 它依赖必要的检查,或在不相关变更时执行轻量检查并成功结束。
  3. 资源昂贵的 workflow 可以按路径过滤,但不直接作为 required check。

不要把手动重跑当作绕过触发器过滤的可靠办法:没有创建 run 的事件没有可重跑对象,另一个手动 ref 的检查也不必然满足 PR 所需状态。若必须保留路径过滤,应明确设计始终触发的汇总检查或其他能为目标 PR SHA 产生状态的机制,并记录验收证据。

6. Reusable Workflow 与 Composite Action 的采用边界#

复用应当解决真实重复,而不是把一个 workflow 提前抽象成框架。官方说明见 Reusing workflows。

6.1 Reusable workflow:复用完整 job 图

使用 on: workflow_call 的 reusable workflow,适合:

  • 组织内多个仓库共享同一套构建、测试和汇总 job;
  • 需要统一 runner、matrix、权限边界和 artifact 约定;
  • 希望调用方只传入运行时版本、工作目录等少量输入。

它的粒度是 workflow/job 图,不是单个 shell 命令。调用者仍应能看懂输入、输出、失败状态和 required check 的关系。版本化复用 workflow,避免所有仓库无提示地跟随默认分支变化。

不适合复用 workflow 的情况:只有一个调用方、步骤还在快速变化,或抽象后必须通过大量布尔输入才能表达项目差异。先保留本地 YAML,第二个真实调用方出现且结构稳定时再抽取。

6.2 Composite action:复用一个 job 内的步骤序列

Composite action 适合把重复的 checkout 后设置、工具安装或测试准备步骤放在 .github/actions/<name>/action.yml 中。它不创建新的 runner、job 或 matrix,只是把多步命令作为一个步骤调用。

选择 composite action 的信号:

  • 重复的是步骤序列,不是完整 job 图;
  • 调用方仍需要自己控制 runs-on、permissions、needs 和 artifacts;
  • 输入和输出很少,且本地 action 的版本跟仓库一起演进。

不要为两三行一次性 shell 片段建 action;重复出现、边界清楚后再抽取。也不要用 composite action 伪装跨 job 依赖,那应该由 workflow 的 needs 表达。

7. 示例:主题项目的 Nix CI#

以下是匿名化主题项目的教学配置;它保留求值、构建与输出检查的设计,不报告任何个人仓库的真实运行结果。假设 .github/workflows/nix.yml 的结构如下:

示例配置 作用
on: push 与 pull_request 均未加分支过滤 分支/tag push 与默认活动类型的 PR 触发 workflow,避免先引入路径/分支 Pending 陷阱
只有 job flake 当前没有 matrix 并行;所有步骤在一次 runner 上顺序执行
runs-on: ubuntu-latest 使用一个 GitHub-hosted Ubuntu runner
permissions: contents: read checkout 和只读检查所需的仓库权限
nix flake check --all-systems --no-build 先做 flake 检查,但明确不构建 derivation
一次 nix build --no-link 构建六个主题变体 both-blur、light-blur、dark-blur、both-solid、light-solid、dark-solid 都必须成功
nix build --no-link .#checks.x86_64-linux.variants 构建并验证 variants 输出契约
没有 workflow-level matrix/cache/artifact/concurrency 当前配置没有矩阵拆分、Actions 缓存、报告上传或陈旧运行取消

这份示例 workflow 形成一个清楚的质量门顺序:先验证 flake 结构,再验证六种输出,最后验证 variants check。--no-build 不是“所有东西都已构建”的同义词;它与后面两个 nix build 步骤承担不同边界。单 job 也意味着任何一个变体失败时,下一次运行会重新执行整个 job。

7.1 可选增强,不是示例实现

以下是设计空间,不应写成“示例已经支持”:

  • Matrix:可以按真正需要的系统或构建特性拆分,但只把能由可用 runner 和项目输入稳定表达的维度加入;示例中的 --all-systems 是一次 flake 检查,不等于 GitHub 已建立多 runner matrix。
  • Cache:可以研究 Nix substituter 或项目认可的缓存方案,先测量冷/热构建时间和缓存命中率;示例 nix.yml 没有 actions/cache 配置。
  • Artifact:可在需要审查日志、导出的主题压缩包或失败报告时上传;示例 workflow 没有 upload-artifact,也没有声明需要保留的产物。
  • Concurrency:可按 PR ref 取消陈旧运行,以减少连续 push 的队列浪费;应先确认 Required Check 不会被项目的取消策略误解,示例 workflow 没有 concurrency。
  • Paths:可以在确认哪些文档/源码会影响 Nix 输出后再过滤;required check 场景必须先处理 Pending 陷阱,示例 workflow 未使用 paths。

增强顺序应由运行时间、失败定位和账单/配额数据决定,而不是照抄矩阵、缓存和 artifact 模板。对于六个变体,先拆分就会增加六次环境初始化;如果单次构建已经足够快,保留单 job 反而更便于审查。

8. 维护清单与成本控制#

每次修改 CI 时,按以下清单做一次小型审查:

  • 本地能运行的命令与 CI 命令一致,失败通过非零退出码传递。
  • 触发器覆盖默认分支合并路径,且没有无意的 push/PR 重复运行。
  • 分支过滤匹配的是预期的 base 分支;路径过滤覆盖源码、锁文件、构建脚本和 workflow。
  • required check 的 workflow/job 名称保持稳定,矩阵变更不会悄悄改变合并门。
  • matrix 的每个维度都对应一个现实兼容性承诺,而不是为了并行而并行。
  • cache key 包含运行时和锁文件;缓存 miss 仍能成功完成构建。
  • artifact 只上传日志、报告或明确需要的结果,并有合适的保留期。
  • concurrency 只取消仍可被新提交替代的工作,不取消不可重入的发布或迁移。
  • Action 引用使用已审查的版本和完整 commit SHA;占位符不能直接留在生产 workflow。
  • 权限保持最小化,敏感输入和不可信 PR 的边界遵循 06 Actions 安全与排障。
  • 运行时间、队列时间、缓存命中和失败类型有变化时,重新评估是否保留该复杂度。

GitHub 的 runner、Actions、套餐和配额会随时间、仓库可见性与组织计划变化,不要把某一时点的并发数、分钟数或存储上限写成永久规则。需要做预算或容量判断时,查阅当前仓库/组织界面和官方说明,并把核验日期写进运维记录。

官方文档入口

参考与延伸

  1. on`、jobs 和 steps 的 Workflow syntax ↗
  2. Running variations of jobs with matrix ↗
  3. Caching dependencies to speed up workflows ↗
  4. Storing workflow data as artifacts ↗
  5. Reusing workflows ↗
  6. Using concurrency ↗
  7. GitHub Actions 安全使用参考 ↗
  8. GitHub Actions 事件参考 ↗

根据技术笔记重新整理,示例使用通用名称。

沿专题继续阅读

GitHub Actions 与 CI

  1. 01GitHub Actions 执行模型
  2. 02GitHub Actions CI 配置
  3. 03GitHub Actions 安全与排障
  4. 04Nix CI:求值、构建与输出契约
CONTINUE READING / 下一篇GitHub Actions 安全与排障

沿事件、权限、凭据、Runner 和供应链边界定位 CI 风险与失败。