# JCAI-WOS CI —— 五道门禁 + 从空库起的集成测试 + 生产构建 # # 只在主干跑:push 到 mvp-foundation(PR 合并产生的那一推同样算)。 # runner 是 host 模式,label `ubuntu-latest`;宿主机自带 node22 / pnpm10 / docker。 # 门禁一律以退出码验(CLAUDE.md 铁规矩 1),不以 echo 验。 name: CI on: push: branches: [mvp-foundation] concurrency: group: ci-${{ github.ref }} cancel-in-progress: true env: # 国内镜像源:pnpm 认 npm 的 env 形式 NPM_CONFIG_REGISTRY: https://registry.npmmirror.com COREPACK_NPM_REGISTRY: https://registry.npmmirror.com # NODE_ENV 一律不设 production —— secret-box.ts 在 production 下拿不到 # JCAI_SECRET_KEY 会直接抛;CI 要的是 dev 兜底那条路。 jobs: ci: runs-on: ubuntu-latest timeout-minutes: 40 steps: # ── 取代码:纯 git,一个第三方 action 都不依赖 ───────────────────── - name: 取代码 env: T1: ${{ secrets.GITEA_TOKEN }} T2: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail cd "$GITHUB_WORKSPACE" git init -q . git config --global --add safe.directory "$GITHUB_WORKSPACE" URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" git remote set-url origin "$URL" 2>/dev/null || git remote add origin "$URL" TOKEN="${T1:-${T2:-}}" HEADER="http.${GITHUB_SERVER_URL}/.extraheader" if [ -n "$TOKEN" ]; then AUTH="$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')" git config --local "$HEADER" "Authorization: basic $AUTH" fi fetch() { git fetch --depth=1 --no-tags origin "+${1}:refs/ci/head"; } # 两档退让:先按 commit(服务端未必允许按 SHA 取),再按分支 ref if ! fetch "$GITHUB_SHA" && ! fetch "$GITHUB_REF"; then # 换一种带凭据的写法再试一遍(extraheader 与 token 进 URL,Gitea 各版本认的不一样) if [ -z "$TOKEN" ]; then echo '取代码失败:没有可用 token,匿名也拉不下来'; exit 1 fi git config --local --unset-all "$HEADER" || true SCHEME="${GITHUB_SERVER_URL%%://*}" HOST="${GITHUB_SERVER_URL#*://}" git remote set-url origin "${SCHEME}://${TOKEN}@${HOST}/${GITHUB_REPOSITORY}.git" fetch "$GITHUB_SHA" || fetch "$GITHUB_REF" fi git checkout -qf refs/ci/head # 留着 node_modules(host 模式工作区跨次复用,留着就等于一份缓存), # 其余未跟踪物一律清掉 —— 上一跑的 dist/.build/.api-tmp 不许影响这一跑 git clean -ffdx -e node_modules echo "HEAD=$(git rev-parse HEAD) 期望=${GITHUB_SHA}" - name: 工具链断言 run: | set -euo pipefail node -v pnpm -v node -e 'const [a, b] = process.versions.node.split(".").map(Number); if (a < 22 || (a === 22 && b < 6)) { console.error("需要 Node >= 22.6 —— gen:tiers 用 --experimental-strip-types"); process.exit(1); }' case "$(pnpm -v)" in 10.*) ;; *) echo "需要 pnpm 10.x(仓库钉的是 10.33.0)"; exit 1 ;; esac - name: 装依赖 run: pnpm install --frozen-lockfile # ── 五道门禁,逐条一步 ──────────────────────────────────────────── - name: 门禁前置 · 生成层级表 run: pnpm gen:tiers - name: 门禁 1/5 · 类型检查 run: pnpm typecheck - name: 门禁 2/5 · Lint run: pnpm lint - name: 门禁 3/5 · 依赖门禁 run: pnpm depcruise - name: 门禁 4/5 · 契约快照 run: pnpm api:check # api:check 自己那道 git 比对是**会失败开口**的(git 一抛错就印「非 git 仓库, # 跳过比对」然后 exit 0)。这一步自己再 status 一遍,吞不掉; # 顺带管住 gen:tiers 的入库产物 tools/depcruise-tiers.generated.cjs。 # 位置在测试之前 —— 测试期的 seed 可能往 .data/ 落文件,那不算漂移。 - name: 生成物无漂移 run: | set -euo pipefail DIRTY="$(git status --porcelain)" if [ -n "$DIRTY" ]; then echo "$DIRTY" echo '---' git --no-pager diff --stat echo '门禁跑完工作树有改动:入库的生成物与现生成的不一致。' echo '本地跑 pnpm gates,把 api-report/ 与 tools/depcruise-tiers.generated.cjs 一并提交。' exit 1 fi echo '工作树干净 ✔' # ── 库:一次性,跑完就拆 ────────────────────────────────────────── - name: 建本次跑的一次性库 id: db env: CI_PG_ADMIN_URL: ${{ secrets.CI_PG_ADMIN_URL }} run: node tools/ci.mjs db-create "jcai_ci_${GITHUB_RUN_ID}" - name: 从空库跑完整条迁移链 run: pnpm db:migrate - name: 库真的连得上 run: node tools/ci.mjs db-probe - name: 门禁 5/5 · 单元与集成测试 run: | set -euo pipefail pnpm test --reporter=default --reporter=json \ --outputFile.json="${RUNNER_TEMP:-/tmp}/vitest.json" node tools/ci.mjs assert-it "${RUNNER_TEMP:-/tmp}/vitest.json" # 用 steps.db.outcome 而不是裸 always():装依赖就失败的那种跑法里 # 根本没有库可拆,裸 always() 只会多吐一条看着吓人的假失败。 - name: 拆掉一次性库 if: ${{ always() && steps.db.outcome == 'success' }} env: CI_PG_ADMIN_URL: ${{ secrets.CI_PG_ADMIN_URL }} run: node tools/ci.mjs db-drop "jcai_ci_${GITHUB_RUN_ID}" # ── 生产构建 ──────────────────────────────────────────────────── - name: 生产构建 · api run: pnpm --filter @app/api build - name: 生产构建 · web run: pnpm --filter @app/web build - name: 生产构建 · 活动包 run: pnpm activities:build # ══ CD ══════════════════════════════════════════════════════════ # 门禁全绿之后才走到这儿(同一个 job,前面任一步红后面自然不跑)。 # 机器相关的东西一律从 vars.* 读,不写死;机密从 secrets.* 读。 - name: 部署 · 前置断言 env: DEPLOY_DIR: ${{ vars.DEPLOY_DIR }} PM2_APP: ${{ vars.PM2_APP }} WEB_ROOT: ${{ vars.WEB_ROOT }} PG_CONTAINER: ${{ vars.PG_CONTAINER }} PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }} JCAI_SEED_PASSWORD: ${{ secrets.JCAI_SEED_PASSWORD }} JCAI_DEEPSEEK_API_KEY: ${{ secrets.JCAI_DEEPSEEK_API_KEY }} run: node tools/cd.mjs preflight # 迁移没有 down,备份是唯一的回滚手段 —— 所以它在换代码之前,且备不成就红 - name: 部署 · 备份生产库 env: DEPLOY_DIR: ${{ vars.DEPLOY_DIR }} PG_CONTAINER: ${{ vars.PG_CONTAINER }} PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} run: node tools/cd.mjs backup - name: 部署 · 确保生产库在 env: PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} run: node tools/cd.mjs db-ensure - name: 部署 · 发布产物 env: DEPLOY_DIR: ${{ vars.DEPLOY_DIR }} run: | set -euo pipefail REL="${DEPLOY_DIR}/releases/${GITHUB_SHA}" rm -rf "$REL" mkdir -p "$REL" # 产物 + db/(migrate.js 靠向上找 db/migrations,相对位置不能变)+ 根三件 rsync -a --relative \ ./apps/api/dist ./apps/api/package.json \ ./apps/web/dist \ ./db \ ./package.json ./pnpm-lock.yaml ./pnpm-workspace.yaml \ "$REL"/ # 活动包产物(装包时要读;装完就不再需要) rsync -a --relative ./activities/*/dist "$REL"/ # 各 workspace 包的 package.json —— pnpm 认工作区要它们全在,少一个 lockfile 就对不上 git ls-files '*/package.json' | grep -v '^spike/' > "$RUNNER_TEMP/pkgs.txt" rsync -a --files-from="$RUNNER_TEMP/pkgs.txt" ./ "$REL"/ # 产物是 ESM 且 skipNodeModulesBundle —— 目标目录必须有自己的 node_modules cd "$REL" pnpm install --prod --frozen-lockfile --ignore-scripts printf 'sha=%s\nref=%s\nrun=%s\nat=%s\n' \ "$GITHUB_SHA" "$GITHUB_REF_NAME" "$GITHUB_RUN_ID" "$(date -Iseconds)" > "$REL/RELEASE" echo "RELEASE_DIR=$REL" >> "$GITHUB_ENV" echo "发布目录:$REL" # 直接跑 dist 里的入口,不走 pnpm db:migrate —— 那条会先 tsup(clean:true), # 在正跑着的服务脚下把 dist 清掉 - name: 部署 · 迁移 env: DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} run: cd "$RELEASE_DIR" && node apps/api/dist/migrate.js - name: 部署 · 迁移到位断言 env: PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} run: node tools/cd.mjs migrate-verify # 出两个旗:FIRST_DEPLOY / ACTIVITIES_INSTALL。 # 版本没升但产物内容变了会在这里当场红(否则新代码静默不上线)。 - name: 部署 · 判首次播种与活动包升级 env: PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} run: node tools/cd.mjs activities-plan # 空库唯一该跑 seed 的时刻。升级时绝不跑 —— 它会把治理台配好的 # 工具连接端点打回 replay://crm、重置白名单、重置模型实例的 baseUrl/model。 - name: 部署 · 首次播种 if: ${{ env.FIRST_DEPLOY == '1' }} env: DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }} JCAI_SEED_PASSWORD: ${{ secrets.JCAI_SEED_PASSWORD }} JCAI_DEEPSEEK_API_KEY: ${{ secrets.JCAI_DEEPSEEK_API_KEY }} run: | set -euo pipefail # 上面 if: 判错的危险方向是「升级时误跑 seed」—— 那会重写整套治理台配置。 # 所以这里自己再确认一次:表达式判错也伤不到生产。 [ "${FIRST_DEPLOY:-0}" = "1" ] || { echo "FIRST_DEPLOY 不是 1,不播种(这一步本不该执行到)"; exit 0; } cd "$RELEASE_DIR" JCAI_ACTIVITIES_DIR="$RELEASE_DIR/activities" node apps/api/dist/seed.js - name: 部署 · 装活动包 if: ${{ env.FIRST_DEPLOY == '0' && env.ACTIVITIES_INSTALL == '1' }} env: DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }} run: | set -euo pipefail # 同上:装包会按主键覆盖 8 行样例主数据并发出 data 事件,不该装的时候别装 [ "${ACTIVITIES_INSTALL:-0}" = "1" ] || { echo "ACTIVITIES_INSTALL 不是 1,不装(这一步本不该执行到)"; exit 0; } cd "$RELEASE_DIR" JCAI_ACTIVITIES_DIR="$RELEASE_DIR/activities" node apps/api/dist/install-activities.js # install-activities 的所有失败路径都只打日志、永远 exit 0 —— 回查库才说得出实情 - name: 部署 · 活动包回查 env: PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} run: node tools/cd.mjs activities-verify - name: 部署 · 发前端 env: WEB_ROOT: ${{ vars.WEB_ROOT }} run: | set -euo pipefail rsync -a --delete ./apps/web/dist/ "$WEB_ROOT"/ echo "前端已发到 $WEB_ROOT($(find "$WEB_ROOT" -type f | wc -l | tr -d ' ') 个文件)" # 用 releases 的**真实路径**起,不经 current 软链: # main.ts 的入口守卫拿 import.meta.url 与 process.argv[1] 做字符串比较, # 经软链启动两边不等 → boot() 永不执行 → 进程零输出 exit 0(实测过)。 # 换版本时脚本路径会变,所以是 delete+start 而不是 restart。 - name: 部署 · 重启 pm2 env: PM2_APP: ${{ vars.PM2_APP }} DEPLOY_DIR: ${{ vars.DEPLOY_DIR }} APP_NODE_ENV: ${{ vars.APP_NODE_ENV }} DEPLOY_PORT: ${{ vars.DEPLOY_PORT }} PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }} JCAI_SEED_PASSWORD: ${{ secrets.JCAI_SEED_PASSWORD }} run: | set -euo pipefail # 有 HTTPS 才该设 production:production 会给登录 cookie 加 Secure, # 而 Secure 的 cookie 浏览器在 HTTP 下根本不回传 —— 登录会静默失效。 export NODE_ENV="${APP_NODE_ENV:-production}" export PORT="${DEPLOY_PORT:-8787}" export DATABASE_URL="$PROD_DATABASE_URL" export JCAI_ATTACHMENT_ROOT="$DEPLOY_DIR/data/attachments" pm2 delete "$PM2_APP" >/dev/null 2>&1 || true pm2 start "$RELEASE_DIR/apps/api/dist/main.js" --name "$PM2_APP" --cwd "$RELEASE_DIR" --update-env pm2 list # 探活地址交给下一步,别让它去猜端口 —— 猜错会打到别人的服务上,还判成绿 echo "HEALTH_URL=http://127.0.0.1:$PORT/api/health" >> "$GITHUB_ENV" # 库挂了 /api/health 照样返 200,只是 body 里 db:'down' —— 所以验 body - name: 部署 · 探活 run: node tools/cd.mjs health - name: 部署 · 收尾 env: DEPLOY_DIR: ${{ vars.DEPLOY_DIR }} run: | set -euo pipefail ln -sfn "$RELEASE_DIR" "$DEPLOY_DIR/current" node tools/cd.mjs prune echo '── 这一版发出去了 ──' cat "$RELEASE_DIR/RELEASE" ls -1t "$DEPLOY_DIR/backups" | head -1 | sed 's/^/最近一份备份:/'