前言

最近我给一个 Spring Boot 4 + Vue 3 的前后端项目补全了测试和 CI/CD 体系。在这个过程中,CI 流水线经历了多次调试才全部通过。这些问题覆盖了环境一致性、平台差异、依赖冲突等几乎所有新手都会遇到的坑。

本文将以这次实战为线索,带你从零搭建一套完整的 CI/CD 流水线。读完你将能够:

  • 理解 CI/CD 的概念和必要性
  • 为自己的项目写出第一个 GitHub Actions 工作流
  • 知道常见的坑和应对方法

一、CI/CD 是什么?为什么需要它?

一句话理解

1
你 push 代码 → GitHub Actions 自动执行检查 → 全绿就放心了
  • CI(Continuous Integration,持续集成):每次提交代码,自动编译、测试、检查代码质量。确保”新代码没有搞坏旧功能”。
  • CD(Continuous Delivery / Deployment,持续交付/部署):CI 通过后,自动把代码部署到服务器。

一张图理解 CI/CD 在项目中的位置

CI/CD 流程图

PR(Pull Request)触发 CI,CI 不通过则管理员根本不应该看到这个 PR。这就是 “安全检查门” 的含义——机器先替你过滤一遍,人只审绿色代码。

没有 CI 会怎样?

问题 没有 CI 有 CI
新同事 clone 后跑不起来 口头告知”你缺个 xxx 依赖” CI 直接报错,push 前就能发现
代码写错了破坏已有功能 上线后用户发现 测试在合并前就挂了
Windows 能跑 Linux 不能跑 上了服务器才炸 CI 运行在 Linux,第一时间暴露
没人记得跑 lint 代码风格越来越乱 CI 自动跑,不通过不给合并

二、动手前的准备

CI 的核心是跑你的测试构建命令。所以在写 CI 文件之前,先把这些东西准备好:

2.1 单元测试

后端用 Maven:

1
2
cd edumind
./mvnw test # 跑所有测试

前端用 npm:

1
2
cd vue-project
npm test # 等价于 npx vitest run

确保这两条命令在你的终端里能跑通之后,再交给 CI。

2.2 依赖清单

对于 Java(Maven 项目),确保 pom.xml 里测试依赖齐全。至少需要:

1
2
3
4
5
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

对于前端,确保 package-lock.json 已提交到 Git。CI 用的是 npm ci,它严格按照 lockfile 安装依赖——如果 lockfile 没提交或过期,直接挂。

2.3 覆盖率工具(可选但推荐)

pom.xml 中加入 JaCoCo 插件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>

./mvnw verify 后会在 target/site/jacoco/ 生成覆盖率报告。


三、编写你的第一个 CI 工作流

GitHub Actions 的配置文件放在 .github/workflows/ 目录下,只要是 .yml 文件,GitHub 就会自动识别。

3.1 文件结构

1
2
3
4
5
项目根目录
└── .github
└── workflows
├── ci.yml ← 质量检查(PR 时触发)
└── deploy.yml ← 自动部署(打 tag 时触发)

3.2 后端测试 Job

从最简单的开始——写一个只跑 Maven 测试的 Job:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
name: CI  Quality Gate

on:
push:
branches: [master, main] # 主分支有推送 → 触发
pull_request:
branches: [master, main] # 有人提 PR → 触发
workflow_dispatch: # 也可以在 GitHub 页面手动触发

jobs:
backend-test:
name: Backend Java 21
runs-on: ubuntu-latest # 跑在 Linux 上
services: # CI 提供的辅助容器
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432

steps:
- name: 拉取代码
uses: actions/checkout@v4

- name: 安装 JDK
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven' # Maven 缓存,下次跑更快

- name: 编译 & 测试
run: |
cd edumind
chmod +x mvnw # Linux 需要执行权限
./mvnw verify -Dspring.profiles.active=test \
-Dspring.datasource.url=jdbc:postgresql://localhost:5432/postgres \
-Dspring.datasource.username=postgres \
-Dspring.datasource.password=postgres \
--batch-mode

- name: 上传覆盖率报告
uses: actions/upload-artifact@v4
if: always() # 即使测试挂了也上传,方便排查
with:
name: jacoco-report
path: edumind/target/site/jacoco/

关键点解读:

  • runs-on: ubuntu-latest — CI 跑在 GitHub 提供的 Linux 虚拟机上。这意味着你的构建环境是 Linux,不是 Windows。这是最常见的坑源。
  • services: — 如果你的集成测试需要数据库/缓存,这里可以声明 GitHub 提供的服务容器,CI 会自动启动它们。
  • chmod +x mvnw — Git 不保留文件的可执行位。在 Windows 上开发时 mvnw 无所谓权限,但 Linux 上必须 chmod +x 才能运行。每处 ./mvnw 前都要加
  • --batch-mode — 让 Maven 不等待交互输入,适合 CI 环境。

3.3 前端测试 Job

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
frontend-test:
name: Frontend Node 22
runs-on: ubuntu-latest # 同样是 Linux

steps:
- name: 拉取代码
uses: actions/checkout@v4

- name: 安装 Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: vue-project/package-lock.json

- name: 安装依赖
run: cd vue-project && npm ci

- name: 类型检查
run: cd vue-project && npx vue-tsc --build

- name: 代码 Lint
run: cd vue-project && npm run lint

- name: 运行测试
run: cd vue-project && npx vitest run

npm cinpm install 的区别:

命令 行为
npm install 根据 package.json 安装,可能更新 package-lock.json
npm ci 严格按照 package-lock.json 安装,不一致就报错

CI 里必须npm ci,这能保证你本地锁定的版本和 CI 完全一致。

3.4 Docker 构建 Job(可选)

如果你的项目用 Docker 部署,加一个镜像构建的 sanity check:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
docker-build:
name: Docker Build image
runs-on: ubuntu-latest
needs: [backend-test, frontend-test] # 前两个都绿了才跑

steps:
- uses: actions/checkout@v4

- name: 安装 JDK 并构建 JAR
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'

- name: 构建 JAR Docker 镜像
run: |
cd edumind
chmod +x mvnw
./mvnw package -DskipTests --batch-mode
docker build -t edumind:ci-test .

- name: 确认镜像存在
run: docker images edumind:ci-test

needs: [backend-test, frontend-test] 的作用:Docker 构建需要跑测试时必须的 Maven 编译产物(JAR 包),所以等前两个 Job 通过后再执行。


四、常见的坑及排查思路

上面写完了完整的 CI 文件。但第一次 push 大概率不会直接绿——以下是在实战中遇到的几个典型问题和你需要知道的排查方法。

坑 1:package-lock.json 没提交

1
npm ERR! Missing: vitest from lock file

原因:本地 npm install 更新了 lockfile,但 git add 漏了。CI 用的 npm ci 严格校验 lockfile。

排查git statuspackage-lock.json 是否为 M(modified)。确保它和 package.json 一起提交。

坑 2:文件只存在于本地,没被 Git 跟踪

1
[ERROR] cannot find symbol: class MineruException

原因:IDE 里新建的类,从未 git add。本地编译没错是因为 IDE 读取文件系统,CI 只认得 Git 仓库里的东西。

排查git status 里的 ??(untracked)就是地雷。推送前跑一遍 git status 确认没有漏掉的新文件。

坑 3:Windows 上能运行,Linux 上报 Permission denied

1
./mvnw: Permission denied

原因:Windows 不管文件权限,Linux 执行脚本需要 x 权限。Git 在 Windows 上不保留 Unix 可执行位。

解决:在 CI 配置里,每个使用 ./mvnw 的步骤前加 chmod +x mvnw

坑 4:依赖版本冲突 —— “静默退化”

1
2
3
4
5
# Windows 上 JWT 正常
{"sub":"1","iss":"edumind","aud":"edumind-client","jti":"uuid...","iat":...}

# Linux CI 上三个字段消失
{"sub":"1","username":"teacher1","iat":...}

原因:Spring Boot 4 自带 Jackson 3.x,但 jjwt 0.11.5 依赖 Jackson 2.x。两个版本共存时,classpath 加载顺序在不同 OS 上可能不同,导致部分序列化功能静默失效——不报错,但行为不对。

通用排查思路

  1. 在 CI 日志中加 debug 输出,对比本地和 CI 的行为差异
  2. 检查 pom.xml 的 dependency tree:./mvnw dependency:tree
  3. 如果是版本冲突,升级到兼容双方的最新版本

坑 5:集成测试依赖真实数据库

1
Failed to load ApplicationContext

原因:有的测试用了 @SpringBootTest,它会启动完整 Spring 上下文,要求 PostgreSQL、Redis 等服务都在线。CI 的 services 容器虽然提供了数据库,但某些需要额外配置。

方案

  • 纯单元测试(@ExtendWith(MockitoExtension.class))— 毫秒级,不依赖外部服务
  • 集成测试用 Testcontainers — 自动拉取数据库 Docker 镜像,用完即弃
  • GitHub Actions 的 services: 字段 — 声明 CI 环境所需的辅助容器

五、CD —— 自动部署

CI 是”检查”,CD 是”送到线上”。下面是一份简单的自动部署模板,在打 v 开头的 tag 时触发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
name: Deploy

on:
push:
tags:
- 'v*' # 推送 v1.0.0 这种 tag → 自动部署
workflow_dispatch: # 也可以手动触发

jobs:
build-and-push:
name: 构建并推送镜像
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'

- name: 构建 JAR
run: |
cd edumind
chmod +x mvnw
./mvnw package -DskipTests --batch-mode

- name: 构建并推送 Docker 镜像
uses: docker/build-push-action@v6
with:
context: ./edumind
push: true
tags: ghcr.io/${{ github.repository }}/edumind:latest

deploy:
name: SSH 部署到服务器
runs-on: ubuntu-latest
needs: build-and-push
steps:
- name: 远程部署
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd /opt/edumind
docker compose pull app
docker compose up -d --remove-orphans

敏感信息管理

注意上面的 ${{ secrets.DEPLOY_HOST }} 等变量——这些不能写在文件里明文暴露。

在 GitHub 仓库的 Settings → Secrets and variables → Actions 中添加:

Secret 名 内容
DEPLOY_HOST 服务器 IP
DEPLOY_USER SSH 用户名
DEPLOY_SSH_KEY SSH 私钥

六、完整流程一览

全部配置就绪后,你的日常工作流是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
1. 开分支写代码
git checkout -b feature/xxx

2. 写代码 + 写测试 + 本地验证
./mvnw test && npm test

3. 提交 & 推送
git add -A && git commit -m "xxx" && git push

4. 去 GitHub 创建 Pull Request(feature/xxx → master)

5. CI 自动启动:
┌─ Backend Test ── ✅ 53 tests passed
├─ Frontend Test ─ ✅ 12 tests passed
└─ Docker Build ── ✅ image built

6. 全绿 → 通知管理员 review → 合并

7. 打 tag 发布:
git tag v1.0.0 && git push --tags

8. CD 自动部署:
📦 构建镜像 → 🚀 推送到仓库 → 🔄 服务器更新

结语

“在我机器上能跑”是软件开发中最昂贵的一句话。

CI/CD 的价值不在于配置文件的语法有多精妙,而在于它把”能跑”的标准从”你的机器”提高到了”任何一台机器”。每一次红叉,都在替你发现一个上线后才会暴露的问题。

如果你现在有一个还没有 CI 的项目——从今天开始,在 .github/workflows/ 下创建第一个 .yml 文件,只写一个最简单的 ./mvnw test。从那一行开始,你就已经在通往生产级的路上。