← 返回 Day 174 正文

Day 174 · 图解

Day 174 图解 — CI/CD(GitHub Actions)

1. 一次 push 到底发生了什么

        git push
            │
            ▼
   ┌──────────────────┐
   │ GitHub 收到 push  │  → 生成 event payload(JSON)
   └────────┬─────────┘
            │  按 on: 的规则匹配哪个 workflow 该被触发
            ▼
   ┌──────────────────┐
   │ 匹配 .github/     │
   │ workflows/*.yml  │  (一个仓库可以有 N 个 workflow)
   └────────┬─────────┘
            │  为每个匹配的 job 分配 runner
            ▼
   ┌──────────────────────────────────────────────┐
   │  调度器:读 needs 建 DAG,拓扑排序决定顺序     │
   │  无依赖的 job 并行跑(最多 256 个并发)       │
   └────────┬─────────────────────────────────────┘
            │
      ┌─────┴─────┐
      ▼           ▼
  ┌───────┐   ┌───────┐
  │ lint  │   │ test  │   ← 两者无依赖,并行
  └───┬───┘   └───┬───┘
      │           │
      └─────┬─────┘
            ▼
       ┌────────┐
       │package │   ← needs: [test],且只在 main 分支跑
       └────────┘

关键理解:GitHub Actions 本身不执行任何东西,它只做调度。 真正干活的是 runner(ubuntu-latest 是一台临时开的虚拟机,用完即销毁)。 所以「为什么我的流水线卡住」的答案往往是「在等 runner 排队」。

2. needs 构成的有向无环图

        ┌─────────┐
        │  lint   │  静态检查,最快,失败要尽早暴露
        └────┬────┘
             │ needs
             ▼
   ┌──────────────────┐
   │  test (matrix)   │  一个 job 展开成 N 个并行实例
   │  ├─ py 3.11      │──┐
   │  └─ py 3.12      │──┤ 共享同一份代码,但环境独立
   └────────┬─────────┘  │
            │            ▼
            │      每个实例都是独立的 runner
            ▼
   ┌──────────────────┐
   │     package      │  合并前才执行
   └──────────────────┘

needs 表达的是依赖,不是顺序。没有 needs 的 job 一定并行。 本课 code/02-pipeline-builder.py 用 Kahn 算法把这张图拓扑排序, 并能检出环(GitHub 只会报一句含糊的 Invalid workflow file)。

3. 一次 step 的生命周期

   ┌──────────┐
   │ Set up   │  GitHub 自动生成:装 action、准备环境变量
   └────┬─────┘
        ▼
   ┌─────────────────────────────────────────────┐
   │ Main  actions/checkout@v4                    │  ← uses:调用别人的 action
   │      ├ 下载 action 源码                      │     (本质是一个 Docker/JS 程序)
   │      └ 把仓库代码拉到工作区                    │
   ├─────────────────────────────────────────────┤
   │ Main  pip install -r requirements.txt         │  ← run:执行 shell 命令
   │      └ 在 runner 上真的起了一个 shell         │
   ├─────────────────────────────────────────────┤
   │ Main  pytest -q                              │
   └────┬────────────────────────────────────────┘
        ▼
   ┌──────────┐
   │ Post     │  逆序执行 Post 步骤(即使失败也会跑)
   │ steps    │  典型用途:保存 cache、上传覆盖率
   └────┬─────┘
        ▼
   ┌──────────┐
   │ Complete │  汇总状态:任一 step 非 0 退出码 → job 失败
   │  job     │  后续依赖它的 job 全部 skipped
   └──────────┘

这是最容易踩坑的地方:Post 步骤即使 Main 失败也会执行。 所以清理逻辑要放 Post,但不能放任何"发布成功后才做"的事。

4. 表达式求值:if 的三种常见形态

yaml
# 1. 直接布尔(最常见)
if: github.ref == 'refs/heads/main'

# 2. 隐式成功检查:没有 status() 包裹时,隐含 && success()
if: matrix.py == '3.12'
#   ↑ 完整含义是:success() && matrix.py == '3.12'
#   ↑ 这就是为什么 "always() 很重要" —— 失败后这个 if 永远为 false

# 3. 显式状态函数(需要「即使失败也要跑」时必须显式写)
if: always() && github.event_name == 'push'
if: failure() && steps.build.outcome == 'failure'

四把状态钥匙:

函数 含义 典型场景
success() 到目前为止没失败过(默认) 大多数情况
always() 永远为真 无论成败都要做的通知/清理
failure() 有步骤失败过 失败时打诊断信息
cancelled() 被取消 记录中断原因

5. matrix 展开规则

yaml
strategy:
  fail-fast: false          # 一个组合失败,其余继续跑完
  matrix:
    py: ["3.11", "3.12"]
    os: [ubuntu, macos]
    exclude:
      - py: "3.11"
        os: macos
    include:
      - py: "3.12"
        experimental: true

展开过程:

初始笛卡尔积(2 × 2 = 4):
  {py:3.11, os:ubuntu}   {py:3.11, os:macos}
  {py:3.12, os:ubuntu}   {py:3.12, os:macos}

应用 exclude(剔除 py3.11+macos):
  {py:3.11, os:ubuntu}   {py:3.12, os:ubuntu}   {py:3.12, os:macos}

应用 include(能对上 py3.12 的两条就扩展它们):
  {py:3.11, os:ubuntu}
  {py:3.12, os:ubuntu, experimental:true}
  {py:3.12, os:macos,  experimental:true}

最终 3 个并行 job

fail-fast 默认是 true:一个组合失败,其余立刻取消。 跑兼容性矩阵时务必设 false,否则老 Python 版本挂了你永远看不到它的错误。

6. CI 与 CD 的分界

   提交代码
      │
      ├──────────── CI(Continuous Integration)────────────┐
      │  目的:证明这次改动是安全的                        │
      │  内容:lint → 单测 → 覆盖率 → 多版本矩阵           │
      │  失败代价:几分钟,开发者当场就能修                 │
      │  特点:**每个 PR 都跑,要求快**                    │
      └──────────────────────────────────────────────────┘
      │ 全绿
      ▼
   合并到 main
      │
      ├──────────── CD(Continuous Delivery/Deployment)────┐
      │  目的:把已验证的产物送到用户手里                    │
      │  内容:构建镜像 → 推 registry → 灰度 → 健康检查     │
      │  失败代价:影响真实用户,必须能回滚                 │
      │  特点:**只在 main 跑,要求稳**                     │
      └──────────────────────────────────────────────────┘

两者共用同一份 workflow,靠 needs 串起来。分界点通常是一个 if:

yaml
package:
  needs: [test]
  if: github.ref == 'refs/heads/main'   # ← 这行就是 CI / CD 的分界

7. 本地跑 GitHub Actions(act)

GitHub 官方 runner 不开源,但社区项目 act 用 Docker 复刻了调度逻辑:

   你的 workflow.yml
          │
          ▼
   ┌──────────────┐   解析 YAML,展开 matrix,建 needs 图
   │     act      │   ── 与 GitHub 用的是同一套语义 ──
   └──────┬───────┘
          │ 对每个 job
          ▼
   ┌──────────────┐
   │ docker run   │   用 catthehacker/ubuntu:act-latest
   │ (每个 job    │   模拟 ubuntu-latest runner
   │  一个容器)    │
   └──────────────┘
          │
    ✅ 能跑:run 步骤、setup-python、矩阵、build、容器内测试
    ❌ 跑不了:upload-artifact / actions/cache
              (都依赖 ACTIONS_RUNTIME_TOKEN,本地没有 artifact 后端)

局限必须知道:act 跑通不等于 GitHub 上跑通。 第 4 节讲过 Post 步骤、if: github.ref 这些,act 都做了近似实现而非完全一致。 它的价值是秒级反馈,不是替代真实 runner。