KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
02 · 流水线解剖:触发器、作业、产物与缓存 — keel 龙骨
这一章回答:把一条真实的流水线拆开,每一块负责什么、谁在前谁在后、哪些参数是有成本的隐喻。看完能自己读懂任何一个 CI 平台的配置文件。
这一章回答:把一条真实的流水线拆开,每一块负责什么、谁在前谁在后、哪些参数是有成本的隐喻。看完能自己读懂任何一个 CI 平台的配置文件。
不同平台的语法差别很大(GitHub Actions / GitLab CI / Jenkins / 阿里云效 / GitLab Runner),但抽象模型几乎完全一致。记住下面这套通用语言,换个平台只是换个写法。
一、通用模型:六个零件
| 零件 | 作用 | 常见字段 |
|---|---|---|
| Trigger(触发) | 决定这条流水线什么时候被创建 | on: push/pull_request/release/workflow_dispatch/schedule |
| Runner(执行机) | 跑作业的机器:托管还是自建,什么 OS,有没有 Docker | runs-on: ubuntu-latest、tags: |
| Job(作业) | 一组在同一个环境里顺序执行的步骤,是并发与依赖的粒度 | jobs.<id>、needs:、if: |
| Step(步骤) | 作业里的一条命令或一个复用单元 | run:、uses: |
| Artifact / Cache(产物与缓存) | 跨作业传文件 vs 跨运行复用中间结果 | actions/upload-artifact、actions/cache |
| Environment / Gate(环境与门禁) | 把作业绑到某个环境,可附审批人、变量隔离 | environment:、required reviewers |
一条心法:Job 是资源单位(一个 VM、一次权限授予、一个并发槽),Step 是逻辑单位。 决定"要不要拆成两个 job"时,看的是它们是否需要不同的环境、不同的权限、或者需要并行,而不是看代码行数。
二、触发条件:CI 与 CD 的分水岭
push / pull_request → CI:验证,快、频繁、只读
tag (v*) / release → CD:发布,慢、谨慎、可写外部资源
schedule (cron) → 巡检:依赖漏洞扫描、nightly 全量测试
workflow_dispatch → 人工:带参数手动触发(发布/回滚/重建)
pull_request_target → ⚠️ 危险:在仓库上下文运行 fork PR 的代码,能拿 secrets
判断一个人的流水线是真是假,看触发条件就够:说不清"CI 什么时候跑、CD 什么时候跑",基本就是背别人的 yaml。CI 天天跑、DD 只在 release published 时跑,两条链路各自的入口不同,但 CD 必须包含 CI 的全部检查(否则会出现"上次绿了这次偷偷改了")。
三、依赖、并行与矩阵
测试 job(矩阵 3 OS × 2 Python,fail-fast: false)
│ ← 6 个并发作业,一个挂了其余继续跑
▼
构建 job(needs: 测试)
│ ← 只用一次,产出 artifact
▼
发布 job A(PyPI) ─┐
发布 job B(npm,needs: A)─┤ ← 顺序由数据依赖决定,不是拍脑袋
发布 job C(Homebrew,needs: A)─┘
needs:构成 DAG,默认图上可并行的都并行。matrix是同一份 Job 定义跑多组参数。fail-fast: false的取舍很重要:默认true是一负一全停(省钱省时),false是拿到全部平台结果(CI 的目的是信息,多花几分钟拿到六个平台的完整诊断更值)。concurrency:把同一分支的旧运行取消掉,防止 push 风暴占满 runner 队列;cancel-in-progress要谨慎用于部署作业(部署被半路取消可能产生中间状态)。
四、缓存 vs 产物:最容易被混用的两个东西
| Artifact(产物) | Cache(缓存) | |
|---|---|---|
| 语义 | 这次运行的结果,要给别人用 | 可加速的中间件,丢了也要能跑通 |
| 丢失后果 | 流水线失败,必须重跑构建 | 只是变慢,重下依赖即可 |
| 典型内容 | wheel、tar 包、覆盖率报告、SBOM | 依赖目录、构建中间层、下载好的工具链 |
| 关键纪律 | 有保留期,过了要能重建 | 缓存命中与否都必须能跑出同样结果 |
一条硬性原则:流水线不能依赖缓存才能正确。凡是"清了缓存就红"的流水线,本质上藏着一个没人知道的环境依赖。 每周做一次 cache: false 的全量跑,能把这个雷提前排掉。
五、幂等:唯一能让流水线"敢重试"的性质
事件再次发生 ──► 流水线再次执行 ──► 结果一致(不产生副作用/半成品/重复发布)
让你的发布作业可重跑的三件套很简单,缺一个都会在半夜坑人:
| 手段 | 适用 | 为什么 |
|---|---|---|
skip-existing: true |
发到 PyPI / npm / 制品库 | 版本不可变,重复提交同一版本要么跳过要么失败,但不会出两个产物 |
提交前查重(先 npm view / 查 API) |
需要显式判断的场景 | 把"已经发过了"变成一次可打印的显式分支,而不是随机报错 |
| 读取远端已存在的事实而不是本地状态 | 下游依赖上游(如 Homebrew formula 引用 PyPI sdist) | 幂等的本质是以外部事实为输入,而不是"我以为我发到哪一步了" |
一句话:流水线的可靠性来源于幂等设计,不来源于不出错。 出错是必然的,冗余遍布分布式系统——能否放心地按下"重跑",才是设计水平的度量。
六、一份可以照着写的参考结构
name: ci
on:
push: { branches: [main] }
pull_request: {}
permissions: # ① 最小权限:仓库默认只读
contents: read
concurrency: # ② 同分支新推送取消旧的
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
test:
permissions: { contents: read } # ③ 作业级按需授予
strategy:
fail-fast: false # ④ 要完整诊断信息
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python: ["3.11", "3.12"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv sync --all-extras --dev # ⑤ 冻结安装:用锁文件
- run: uv run pytest -q
- run: uv run ruff check .
- run: uv run mypy src # ⑥ 类型检查独立成步,失败点清晰
build:
needs: test
steps:
- run: uv build
- uses: actions/upload-artifact@v4
with: { name: dist, path: dist/ } # ⑦ 产物上传而非直接发布
smoke:
needs: build
steps:
- uses: actions/download-artifact@v4
with: { name: dist, path: dist/ }
- run: | # ⑧ 测的是产物不是源码
python -m venv /tmp/smoke
/tmp/smoke/bin/pip install dist/*.whl
/tmp/smoke/bin/myapp --version
动手:可观察结果
| 动作 | 产出物 | 判断标准 |
|---|---|---|
| 读懂一个陌生 CI 配置 | 一张"六个零件"的标注图 | 能标出触发、runner、job、依赖、产物、权限位置 |
加 concurrency 前后对比 |
连续 push 三次的记录 | 前:3 个都跑完;后:前 2 个被取消,只留最新 |
| 清空缓存跑一次 | 全量构建耗时 vs 缓存命中耗时 | 两种情况下结果一致,只有耗时不同;记录差值 |
| 把依赖升级到不兼容版本 | 是否 clone 失败 | 应当失败且错误信息指向依赖,而不是某行源码 |
故障注入
| 注入方式 | 观察什么 | 说明的现象 |
|---|---|---|
| 让某一步非确定性(读当前时间/随机数) | 两次运行结果是否一致 | 不一致 → 缓存会掩盖 flake,禁用缓存后暴露 |
把 needs 删掉让发布 job 与测试并行 |
发布是否可能先于测试 | 会 → 依赖图错了,"发完才发现测试没过" |
在构建 job 里 rm -rf dist 后仍上传 |
流水线是否报错 | 不报错说明上传步骤没做存在性校验,产物缺失会被沉默吞掉 |
给 main 分支开 cancel-in-progress: true 并连推 |
部署作业是否被取消 | 被取消会留下部分实例是新版本的状态 |
自测题
matrix的fail-fast默认是什么?设成false的代价和收益各自是什么?- Artifact 和 Cache 在"丢了会发生什么"这件事上的本质差别?
- 为什么"清空缓存就跑不过"说明流水线里藏着隐式环境依赖?
- 让发布可重跑有哪三种手段?它们之间的共同点是什么?
pull_request_target为什么危险?什么时候不得不用、又该怎么防?