CI/CD 四件与两份文档入库 —— debt.md 早已写「已接」而文件一直没跟踪

.gitea/workflows/ci.yml · tools/ci.mjs · tools/cd.mjs · docs/部署.md:推主干跑五道门禁 +
从空库起的集成测试 + 三份构建,全绿后部署到 runner 那台机(机密一律 secrets.*,机器相关
一律 vars.*,文件里不写死)。debt.md 在 9848e2f 就点名了这几个文件,远端却从未有过 .gitea。

docs/keycloak-backend-guide.md:ADR-012 引为出处的后端接入手册(只有 clientId 与「有/没有」,
无密钥)。docs/数据域字段说明-E04-I01.md:设计中心数据域那一屏的字段说明。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01712YeSo5fTfkCSPZHBiniq
This commit is contained in:
xcs
2026-08-26 14:34:19 +08:00
co-authored by Claude Fable 5
parent 0f1e53d114
commit f52edd5383
6 changed files with 2743 additions and 0 deletions
+320
View File
@@ -0,0 +1,320 @@
# 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/^/最近一份备份:/'