Archived
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:
@@ -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/^/最近一份备份:/'
|
||||
Reference in New Issue
Block a user