Node.js 项目的 GitHub Actions CI

1. 总览

每当有人 push 代码或打开 pull request 时,你需要立即知道代码是否仍能构建,测试是否仍能通过。这必须是自动的,不能依赖每个人的机器。本文指导你用 GitHub Actions 为 Node.js 项目构建 CI 流程。读完后,你将拥有一个具备缓存依赖安装、运行 lint、在多个 Node 版本上测试、生成覆盖率报告以及自动取消过期运行的工作流。

2. 准备示例项目

我们用一个计算订单总金额的小模块。首先是 package.json:

{
  "name": "order-service",
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=22"
  },
  "scripts": {
    "lint": "eslint src test",
    "test": "node --test",
    "test:coverage": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info"
  }
}

项目使用 Node 内置的测试运行器(node:test),所以不需要安装 Jest 或 Mocha。"type": "module" 允许使用 import/export 语法。test:coverage 脚本同时使用两个 reporter。一个 reporter 将结果输出到控制台。另一个 reporter 将结果写入 lcov.info 文件供日后使用。

业务逻辑位于 src/order.js:

export function calculateTotal(items, discountPercent = 0) {
  if (discountPercent < 0 || discountPercent > 100) {
    throw new RangeError('discountPercent 必须在 0–100 范围内');
  }
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  return Math.round(subtotal * (100 - discountPercent)) / 100;
}

这个函数将每项的 price * quantity 相加,然后减去折扣。如果折扣百分比超出 0–100 范围,函数会抛出 RangeError。

对应的测试位于 test/order.test.js:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { calculateTotal } from '../src/order.js';

test('计算有折扣的订单总额', () => {
  const items = [
    { price: 100000, quantity: 2 },
    { price: 50000, quantity: 1 },
  ];
  assert.equal(calculateTotal(items, 10), 225000);
});

test('无效折扣时抛出错误', () => {
  assert.throws(() => calculateTotal([], 120), RangeError);
});

node --test 命令会自动查找后缀为 .test.js 的文件。在本地运行试试:

npm install --save-dev eslint @eslint/js
npm test
✔ 计算有折扣的订单总额 (0.9ms)
✔ 无效折扣时抛出错误 (0.3ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0

ESLint 9 使用 flat config。在项目根目录创建 eslint.config.js 文件:

import js from '@eslint/js';

export default [js.configs.recommended];

最后,务必提交 package-lock.json 文件。如你接下来所见,CI 需要这个文件。

3. 第一个 CI 工作流

GitHub Actions 读取 .github/workflows/ 目录中的 YAML 文件。创建 .github/workflows/ci.yml 文件:

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npm test

文件的各个部分作用如下:

  • on:工作流在 push 到 main 分支或有 pull request 时触发。
  • runs-on: ubuntu-latest:job 在 GitHub 提供的最新 Linux 虚拟机上运行。
  • actions/checkout@v4:将源代码克隆到虚拟机。
  • actions/setup-node@v4:安装 Node 22。cache: npm 选项缓存 npm 缓存目录,使用 package-lock.json 的哈希作为 key。在后续运行中,包将从缓存中获取,而不是从注册表重新下载。
  • npm ci:按 lockfile 中的完全相同方式安装依赖。
  • npm test:运行测试。如果命令返回非 0 的退出代码,job 将失败,pull request 将被标记为红色。

push 这个文件后,进入 repository 的 Actions 标签查看日志。在第二次运行时,setup-node 步骤会输出类似的行:

Cache restored successfully
Cache restored from key: node-cache-Linux-x64-npm-3f9a1c...

注意: 不要在 CI 中使用 npm install。npm install 可能会更新 lockfile,安装与你本地机器不同的版本。npm ci 删除 node_modules,按 lockfile 精确安装,如果 package.json 和 package-lock.json 不匹配则立即报错。这种行为会帮助你提早发现有人忘记 commit lockfile 的情况。

4. 在多个 Node 版本上运行测试

你的库或服务可能需要在多个 Node 版本上运行。策略 matrix 可以为每个版本复制一个 job:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node-version: [22, 24]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
      - run: npm ci
      - run: npm test

GitHub 将并行创建两个 job,分别命名为 test (22) 和 test (24)。表达式 ${{ matrix.node-version }} 在每个 job 中被替换为相应的值。

默认情况下 fail-fast 为 true:只要有一个 job 失败,其他 job 立即被取消。设置 fail-fast: false 让你看到完整的情况。例如,你会发现错误仅出现在 Node 24 上,而 Node 22 仍然通过。

你可以将 matrix 扩展到多个操作系统:

    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        node-version: [22, 24]
    runs-on: ${{ matrix.os }}

这个配置会创建 2 × 2 = 4 个 job。

注意: matrix 中每增加一个维度都会乘以 job 数量。对于只在 Linux 上部署、只用 Node 24 的服务来说,大的 matrix 只会浪费 CI 分钟而没有实际好处。只有在你真正支持多个环境时才使用 matrix,例如发布一个 npm 包时。

如果项目只使用一个 Node 版本,让 CI 和开发机器从同一个来源读取版本。创建 .nvmrc 文件,内容为 24,然后声明:

      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: npm

5. 分离 lint job 和 job 间的依赖关系

Lint 比测试快得多,也不需要在每个 Node 版本上重新运行。因此,应该将 lint 分离为单独的 job,让测试 job 等待 lint 通过:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run lint

  test:
    needs: lint
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node-version: [22, 24]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
      - run: npm ci
      - run: npm test

needs: lint 使得 test job 只在 lint 成功后才开始。假设有人留下了一个未使用的变量:

const unusedTax = 0.1;

此时 lint job 会失败并输出:

/home/runner/work/order-service/src/order.js
  1:7  error  'unusedTax' is assigned a value but never used  no-unused-vars

✖ 1 problem (1 error, 0 warnings)

test job 将不会运行,所以你节省了 matrix 的运行时间。

你需要在这里权衡取舍。使用 needs 会使总时间变长(当所有东西都通过时),因为两个 job 是按顺序运行的。如果你优先考虑快速反馈,应该删除 needs 让 lint 和测试并行运行。

6. 覆盖率报告和保存工件

虚拟机上生成的所有文件在 job 结束时都会丢失。要保留覆盖率报告,使用 actions/upload-artifact:

  coverage:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run test:coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: lcov.info
          retention-days: 7

test:coverage 脚本会在 job 日志中输出覆盖率表:

ℹ start of coverage report
ℹ file           | line % | branch % | funcs %
ℹ src/order.js   | 100.00 |   100.00 |  100.00
ℹ all files      | 100.00 |   100.00 |  100.00
ℹ end of coverage report

条件 if: always() 确保即使测试失败,工件也会被上传。这正是你最需要报告的时候。lcov.info 文件会出现在工作流运行的摘要页面末尾,7 天后自动删除。

如果想在覆盖率低于阈值时让 CI 失败,从 Node 22.8 开始你可以在脚本中添加一个标志:

node --test --experimental-test-coverage --test-coverage-lines=80

这个命令会在行覆盖率低于 80% 时返回退出代码 1。

7. 工作流优化

以下三个设置应该出现在大多数 CI 工作流中。将它们放在文件的顶层:

name: CI

on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'
  pull_request:
    paths-ignore:
      - '**.md'
      - 'docs/**'

permissions:
  contents: read

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

每个设置的含义:

  • paths-ignore:仅修改 README 或文档的 commit 不会触发 CI。
  • permissions: contents: read:将 GITHUB_TOKEN 的权限限制为仅读。如果一个恶意依赖在 CI 中运行脚本,它也无法 push 代码或修改 release。
  • concurrency:当你连续 push 三个 commit 到同一个 pull request 时,只保留最新的运行。较旧的运行被取消,因为它们的结果已不重要。

此外,应该为每个 job 添加 timeout-minutes:

  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10

默认情况下一个 job 可以运行长达 6 小时。即使只是一个测试卡住(例如忘记关闭数据库连接),也可能耗尽你的 CI 分钟配额。

注意: 与分支保护结合使用时,paths-ignore 有一个陷阱。假设你要求 test check 必须通过才能合并。一个只修改 .md 文件的 pull request 不会运行工作流,所以该 check 会一直处于“Expected — Waiting”状态。此时你有两个选择:删除 paths-ignore,或删除对该 check 的强制要求。

8. 常见错误

缺少 lockfile。 如果 package-lock.json 没有被提交,或在 .gitignore 中被列出,setup-node 步骤会报错:

Error: Dependencies lock file is not found in /home/runner/work/order-service/order-service.
Supported file patterns: package-lock.json,npm-shrinkwrap.json,yarn.lock

修复方法是提交 lockfile。如果项目使用 pnpm 或 yarn,将 cache 值改为正确的包管理器。

测试以 watch 模式运行。 某些工具,如配置为 watch 的 Vitest 或 Jest,会永远等待文件变化。GitHub Actions 会自动设置环境变量 CI=true,大多数工具基于这个变量关闭 watch。不过,你应该显式调用一次性运行命令,例如 vitest run。

fork 中的 pull request 没有 secrets。 对于来自 fork 仓库的 pull_request 事件,secrets 不会传递到工作流。这是故意的安全机制。需要真实 API key 的测试在这种情况下会失败。在单元测试中 mock 外部服务。不要切换到 pull_request_target 来“救火”,因为这个事件以完全权限运行未审查的代码。

操作系统差异。 在 macOS 上通过但在 Ubuntu 上失败的测试通常是由于路径中的大小写差异。例如,import './Order.js' 而实际文件名是 order.js。Linux 文件系统区分大小写,所以 CI 会在问题上线前检测到它。

何时不需要完整的流程。 对于只有几十行的内部脚本,一个单独的 job npm ci && npm test 就足够了。只有当项目逐渐变大或有多个贡献者时,才应该添加 matrix、覆盖率和分离的 job。

9. 总结

在本文中,你为 Node.js 项目用 GitHub Actions 构建了一个完整的 CI 流程。该流程使用 npm ci 和 setup-node 的缓存快速稳定地安装依赖。Strategy matrix 帮助检查多个 Node 版本。Lint job 被分离出来,并通过 needs 与测试 job 关联。覆盖率报告通过工件保留。最后,工作流通过 concurrency、permissions 和 timeout-minutes 进行优化。

核心原则是保持 CI 快速可靠。一个运行十五分钟或偶尔随机失败的流程会导致整个团队逐渐忽视其结果。从第 3 部分的最小工作流开始,然后在项目真正需要时逐个添加功能。

github-actionsnodejscitestingdevops