From 68b5ca20f47f62b34b6ead5f6a9570be7f345987 Mon Sep 17 00:00:00 2001 From: Stev_Wang <304865932@qq.com> Date: Mon, 2 Nov 2026 18:14:08 +0800 Subject: [PATCH] =?UTF-8?q?ci(ci):=20=E6=94=AF=E6=8C=81=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=20CI=20=E5=B9=B6=E8=B0=83=E6=95=B4=E5=BC=80=E5=8F=91=E5=BA=93?= =?UTF-8?q?=E5=87=AD=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景: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 通过。 --- .env.example | 2 +- .gitea/workflows/ci.yml | 6 +- AGENTS.md | 2 +- README.md | 4 ++ ...灵犀_A0工程骨架与契约基线_实施报告_V0.1.md | 12 +++- infra/docker-compose.dev.yaml | 8 +-- package.json | 5 +- playwright.config.ts | 12 +--- scripts/ci-local.mjs | 50 ++++++++++++++++ scripts/e2e.mjs | 60 +++++++++++++++++++ 10 files changed, 137 insertions(+), 24 deletions(-) create mode 100644 scripts/ci-local.mjs create mode 100644 scripts/e2e.mjs diff --git a/.env.example b/.env.example index b266ce6..6346dbc 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7539778..f287843 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 24aa7ce..5615aac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` | diff --git a/README.md b/README.md index e84ee3c..27a4782 100644 --- a/README.md +++ b/README.md @@ -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 · 云白蓝(传统企业后台 + 现代浅色质感)**。 --- diff --git a/docs/开发计划/灵犀_A0工程骨架与契约基线_实施报告_V0.1.md b/docs/开发计划/灵犀_A0工程骨架与契约基线_实施报告_V0.1.md index 24cdb85..2e92cd2 100644 --- a/docs/开发计划/灵犀_A0工程骨架与契约基线_实施报告_V0.1.md +++ b/docs/开发计划/灵犀_A0工程骨架与契约基线_实施报告_V0.1.md @@ -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) diff --git a/infra/docker-compose.dev.yaml b/infra/docker-compose.dev.yaml index 4a2c6d4..787aac5 100644 --- a/infra/docker-compose.dev.yaml +++ b/infra/docker-compose.dev.yaml @@ -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 diff --git a/package.json b/package.json index 52f043e..ec28915 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/playwright.config.ts b/playwright.config.ts index 25ccf46..c9427de 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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', - }, }); diff --git a/scripts/ci-local.mjs b/scripts/ci-local.mjs new file mode 100644 index 0000000..e0c245e --- /dev/null +++ b/scripts/ci-local.mjs @@ -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。`); diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs new file mode 100644 index 0000000..f83c6ac --- /dev/null +++ b/scripts/e2e.mjs @@ -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);