Lesson 0008 CI 集成:用 GitHub Actions 自动运行测试

Lesson 0007 给了你自动化验证——npm test 3 秒出结果。但这里有一个 gap:测试只在你记得跑的时候才有用。本课补上缺失的另一半:让测试在每次 push 和 PR 时自动运行。你会学会编写 GitHub Actions workflow 文件,理解 CI 是如何成为"编码 → 发布"流水线中的安全守门人。

📌 本课目标:从"手动跑 npm test"进化为"push 代码 → CI 自动跑 → 不过不会合并"。本课不引入任何 npm 依赖——GitHub Actions 是 GitHub 内置服务,对公开仓库免费。

1. 为什么 CI——从手动验证到自动守门

Lesson 0007 教你写了 8 个 fixture + 8 个测试用例。你每次改代码后可以跑 npm test,3 秒确认没弄坏什么。但这里有一个关键问题:

手动 npm testpre-push hookCI 自动运行
触发方式你记得跑git push 时自动触发每次 push / PR 自动触发
运行环境你的机器(可能有隐藏状态)你的机器全新虚拟机——从零安装
能否被绕过❌ 你忘了就跑不了⚠️ --no-verify 一秒绕过❌ 无法绕过(见第 4 节)
谁看到结果只有你只有你整个团队(GitHub PR 页面上直接显示 ✅/❌)
防回归⚠️ 取决于你的记忆力⚠️ 取决于你的自律✅ 强制执行——没过不能合并
发布前保护❌ 你可以在测试失败时打 tag❌ 无法阻止打 tag⚠️ 需配合分支保护规则(本课第 4 节讨论)
最佳场景开发中的快速反馈本地最后一道防线合并前的强制守门人

三者互补,不是替代——它们分别守在不同位置:

你完全可以三者都用:pre-push hook 快速拦截低级错误,CI 在云端做最终裁决。但 pre-push hook 的"不通过就不 push"有一个根本局限:它是客户端脚本——团队里任何一个人 git push --no-verify 就跳过了。真正不可绕過的守门人,是服务端的 CI + 分支保护规则。

🎯 CI 回答的问题是:"如果换一台全新机器,你的代码还能跑通吗?" 如果你的测试在本地通过但在 CI 失败,那暴露的正是你机器上的隐藏状态——未提交的文件、全局安装的工具、环境变量——这些就是 CI 的价值。

2. GitHub Actions 概览

GitHub Actions 是 GitHub 内置的 CI/CD 服务。它的核心概念用一张表就能说清:

概念YAML 键作用类比
工作流name一个 YAML 文件定义一个自动化流程一个"脚本"
触发器on什么时候运行——push、PR、tag、定时、手动"什么时候执行这个脚本"
作业jobs独立的工作单元——默认并行运行一个"函数"
运行器runs-on操作系统环境——ubuntu、windows、macOS"在哪台机器上跑"
步骤steps顺序执行的命令或市场动作函数内的"语句"
市场动作uses社区共享的可复用步骤——checkout、setup-node"import 一个库"
Shell 命令run直接在运行器上执行的 bash 命令终端里敲的命令

2.1 触发器详解

on 决定了 workflow 什么时候启动。最常用的四种:

触发器YAML 写法什么时候触发
Pushpush: branches: [main]向 main 分支推送任何 commit
Pull Requestpull_request: branches: [main]向 main 分支发起 PR,或 PR 有新的 commit
Tagpush: tags: ['v*']推送一个匹配 v* 的 tag
手动workflow_dispatch在 GitHub Actions 页面手动点击"Run workflow"

2.2 为什么选 GitHub Actions 而不是其他

其他 CI 工具(Jenkins、Travis CI、CircleCI)也能做同样的事。但 GitHub Actions 有两个不可替代的优势:

  1. 零配置集成。 不需要单独注册账号、不需要配置 webhook、不需要管理密钥轮换——workflow 文件和代码放在同一个仓库里,GitHub 自动发现并执行
  2. 公开仓库免费。 一个 VS Code 扩展的 CI 需求(每月几百次运行)完全在免费额度内

对于一个独立开发者的 VS Code 扩展项目,外部 CI 工具的额外复杂度不带来任何收益。

3. 测试工作流详解——逐行拆解

以下是一个最简可用的测试 workflow。把它放在 .github/workflows/test.yml,GitHub 就会自动执行:

# ===== .github/workflows/test.yml =====
name: Test                          ← 在 Actions 页面显示的名称

on:                                    ← 触发器
  push:
    branches: [main]                  ← 向 main 推送时触发
  pull_request:
    branches: [main]                  ← 向 main 发起/更新 PR 时触发

jobs:                                  ← 作业列表
  test:                               ← 作业 ID(自定义名称)
    runs-on: ubuntu-latest            ← 运行器操作系统
    steps:                            ← 步骤列表(顺序执行)
      - uses: actions/checkout@v4   ← 步骤 1:克隆仓库代码

      - name: Setup Node.js
        uses: actions/setup-node@v4 ← 步骤 2:安装 Node.js
        with:
          node-version: '20'          ← 指定 Node 版本

      - name: Install dependencies
        run: npm ci                  ← 步骤 3:安装依赖

      - name: Run tests
        run: npm test               ← 步骤 4:运行测试

3.1 每个步骤在做什么

步骤 1——actions/checkout@v4这是一段 GitHub 维护的开源代码。它的作用是把你的仓库克隆到运行器上。没有这一步,运行器的文件系统是空的——没有源代码,什么都做不了。@v4 是 Git 标签,锁定大版本以保证稳定性。

步骤 2——actions/setup-node@v4安装指定版本的 Node.js。它还自动处理 node_modules 缓存——如果存在 package-lock.json,它会智能地跨运行复用已下载的包,加速后续运行。

步骤 3——npm ci这是最关键的一步,也是新手最容易踩的坑。

⚠️ 为什么是 npm ci 而不是 npm install
npm ci(clean install)专为 CI 环境设计。它做了三件 npm install 不做的事:
1. 先删除 node_modules——确保每次都是干净安装
2. 严格遵循 package-lock.json——如果 lock 文件与 package.json 不同步,直接报错退出(而非静默更新 lock 文件)
3. 跳过依赖解析——直接从 lock 文件读取,速度快得多
在 CI 中使用 npm install 是一个反模式——它会悄悄修改 lock 文件,让 CI 的"干净环境"失去意义。

步骤 4——npm test这是唯一与你代码直接相关的命令。但——等等——package.json 目前还没有 test 脚本。这个 gap 正是第 6 节要解决的问题。

3.2 运行器:一台全新的虚拟机

理解一个关键事实:当 workflow 启动时,ubuntu-latest 是一台全新的虚拟机。它上面没有你的源代码、没有安装 Node.js、没有 node_modules。每一步都在从头构建环境:

Runner 初始状态:空目录
  ├─ Step 1 (checkout)    → 克隆仓库代码 ✅
  ├─ Step 2 (setup-node)  → 安装 Node.js 20 ✅
  ├─ Step 3 (npm ci)      → 安装 npm 依赖 ✅
  └─ Step 4 (npm test)    → 运行测试 ✅
每一步依赖前面步骤构建的状态

这正是 CI 的核心价值:它证明你的代码能在没有你机器上隐藏状态的前提下运行。如果你的测试在本地通过但在 CI 失败,那一定是因为你的机器上有某些"特殊条件"——全局安装的包、未提交的配置文件、环境变量——CI 帮你发现了它们。

💡 与 Lesson 0007 的隐藏关联:Lesson 0007 选 node:test 的一个"副作用"现在显现了——因为 node:test 是 Node 内置模块,CI 中不需要额外安装任何测试框架。如果 0007 选了 Jest,CI workflow 就要多一步 npm install jest。零依赖的好处会传递到 CI 配置中。

4. CI 作为发布前的守门人

🤔 为什么不直接在 push 前跑测试?
你的直觉是对的——pre-push hook 确实可以在 git push 之前跑 npm test,不过就不 push。这听起来比"先 push 再让 CI 跑"更合理。但这里有一个关键的信任边界问题:

1. pre-push hook 是客户端脚本,不是安全机制。它存在你本地的 .git/hooks/ 目录里(不会被 Git 跟踪),git push --no-verify 一键就能跳过。你无法强制团队其他人启用它。

2. push 坏代码到 feature branch 没有危害。在实际协作中,你经常需要 push 未完成的代码——让 CI 在云端跑、让同事 review、换一台机器继续写。真正需要保护的是 main 分支,不是个人分支。

3. 服务端才是唯一可信的执行环境。你本地的测试通过 ≠ 代码没问题——你的机器有隐藏状态(全局包、环境变量、操作系统差异)。CI 的干净虚拟机才能回答"换一台机器还能跑吗"。

所以正确的分层是:pre-push hook → 便利工具(快速拦截),CI → 强制守门人(不可绕过)。你完全可以两者都用,但永远不要把安全性寄托在客户端脚本上。真正的防线是:CI 不通过 → 不能合并 PR → 坏代码进不了 main(需配合分支保护规则)。

你的项目已经有一个 workflow 了——.github/workflows/publish.yml。把它和我们要创建的 test.yml 放在一起看:

test.yml(本课创建)publish.yml(已存在)
触发器push / PR → mainpush tag → v*
目的验证代码正确性发布到 Marketplace
运行器ubuntu-latestubuntu-latest
步骤checkout → node → install → testcheckout → node → install → publish
密钥VSCE_PAT + OVSX_PAT

把它们串起来,就是完整的 dev→publish 流水线:

开发 → 本地测试 → push 到 main

              CI 运行 test.yml
               ↙        ↘
          通过 ✅       失败 ❌ → 修 bug → 回到"开发"

      打 tag v1.5.0 → 推送

                   CI 运行 publish.yml

              发布到 Marketplace + Open VSX
⚠️ 一个设计上的缺口:上面的流水线有一处不完美——test.ymlpublish.yml两个独立 workflow。如果你推送一个有 bug 的 commit 并同时打 tag,test.yml 可能失败但 publish.yml 仍然会成功——它们之间没有依赖关系。在当前设计下,你需要自己确保打 tag 之前 CI 通过。

修复方向(进阶,本课不展开):用一个 workflow 同时包含 test job 和 publish job,让 publish job 用 needs: test 声明依赖——这样 test 不过 publish 不会跑。详见 GitHub Actions: jobs.<job_id>.needs

5. Node 版本矩阵——测试多版本

VS Code 扩展有一个特殊考量:不同版本的 VS Code 内嵌了不同版本的 Node.js。你的扩展在 package.json 中声明 "engines": {"vscode": "^1.74.0"}——这意味着它支持的 VS Code 版本跨度可能覆盖多个 Node 版本:

VS Code 版本内嵌 Node.js状态
1.74(扩展最低要求)Node 16EOL(生命周期结束)
1.85Node 18维护 LTS
1.92Node 20活跃 LTS
1.96+Node 22当前版本

你的扩展用户可能运行在 Node 18、20 或 22 上。测试所有三个版本是务实的折中——Node 16 已 EOL,不值得维护;18/20/22 覆盖了绝大多数用户。

GitHub Actions 的 matrix strategy 让你用几行 YAML 同时在多个 Node 版本上运行测试:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: ['18', '20', '22']  ← 3 个值 = 3 个并行 job
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}  ← 引用矩阵变量
      - run: npm ci
      - run: npm test
🔢 矩阵生成几个 job?矩阵的维度是笛卡尔积。一个维度 + 3 个值 = 3 个并行 job。如果你加 os: [ubuntu-latest, windows-latest],就是 3 × 2 = 6 个 job。对于这个纯 Node.js 项目(无原生模块),跨 OS 测试没必要——但概念知道即可。

${{ }} 是什么?GitHub Actions 的表达式语法。在 workflow 文件中,任何需要动态求值的部分都用 ${{ }} 包裹。matrix.node-version 在每次 job 启动时被替换为当前 job 的矩阵值(18、20 或 22)。

6. 添加 test 脚本到 package.json

回头看 temp_repo/package.json 的 scripts 块——它缺少最关键的一行:

"scripts": {
    "vscode:prepublish": "npm run compile",
    "compile": "tsc -p ./",
    "watch": "tsc -watch -p ./",
    "lint": "eslint src --ext ts",
    //                              ← 没有 "test" 脚本!
    "package": "vsce package",
    "publish": "vsce publish"
}

有两种方式定义 test 脚本:

方案 A:tsx(直接跑 .ts)方案 B:tsc 先编译(本课推荐)
命令"test": "node --import tsx --test src/**/*.test.ts""test": "node --test out/**/*.test.js"
依赖需要新增 tsx devDependency零新依赖——复用现有 tsc
速度快——无需编译步骤慢一次编译——但可以用 pretest 自动化
哲学方便优先与 0007 的"零依赖"一致
🎯 本课推荐方案 B。它延续 Lesson 0007 选择 node:test 的理由——不引入新依赖,利用已有基础设施。但 tsx 是一个值得了解的好工具:零配置 TypeScript 执行器,让 node --test 直接跑 .ts 文件。当你测试文件变多、每次编译变得烦人时,考虑迁移。

6.1 pretest 钩子——自动化编译

npm 有一组内置的生命周期钩子:如果你定义了 pretest,npm 会在执行 test 之前自动运行它。利用这个机制,npm test 可以自动编译:

"scripts": {
    "compile": "tsc -p ./",
    "watch": "tsc -watch -p ./",
    "lint": "eslint src --ext ts",
    "pretest": "npm run compile",     ← npm 在 test 前自动运行
    "test": "node --test out/**/*.test.js",  ← 新增
    "package": "vsce package",
    "publish": "vsce publish"
}

现在 npm test 的执行顺序是:pretest(编译 TypeScript)→ test(运行测试)。一个命令,两步自动完成。

💡 npm test 不需要 run对——test 是 npm 的四个内置生命周期快捷方式之一(另外三个是 startstoprestart)。对于这些,npm <name> 等价于 npm run <name>。所有其他脚本都需要 npm run <name>

7. 完整的 workflow 文件

把所有概念组合在一起,这是最终的 .github/workflows/test.yml

# ===== .github/workflows/test.yml =====
name: Test

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: ['18', '20', '22']
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}

      - name: Install dependencies
        run: npm ci

      - name: Compile TypeScript
        run: npm run compile

      - name: Run tests
        run: npm test
📝 注意:这个 workflow 明确调用了 npm run compile虽然 pretest 在本地会自动编译,但在 CI 中显式列出编译步骤有几个好处:
1. 可观测性——编译失败和测试失败显示在不同的 step 中,一眼就知道出问题的是哪一步
2. 时间追踪——GitHub 会为每个 step 单独计时,你可以看到编译花了多久 vs 测试花了多久
3. 解耦——如果以后要缓存编译产物(如 out/ 目录),独立步骤更容易操作

8. 练习

  1. 编辑 temp_repo/package.json,在 scripts 中添加 "pretest": "npm run compile""test": "node --test out/**/*.test.js"。运行 npm test——即使没有测试文件,node --test 也应该干净退出(无匹配文件不算失败)。确认 pretest → test 的执行顺序
  2. temp_repo/.github/workflows/ 目录下创建 test.yml,使用第 3 节的最简版本(单 Node 版本,无矩阵)。逐行检查 YAML 缩进——必须是 2 个空格,不能是 tab。不要把 out/ 目录提交到 Git——CI 会在运行时编译
  3. 修改 test.yml,加入 strategy.matrixnode-version: ['18', '20', '22'])。在修改之前先回答:这个矩阵会产生几个 job?如果加 os: [ubuntu-latest, windows-latest] 呢?
  4. (思维练习)画一张决策树:以下操作分别会触发哪些 workflow?
    a) git push origin main
    b) 从 feat/foo 分支向 main 发起 PR
    c) git tag v1.6.0 && git push origin v1.6.0
    d) 向 feat/bar 分支 push(没有对应的 PR)
  5. (探索)打开 github.com/ezoosk/claude-context-bar/actions——查看 publish workflow 的历史运行记录。找到一个成功运行和一个失败运行(如果有),展开步骤日志,观察每一步的耗时和输出。这是你的 test.yml 推送后会出现的同一页面
  6. (延伸)在 workflow 中添加 lint 步骤:在 npm test 之前加一个 step 运行 npm run lint。思考:lint 失败应该阻止测试运行吗?还是应该并行跑?如果你不确定答案——这说明你已经触碰到了 CI 设计的核心权衡:快速失败 vs 全面反馈

9. 知识检查

问题 1

在 CI workflow 中使用 npm ci 而非 npm install 的主要原因是什么?

问题 2

当一个 GitHub Actions workflow 启动时,runs-on: ubuntu-latest 的运行器上有什么?

问题 3

你有两个 workflow:test.yml(触发:push/pull_request → main)和 publish.yml(触发:tags → v*)。你执行 git push origin main --tags(同时推送了一个 commit 和一个 v1.5.0 tag)。哪些 workflow 会运行?

问题 4

strategy.matrixnode-version: ['18', '20', '22'] 会产生怎样的执行行为?

问题 5

npm test 可以省略 run(不需要写成 npm run test)。这是为什么?

10. 推荐深入阅读

💡 提问提示:想知道如何在 workflow 中加入 lint 步骤吗?想设置分支保护规则让 CI 不通过就无法合并 PR 吗?想知道 actions/cache 如何缓存 node_modules 以加速后续运行吗?想了解如何用 workflow_dispatch 手动触发 workflow 而不需要 push 代码吗?想看到 needs 关键字如何让 publish job 依赖 test job 通过吗?这些问题都可以继续追问。