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)─┘

四、缓存 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 并连推 部署作业是否被取消 被取消会留下部分实例是新版本的状态

自测题

  1. matrix 的 fail-fast 默认是什么?设成 false 的代价和收益各自是什么?
  2. Artifact 和 Cache 在"丢了会发生什么"这件事上的本质差别?
  3. 为什么"清空缓存就跑不过"说明流水线里藏着隐式环境依赖?
  4. 让发布可重跑有哪三种手段?它们之间的共同点是什么?
  5. pull_request_target 为什么危险?什么时候不得不用、又该怎么防?

进入 keel 阅读