CI/CD 自动化
本教程共 76 篇 · 第 70 篇 · 更新于 2026-07-25 · 约 12 分钟阅读
70. CI/CD 自动化
本节目标:- CI/CD 到底是什么,解决什么问题 - 写一个 Node.js 的 GitHub Actions 工作流(Workflow) - 在工作流里跑测试、lint、构建,并缓存依赖加速 - 把构建产物发布到容器镜像仓库或直接部署 先厘清两个词:CI(Continuous Integration,持续集成)是「每次提交都自动构建+测试」,目的是早发现问题;CD(Continuous Deployment/Delivery,持续交付/部署)是「测试通过后自动发布到环境」。本章两个都沾。
上一章你把部署三件套配好了,但发布还得手动 SSH 上去、拉代码、重启——这套动作做一次两次还行,天天做就是折磨,而且人一慌就容易漏步骤。CI/CD 的核心就是:把「构建 → 测试 → 发布」固化成一条机器自动跑的流水线,你只管往 main 分支推代码。
本章以 GitHub Actions 为主讲对象(最普及、和 GitHub 仓库无缝集成),但思路对任何 CI 平台都成立。
一个最小工作流
GitHub Actions 的配置放在仓库的 .github/workflows/ 目录下,文件是 YAML 格式。
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm test
推上去之后,每次往 main 推代码或开 PR,GitHub 都会自动跑一遍 npm ci 和 npm test。这不只是一道保险——它让「能合并」和「能跑通」画上等号,团队越大越值钱。
Note
actions/setup-node@v4里cache: npm会自动按package-lock.json缓存node_modules,下次构建省掉重复下载,速度能快几倍。这是现在的标准写法,别忘了。
节点版本矩阵
你不一定只支持一个 Node 版本。想验证「在 v22 和 v24 上都能跑」,用 matrix:
jobs:
test:
runs-on: ubuntu-latest
strategy:
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
矩阵会把每个版本各跑一遍。注意我们基准是 v24 LTS,所以至少覆盖 22(维护性 LTS)和 24(当前 LTS)。
加上 lint 和类型检查
测试之外,通常还想跑 lint。把步骤串起来:
- run: npm ci
- run: npm run lint
- run: npm run build
- run: npm test
前提是你的 package.json 里得有对应脚本:
{
"scripts": {
"start": "node app.js",
"dev": "node --watch app.js",
"build": "node --run build || true",
"lint": "eslint .",
"test": "node --test"
}
}
Tip自 v24 LTS 起,
node --run <script>是运行package.json脚本的推荐方式,比npm run更轻(不加载 npm 的全部逻辑)。写脚本的start/build/lint/test时可以直接用node --run调子脚本,但 CI 里用npm ci && npm test仍然最稳,因为它会处理依赖安装。
缓存与构建加速
除了 setup-node 的依赖缓存,如果你的构建产物(比如 dist/)在多次运行间可复用,可以用 actions/cache:
- name: Cache build output
uses: actions/cache@v4
with:
path: dist
key: ${{ runner.os }}-build-${{ github.sha }}
key 用 github.sha(本次提交的哈希)保证每次提交对应独立缓存,避免拿到旧产物。
发布:构建并推送 Docker 镜像
CI 通过后,常见的 CD 动作是把镜像推到仓库(Docker Hub、GHCR、阿里云 ACR 等)。下面是把镜像推到 GitHub 容器仓库(GHCR)的例子:
# .github/workflows/deploy.yml
name: Build and Push Image
on:
push:
branches: [main]
tags: ['v*']
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # 推 GHCR 需要这个权限
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}
secrets.GITHUB_TOKEN 是 GitHub 自动注入的,不需要你手动建;但 permissions 里必须声明 packages: write,否则推送会被拒。
Warning镜像 tag 别只打
latest。latest没法回滚、不好追溯。至少带上github.sha(提交哈希)或语义化版本 tag,真出问题能精准回退到某一版。
部署到服务器(SSH)
如果你是自己的一台 VPS,可以在镜像推送后 SSH 上去拉新镜像重启:
- name: Deploy over SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd /opt/my-api
docker compose pull
docker compose up -d
docker image prune -f
敏感信息(主机地址、私钥)一律走仓库的 Settings → Secrets,用 ${{ secrets.XXX }} 引用,绝对不要明文写进 YAML。
在 CI 里跑带数据库的集成测试
单元测试不碰外部依赖,但集成测试需要真数据库。GitHub Actions 可以用 services 起一个临时数据库容器,跑完就销毁,干净又隔离:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgres://test:test@localhost:5432/testdb
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run migrate # 建表
- run: npm test
services 里的 options 是容器健康检查,等数据库真正能连了再开始跑测试,避免「库还没起来,测试已经连不上报错了」。这种测试跑一次的成本比本地手动起库低得多。
覆盖率与测试产物
CI 的价值之一是「每次提交的质量可见」。把覆盖率跑出来,并作为产物(artifact)保留,方便事后翻:
- name: Run tests with coverage
run: npm test -- --experimental-test-coverage
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
Tip
if: always()很关键:即使测试挂了,覆盖率报告照样上传,你能看到「是在哪一步开始崩的」。--experimental-test-coverage是 Node.js 内置测试运行器(node --test)的覆盖率开关,自 v22 起逐步稳定,v24 可用。
防止并发部署打架
如果你和同事同时往 main 推,可能两个部署流水线同时跑、互相覆盖。加 concurrency 让同一条流水线排队:
concurrency:
group: deploy-production
cancel-in-progress: false # 不取消正在跑的,等它结束再跑下一个
cancel-in-progress: true 则更激进:新提交来了直接掐掉旧的那次。选哪个看你能不能接受「正在发布的版本被中途打断」。
可复用工作流
如果你的组织有多个 Node 项目,每个都抄一遍 setup-node + npm ci + npm test 很重复。GitHub Actions 支持「可复用工作流(Reusable Workflow)」:把通用步骤抽成一个被调用的文件,各项目 uses 它。
# .github/workflows/node-ci.yml (被复用)
name: Node CI Reusable
on:
workflow_call:
inputs:
node-version:
required: false
type: string
default: '24'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test
别的项目直接调:
# 某业务的 .github/workflows/ci.yml
jobs:
ci:
uses: org/shared/.github/workflows/node-ci.yml@main
with:
node-version: '24'
workflow_call 是触发条件,表示「被别人调用时才跑」。这样团队的测试规范改一处,所有项目同时生效。
Tip复用工作流有个坑:
secrets不会自动传给被调用方,要在uses里显式传secrets: inherit,或者在工作流里声明secrets输入。忘了传,构建到一半会因为拿不到 token 而失败。
部署前先跑数据库迁移
很多团队卡在「代码发上去了,表结构却没更新」。正确顺序是把迁移(migration)作为发布流水线的一步,且要在「替换新代码之前」完成(向下兼容的迁移先跑,再发新版本):
- name: Run database migration
run: npm run migrate
env:
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
- name: Pull and restart
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd /opt/my-api
docker compose pull
docker compose up -d
迁移脚本本身要写成「可重复执行、失败可重跑」的(幂等),否则流水线一重试就出错。
回滚:发错了怎么办
再严谨的流水线也有发崩的时候。回滚(Rollback)能力必须提前想好,而不是事故了才手忙脚乱。两个常见做法:
- 镜像回滚:因为每次发布都打了
github.sha的镜像 tag,回滚就是「把 compose 里的镜像换回上一个 sha 重新拉起」:
# 在服务器上切回上一个镜像版本
sed -i 's|:newsha|:oldsha|' docker-compose.yml
docker compose up -d
- 数据库迁移要可降:这是最容易翻车的地方。如果你上线时跑了「加列」迁移,回滚代码后旧代码不认新列通常没事;但如果你跑了「删列 / 改类型」迁移,旧代码一回来就炸。所以破坏性迁移要单独排期、单独审批,别混在普通发布里。
Warning千万不要「代码回滚了,但数据库迁移没回滚」或者反过来。代码和数据库 schema 必须当成一对一起考虑。破坏性的 schema 变更,宁可多花一次发布周期,也要保证新旧代码都能跑。
多环境:staging 与 production
正规一点会有至少两个环境:staging(预发,尽量和生产一致,用来做最后验证)和 production(生产)。GitHub Actions 用 environment 区分,可以给 production 加人工审批门槛:
deploy-prod:
needs: deploy-staging
environment: production # 在仓库 Settings 里给这个环境配审批人
runs-on: ubuntu-latest
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd /opt/my-api && docker compose pull && docker compose up -d
environment: production 配上仓库里设置的「required reviewers」,流水线跑到这步会暂停,等人点批准才继续。staging 环境通常不设审批,自动过。
用 tag 触发正式发布
日常提交只跑 CI,只有打 v1.2.0 这种 tag 才走发布流程,更符合实际节奏:
on:
push:
branches: [main]
tags: ['v*']
jobs:
test:
if: github.ref_type != 'tag' || startsWith(github.ref, 'refs/tags/v')
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 test
github.ref_type 和 github.ref 是 Actions 提供的上下文变量,能区分「这次触发是分支还是 tag」。
Tip别忘了
pull_request也接 CI。PR 合入前的那次测试,比合入后再发现错误便宜得多。很多团队规定:CI 不绿,不准合并。
打 tag 发布时,建议用语义化版本号(v1.2.0)而不是 latest,这样版本历史清晰、出问题能精准定位到哪一版。配合 actions/create-release 还能自动生成带 changelog 的发布说明,省去手写的麻烦。
失败怎么办
流水线最怕「红了也没人在意」。几个实用习惯:
- 测试不全就别写
npm test占位——空测试永远绿,等于没 CI。 - 给仓库开「保护分支(branch protection)」,强制
main必须过 CI 才能合并。 - 把构建通知接到你的 IM(钉钉/飞书/Slack),红了立刻可见。
- 关键发布步骤加人工审批(
environment+required reviewers),正式环境别全自动。
一个项目的完整 CI/CD 串起来
把前面零散的配置拼成一个真实项目,你大概会看到这样的两个文件:
.github/workflows/ci.yml——每次提交和 PR 都跑:
name: CI
on:
pull_request:
branches: [main]
jobs:
test:
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
- run: npm test
.github/workflows/deploy.yml——只往 main 推且测试通过后才发:
name: Deploy
on:
push:
branches: [main]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd /opt/my-api
docker compose pull
docker compose up -d
needs: build-and-push 保证「镜像没推成功就不去部署」,这是流水线里最基本的依赖顺序。两边合起来,就是你从「提交代码」到「线上更新」的完整自动化。
把发布流程写进代码之后,你半夜被叫醒的次数会肉眼可见地减少。