Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -340,16 +340,47 @@ jobs:
if-no-files-found: warn
retention-days: 7

# Build actual shipping Docker targets and recover disposable synthetic data.
# No registry publication, deployment credentials or production environment.
container-rehearsal:
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
env:
ONEERP_CONTAINER_REHEARSAL: '1'
steps:
- name: Checkout exact source revision
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Build, start and recover isolated application containers
run: node scripts/ci/container-rehearsal.mjs
- name: Upload sanitized container rehearsal evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: container-rehearsal-report
path: ${{ runner.temp }}/container-rehearsal-report.json
if-no-files-found: error
retention-days: 7

# Retain the existing required check name and require ALL independent jobs.
validate:
if: always()
needs: [security-audit, validation-tests, http-journey, browser-workspace]
needs: [security-audit, validation-tests, http-journey, browser-workspace, container-rehearsal]
runs-on: ubuntu-latest
steps:
- name: Require security, validation, HTTP and browser acceptance success
- name: Require security, validation, HTTP, browser and container recovery success
env:
SECURITY_RESULT: ${{ needs.security-audit.result }}
VALIDATION_RESULT: ${{ needs.validation-tests.result }}
HTTP_RESULT: ${{ needs.http-journey.result }}
BROWSER_RESULT: ${{ needs.browser-workspace.result }}
run: test "$SECURITY_RESULT" = success && test "$VALIDATION_RESULT" = success && test "$HTTP_RESULT" = success && test "$BROWSER_RESULT" = success
CONTAINER_RESULT: ${{ needs.container-rehearsal.result }}
run: test "$SECURITY_RESULT" = success && test "$VALIDATION_RESULT" = success && test "$HTTP_RESULT" = success && test "$BROWSER_RESULT" = success && test "$CONTAINER_RESULT" = success
32 changes: 32 additions & 0 deletions docs/CI_CONTAINER_REHEARSAL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# 当前提交容器交付演练 / Current-head container rehearsal

`scripts/ci/container-rehearsal.mjs` 在 GitHub Actions 的一次性 Linux runner 上,使用当前干净 checkout 的现有 Dockerfile 构建 API `production`、API `builder`(migration/init)及 Web `production` 镜像。它不推送镜像、不调用 SSH、不部署生产、不合并分支。

运行入口(只在一次性 GitHub Actions runner 执行):

```bash
CI=true GITHUB_ACTIONS=true ONEERP_CONTAINER_REHEARSAL=1 \
CONTAINER_REHEARSAL_REPORT="$RUNNER_TEMP/container-rehearsal-report.json" \
node scripts/ci/container-rehearsal.mjs
```

工作流应 checkout PR 的精确 head,授予 `contents: read`,设置 60 分钟 job 超时,并把该 job 纳入最终 `validate` 必须成功的依赖。脚本不需要主机 npm 安装;HTTP 验收脚本只使用 Node 22 内置 API。

## 实际验证边界

1. 记录 `git rev-parse HEAD`、tree 和实际本地 image ID;开始与结束都要求 source checkout 干净。
2. 顺序构建三个应用镜像以及现有 `scripts/ci/minio.Dockerfile` 的 CI 专用固定源码 MinIO 镜像。MinIO **不是生产镜像替代方案,未解决 #35**。
3. 用随机 `oneerp-ci-<24 hex>` 项目创建 PostgreSQL、Redis、MinIO 私有网络和卷。仅 API/Web 使用随机回环端口。依赖 readiness 通过后,实际执行生产 migration/init 镜像。
4. API/Web 使用生产镜像启动;验证运行镜像 ID,读取 Web `/login` 及其引用的静态资源,验证 Web API proxy health 和登录。库存贸易 13 步与制造 12 步通过真实 HTTP 容器边界执行。
5. 调用 `recovery-rehearsal.mjs`:备份合成 PostgreSQL 和上传对象,使用同一组实际运行 image ID 在独立空卷中恢复;比较业务行、文件身份和下载字节。恢复不重新迁移或初始化。
6. 默认删除本次随机项目的容器、网络和卷,删除临时凭据/原始验收报告;仅输出允许字段构成的 JSON 证据。清理失败也使门禁失败。

脚本拒绝缺失显式 CI 开关、非默认 Docker host/context、非本地 runner socket、脏 checkout 及非回环发布端口。随机密码通过 GitHub mask 注册,临时 `.env` 权限为 `0600`。不要上传临时目录、Docker config/inspect 全文、容器日志、SQL、归档、signed URL 或 token。工作流只上传 `container-rehearsal-report.json`。

## 资源与证据解释

标准 Ubuntu runner 顺序构建,运行时每个 stack 内存上限合计约 3 GiB(一次性 migration 上限 1 GiB;恢复阶段最多两个 stack)。构建、镜像和 BuildKit 缓存需要额外内存/磁盘;预留约 10–15 GB 磁盘作为初始预算,实际消耗以 runner 为准。每个命令有超时,job 的 60 分钟限额是最终上界。没有全局 prune、共享卷删除或镜像发布;runner 销毁负责最终镜像/缓存回收。

本地无 Docker daemon 时,`node --test scripts/ci/container-rehearsal.test.mjs` 只证明命令编排、防误用、清理和证据脱敏;它不证明镜像能构建或服务能启动。只有精确提交的 GitHub 容器 job 通过后,才能报告该提交的真实构建、25 步 HTTP 与合成恢复已通过。证据 JSON 中的本地 image ID 是本次构建身份,不是可供生产拉取的 registry digest。

该结果仍不代替目标生产环境的 MinIO 分发治理、TLS/唯一凭据、异地备份、生产 RPO/RTO、真实数据与角色验收、库存/财务/运维签字。生产批准继续使用 `PRODUCTION_READINESS.md`、`HA_LITE_RUNBOOK.md` 与 `GO_LIVE_CHECKLIST.md`。
30 changes: 30 additions & 0 deletions docs/CONTROLLED_TRIAL_DELIVERY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# 受控试运行交付

本轮把当前源代码的容器构建、业务验收与隔离恢复接到同一个 CI 门禁。交付状态以对应 PR 精确 head 的运行记录为准;本文件不是生产上线批准。

## 验收入口

GitHub Actions 的 `CI / container-rehearsal` 从 PR head 构建现有 API production、API builder(迁移)和 Web production Docker target。所有服务及恢复副本都使用随机的 `oneerp-ci-*` 项目和临时凭据;端口只绑定 runner 的 loopback。

执行顺序:构建镜像 → 初始化空数据库 → 启动 API/Web → 检查登录页、静态资源和 API 代理 → 13 步库存贸易与 12 步制造 HTTP 链路 → 备份 PostgreSQL/MinIO → 在新卷中恢复并验证。恢复复用正在运行的镜像 ID,不重新执行管理员初始化,不覆盖源数据。

下载该次运行的 `container-rehearsal-report` artifact,可核对源提交、源码树、镜像 ID、各步骤状态、恢复表记录数和文件校验结果。任何失败都阻断 aggregate validate。模拟 Docker 的脚本测试不等于真实容器运行通过。

此演练仅允许 GitHub Actions 的一次性环境,不能把 CI 标志加到生产服务器后运行。临时数据库、归档、凭据和完整容器日志不作为 artifact 发布。

恢复组件独立执行 `pg_dump`、`tar` 和 `psql`,不等于已经验收运维用的 `backup.*`、`restore.*` 脚本。它在合成业务写入结束、事件队列排空后归档;不证明并发写入中的跨存储一致性。恢复 Redis 使用新卷,原有会话与缓存连续性不在本次验收范围。

## 集成和部署边界

- 保留现有审查栈的提交历史;通过精确 head 验证后,再固定独立的 `release/controlled-trial-*` 分支作为试运行基线。
- 主分支推送可能触发镜像发布和服务器部署。创建试运行分支与 Draft PR 不代表已合并或已部署。
- CI 的 MinIO 源码构建仅用于测试;生产分发问题 #35 仍需可访问、经过版本治理的生产镜像。不能把这份旧版 CI 镜像直接发布为生产方案。
- 当前恢复演练使用合成数据,证明的是当前镜像及数据库/对象文件恢复链路;真实目标的 TLS、异地备份、恢复时长与业务签字仍须独立验证。
- 已有数据库的切换必须先停止写入并排空旧事件消费者,尤其是 #41 会计事件来源字段变更。不能在旧消费者运行时直接迁移,再用新版本重放历史事件。
- 回退应同时固定应用镜像、Compose/脚本版本、数据库兼容性与备份。仅切换旧 image tag 不能证明数据库可回退。

## 实际环境接入所需信息

目标环境地址与现有访问方式、部署开关及环境保护状态、生产对象存储镜像/服务选择,以及负责验收的业务角色。未能通过当前工具读取 GitHub Secrets/Variables,不等于这些配置不存在;不得为了验证其存在而打印凭据或触发部署。

接入之后应先在隔离试运行目标验证当前固定版本,再按业务清单验收采购、销售、库存、制造、工程文档和财务边界。生产切换需保留停写、备份、恢复与回退记录。
4 changes: 3 additions & 1 deletion docs/PRODUCTION_READINESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ Deploy workflow 会校验部署变量格式:`DEPLOY_PATH` 必须是无空格

远端部署目录必须保留生产 `.env`;workflow 会在部署前同步当前 commit 的 `docker-compose.prod.yml`、`scripts/audit-prod-config.sh` 和 `scripts/deploy-check.sh`,并在拉取镜像前审计远端 `.env`。部署完成后会自动执行远端 `scripts/deploy-check.sh`;`scripts/prod-smoke.*` 和 `scripts/business-acceptance.*` 仍需在受控试运行前单独执行并留存报告。

`scripts/deploy-check.*` 不只检查 Docker Compose 命令可执行,还会把 `exited`、`unhealthy`、`restarting`、`dead` 等服务状态视为失败。
`scripts/deploy-check.*` 会从所选 Compose 配置确认 `api`、`web`、`db`、`redis`、`minio` 五个服务已定义,并逐容器检查它们都在运行;配置了 healthcheck 的容器必须为 `healthy`。检查会等待最多 60 秒(可用 `DEPLOY_CHECK_TIMEOUT_SECONDS` 调整,范围 0–900 秒),`starting` 和尚未创建的容器会在期限内重试;超时、缺失服务、已退出或 unhealthy 容器会失败。若所选配置定义了 `migrate`,还要求其容器以退出码 0 完成;未定义 migrate 时跳过此项。Compose 文件中的 profile-only 服务(例如 `with-nginx` 下的 `nginx`)不会成为必需服务。

Shell 脚本使用 `COMPOSE_FILE`、`ENV_FILE` 环境变量;PowerShell 脚本支持同名环境变量和 `-ComposeFile`、`-EnvFile` 参数。健康探测使用 `docker compose ps --all -q <service>` 和仅包含容器状态、health 状态、退出码的 `docker inspect --format` 输出,不打印渲染后的 Compose 环境值。

## P1:强烈建议 / Strongly Recommended

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"commitlint": "commitlint --config commitlint.config.cjs --from=HEAD~1 --to=HEAD",
"typecheck": "npm --prefix apps/api run typecheck && npm --prefix apps/web run typecheck",
"lint": "npm --prefix apps/api run lint && npm --prefix apps/web run lint",
"test": "node --test scripts/*.test.mjs && npm --prefix apps/api run test -- --passWithNoTests && npm --prefix apps/web run test",
"test": "node --test scripts/*.test.mjs scripts/ci/*.test.mjs && npm --prefix apps/api run test -- --passWithNoTests && npm --prefix apps/web run test",
"build": "npm --prefix apps/api run build && npm --prefix apps/web run build",
"validate": "node scripts/prisma-static-check.mjs && npm run typecheck && npm run lint && npm run test && npm run build",
"audit:security": "npm audit --audit-level=high && npm --prefix apps/api audit --audit-level=high && npm --prefix apps/web audit --audit-level=high && npm --prefix apps/mobile audit --audit-level=high",
Expand Down
134 changes: 134 additions & 0 deletions scripts/ci/compose.rehearsal.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Disposable GitHub CI only. The production Compose file and MinIO policy are
# deliberately unchanged; this uses the existing verified-source CI MinIO build.
# All images are built locally by container-rehearsal.mjs. Never publish this stack.
x-runtime: &runtime
restart: "no"
logging:
driver: json-file
options:
max-size: "5m"
max-file: "2"

services:
db:
<<: *runtime
image: postgres:15-alpine
mem_limit: 512m
environment:
POSTGRES_USER: ${POSTGRES_USER:?required}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required}
POSTGRES_DB: ${POSTGRES_DB:?required}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 3s
timeout: 3s
retries: 30

redis:
<<: *runtime
image: redis:7-alpine
mem_limit: 192m
command: redis-server --appendonly yes --save 60 1000
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 3s
retries: 30

minio:
<<: *runtime
image: ${REHEARSAL_MINIO_IMAGE:?required}
pull_policy: never
mem_limit: 768m
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?required}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?required}
command: server /data
volumes:
- miniodata:/data
# The scratch CI image intentionally has no shell/curl/mc. The orchestrator
# probes its HTTP readiness from the API image on this private network.

migrate:
<<: *runtime
image: ${REHEARSAL_MIGRATE_IMAGE:?required}
pull_policy: never
mem_limit: 1g
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
INIT_ADMIN_EMAIL: ${INIT_ADMIN_EMAIL:?required}
INIT_ADMIN_PASSWORD: ${INIT_ADMIN_PASSWORD:?required}
INIT_ADMIN_NAME: Container Rehearsal
INIT_COMPANY_NAME: ${INIT_COMPANY_NAME:?required}
command: ["sh", "-c", "npx --no-install prisma migrate deploy --schema=./prisma/schema.prisma && npm run init:prod"]

api:
<<: *runtime
image: ${REHEARSAL_API_IMAGE:?required}
pull_policy: never
mem_limit: 1g
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_started
environment:
NODE_ENV: production
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET:?required}
CORS_ORIGINS: http://127.0.0.1
REDIS_HOST: redis
REDIS_PORT: "6379"
MINIO_ENDPOINT: minio
MINIO_PORT: "9000"
MINIO_USE_SSL: "false"
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
AI_WRITE_ENABLED: "false"
PORT: "8000"
ports:
- "127.0.0.1::8000"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 3s
timeout: 3s
retries: 40
start_period: 15s

web:
<<: *runtime
image: ${REHEARSAL_WEB_IMAGE:?required}
pull_policy: never
mem_limit: 512m
depends_on:
api:
condition: service_healthy
environment:
NODE_ENV: production
PORT: "3000"
HOSTNAME: 0.0.0.0
NEXT_PUBLIC_API_BASE_URL: /api/proxy
API_BASE_URL: http://api:8000
ports:
- "127.0.0.1::3000"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/login').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 3s
timeout: 3s
retries: 40
start_period: 10s

volumes:
pgdata:
redisdata:
miniodata:
Loading
Loading