Skip to content

Commit 0e96ec6

Browse files
committed
ci: 添加 GitHub Actions CI/CD 工作流
1 parent 508dc48 commit 0e96ec6

File tree

7 files changed

+734
-34
lines changed

7 files changed

+734
-34
lines changed

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
## 变更说明
2+
3+
描述这次 PR 做了什么。
4+
5+
## 检查清单
6+
7+
- [ ] pnpm build 通过
8+
- [ ] 单元测试通过(pnpm test:unit
9+
- [ ] 没有降低测试覆盖率
10+
- [ ] 没有引入新的 lint 错误
11+
- [ ] 如有 API 变更,已更新 docs/API.md

.github/workflows/ci.yml

Lines changed: 111 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,143 @@
11
name: CI
22

33
on:
4-
workflow_dispatch: # 仅手动触发,节省 Actions 配额
4+
push:
5+
branches: [main, develop]
6+
pull_request:
7+
branches: [main]
58

69
jobs:
710
test:
8-
name: Unit & Integration Tests
911
runs-on: ubuntu-latest
1012

13+
services:
14+
mariadb:
15+
image: mariadb:10.11
16+
env:
17+
MARIADB_ROOT_PASSWORD: test
18+
MARIADB_DATABASE: leverage_test
19+
MARIADB_USER: leverage
20+
MARIADB_PASSWORD: test
21+
ports: ["3306:3306"]
22+
options: --health-cmd="healthcheck.sh --connect --innodb-buffer-pool-pages-free=0" --health-interval=10s --health-timeout=5s --health-retries=10
23+
24+
redis:
25+
image: redis:7-alpine
26+
ports: ["6379:6379"]
27+
options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=5
28+
29+
env:
30+
NODE_ENV: test
31+
PORT: 3000
32+
SKIP_INIT: true
33+
34+
# Database
35+
DB_HOST: 127.0.0.1
36+
DB_PORT: 3306
37+
DB_DATABASE: leverage_test
38+
DB_USERNAME: leverage
39+
DB_PASSWORD: test
40+
41+
# Redis
42+
REDIS_HOST: 127.0.0.1
43+
REDIS_PORT: 6379
44+
45+
# JWT
46+
JWT_ACCESS_SECRET: ${{ secrets.JWT_ACCESS_SECRET }}
47+
JWT_REFRESH_SECRET: ${{ secrets.JWT_REFRESH_SECRET }}
48+
JWT_ACCESS_EXPIRES_IN: 15m
49+
JWT_REFRESH_EXPIRES_IN: 7d
50+
51+
# App
52+
BASE_URL: http://localhost:3000
53+
MAX_SUBMISSION_PER_MINUTE: 10
54+
55+
# Heng Judge (mock in CI)
56+
HENG_BASE_URL: http://localhost:5000
57+
HENG_AK: ci_access_key
58+
HENG_SK: ci_secret_key
59+
HENG_ALLOW_INSECURE_TLS: false
60+
61+
# Init
62+
INIT_SA_USERNAME: admin
63+
INIT_SA_PASSWORD: Admin@123456
64+
1165
steps:
1266
- uses: actions/checkout@v4
1367

14-
- name: Setup Node.js
15-
uses: actions/setup-node@v4
68+
- uses: pnpm/action-setup@v3
1669
with:
17-
node-version: '22'
70+
version: 9
1871

19-
- name: Setup pnpm
20-
uses: pnpm/action-setup@v4
72+
- uses: actions/setup-node@v4
2173
with:
22-
version: latest
74+
node-version: "20"
75+
cache: pnpm
2376

24-
- name: Install dependencies
25-
run: pnpm install
77+
- run: pnpm install
2678

27-
- name: Run unit tests
79+
- run: pnpm build
80+
81+
- name: Unit tests
2882
run: pnpm test:unit --coverage
2983

30-
- name: Run integration tests
84+
- name: Integration tests
3185
run: pnpm test:integration
86+
env:
87+
DB_TYPE: sqlite
3288

3389
- name: Upload coverage
3490
uses: codecov/codecov-action@v4
91+
if: always()
3592
with:
36-
files: ./coverage/lcov.info
37-
fail_ci_if_error: false
38-
continue-on-error: true
93+
token: ${{ secrets.CODECOV_TOKEN }}
94+
95+
- name: Lint
96+
run: pnpm lint
3997

40-
lint:
41-
name: Lint
98+
e2e:
4299
runs-on: ubuntu-latest
100+
needs: test
101+
102+
env:
103+
NODE_ENV: test
104+
PORT: 3000
105+
SKIP_INIT: true
106+
DB_HOST: 127.0.0.1
107+
DB_PORT: 3306
108+
DB_DATABASE: leverage_test
109+
DB_USERNAME: leverage
110+
DB_PASSWORD: test
111+
REDIS_HOST: 127.0.0.1
112+
REDIS_PORT: 6379
113+
JWT_ACCESS_SECRET: ${{ secrets.JWT_ACCESS_SECRET }}
114+
JWT_REFRESH_SECRET: ${{ secrets.JWT_REFRESH_SECRET }}
115+
JWT_ACCESS_EXPIRES_IN: 15m
116+
JWT_REFRESH_EXPIRES_IN: 7d
117+
BASE_URL: http://localhost:3000
118+
MAX_SUBMISSION_PER_MINUTE: 10
119+
HENG_BASE_URL: http://localhost:5000
120+
HENG_AK: ci_access_key
121+
HENG_SK: ci_secret_key
122+
HENG_ALLOW_INSECURE_TLS: false
123+
INIT_SA_USERNAME: admin
124+
INIT_SA_PASSWORD: Admin@123456
125+
43126
steps:
44127
- uses: actions/checkout@v4
45-
- uses: actions/setup-node@v4
46-
with:
47-
node-version: '22'
48-
- uses: pnpm/action-setup@v4
128+
129+
- uses: pnpm/action-setup@v3
49130
with:
50-
version: latest
51-
- run: pnpm install
52-
- run: pnpm lint
131+
version: 9
53132

54-
build:
55-
name: Build
56-
runs-on: ubuntu-latest
57-
steps:
58-
- uses: actions/checkout@v4
59133
- uses: actions/setup-node@v4
60134
with:
61-
node-version: '22'
62-
- uses: pnpm/action-setup@v4
63-
with:
64-
version: latest
135+
node-version: "20"
136+
cache: pnpm
137+
65138
- run: pnpm install
66-
- run: pnpm build
139+
140+
- name: E2E tests (testcontainers)
141+
run: pnpm test:e2e
142+
env:
143+
TESTCONTAINERS_RYUK_DISABLED: true

.github/workflows/deploy.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: Deploy
2+
3+
on:
4+
push:
5+
tags: ["v*"]
6+
7+
jobs:
8+
build-docker:
9+
runs-on: ubuntu-latest
10+
11+
permissions:
12+
contents: read
13+
packages: write
14+
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- uses: docker/setup-buildx-action@v3
19+
20+
- uses: docker/login-action@v3
21+
with:
22+
registry: ghcr.io
23+
username: ${{ github.actor }}
24+
password: ${{ secrets.GITHUB_TOKEN }}
25+
26+
- uses: docker/build-push-action@v5
27+
with:
28+
context: .
29+
push: true
30+
tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}
31+
labels: |
32+
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
33+
org.opencontainers.image.revision=${{ github.sha }}
34+
cache-from: type=gha
35+
cache-to: type=gha,mode=max

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
2323
"test:e2e": "jest --config jest.e2e.config.js --forceExit --testTimeout=120000",
2424
"test:e2e:legacy": "jest --config ./test/jest-e2e.json",
25+
"seed": "ts-node -r tsconfig-paths/register scripts/seed.ts",
26+
"seed:clear": "ts-node -r tsconfig-paths/register scripts/clear-seed.ts",
2527
"migration:generate": "typeorm-ts-node-commonjs migration:generate -d data-source.ts",
2628
"migration:run": "typeorm-ts-node-commonjs migration:run -d data-source.ts",
2729
"migration:revert": "typeorm-ts-node-commonjs migration:revert -d data-source.ts",

scripts/README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
## 开发种子数据
2+
3+
### 前置条件
4+
5+
- 已配置好 `.env`(数据库 + Redis)
6+
- 已运行 `pnpm migration:run``synchronize` 已初始化表结构
7+
8+
### 注入测试数据
9+
10+
```bash
11+
pnpm seed
12+
```
13+
14+
### 清理测试数据
15+
16+
```bash
17+
pnpm seed:clear
18+
```
19+
20+
### 测试账号
21+
22+
| 类型 | 用户名 | 密码 |
23+
|----------|-----------------|---------------|
24+
| 普通用户 | user1 ~ user10 | Test@123456 |
25+
| 管理员 | testadmin | Admin@123456 |
26+
| 超管 | 见 InitModule |`.env` 或数据库 |
27+
28+
> 超管账号(sa)由 `InitModule` 在应用启动时自动创建,密码来自环境变量 `INIT_SA_PASSWORD`,不由种子脚本管理。
29+
30+
### 种子数据内容
31+
32+
| 类型 | 内容 |
33+
|------|------|
34+
| 普通用户 | user1 - user10(权限 `user`|
35+
| 管理员 | testadmin(权限 `admin`|
36+
| 标签 | 动态规划、图论、字符串 |
37+
| 题目 | SEED-1001 A+B Problem |
38+
| | SEED-1002 斐波那契数列 |
39+
| | SEED-1003 快速排序 |
40+
| | SEED-1004 字符串反转 |
41+
| | SEED-1005 最短路径(Dijkstra) |
42+
| 竞赛 | SEED 测试竞赛(含 1001-1003) |
43+
| 课程 | SEED 测试课程(含全部 5 题) |
44+
45+
### 幂等性
46+
47+
两个脚本均为**幂等**操作:
48+
49+
- `pnpm seed` — 已存在的记录自动跳过,可重复运行
50+
- `pnpm seed:clear` — 仅删除 SEED 前缀数据,不影响 sa 账号及其他数据

scripts/clear-seed.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import 'reflect-metadata'
2+
import { DataSource, In, Like } from 'typeorm'
3+
import { config } from 'dotenv'
4+
import * as path from 'path'
5+
6+
config({ path: path.resolve(__dirname, '../.env') })
7+
8+
import { User } from '../src/database/entities/user.entity'
9+
import { Problem } from '../src/database/entities/problem.entity'
10+
import { Contest } from '../src/database/entities/contest.entity'
11+
import { Course } from '../src/database/entities/course.entity'
12+
import { Tag } from '../src/database/entities/tag.entity'
13+
import { ContestProblem } from '../src/database/entities/contest-problem.entity'
14+
import { CourseProblem } from '../src/database/entities/course-problem.entity'
15+
16+
const AppDataSource = new DataSource({
17+
type: 'mysql',
18+
host: process.env.DB_HOST ?? 'localhost',
19+
port: parseInt(process.env.DB_PORT ?? '3306', 10),
20+
database: process.env.DB_DATABASE,
21+
username: process.env.DB_USERNAME,
22+
password: process.env.DB_PASSWORD,
23+
entities: [User, Problem, Contest, Course, Tag, ContestProblem, CourseProblem],
24+
synchronize: false,
25+
})
26+
27+
async function main() {
28+
console.log('🧹 开始清理种子数据...')
29+
await AppDataSource.initialize()
30+
console.log('✅ 数据库连接成功')
31+
32+
const userRepo = AppDataSource.getRepository(User)
33+
const problemRepo = AppDataSource.getRepository(Problem)
34+
const contestRepo = AppDataSource.getRepository(Contest)
35+
const courseRepo = AppDataSource.getRepository(Course)
36+
const contestProblemRepo = AppDataSource.getRepository(ContestProblem)
37+
const courseProblemRepo = AppDataSource.getRepository(CourseProblem)
38+
39+
// ── 1. 清理竞赛题目关联(先删关联,再删竞赛)────────────────────────────────
40+
console.log('\n🏆 清理竞赛...')
41+
const seedContests = await contestRepo.find({ where: { name: Like('SEED%') } })
42+
if (seedContests.length > 0) {
43+
const contestIds = seedContests.map((c) => c.id)
44+
const cpDeleted = await contestProblemRepo.delete({ contestId: In(contestIds) })
45+
console.log(` ✓ 删除竞赛题目关联 ${cpDeleted.affected ?? 0} 条`)
46+
const cDeleted = await contestRepo.delete({ name: Like('SEED%') })
47+
console.log(` ✓ 删除竞赛 ${cDeleted.affected ?? 0} 条`)
48+
} else {
49+
console.log(' - 无 SEED 竞赛,跳过')
50+
}
51+
52+
// ── 2. 清理课程题目关联(先删关联,再删课程)────────────────────────────────
53+
console.log('\n📚 清理课程...')
54+
const seedCourses = await courseRepo.find({ where: { name: Like('SEED%') } })
55+
if (seedCourses.length > 0) {
56+
const courseIds = seedCourses.map((c) => c.id)
57+
const cpDeleted = await courseProblemRepo.delete({ courseId: In(courseIds) })
58+
console.log(` ✓ 删除课程题目关联 ${cpDeleted.affected ?? 0} 条`)
59+
const cDeleted = await courseRepo.delete({ name: Like('SEED%') })
60+
console.log(` ✓ 删除课程 ${cDeleted.affected ?? 0} 条`)
61+
} else {
62+
console.log(' - 无 SEED 课程,跳过')
63+
}
64+
65+
// ── 3. 清理题目 ────────────────────────────────────────────────────────────
66+
console.log('\n📝 清理题目...')
67+
const pDeleted = await problemRepo.delete({ prefix: 'SEED' })
68+
console.log(` ✓ 删除题目 ${pDeleted.affected ?? 0} 条`)
69+
70+
// ── 4. 清理测试用户(保留 sa 账号)───────────────────────────────────────────
71+
console.log('\n👤 清理测试用户...')
72+
const seedUsernames = [
73+
'user1', 'user2', 'user3', 'user4', 'user5',
74+
'user6', 'user7', 'user8', 'user9', 'user10',
75+
'testadmin',
76+
]
77+
const uDeleted = await userRepo.delete({ username: In(seedUsernames) })
78+
console.log(` ✓ 删除用户 ${uDeleted.affected ?? 0} 个`)
79+
80+
await AppDataSource.destroy()
81+
console.log('\n✅ 种子数据清理完成!(sa 账号已保留)')
82+
}
83+
84+
main().catch((err) => {
85+
console.error('❌ 清理失败:', err)
86+
process.exit(1)
87+
})

0 commit comments

Comments
 (0)