#!/usr/bin/env node /** * CD 的八件小事(只在 Gitea Actions 的部署段用,本地开发不需要): * * preflight 配置齐不齐、密钥换没换、外部命令在不在、目录写不写得动 * backup pg_dump 生产库(迁移没有 down,这是唯一的回滚手段) * db-ensure 生产库不在就建 + 灌 db/init * migrate-verify 已应用迁移条数 == journal 条数 * activities-plan 判「首次播种 / 装活动包 / 什么都不做 / 当场红」 * activities-verify 回查 sys.activity_package * health 轮询 /api/health,**验 body** * prune releases 只留最近几版 * * 三条设计律,每条都对着一个「信号全绿而事情没办成」的真实机制: * ① `install-activities` 的所有失败路径都是「打印一行然后 continue」,**永远 exit 0** * —— 所以装完必须回查库,不能信它的退出码(activities-verify)。 * ② 活动包版本没升时 `savePackage` 是 onConflictDoNothing,新代码静默丢失而日志照样说成功 * —— 所以版本没变但产物内容变了要**当场红**(activities-plan)。 * ③ `/api/health` 在库挂掉时**照样返 200**,只是 body 里 `db:'down'` * —— 所以探活验 body 不验状态码(health)。 * * 不新增依赖:pg 是根 devDependency(§12.5#3);共用的连库与灌 init 从 ci.mjs 取,不另抄一份。 */ import { createHash } from 'node:crypto'; import { spawn, spawnSync } from 'node:child_process'; import { accessSync, appendFileSync, constants, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { createGzip } from 'node:zlib'; import { pipeline } from 'node:stream/promises'; import { applyInit, connect, die, withDatabase } from './ci.mjs'; const root = dirname(dirname(fileURLToPath(import.meta.url))); /** 路径类配置(Gitea Variables)与机密(Gitea Secrets)—— 少一条就别往下走 */ const VARS = ['DEPLOY_DIR', 'PM2_APP', 'WEB_ROOT']; const SECRETS = ['PROD_DATABASE_URL', 'JCAI_SECRET_KEY', 'JCAI_SEED_PASSWORD', 'JCAI_DEEPSEEK_API_KEY']; function need(name) { const value = process.env[name]; if (value === undefined || value.trim() === '') die(`缺 ${name} —— 在 Gitea 仓库设置里补上再部署`); return value; } function emit(line) { process.stdout.write(`${line}\n`); } /** 写进 $GITHUB_ENV 供后续步骤读;本地跑没有这个文件就把该导的那行摆出来 */ function exportEnv(key, value) { const file = process.env['GITHUB_ENV']; if (file === undefined) { emit(`export ${key}='${value}'`); return; } appendFileSync(file, `${key}=${value}\n`); } /** * 命令在不在 PATH 上 —— 借 shell 的 `command -v`,别自己拼 PATH 去猜。 * 走 `sh -c` 而不是 `{ shell: true }`:后者在 node 22 上每次都吐一条 * 「传 args 给 shell 有安全风险」的弃用告警,而 CI 日志里那种噪音会让人不信别的话。 * 参数来自本文件里写死的名单,不接外部输入。 */ function which(command) { return spawnSync('sh', ['-c', `command -v ${command}`], { stdio: 'ignore' }).status === 0; } /* ── 生产库地址 ─────────────────────────────────────────────────────── */ function prodUrl() { return need('PROD_DATABASE_URL'); } function adminUrlOf(url) { return withDatabase(url, 'postgres'); } function databaseNameOf(url) { return new URL(url).pathname.replace(/^\//, ''); } async function databaseExists(url) { const admin = await connect(adminUrlOf(url)); try { const found = await admin.query('select 1 from pg_database where datname = $1', [databaseNameOf(url)]); return found.rowCount > 0; } finally { await admin.end(); } } /* ── preflight ─────────────────────────────────────────────────────── */ async function preflight() { const missing = [...VARS, ...SECRETS].filter((name) => { const value = process.env[name]; return value === undefined || value.trim() === ''; }); if (missing.length > 0) { die( `以下配置还没设(一次说全,免得你来回补):\n` + missing.map((name) => ` · ${name}${VARS.includes(name) ? '(Variables)' : '(Secrets)'}`).join('\n'), ); } const key = Buffer.from(need('JCAI_SECRET_KEY'), 'base64'); if (key.length !== 32) { die(`JCAI_SECRET_KEY 必须是 32 字节的 base64(现在解出来 ${key.length} 字节)—— openssl rand -base64 32`); } const deployDir = need('DEPLOY_DIR'); const webRoot = need('WEB_ROOT'); mkdirSync(join(deployDir, 'releases'), { recursive: true }); mkdirSync(join(deployDir, 'backups'), { recursive: true }); mkdirSync(join(deployDir, 'data', 'attachments'), { recursive: true }); for (const [label, path] of [['DEPLOY_DIR', deployDir], ['WEB_ROOT', webRoot]]) { if (!existsSync(path)) die(`${label} 不存在:${path} —— 先在主机上建好并让 runner 用户写得动`); try { accessSync(path, constants.W_OK); } catch { die(`${label} 写不动:${path} —— runner 用户没有写权限(nginx 的目录常常是 root 的)`); } } const tools = ['pm2', 'rsync'].filter((command) => !which(command)); if (tools.length > 0) { die( `这几个命令不在 runner 用户的 PATH 上:${tools.join(' / ')}\n` + '(pm2 常装在某个 node 版本管理器的 shim 里,而非交互 shell 读不到 ~/.zshrc)', ); } if (!which('pg_dump') && !(which('docker') && (process.env['PG_CONTAINER'] ?? '') !== '')) { die( '备份没有着落:PATH 上没有 pg_dump,也没给 PG_CONTAINER(库在容器里时用它 docker exec)。\n' + '你选的是「自动跑迁移,先备份」—— 而迁移没有 down,备份是唯一的回滚手段,不能悄悄跳过。', ); } // 密钥指纹:换了这把钥匙 = 库里所有凭证密文全部解不开,而且不在启动时炸,是第一次真调模型才炸 const fingerprint = createHash('sha256').update(key).digest('hex').slice(0, 16); const marker = join(deployDir, '.secret-fingerprint'); if (existsSync(marker)) { const known = readFileSync(marker, 'utf8').trim(); if (known !== fingerprint) { die( `JCAI_SECRET_KEY 与上一次部署的不是同一把(${known} → ${fingerprint})。\n` + 'sys.llm_instance.api_key_cipher 与 sys.tool.connection_cipher 都是用它封的 ——\n' + '换了就全部解不开,而且要等第一次真调模型才炸。\n' + `确实要换,先把 ${marker} 删掉,并准备重配所有凭证。`, ); } } else { writeFileSync(marker, `${fingerprint}\n`, 'utf8'); emit(`记下密钥指纹:${fingerprint}(以后每次部署都比对)`); } emit('部署前置检查通过 ✔'); } /* ── backup ────────────────────────────────────────────────────────── */ async function backup() { const url = prodUrl(); if (!(await databaseExists(url))) { emit(`生产库 ${databaseNameOf(url)} 还不存在 —— 这是首次部署,没有东西可备份,跳过`); return; } const deployDir = need('DEPLOY_DIR'); const dir = join(deployDir, 'backups'); mkdirSync(dir, { recursive: true }); const parsed = new URL(url); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const sha = (process.env['GITHUB_SHA'] ?? 'local').slice(0, 8); const file = join(dir, `${stamp}-${sha}.sql.gz`); const database = databaseNameOf(url); const user = decodeURIComponent(parsed.username); const env = { ...process.env, PGPASSWORD: decodeURIComponent(parsed.password) }; // 口令一律走环境,不进 argv(argv 在 ps 里对同机其他用户可见) const [command, args] = which('pg_dump') ? ['pg_dump', ['-h', parsed.hostname, '-p', parsed.port === '' ? '5432' : parsed.port, '-U', user, '-d', database, '--no-owner', '--no-privileges']] : ['docker', ['exec', '-e', 'PGPASSWORD', '-i', need('PG_CONTAINER'), 'pg_dump', '-U', user, '-d', database, '--no-owner', '--no-privileges']]; const child = spawn(command, args, { env, stdio: ['ignore', 'pipe', 'pipe'] }); let stderr = ''; child.stderr.on('data', (chunk) => { stderr += String(chunk); }); const finished = new Promise((resolve, reject) => { child.on('error', reject); child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`${command} 退出码 ${code}\n${stderr}`)))); }); try { await Promise.all([pipeline(child.stdout, createGzip(), createWriteStream(file)), finished]); } catch (error) { die(`备份失败:${error instanceof Error ? error.message : String(error)}`); } emit(`已备份 → ${file}(${(statSync(file).size / 1024).toFixed(0)} KB)`); keepNewest(dir, Number(process.env['KEEP_BACKUPS'] ?? 10), '备份'); } /* ── db-ensure ─────────────────────────────────────────────────────── */ async function dbEnsure() { const url = prodUrl(); const name = databaseNameOf(url); if (await databaseExists(url)) { emit(`生产库 ${name} 已在 ✔`); return; } const admin = await connect(adminUrlOf(url)); try { await admin.query(`create database "${name}"`); } catch (error) { die( `建库失败:${error instanceof Error ? error.message : String(error)}\n` + 'PROD_DATABASE_URL 里那个账号需要 CREATEDB 权限,或者你先手工把库建好。', ); } finally { await admin.end(); } emit(`已建生产库 ${name}`); // 空库不灌 db/init 迁不动:0026 往 ledger.__object_meta 建表,而迁移链只会替自己建 sys await applyInit(url); } /* ── migrate-verify ────────────────────────────────────────────────── */ async function migrateVerify() { const journal = JSON.parse(readFileSync(join(root, 'db', 'migrations', 'meta', '_journal.json'), 'utf8')); const expected = Array.isArray(journal.entries) ? journal.entries.length : 0; const client = await connect(prodUrl()); let applied; try { const result = await client.query('select count(*)::int as n from sys.__drizzle_migrations'); applied = result.rows[0].n; } catch (error) { const detail = error instanceof Error ? error.message : String(error); // 记账表不存在 = 迁移那一步压根没跑成(而它可能仍然打印了「完成」并 exit 0) if (detail.includes('__drizzle_migrations')) { die('库里连 sys.__drizzle_migrations 都没有 —— 迁移那一步根本没跑成。先看上一步的输出。'); } die(`查迁移记账失败:${detail}`); } finally { await client.end(); } if (applied !== expected) { die( `迁移没到位:库里 ${applied} 条,db/migrations 里 ${expected} 条。\n` + 'drizzle 只比「最后一条的时间戳」当水位线,不逐条比 hash ——\n' + '新迁移在 _journal.json 里的 when 若 ≤ 水位线,会被永久静默跳过,而 migrate 照样打印「完成」。\n' + '查:select max(created_at) from sys.__drizzle_migrations,与新增那条的 when 比一比。', ); } emit(`迁移到位 ✔ ${applied}/${expected} 条`); } /* ── 活动包 ────────────────────────────────────────────────────────── */ /** 装哪几个包是 seed-activities.ts 说了算 —— 读它,不在这儿另存一份名单 */ function realPackages() { const source = readFileSync(join(root, 'apps', 'api', 'src', 'app', 'seed-activities.ts'), 'utf8'); const matched = /const REAL_PACKAGES = \[([^\]]*)\]/.exec(source); if (matched === null) { die('读不出 REAL_PACKAGES —— seed-activities.ts 的写法变了,来改 tools/cd.mjs(别让它拿一份过期名单继续跑)'); } const names = [...matched[1].matchAll(/'([^']+)'/g)].map((one) => one[1]); if (names.length === 0) die('REAL_PACKAGES 是空的 —— 那就没有活动包要装,确认这是不是你要的'); return names; } function distOf(pkg) { const dir = join(root, 'activities', pkg, 'dist'); const manifestPath = join(dir, 'manifest.json'); const entryPath = join(dir, 'index.js'); if (!existsSync(manifestPath) || !existsSync(entryPath)) { die(`活动包 ${pkg} 没有构建产物(${dir})—— 先跑 pnpm activities:build`); } const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); return { activityId: manifest.identity.activityId, version: manifest.identity.packageVersion, artifact: readFileSync(entryPath, 'utf8'), }; } async function activitiesPlan() { const client = await connect(prodUrl()); let staffCount; const decisions = []; try { const staff = await client.query('select count(*)::int as n from sys.staff'); staffCount = staff.rows[0].n; for (const pkg of realPackages()) { const dist = distOf(pkg); const row = await client.query( 'select artifact from sys.activity_package where activity_id = $1 and version = $2', [dist.activityId, dist.version], ); if (row.rowCount === 0) decisions.push({ pkg, dist, verdict: 'install' }); else if (row.rows[0].artifact === dist.artifact) decisions.push({ pkg, dist, verdict: 'skip' }); else decisions.push({ pkg, dist, verdict: 'conflict' }); } } finally { await client.end(); } const conflicts = decisions.filter((one) => one.verdict === 'conflict'); if (conflicts.length > 0) { die( `这几个活动包**版本没升但内容变了**:\n` + conflicts.map((one) => ` · ${one.pkg}@${one.dist.version}`).join('\n') + '\n安装恒新增:同版本重装是 no-op(onConflictDoNothing),库里还是旧产物,\n' + '而日志会照样打印「活动包已装」、退出码 0 —— 新代码静默不上线。\n' + '改了内容就升 manifest 里的 packageVersion(CLAUDE.md 铁规矩 7)。', ); } const first = staffCount === 0; const toInstall = decisions.filter((one) => one.verdict === 'install'); if (first) { emit('库里一个员工都没有 → 判为**首次部署**,走 seed.js 播一次种(此后永不再跑 seed)'); exportEnv('FIRST_DEPLOY', '1'); exportEnv('ACTIVITIES_INSTALL', '0'); return; } exportEnv('FIRST_DEPLOY', '0'); exportEnv('ACTIVITIES_INSTALL', toInstall.length > 0 ? '1' : '0'); if (toInstall.length === 0) { emit('活动包版本与库里一致、内容也一致 → 不装(顺带避开它会覆盖样例主数据、并发出 data 事件)'); return; } emit(`要装的活动包:${toInstall.map((one) => `${one.pkg}@${one.dist.version}`).join(' / ')}`); } async function activitiesVerify() { const client = await connect(prodUrl()); const bad = []; try { for (const pkg of realPackages()) { const dist = distOf(pkg); const row = await client.query( 'select artifact from sys.activity_package where activity_id = $1 and version = $2', [dist.activityId, dist.version], ); if (row.rowCount === 0) bad.push(`${pkg}@${dist.version}:库里根本没有这一版`); else if (row.rows[0].artifact !== dist.artifact) bad.push(`${pkg}@${dist.version}:库里那一版的产物与刚构建的不一致`); else emit(` ✔ ${pkg}@${dist.version}`); } } finally { await client.end(); } if (bad.length > 0) { die( `活动包没装成:\n${bad.map((one) => ` · ${one}`).join('\n')}\n` + 'install-activities 的所有失败路径都是「打印一行然后 continue」,**永远 exit 0** ——\n' + '所以这道回查才是唯一说得出实情的地方。看它的日志里有没有「本次跳过」。', ); } emit('活动包已在库里 ✔'); } /* ── health ────────────────────────────────────────────────────────── */ async function health() { const url = process.env['HEALTH_URL'] ?? `http://127.0.0.1:${process.env['PORT'] ?? '8787'}/api/health`; const deadline = Date.now() + Number(process.env['HEALTH_TIMEOUT_MS'] ?? 60_000); let last = '还没连上'; while (Date.now() < deadline) { try { const response = await fetch(url); const body = await response.json(); // 库挂了它照样返 200,只是 body 里 db:'down' —— 所以验 body,不验状态码 if (body.ok === true && body.db === 'up') { emit(`探活通过 ✔ ${url} → ok=true db=up version=${body.version}`); return; } last = `HTTP ${response.status} ${JSON.stringify(body)}`; } catch (error) { last = error instanceof Error ? error.message : String(error); } await new Promise((resolve) => setTimeout(resolve, 2000)); } die( `探活没过:${url}\n最后一次看到的是:${last}\n` + '两种常见死法:①进程压根没起来(入口守卫遇软链会静默 exit 0 —— pm2 必须用 releases 的真实路径起);\n' + '②起来了但连不上库(body 里 db:"down",而 HTTP 仍是 200)。先看 pm2 logs。', ); } /* ── prune ─────────────────────────────────────────────────────────── */ function keepNewest(dir, keep, label) { const entries = readdirSync(dir) .map((name) => ({ name, path: join(dir, name) })) .map((one) => ({ ...one, at: statSync(one.path).mtimeMs })) .sort((a, b) => b.at - a.at); const drop = entries.slice(keep); for (const one of drop) rmSync(one.path, { recursive: true, force: true }); emit(`${label}:留 ${Math.min(keep, entries.length)} 份,清掉 ${drop.length} 份`); } function prune() { const deployDir = need('DEPLOY_DIR'); const dir = join(deployDir, 'releases'); if (!existsSync(dir)) return; const keep = Number(process.env['KEEP_RELEASES'] ?? 5); const currentLink = join(deployDir, 'current'); const current = existsSync(currentLink) ? realpathSync(currentLink) : undefined; const entries = readdirSync(dir) .map((name) => join(dir, name)) .map((path) => ({ path, at: statSync(path).mtimeMs })) .sort((a, b) => b.at - a.at); let kept = 0; let dropped = 0; for (const one of entries) { // 在跑的那一版永远不删,哪怕它排到了第 6 位 if (kept < keep || one.path === current) { kept += 1; continue; } rmSync(one.path, { recursive: true, force: true }); dropped += 1; } emit(`发布目录:留 ${kept} 版,清掉 ${dropped} 版`); } /* ── 分发 ──────────────────────────────────────────────────────────── */ const isMain = process.argv[1] !== undefined && pathToFileURL(process.argv[1]).href === import.meta.url; const [command] = process.argv.slice(2); if (isMain) { switch (command) { case 'preflight': await preflight(); break; case 'backup': await backup(); break; case 'db-ensure': await dbEnsure(); break; case 'migrate-verify': await migrateVerify(); break; case 'activities-plan': await activitiesPlan(); break; case 'activities-verify': await activitiesVerify(); break; case 'health': await health(); break; case 'prune': prune(); break; default: die('用法:node tools/cd.mjs '); } }