GitHub CI 实战:workflow、矩阵缓存与分支保护

发布时间:2026/10/2 5:34:24
GitHub CI 实战:workflow、矩阵缓存与分支保护 1. 先算清楚 GitHub CI 到底替你省了哪部分人力第一次把 GitHub CI 接进项目起因是一件挺丢人的事我改了一个通用的日期格式化函数本地只跑了手头那个模块的测试觉得没问题就合并了。结果另外一个依赖这个函数的导出任务在凌晨挂了日志里报的还是格式不对。事后复盘问题不在代码本身而在于我把我记得要跑测试当成了流程而流程一旦依赖人的记忆迟早会漏。GitHub CI 解决的正是这件事——它把应该跑测试从人的自觉变成机器必须执行的门槛。说白了持续集成就是把验证动作从本地搬到服务端让每一次提交都经过一遍固定流程装依赖、跑测试、做构建、产出结果。它不是什么高深的东西本质就是一台别人替你维护、随时待命的机器。很多人对 GitHub CI 有误解以为它只是自动跑一下测试。实际上它覆盖的范围比你想象的宽跑单元测试和集成测试只是最基础的一层往上还有代码风格检查、类型检查、打包构建、生成产物、构建容器镜像、打标签发版本、部署到目标环境甚至包括定时任务和手动触发的一次性运维脚本。它的载体是GitHub Actions也就是你在仓库根目录下.github/workflows/里写的那些 YAML 文件。一套完整可用的配置通常几十行就能跑起来学习成本远低于自己搭一台 Jenkins 再维护插件。还有一点值得先说清楚GitHub CI 的执行环境分两类。一类是GitHub 托管的 runner你在runs-on里写ubuntu-latest、windows-latest这类标签它每次开一台干净的虚拟机给你用跑完就销毁另一类是自托管 runner机器在你自己的内网或者云主机上你装一个 agent 注册到仓库任务就跑在你自己那台机器上。默认从前者开始就行省事、干净、隔离好。什么时候需要切到自托管通常是三种情况需要访问你内网的服务比如内网数据库、私有制品库、需要特定的硬件架构比如 ARM 或者带 GPU 的机器、或者任务量大到托管额度不够用。这三种之外绝大多数项目用托管 runner 完全够。1.1 把验证从人的习惯变成平台的规则CI 真正的价值不在能自动跑而在跑不过是拦得住的。这两者差别巨大。前者只是省了你敲命令的时间后者改变了团队协作的默认行为。你可以在仓库设置里把某个 job 配成必需检查这样 PR 上只要这个检查红了合并按钮就是灰的。这一步做完代码质量的底线就不再由某个人当天的心情决定。我自己带过的项目里加上这一步之后最明显的变化是提交前自测的比例大幅提升。原因很简单人不愿意在 PR 上挂着红色的叉被人看到。规则本身就在塑造习惯比开会强调一百遍有用。1.2 什么项目该接什么项目先别折腾不是所有仓库都值得写 workflow。判断标准很简单这个仓库里有没有重复、固定、错了会痛的动作。有就接没有就先放着。一个人维护、每周改两次、只有几十行代码的脚本仓库写个 CI 跑 lint收益有限维护 YAML 的时间可能比省下的还多。多人协作、有测试、有发布流程、有依赖升级需求的库几乎是必接。尤其是需要多版本兼容的库靠矩阵构建能一次性验证好几个运行环境这是人工很难系统完成的。前端项目、容器化服务、需要定期构建产物的项目也值得接。构建产物和发布动作是天然适合自动化的。我见过一个反例一个内部工具仓库为了看起来规范硬塞了七个 job其中四个是复制粘贴改名字的跑一次二十多分钟结果大家宁可绕过 CI 直接推。这种配置比没有更糟因为它会让人讨厌自动化本身。1.3 成本这件事得提前有概念托管 runner 对公开仓库基本是免费的私有仓库会消耗配额用超了要付费。真正烧额度的不是任务多而是任务慢和无效触发。一个跑四十分钟的 job和一个跑四分钟的 job成本差十倍。后面我在第 4 节会专门讲怎么把时间压下来但这里先给你一个意识写 workflow 的时候脑子里要有一根这次运行值不值这个钱的弦。2. 从零写第一份 workflow每个字段落到一个具体动作上YAML 这东西看着简单坑全在缩进和字段含义上。很多人第一次写 GitHub CI 失败不是因为逻辑错而是因为不知道某个字段该放在jobs下面还是steps下面。我建议你一开始就别贪多先把一份最小可用的配置写通再往上加东西。2.1 目录和文件名的约定工作流文件必须放在仓库根目录的.github/workflows/下后缀是.yml或者.yaml。文件名随便起但它是显示在 Actions 页面左侧的名字来源之一。我通常按用途命名ci.yml放测试和检查release.yml放打标签发布nightly.yml放定时任务。一个仓库可以放多个 workflow 文件它们互相独立各自有自己的触发条件。注意.github这个目录很多人第一次会漏掉前面的点或者说本地看不到以为没建成功。它在 Unix 系系统里是隐藏目录用ls -a才能看到Windows 资源管理器需要在查看选项里打开隐藏项目。2.2 on 字段什么情况下才触发on决定这套流程什么时候跑。写宽了浪费额度写窄了漏掉场景。常用的有这几个触发方式写法适用场景推送到指定分支push: branches: [main]主干验证、部署PR 到指定分支pull_request: branches: [main]合并前必过的检查打标签push: tags: [v*]版本发布手动触发workflow_dispatch运维脚本、临时任务定时schedule: cron每日构建、依赖扫描这里有个新手常犯的错误只写push不写pull_request结果 PR 上什么检查都不跑等到合并进主干才发现问题。我自己的习惯是两个都写上并且用paths做过滤只在与代码相关的文件变化时才触发on: push: branches: [main] paths-ignore: - docs/** - **.md pull_request: branches: [main] workflow_dispatch:paths-ignore能省下不少额度——改个 README 没必要跑一整轮测试。2.3 jobs 和 steps机器、环境、动作三层结构一份 workflow 的骨架是jobs每个 job 是一台独立的机器job 之间的文件系统不共享。每个 job 下面是一串steps步骤从上到下顺序执行共享同一个工作目录。这是最容易搞混的地方同一 job 里的步骤能拿到上一步产生的文件跨 job 就不行必须用 artifact 传。runs-on指定机器类型。选版本号的时候别用太老的镜像比如ubuntu-20.04已经逐步被淘汰写ubuntu-latest能一直拿到较新的环境。但反过来说如果你对系统库版本敏感latest会带来不可预期的漂移那就该固定成具体版本。语言环境的准备一般交给官方提供的 setup 类 Action比自己写命令装要可靠得多。Python 用actions/setup-pythonNode 用actions/setup-nodeGo 用actions/setup-go。这些 Action 都支持顺带帮你开启依赖缓存省掉手动配 cache 的麻烦name: ci on: push: branches: [main] pull_request: workflow_dispatch: permissions: contents: read jobs: test: runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.12 cache: pip - name: 安装依赖 run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: 跑测试 run: pytest -q这份配置大概二十行已经能覆盖一个小型 Python 项目 80% 的需求。permissions: contents: read这行看着无关紧要其实是安全设置第 5 节会展开说。2.4 三个必须养成的书写习惯第一每一步都写 name。默认情况下日志里显示的是一串命令一长串命令混在一起出了问题你根本不知道是哪一步红的。加上中文的name出错时一眼就能定位。第二shell 里多行命令用|每行独立执行并且默认开启set -e中间一步失败会立刻终止不会带着错误状态继续往下跑。第三变量引用统一用${{ }}字符串里有特殊字符记得加引号比如版本号3.10不加引号在某些场景下会被解析成数字3.1这是个很隐蔽的坑。提示写完 YAML 之后不要直接推主干。开一个分支推上去看看 Actions 页面第一次跑的情况。YAML 语法错误会直接显示 parse 失败这种错误改起来最快但如果是逻辑错误日志会给你完整线索。3. 让流水线真正产出东西测试、构建、发布三件事怎么串只跑测试的 CI 只完成了三分之一的工作。真正让流水线产生价值的是它能把构建结果稳定地做出来并且在合适的时候把东西发出去。这一段讲的是从验证代码走向产出交付物的思路。3.1 测试阶段快反馈比全覆盖更重要测试阶段的设计目标只有一个让开发者在最短时间内知道哪一步坏了。为了这个目标我通常做两件事。一是把测试拆成快慢两层快速的一层单元测试、lint、类型检查跑在主 job 里几十秒出结果慢的一层端到端测试、需要起数据库的集成测试单独放一个 job允许它慢但不阻塞快速反馈。二是开fail-fast: false在多版本矩阵下让所有版本都跑完再报结果而不是第一个失败就全砍掉。这样你一次提交就能看到全部问题不用来回试四次。e2e: runs-on: ubuntu-latest needs: test services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: test ports: - 5432:5432 options: - --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.12 - run: pip install -r requirements.txt - run: pytest tests/e2e -q这里用到的services是个特别实用的功能它会在 job 运行期间起一个容器化的数据库等健康检查通过才继续。有了它你不需要在 CI 里手动装数据库也不需要维护一堆初始化脚本。3.2 artifact跨 job 传文件的唯一正道前面说过job 之间是隔离的。测试 job 编出来的产物部署 job 是拿不到的必须显式上传和下载。上传用actions/upload-artifact下载用actions/download-artifact。- name: 构建 run: python -m build - uses: actions/upload-artifactv4 with: name: dist-packages path: dist/ retention-days: 7这里有两个细节值得注意。retention-days默认是 90 天对一个天天构建的项目来说存储很快就堆起来了我一般设成 7 天够排查问题就行。另外如果上传路径不存在早期版本的upload-artifact默认会警告后继续后来改成了直接失败。这个改动其实是好事因为产物没生成但流程显示成功是最坑人的一种假绿灯。3.3 发布流程用标签触发比用分支干净发布这件事我建议用 tag 触发而不是在main分支上做判断。理由是标签是人为打的、有明确意图的动作而main上的每次提交都触发发布逻辑迟早会误发。on: push: tags: - v* jobs: release: runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.12 - run: pip install build - run: python -m build - uses: softprops/action-gh-releasev2 with: files: dist/*contents: write是必须的否则这个 job 没有权限往 release 里挂文件。这类权限字段的默认值现在收得很紧写之前一定要确认目标 Action 的文档里要求的权限范围。发布流程还有个习惯先在一个专门的分支或者预发布标签上跑一遍全流程确认无误再打正式标签。发布这件事慢一点比快一点安全。4. 矩阵构建与依赖缓存把几分钟的冷启动砍掉流水线跑得慢多数时候不是任务本身重而是重复劳动太多。装依赖这件事如果每次从零下载一个中型 Python 项目稳定花掉一到三分钟。缓存做对之后这一分钟基本可以抹掉。4.1 matrix一次写多环境验证矩阵的写法很简单但用得好不好差别很大。基础用法就是给一个变量一组值让 job 复制出多份并分别用不同值运行strategy: fail-fast: false matrix: python-version: [3.10, 3.11, 3.12] os: [ubuntu-latest, macos-latest] exclude: - os: macos-latest python-version: 3.10注意exclude的重要性。上面这个组合如果不排除会跑六个任务但如果你明知道某个版本在某个系统上不支持那多跑一次就是纯浪费。反过来include是往已有组合里追加常用来给某个特定组合加一个额外变量比如给 Linux 加上跑覆盖率上报的标记。矩阵的展开数会相乘三乘二等于六如果你不小心写了四五个维度一次推送就是几十个任务同时开跑。我在一个项目里见过不小心写出 24 个组合的配置一次 push 直接吃掉当天大半的额度。写完矩阵先自己算一下总数这个动作能救命。4.2 缓存的键怎么设计才不容易失效缓存的核心是 key。key 相同就命中不同就重新下载。所以 key 的设计原则是只要依赖内容变了key 就必须变依赖没变key 就绝对不能变。最稳的做法是用依赖清单文件的哈希值拼进去- uses: actions/cachev4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles(requirements*.txt) }} restore-keys: | ${{ runner.os }}-pip-hashFiles会算出文件内容的指纹文件一改key 就变缓存自动失效重建。restore-keys是兜底当精确 key 找不到时它会按前缀找最近的缓存先恢复回来然后你在它基础上增量更新这比完全从零下载快得多。这个前缀恢复的机制很多人不知道但它恰恰是缓存效果的关键。至于前面用setup-python的cache: pip一行搞定的写法本质上就是把上面的逻辑封装好了。能用封装的就用封装的除非你要缓存的东西不在标准位置。注意缓存不是永久存储。它有过期策略一段时间没人访问就会被清理。所以不要把缓存当成产物的备份手段两者目的完全不同——缓存是为了快artifact 是为了留。4.3 concurrency 和超时防止白白烧钱同一分支连续推两次会启动两轮流程第一轮其实已经没意义了。concurrency就是解决这个的concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: truegroup按流程名加分支名分组同一组内新的运行会自动取消还在跑的旧运行。这个配置我一律加上尤其在频繁推送的时候省下的额度非常可观。timeout-minutes也建议每个 job 都写。默认是 360 分钟也就是六小时一旦某个步骤卡死比如等一个永远不返回的网络请求它会一直挂在那里直到把额度耗完。设成 15 到 30 分钟卡住就早点报错反而更容易排查。5. 密钥、权限与第三方 Action流水线最容易被忽略的攻击面大部分人写 workflow 时只关心能不能跑通很少关心它能碰到什么。而 workflow 往往持有仓库写权限、部署密钥、云平台凭证一旦配置不当风险远大于本地脚本。5.1 GITHUB_TOKEN 的默认权限要主动收窄workflow 运行时自带一个GITHUB_TOKEN用来访问这个仓库。早期的默认权限很宽几乎是可读可写。现在新建仓库默认偏保守但不能指望默认值最好在文件顶部显式声明permissions: contents: read这行的意思是整个 workflow 默认只能读代码。哪个 job 需要更多权限就在那个 job 里单独开deploy: permissions: contents: read id-token: write原则是最小够用。发布 job 需要写 release就给contents: write部署 job 需要云平台临时凭证就给id-token: write配合 OIDC 换取短期凭证而不是把长期密钥存进 secrets。这个思路的本质是长期密钥一旦泄露就是长期风险短期凭证泄露了也会自己过期。5.2 secrets 的边界比你想的窄secrets 存在仓库或组织设置里通过${{ secrets.NAME }}引用。有三件事必须心里有数。第一secrets 不会传给来自 fork 仓库的 PR。这是刻意设计防止有人提个 PR 就把你的密钥偷走。所以如果你的检查依赖密钥来自 fork 的 PR 会跑失败。常见做法是让这类检查只在内部 PR 上跑或者用pull_request_target配合严格的代码审查——但后者风险更高因为它是在有权限的上下文里运行 fork 的代码用之前务必想清楚。第二不要打印 secrets。echo出来虽然会被日志自动打码但拼接到 URL 或者中间文件里就可能漏出去。凡是涉及密钥的命令都别加-x之类的调试开关。第三别把密钥写进代码再删掉。Git 历史是永久的删掉的那次提交里密钥照样在。真要是一时手滑提交了唯一正确的处理是立刻去平台上吊销并重新生成历史清理是次要的。5.3 第三方 Action 版本要固定uses: some/actionv3这种写法看着简洁但v3是一个浮动标签。作者完全可以把这个标签指向新的提交你下一次运行拿到的就是另一份代码。这在大多数时候没问题但它意味着你在执行别人随时可以改的代码而且这些代码运行在你的 CI 环境里能读到你的密钥。更稳的写法是固定到完整的 commit SHA- uses: actions/checkoutb4ffde65f46336ab88eb53be808477a3936bae11可读性确实差但安全性高。实践中我通常这么折中核心的几个官方 Action 用大版本标签因为官方仓库的可信度高其余第三方 Action尤其是能接触到密钥的固定 SHA 并在旁边写注释说明它对应哪个版本。另外仓库设置里可以开启只允许指定作者发布的 Action这是道不错的兜底。6. 从跑得通到拦得住让 CI 真正约束住代码流水线能跑只是及格线。真正让它产生约束力的是把它接到合并流程里让红灯成为物理阻挡。6.1 分支保护与必需检查在仓库的分支保护规则里可以指定某些状态检查必须通过才能合并。配置完的效果是PR 上只要那个检查还是黄色或者红色合并按钮就是不可点的。这是整个 CI 体系里最有价值的一步因为它是唯一不依赖人自觉的环节。配置时要注意两点。一是检查名要写准它显示的是 job 的完整名字如果你的检查是矩阵跑出来的名字里会带上参数得确认选的是哪一个。二是新仓库第一次配置时可能找不到选项因为检查必须至少成功跑过一次才会出现在列表里。所以顺序是先写 workflow 推一次跑通了再去分支保护里勾。6.2 状态徽章和结果可见性在 README 里挂一个徽章成本极低但能让人一眼看到主干的状态。徽章地址在 Actions 页面右上角的菜单里可以直接复制。比徽章更有用的是把结果往沟通渠道推——测试失败时自动发一条消息比等人自己去页面看有效得多。6.3 本地复现别把 CI 当唯一验证场CI 环境和你本地环境有差异这个差异有时会造成本地过、CI 挂或者反过来。常见的差异来源包括系统库版本、默认字符编码、文件系统大小写敏感、时区、以及环境变量。我遇到最多的是大小写问题——Linux 上文件名区分大小写Windows 和 macOS 默认不区分本地引用了Utils.py而实际文件叫utils.py本地能跑CI 直接报找不到模块。排查这类问题有个笨但有效的方法把 CI 的步骤在本地用同样的基础镜像跑一遍。如果你本地装了容器工具直接用docker run起一个最接近 runner 镜像的环境把脚本原样执行。这会花点时间但比在 CI 上反复推代码试要快得多。6.4 自托管 runner 的取舍当你的流程需要访问内网、需要特定硬件、或者用量已经明显超出托管额度时自托管 runner 就是选项。它的优势是环境和网络可控、成本模型不同代价是你得自己维护机器的补丁、清理残留文件、处理并发。我一般建议只在明确有这几类需求时才上自托管因为维护一台 CI 机器的隐性成本往往比看起来的高。7. 我踩过的坑以及一套可复用的排查套路前面讲的是怎么写对这一段讲写错了怎么找。我这些年踩的坑八成集中在下面这几类。7.1 一套从日志入手的定位流程CI 报错时别急着改配置按这个顺序走一遍通常五分钟内能定位先看失败的是哪个 job是主测试还是矩阵里的某个特定组合。只挂一个组合基本可以锁定环境差异。再看失败的是哪个 step如果日志里全是红色一大片往上翻找第一个报错后面的多半是连带反应。看报错的类型是找不到文件、权限被拒、命令不存在还是断言失败。这三类的排查方向完全不同。如果是偶发失败重跑就过怀疑并发写冲突、网络超时、或者缓存损坏。重跑能过不等于问题解决了得记下来观察频率。7.2 高频问题的对照排查现象常见原因处理方式启动就报 YAML 解析失败缩进用了 Tab、冒号后没空格全用空格缩进冒号后留一个空格提示找不到模块大小写不一致、依赖没装全统一小写命名检查依赖清单403 或者权限被拒token 权限不足在 job 上加对应的 permissions缓存从来没有命中key 里含了每次都会变的值key 只用依赖文件的哈希部署 job 拿不到文件job 之间不共享文件系统用 artifact 上传下载矩阵任务数量爆炸维度相乘没算过用 exclude 砍掉无效组合明明失败却显示成功命令用了管道退出码被吞加set -o pipefail或拆开写流程挂很久不结束有命令在等输入或网络卡死加 timeout-minutes命令加非交互参数最后一行值得展开说一下。退出码被吞这个问题特别隐蔽。比如pytest | tee output.log这种写法管道最后一个命令是tee它的退出码永远是 0于是 pytest 失败了流程却显示绿色。这种事发生过一次之后我在所有涉及管道的命令前面都加set -o pipefail。7.3 npm ci 和 npm install 在 CI 里到底该用哪个这是个问得特别多的问题。结论很明确CI 里用npm ci本地开发用npm install。两者区别在于npm ci要求必须存在 lock 文件它会严格按 lock 文件里锁定的版本安装并且安装前会先删掉node_modules保证干净。而npm install会在 lock 文件的基础上做版本解析、更新 lock 文件结果依赖你的本地状态。在 CI 环境里你要的是可复现——同样的提交任何时候跑出同样的依赖树。npm install做不到这一点npm ci可以。顺带一提这也解释了为什么 lock 文件必须提交到仓库。没有 lock 文件npm ci直接失败这是它刻意的设计要求。7.4 缓存相关的两个典型误判第一个误判是缓存命中了但速度没变快。这通常是因为缓存的目录不对。不同语言的包管理器缓存位置不一样Python 的 pip 在 Linux 上是~/.cache/pipNode 的是~/.npm你挂载的路径和实际写入的路径不一致缓存就是空的。查这个问题的办法是看日志里 cache 那一步的输出它会明确写出是否命中、恢复了多少大小。第二个误判是缓存永远不更新。这类问题往往出在 key 里包含了固定字符串或者哈希的文件和你实际安装依赖读取的文件不是同一个。比如你改了pyproject.toml里的依赖但 key 哈希的是requirements.txt那缓存自然不会失效装出来的东西还是旧的。key 里的哈希文件必须和真正决定依赖内容的文件一致这是缓存这块唯一需要死记的规则。7.5 关于动作版本更新的一点个人习惯官方 Action 的大版本会隔一段时间升级一次比如 checkout 从 v3 到 v4。升级有时会带来行为变化比如默认行为改变导致构建路径不同。我的做法是不无脑跟最新但也不长期停在老版本。每次升级先在非关键分支上跑一轮完整流程确认产物和之前一致再合到主干。这套动作看着笨但比发布当天发现构建产物结构变了要省心得多。如果你现在手头有一个还没接 CI 的仓库我的建议是从最小的一份配置开始只做 checkout、装依赖、跑测试这三步先把红灯亮起来。等它稳定跑上一周再考虑加构建、加缓存、加发布。一次加太多出了问题你根本分不清是新配置的错还是本来就有问题。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询