前置阅读:04 Actions 基础与执行模型。本文假设已经理解 Workflow、Event、Job、Runner、Step、Matrix、Cache 和 Artifact 的执行原语,重点回答“如何把本地质量门设计成可维护的 CI”。
持续集成(Continuous Integration,CI)不是“把命令搬进 YAML”这么简单。一个好的 CI 契约要说明:什么变化触发它、在哪些环境运行、哪些检查决定成功、失败时留下什么证据,以及哪些结果会阻止合并。配置应当从项目已经认可的本地命令出发,再逐步增加并行、缓存和产物,而不是先堆出复杂的工作流。
1. 从本地质量门到 CI 契约#
1.1 先定义本地质量门
在写 workflow 之前,先把项目的质量门写成别人可以复制的命令。最小集合通常包括:
- 安装依赖:使用锁文件和项目声明的运行时版本。
- 静态检查:格式化检查、lint、类型检查或 Nix 求值检查。
- 测试:单元测试、集成测试,或项目能可靠自动化的最小测试集。
- 构建/打包:确认最终产物能够生成,而不是只确认源码可以解析。
- 契约检查:验证导出的文件、包元数据、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 分支不同,不能把两者混为一谈。
有三种处理策略:
- 把 required 的入口 workflow 设计成不使用路径过滤;在 job 内判断变更范围,并对“不相关”情况明确成功退出。
- 把路径过滤的 workflow 保持为 advisory,不把它的 check 设为 required。
- 使用一个总是触发的轻量 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
可以用下面的顺序做决定:
- 一个环境就足以表达契约:使用单 job。
- 多个运行时必须同时通过,且步骤基本相同:使用 matrix。
- 不同系统需要完全不同安装或权限:拆成不同 job,必要时拆成不同 workflow。
- 只有夜间才需要的昂贵兼容性:移到 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 发生冲突。推荐的低风险方案是:
- 一个无路径过滤、运行迅速的
Required CI入口始终触发。 - 它依赖必要的检查,或在不相关变更时执行轻量检查并成功结束。
- 资源昂贵的 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、套餐和配额会随时间、仓库可见性与组织计划变化,不要把某一时点的并发数、分钟数或存储上限写成永久规则。需要做预算或容量判断时,查阅当前仓库/组织界面和官方说明,并把核验日期写进运维记录。
官方文档入口
- Workflow syntax for GitHub Actions
- Using a matrix for your jobs
- Caching dependencies to speed up workflows
- Storing workflow data as artifacts
- Using concurrency
- Reusing workflows
参考与延伸
- on`、jobs 和 steps 的 Workflow syntax ↗
- Running variations of jobs with matrix ↗
- Caching dependencies to speed up workflows ↗
- Storing workflow data as artifacts ↗
- Reusing workflows ↗
- Using concurrency ↗
- GitHub Actions 安全使用参考 ↗
- GitHub Actions 事件参考 ↗
根据技术笔记重新整理,示例使用通用名称。