ci(ci): 支持本地 CI 并调整开发库凭据
ci / 质量门禁 (push) Canceled after 0s

背景:Gitea 暂未部署 Actions runner,远程 CI 会停留在 queued;本地开发库按约定改用 lingxi / postgres / postgres。

改动:
- 新增 pnpm ci:local,按 CI 工作流顺序执行 compose、格式、Lint、类型、单测、构建、迁移种子、集成、OpenAPI 与 e2e;
- 新增 scripts/e2e.mjs 管理 vite preview 生命周期,e2e 改为单 worker 避免退出挂起;
- openapi:export 生成后自动 Prettier,保持工作区干净;
- compose、.env.example、CI services 数据库凭据统一为 postgres/postgres,库名 lingxi;
- README、AGENTS、A0 报告同步本地 CI 与数据库说明。

影响面:仅本地开发与 CI 执行方式,无业务代码/接口/数据模型变更;本地库已重建为 lingxi/postgres。
回滚方式:git revert 本提交;数据库需按回滚后的 compose 凭据重新初始化。
验证:pnpm ci:local 全绿(158s),format:check、lint、typecheck、openapi:diff 通过。
This commit is contained in:
Stev_Wang committed 2026-11-02 18:14:08 +08:00
1 parent e35637cfd7
commit 68b5ca20f4
10 files changed
+137 -24

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@ NODE_ENV=development
LOG_LEVEL=debug
# ---------- 数据库(PostgreSQL 18) ----------
DATABASE_URL=postgresql://lingxi:lingxi_dev_password@127.0.0.1:15432/lingxi_dev?schema=public
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:15432/lingxi?schema=public
# ---------- Redis(7.4) ----------
REDIS_URL=redis://127.0.0.1:16379
+3 -3
View File
@@ -19,8 +19,8 @@ jobs:
postgres:
image: postgres:18-alpine
env:
POSTGRES_USER: lingxi
POSTGRES_PASSWORD: lingxi_dev_password
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: lingxi_test
ports:
- 5432:5432
@@ -42,7 +42,7 @@ jobs:
env:
NODE_ENV: test
LOG_LEVEL: error
DATABASE_URL: postgresql://lingxi:lingxi_dev_password@127.0.0.1:5432/lingxi_test?schema=public
DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/lingxi_test?schema=public
REDIS_URL: redis://127.0.0.1:6379
JWT_SECRET: ci-only-access-secret
JWT_REFRESH_SECRET: ci-only-refresh-secret
+1 -1
View File
@@ -114,7 +114,7 @@ docs/ PRD / 技术架构 / 立项建议 / 工程规范
| 开发 | `pnpm dev` |
| 构建 | `pnpm build` |
| 质量 | `pnpm lint` · `pnpm lint:fix` · `pnpm format` · `pnpm format:check` · `pnpm typecheck` |
| 测试 | `pnpm test` · `pnpm test:int` · `pnpm test:e2e` |
| 测试 | `pnpm test` · `pnpm test:int` · `pnpm test:e2e` · `pnpm ci:local` |
| 数据库 | `pnpm db:migrate` · `pnpm db:reset`(禁止 `docker compose down -v`) |
| 契约 | `pnpm openapi:diff` |
+4
View File
@@ -79,6 +79,7 @@ pnpm db:seed # 幂等写入 system_configs 基线
pnpm dev # 并行启动 api(3000) / web(5173) / worker
```
- **本地数据库**:库名 `lingxi`,用户 `postgres`,密码 `postgres`(仅开发环境,见 `infra/docker-compose.dev.yaml` 与 `.env.example`)。
- **端口**:为避免与本机已有容器冲突,compose 宿主端口为 PostgreSQL `15432`、Redis `16379`、S3 `19000/19001`,容器内仍是 `5432/6379/9000/9001`;`.env.example` 已按宿主端口给出连接串。
- **对象存储**:当前 Docker 环境拉取不到 `minio/minio` 镜像(registry denied、二进制 410),A0 的 compose 使用 `adobe/s3mock` 并提供 `assets / renders / temp / archive` 四个初始桶;CI 或生产接入真实 MinIO 时只需替换 `infra/docker-compose.dev.yaml` 的 `s3` 服务。
- **API 文档**:启动 api 后访问 `http://127.0.0.1:3000/api/docs`,OpenAPI JSON 在 `openapi/lingxi.openapi.json`。
@@ -90,8 +91,11 @@ pnpm dev # 并行启动 api(3000) / web(5173) / worker
pnpm lint && pnpm typecheck && pnpm test # 本地门禁
pnpm build && pnpm test:e2e # 构建与端到端冒烟
pnpm openapi:export && pnpm openapi:diff # 契约快照与破坏性变更检查
pnpm ci:local # 本地跑完整 CI 门禁
```
> Gitea 暂未部署 Actions runner,推送后远程 CI 会停留在 queued;当前以 `pnpm ci:local` 为准,runner 就绪后 `.gitea/workflows/ci.yml` 会自动接管。
命令与门禁见 [AGENTS.md §6](AGENTS.md) 与 [06-测试与CI规范](docs/工程规范/06-测试与CI规范.md)。当前 UI 采用「顶部模块菜单 + 左侧功能菜单」布局,视觉方向为 **A · 云白蓝(传统企业后台 + 现代浅色质感)**。
---
@@ -10,7 +10,7 @@
| --- | --- | --- |
| A0-INF-01 Monorepo / 工具链 | 完成 | pnpm workspace + Turborepo;TS strict、ESLint 9 flat + Prettier + commitlint + lint-staged + husky;`.nvmrc` / `engines` / `packageManager` / preinstall 仅允许 pnpm;目录与 AGENTS.md §5 一致 |
| A0-INF-02 本地依赖与迁移种子 | 完成(种子范围受限,见 §4.4) | `infra/docker-compose.dev.yaml`(PG 18 / Redis 7.4 / S3 兼容服务)+ 健康检查;`.env.example`;Prisma 初始化与首版迁移;`system_configs` 幂等种子 |
| A0-INF-03 CI 与质量门禁 | 完成 | `.gitea/workflows/ci.yml`:install → format → lint → typecheck → test → build → migrate → test:int → openapi:export/diff → e2e;产物归档;失败阻断 |
| A0-INF-03 CI 与质量门禁 | 完成 | `.gitea/workflows/ci.yml`:install → format → lint → typecheck → test → build → migrate → test:int → openapi:export/diff → e2e;新增 `pnpm ci:local` 在无 runner 时本地执行同一门禁;失败阻断 |
| A0-BE-01 API 骨架 | 完成 | NestJS 11 + Fastify;Pino + `trace_id`;统一错误体 `{code,message,details,trace_id}`;`GET /health`;`/api/docs` 与 OpenAPI JSON;Zod 管道;CORS / Helmet |
| A0-BE-02 Prisma 基线 | 完成 | `PrismaService` 封装;`outbox_events` / `idempotency_keys` / `system_configs`;ULID `char(26)`、软删/时间字段约定;首版迁移可重复执行 |
| A0-SH-01 shared 包 | 完成 | 20 个错误码(40001–50003)、权限点常量与 `模块:动作` 规范、ULID、游标分页、时间/命名工具、通用 Zod schema;前后端共同引用 |
@@ -50,7 +50,7 @@
### 4.1 宿主端口(环境适配)
本机已有容器占用 5432 / 6379,A0 compose 宿主端口改为 **15432(PG)/ 16379(Redis)/ 19000–19001(S3)**,容器内端口不变。`.env.example` 已按宿主端口提供连接串;CI 使用 GitHub Actions services 的标准端口。
本机已有容器占用 5432 / 6379,A0 compose 宿主端口改为 **15432(PG)/ 16379(Redis)/ 19000–19001(S3)**,容器内端口不变。`.env.example` 已按宿主端口提供连接串;本地数据库为 `lingxi`,用户 `postgres`,密码 `postgres`(仅开发环境)。CI services 使用标准端口,凭据同为 `postgres/postgres`。
### 4.2 对象存储镜像不可得(环境适配)
@@ -76,6 +76,12 @@ Prisma CLI 只自动读取 `.env`,不读取 `.env.local`。本地需执行 `cp
npm 上 `oasdiff` 为占位包(0.0.1-security),A0 暂用自研 `scripts/openapi-diff.mjs` 做路径/操作/字段级破坏性检查;CI 可后续替换为 oasdiff 官方二进制,命令仍为 `pnpm openapi:diff`。
### 4.9 本地 CI 与数据库凭据
当前离线 Gitea 未部署 Actions runner,推送后远程 CI 会停留在 `queued`。A0 增加 `pnpm ci:local`:按 `.gitea/workflows/ci.yml` 的顺序在本地执行 compose、格式、Lint、类型、单测、构建、迁移与种子、集成、OpenAPI 契约与 e2e,全部通过才退出 0;runner 就绪后远程工作流可直接接管。
本地开发数据库已按约定改为:库名 `lingxi`、用户 `postgres`、密码 `postgres`;compose、`.env.example`、CI services 三处保持一致。
### 4.8 前端视觉方向
已按 oil-ui-pro 流程产出两轮方案:
@@ -94,7 +100,7 @@ npm 上 `oasdiff` 为占位包(0.0.1-security),A0 暂用自研 `scripts/op
2. 种子业务数据待 A1 / A3 / A4 / B0 表就绪后补齐(Q-A0-01)。
3. MinIO 本地替代方案需在具备镜像的环境回归;生产 compose 与 CI 镜像另行处理。
4. Web 主包约 599 kB(Semi 全量 CSS 689 kB),A0 接受;M1 前做路由级拆分与 Semi 按需样式。
5. CI 首次运行需在 Gitea Actions 上验证 runner 是否支持 services 与 Playwright 浏览器安装。
5. 远程 CI 待 Gitea Actions runner 就绪后验证 services 与 Playwright 浏览器安装;当前以 `pnpm ci:local` 为准(已实测通过)。
## 6. 下一步(A1)
+4 -4
View File
@@ -9,9 +9,9 @@ services:
image: postgres:18-alpine
container_name: lingxi-pg
environment:
POSTGRES_USER: lingxi
POSTGRES_PASSWORD: lingxi_dev_password
POSTGRES_DB: lingxi_dev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: lingxi
TZ: Asia/Shanghai
ports:
- '15432:5432'
@@ -19,7 +19,7 @@ services:
# PG18+ 镜像要求挂载 /var/lib/postgresql(数据放在版本子目录,便于 pg_upgrade)
- lingxi-pg-data:/var/lib/postgresql
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U lingxi -d lingxi_dev']
test: ['CMD-SHELL', 'pg_isready -U postgres -d lingxi']
interval: 5s
timeout: 3s
retries: 20
+3 -2
View File
@@ -20,14 +20,15 @@
"typecheck": "turbo run typecheck && tsc -p tsconfig.json --noEmit",
"test": "turbo run test",
"test:int": "turbo run test:int",
"test:e2e": "turbo run build && playwright test",
"test:e2e": "turbo run build && node scripts/e2e.mjs",
"ci:local": "node scripts/ci-local.mjs",
"compose:up": "docker compose -f infra/docker-compose.dev.yaml up -d --wait",
"compose:down": "docker compose -f infra/docker-compose.dev.yaml down",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:reset": "prisma migrate reset --force",
"db:seed": "tsx prisma/seed.ts",
"openapi:export": "tsx --tsconfig apps/api/tsconfig.json apps/api/src/scripts/export-openapi.ts && openapi-typescript openapi/lingxi.openapi.json -o apps/web/src/api/generated/openapi.d.ts",
"openapi:export": "tsx --tsconfig apps/api/tsconfig.json apps/api/src/scripts/export-openapi.ts && openapi-typescript openapi/lingxi.openapi.json -o apps/web/src/api/generated/openapi.d.ts && prettier --write apps/web/src/api/generated/openapi.d.ts",
"openapi:diff": "node scripts/openapi-diff.mjs",
"postinstall": "prisma generate"
},
+2 -10
View File
@@ -6,7 +6,8 @@ export default defineConfig({
testDir: './e2e',
timeout: 60_000,
expect: { timeout: 10_000 },
fullyParallel: true,
fullyParallel: false,
workers: 1,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI
@@ -24,13 +25,4 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'], channel },
},
],
webServer: {
command: 'node node_modules/vite/bin/vite.js preview --host 127.0.0.1 --port 4173 --strictPort',
cwd: 'apps/web',
url: 'http://127.0.0.1:4173/login',
reuseExistingServer: false,
timeout: 60_000,
stdout: 'ignore',
stderr: 'pipe',
},
});
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env node
/**
* 本地 CI:在无 Gitea Actions runner 时,按 .gitea/workflows/ci.yml 的门禁顺序执行。
* 用法:pnpm ci:local(推荐通过 ./.tools/pnpm ci:local 锁定 pnpm 10)
*/
import { spawnSync } from 'node:child_process';
const pnpm = process.env.npm_execpath ? [process.execPath, process.env.npm_execpath] : ['pnpm'];
/** 每一步可包含多条 pnpm 命令,任一失败立即退出。 */
const steps = [
{ label: '启动本地依赖(PG / Redis / S3)', commands: [['compose:up']] },
{ label: '格式化检查', commands: [['format:check']] },
{ label: 'Lint', commands: [['lint']] },
{ label: '类型检查', commands: [['typecheck']] },
{ label: '单元测试', commands: [['test']] },
{ label: '构建', commands: [['build']] },
{
label: '数据库迁移与种子',
commands: [['exec', 'prisma', 'migrate', 'deploy'], ['db:seed']],
},
{ label: '集成测试', commands: [['test:int']] },
{ label: 'OpenAPI 导出与契约检查', commands: [['openapi:export'], ['openapi:diff']] },
{ label: '端到端测试', commands: [['test:e2e']] },
];
function runPnpm(args) {
const result = spawnSync(pnpm[0], [...pnpm.slice(1), ...args], {
env: process.env,
stdio: 'inherit',
});
if (result.error) {
console.error(`\n执行 pnpm ${args.join(' ')} 失败:${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) {
console.error(`\n门禁失败:pnpm ${args.join(' ')}(exit ${result.status ?? 1})`);
process.exit(result.status ?? 1);
}
}
const startedAt = Date.now();
for (const [index, step] of steps.entries()) {
console.log(`\n[${index + 1}/${steps.length}] ${step.label}`);
for (const args of step.commands) {
runPnpm(args);
}
}
const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
console.log(`\n本地 CI 全部门禁通过,用时 ${seconds}s。`);
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env node
/**
* 端到端测试启动器:先起 vite preview,再跑 Playwright,结束后确保关闭预览服务。
* 不使用 Playwright webServer,避免 vite preview 在部分环境下退出挂起。
* 用法:pnpm test:e2e(会先执行 build)
*/
import { spawn, spawnSync } from 'node:child_process';
import { setTimeout as delay } from 'node:timers/promises';
const baseUrl = 'http://127.0.0.1:4173';
const pnpm = process.env.npm_execpath ? [process.execPath, process.env.npm_execpath] : ['pnpm'];
function runPnpm(args, options = {}) {
return spawnSync(pnpm[0], [...pnpm.slice(1), ...args], {
env: process.env,
stdio: 'inherit',
...options,
});
}
const server = spawn(pnpm[0], [...pnpm.slice(1), '--filter', '@lingxi/web', 'preview'], {
env: process.env,
stdio: 'ignore',
detached: true,
});
async function waitForServer(url, timeoutMs = 60_000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch(url);
if (response.ok) return;
} catch {
// 预览服务尚未就绪,继续重试。
}
await delay(500);
}
throw new Error(`预览服务在 ${timeoutMs}ms 内未就绪:${url}`);
}
let exitCode = 1;
try {
await waitForServer(`${baseUrl}/login`);
const result = runPnpm(['exec', 'playwright', 'test']);
exitCode = result.status ?? 1;
} finally {
try {
process.kill(-server.pid, 'SIGTERM');
} catch {
// 进程可能已退出。
}
await delay(1_000);
try {
process.kill(-server.pid, 'SIGKILL');
} catch {
// 进程可能已退出。
}
}
process.exit(exitCode);