前端 CI/CD 实践:把发布从手工步骤变成可回放流程

从手工发布切到 GitHub Actions,比流水线怎么写更让人犹豫的是这一步要担多大的风险:原来的发布方式虽然糙,但至少所有人都熟悉;换成流水线之后,一旦某个环节配错,出问题的可能不是一次发布,而是往后所有发布。团队里这半年一直在推进这件事,我把踩过的判断和取舍记一下。

我们项目原来的发布方式很朴素:本地跑一下 build,确认没报错,把 dist 传到服务器。项目小的时候没什么问题,人多了之后开始出岔子——有人忘了跑 lint,有人本地 Node 版本和线上不一致,构建产物里混进了没降级的语法,线上老版本浏览器直接白屏。那次问题不复杂,但排查很费劲:先怀疑接口,再怀疑缓存,最后才发现是发布环境和开发环境没对齐。这类问题的共同点是,它们都不该由人去记,而应该由流程去挡。

GitHub Actions 这一年在国内前端团队里已经很成熟,不再是要不要用的问题,而是怎么用得干净的问题。搭一套基础流水线成本不高,真正的成本在后面:怎么让缓存不拖累正确性,怎么让 matrix 构建覆盖到该覆盖的组合,怎么在部署失败时能干净地退回来。这些判断标准比"写一个 yaml"要重要得多。

迁移的第一笔账:哪些步骤先固定下来

从手工发布迁移过来,不是把所有环节一次性搬进流水线,而是先挑最容易漏、最容易出问题的几步固定住。对我们来说,优先级是安装依赖、lint、测试、构建,这四步谁都不能绕过去。

一个最基础的配置:

1name: CI
2
3on:
4  pull_request:
5  push:
6    branches: [main]
7
8jobs:
9  build:
10    runs-on: ubuntu-latest
11
12    steps:
13      - name: Checkout
14        uses: actions/checkout@v3
15
16      - name: Setup Node
17        uses: actions/setup-node@v3
18        with:
19          node-version: 16
20          cache: npm
21
22      - name: Install dependencies
23        run: npm ci
24
25      - name: Lint
26        run: npm run lint
27
28      - name: Test
29        run: npm test
30
31      - name: Build
32        run: npm run build

这里用 npm ci 而不是 npm install,因为它严格按 lockfile 安装,不会因为本地和 CI 的依赖解析结果不一致而产生"我本地是好的"这种说法。npm install 在没有严格锁定的情况下,同一个 lockfile 在不同时间跑出来的 node_modules 可能有细微差别,CI 环境尤其不能承受这种不确定性。

这套检查不需要一开始就完整。哪怕项目暂时没有像样的单元测试,也可以先把 lint、类型检查和 build 定住,等测试补起来了,再加进去当新的门禁。我们组内部就是这么推进的:先把"能不能构建通过"焊死,测试覆盖率是后面几个月慢慢加上去的。

Node 版本要写进契约,而不是留在个人习惯里

很多"本地正常、CI 失败"的问题根源都是 Node 版本没对齐。项目里放一个 .nvmrc

116

GitHub Actions 里直接读取这个文件:

1- name: Setup Node
2  uses: actions/setup-node@v3
3  with:
4    node-version-file: .nvmrc
5    cache: npm

这样本地和 CI 用的是同一个版本号,问题会少很多。我们现在 Node 16 是团队的 LTS 主力版本,官方也已经预告了 18 会在今年晚些时候转为下一个 LTS,但目前项目里还没有理由现在就切,构建工具链和依赖的适配都还在观望阶段,贸然升上去反而会引入新的不确定性。

我现在把 Node 版本当成项目契约的一部分,而不是个人习惯——README、.nvmrc、CI 配置三处必须一致。之前有次没同步,新同事按 README 装了一个版本,结果 CI 用的是另一个,装依赖的报错信息完全对不上,排查花了小半天才反应过来是版本没对齐,不是代码问题。

过期流水线要能自动取消

PR 开发过程中,一次功能改动往往会连续 push 好几次。如果每次提交都完整跑一遍构建,队列会被旧任务占住,真正想看的那次反馈反而慢。

GitHub Actions 的 concurrency 可以取消同一分支上还在跑的旧任务:

1concurrency:
2  group: ${{ github.workflow }}-${{ github.ref }}
3  cancel-in-progress: true

这个配置在稍大一点的前端项目里很有必要。我们主仓库 build 加测试要跑三四分钟,一次 PR 改上七八次很常见,不设这个的话,GitHub 的并发 runner 很快就被旧任务占满,团队里其他人的检查也会跟着排队变慢。设了之后还顺带解决了一个容易被忽视的问题:旧提交的检查结果通过了,但新提交其实还没跑完,这时候如果有人手快点了合并,看到的是过期的绿勾。

matrix 构建:覆盖组合,而不是重复劳动

如果项目要同时兼容多个 Node 版本,或者要在多个操作系统上验证构建产物,逐个写 job 会很啰嗦。GitHub Actions 的 matrix 策略能把这类重复的组合收拢成一份配置:

1jobs:
2  build:
3    runs-on: ubuntu-latest
4    strategy:
5      matrix:
6        node-version: [14, 16]
7
8    steps:
9      - uses: actions/checkout@v3
10
11      - uses: actions/setup-node@v3
12        with:
13          node-version: ${{ matrix.node-version }}
14          cache: npm
15
16      - run: npm ci
17      - run: npm test

我们目前只在"确认旧版本 Node 还能跑"这类场景下用 matrix,比如某些内部工具还没跟上团队的 Node 16 基线,用 matrix 同时验证 14 和 16,能提前发现只在某个版本上才炸的问题,而不是等真出问题了才知道。matrix 的代价是任务数量会成倍增加,跑的分钟数也跟着涨,不是所有项目都值得铺开用,先想清楚要验证的组合是什么,再决定要不要上。

缓存能提速,但正确性不能靠它

Actions 自带的缓存能明显缩短安装依赖的时间:

1with:
2  cache: npm

但缓存终究只是优化手段,流水线在没有缓存、或者缓存失效的情况下也必须能完整跑通。缓存策略这里有个容易被忽略的点:缓存 key 如果绑定得不够精确,比如只按分支名而不按 lockfile 内容做 hash,就可能出现"lockfile 已经改了,但缓存命中了旧依赖"这种情况,表现出来是本地能装上的包在 CI 里死活装不上,或者版本对不上。排查这类问题时,第一步永远是先清缓存或者临时关掉缓存,确认问题是不是缓存本身导致的,不要把缓存当成构建产物的管理工具去依赖它的"稳定性"。

部署前要把环境边界划清楚

前端项目通常至少有三层环境:开发、预发、生产。CI 可以在 PR 阶段只做检查,合并到 main 后部署预发环境,打 tag 之后才部署生产:

1on:
2  push:
3    branches: [main]
4    tags:
5      - "v*"

环境变量必须跟着环境分开,绝不能让生产 token 对所有分支可见。GitHub Actions 里用 Secrets:

1- name: Deploy
2  run: npm run deploy
3  env:
4    DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

有一点要提醒自己:前端构建时注入进浏览器产物的环境变量,一旦进了 bundle 就不再是秘密,用户随手看源码就能看到。真正的密钥只能留在构建服务器和部署脚本这一侧,不能出现在客户端代码里。

生产环境最好再加一层保护,比如 GitHub Environments 的人工审批、受保护分支、只允许从 tag 触发生产部署。我们组这半年遇到的几次险情,不是代码写错了,而是把预发环境的配置误发到了生产——流程上多一道审批,成本很低,但能挡掉这类低级失误。

构建产物要能对上号

部署时最好带上版本信息,比如 commit hash:

1- name: Build
2  run: npm run build
3  env:
4    VITE_COMMIT_SHA: ${{ github.sha }}

前端页面上报错误时带上这个版本:

1reportError({
2  message: error.message,
3  version: import.meta.env.VITE_COMMIT_SHA,
4})

这样线上报错时,能立刻知道是哪次发布引入的问题,而不是对着一堆报错猜是不是"最近那次改动"。没有版本信息的错误监控,很难和发布记录对上号。

构建产物本身也要尽量保持不可变。一个常见的坑是:verify 阶段 build 一次,deploy 阶段又重新 build 一次,两次构建的产物不一定完全一致,尤其构建里带了时间戳、随机 hash 或者远程资源的时候,差异会更明显。更稳妥的做法是 verify 阶段生成 artifact,deploy 阶段只下载并发布同一份 artifact——这样出问题时,能明确知道发布的是哪一份产物,而不是"应该是刚才 build 出来的那份"。

artifact 还可以顺手带一份发布元信息,比如构建后生成 dist/version.json

1node -e "require('fs').writeFileSync('dist/version.json', JSON.stringify({ commit: process.env.GITHUB_SHA, time: new Date().toISOString() }))"

页面或监控系统读取这个文件,排查时就不用猜当前环境跑的是哪次提交。静态站尤其需要这个,因为部署平台、CDN 和浏览器缓存之间隔了好几层,光看 GitHub 上最后一次成功的 workflow,不一定能证明用户访问到的就是那份产物。

部署失败必须停下来,不能吞掉

流水线里每一步失败都应该挡住后续步骤。以下这种写法要坚决避免:

1npm run lint || true
2npm run build

这样写会让 lint 失败被无声吞掉。除非非常明确某一步允许失败,否则不要吞错误。发布流程尤其要谨慎——构建失败不该部署,测试失败也不该部署。

部署脚本里也不建议默默吞掉错误。上传 CDN 失败、刷新缓存失败、通知失败,这些都应该明确暴露出来。可以把"通知失败"设成非阻塞,但构建和上传失败绝不能假装成功继续往下走。

一个相对完整的前端部署流水线

把前面几节拼起来,大概是这样:

1name: Frontend Pipeline
2
3on:
4  push:
5    branches: [main]
6
7jobs:
8  verify:
9    runs-on: ubuntu-latest
10
11    steps:
12      - uses: actions/checkout@v3
13
14      - uses: actions/setup-node@v3
15        with:
16          node-version: 16
17          cache: npm
18
19      - run: npm ci
20      - run: npm run lint
21      - run: npm test
22      - run: npm run build
23
24      - name: Upload artifact
25        uses: actions/upload-artifact@v3
26        with:
27          name: frontend-dist
28          path: dist
29
30  deploy:
31    needs: verify
32    runs-on: ubuntu-latest
33
34    steps:
35      - name: Download artifact
36        uses: actions/download-artifact@v3
37        with:
38          name: frontend-dist
39          path: dist
40
41      - name: Deploy
42        run: ./scripts/deploy.sh
43        env:
44          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

deploy 依赖 verify,只有检查通过才会真正部署,两边共用同一份 artifact,避免了前面提到的"两次构建不一致"问题。

如果项目需要 PR 预览环境,也可以在 pull_request 事件里部署一个临时地址。预览环境对前端帮助很大,产品、测试、后端都能提前看到真实页面,不用只靠截图沟通。这里要守住权限边界,不能让外部 PR 直接拿到内部 token。

预览环境最好带上自动清理机制。PR 关掉之后,如果临时站点、对象存储目录、CDN 路径一直留着,时间长了会堆出一堆没人敢删的环境。GitHub Actions 可以在 pull_request.closed 事件里跑清理任务:

1on:
2  pull_request:
3    types: [closed]
4
5jobs:
6  cleanup-preview:
7    runs-on: ubuntu-latest
8    steps:
9      - run: ./scripts/remove-preview.sh ${{ github.event.pull_request.number }}

我们让预览环境的 URL 带上 PR 编号,比如 /preview/pr-123/,这样部署和清理能按同一个 key 操作,不会出现清理脚本找不到对应资源的情况。预览环境不是临时传个链接那么简单,它其实也是发布系统的一部分,得当成正经资源来管理生命周期。

API 错误和部署回滚要联动

前端的 CI/CD 不只关心静态资源发上去了没有。发布之后如果接口错误率明显上升,也应该能快速回滚。所以前端项目最好有统一的 API 错误结构和错误上报:

1function normalizeApiError(status, body) {
2  return {
3    status,
4    code: body?.error?.code || 'API_ERROR',
5    message: body?.error?.message || '请求失败',
6  }
7}

上报时带上版本:

1reportApiError({
2  ...error,
3  version: import.meta.env.VITE_COMMIT_SHA,
4})

这样发布之后如果某个版本的 API 错误突然变多,能快速定位到具体是哪次发布,进而决定要不要回滚。

回滚流程最好写成明确的步骤清单,而不是出事的时候临场发挥。我的习惯是至少留三类信息:当前线上版本、上一个稳定版本、回滚命令或平台入口。如果项目走静态资源发布,回滚可能就是切回上一份 artifact;如果走容器发布,可能是切回上一个镜像 tag。前端不能只关心怎么发上去,也得提前想清楚怎么撤回来——这条链路平时不会用到,但真出事的时候,能不能三分钟内回滚,往往决定了这次事故的影响面有多大。

几个还在评估的延伸方向

我们组这段时间还顺带看了几个跟发布流程相关但还没完全定下来的东西。一个是 pnpm:这一年团队里陆续有人在个人项目里试 pnpm workspace,磁盘占用和安装速度确实有优势,但主仓库还没有迁移的计划,CI 配置要跟着换缓存策略和 lockfile 格式,牵一发动全身,暂时还在观望。另一个是 Vite 3——刚发布不久,性能和插件生态比 2.x 更完善了一些,我们内部工具链已经在小范围试用,但主站构建量级更大、依赖的 webpack 插件也更多,短期内不会整体切换,两条构建链路会并存一阵子。还有一个是 workflow 里的权限收紧:GitHub 这一年默认把 GITHUB_TOKEN 的权限收窄了很多,如果 workflow 里要往仓库写内容(比如自动生成 changelog、推 tag),需要显式在 permissions 字段里声明写权限,不然会莫名其妙地在某一步失败,这个坑我们这几个月踩过不止一次。

这套流水线搭起来之后,最早那次"构建产物里混进了没降级的语法导致线上白屏"的问题,团队里没再复现过——不是因为大家突然都记性变好了,是 lint 和 build 这两步现在没人能绕过去,Node 版本也不再是每个人凭记忆装的东西。前端 CI/CD 要做的事情,说到底就是把"这些环节不该由人去记"这句话落到实处:固定 Node 版本、用 lockfile 装依赖、跑 lint/test/build、区分部署环境、保护好密钥、记录发布版本,再加上一条真出事时能在三分钟内启动的回滚路径。