#!/usr/bin/env node /** * CI 的四件小事(只在 Gitea Actions 里用,本地开发不需要): * * db-create 在常驻 PG 上建一个一次性库、灌 db/init(四个 schema), * 连接串写进 $GITHUB_ENV 的 DATABASE_URL * db-drop 拆掉它 * db-probe 拿 DATABASE_URL 探一次 —— 连不上就红 * assert-it 断言 ledger 集成测试**真的跑了**,不是静默跳过 * * 后两条治的是同一个病:`tools/it-database.ts` 连不上库、又起不了 Testcontainers 时, * 打一行警告就返回 undefined —— 整套集成测试跳过而退出码仍是 0, * CI 会拿「什么都没测」冒充绿灯。**界面不撒谎**:探不到库就红在探库那一步, * 跑完发现被跳过就红在断言这一步。 * * 不新增依赖:pg 是根 devDependency(§12.5#3)。 */ import { appendFileSync, readdirSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import pg from 'pg'; /** 一次性库的前缀白名单 —— 本脚本永远只碰以它打头的库,`jcai_wos` 之类绝不误伤 */ const PREFIX = 'jcai_ci_'; /** 唯一一个「库连得上就真跑」的集成测试文件(其余三个被 SKIP_UNTIL_MODEL 钉着) */ const LEDGER_IT = 'ledger-store.it.test.ts'; export function die(message) { process.stderr.write(`${message}\n`); process.exit(1); } function adminUrl() { const url = process.env['CI_PG_ADMIN_URL']; if (url === undefined || url === '') { die('缺 CI_PG_ADMIN_URL —— 仓库设置 → Actions → Secrets 里加一条,指向常驻 PG 的 postgres 库'); } return url; } function checkName(name) { if (name === undefined || !name.startsWith(PREFIX)) { die(`库名必须以 ${PREFIX} 打头(拿到的是「${name ?? ''}」)—— 这道白名单挡的是误删开发库`); } if (!/^[a-z0-9_]+$/.test(name)) die(`库名只许小写字母、数字、下划线:${name}`); return name; } /** 换掉连接串里的库名 —— 走 URL 解析,`?sslmode=` 这类查询串照样保住 */ export function withDatabase(url, name) { const parsed = new URL(url); parsed.pathname = `/${name}`; return parsed.toString(); } export async function connect(url) { const client = new pg.Client({ connectionString: url, connectionTimeoutMillis: 5000 }); await client.connect(); return client; } const root = dirname(dirname(fileURLToPath(import.meta.url))); /** * 新库要先灌 `db/init` 才迁得动 —— `0026_object_meta.sql` 往 `ledger.` 里建表, * 而迁移链本身只会替自己建 `sys`。docker-compose 是靠把这个目录挂进 * `/docker-entrypoint-initdb.d` 在首次起卷时灌的;`createdb` 出来的库没人替它灌。 * **灌的是仓库里那份文件本身**,不在这里抄一份 —— 同一份事实两处各存一份,改一处就分叉。 */ export async function applyInit(url) { const dir = join(root, 'db', 'init'); const files = readdirSync(dir) .filter((name) => name.endsWith('.sql')) .sort(); const client = await connect(url); try { for (const file of files) { await client.query(readFileSync(join(dir, file), 'utf8')); process.stdout.write(`灌了 db/init/${file}\n`); } } catch (error) { const detail = error instanceof Error ? error.message : String(error); die( `灌 db/init 失败:${detail}\n` + '若卡在 CREATE EXTENSION pgcrypto,是账号权限不够(该扩展默认非 trusted,要超级用户)——\n' + '让 DBA 先在那台 PG 上装一次,或给 CI 账号超级用户权限。', ); } finally { await client.end(); } } async function dbCreate(name) { const admin = await connect(adminUrl()); try { // 上一跑被取消时会留下库(拆库那一步没轮到跑)—— 顺手清掉,只清本前缀 const stale = await admin.query('select datname from pg_database where datname like $1', [ `${PREFIX}%`, ]); for (const row of stale.rows) { await admin.query(`drop database if exists "${row.datname}" with (force)`); process.stdout.write(`清掉上一跑的残留:${row.datname}\n`); } await admin.query(`create database "${name}"`); } finally { await admin.end(); } const url = withDatabase(adminUrl(), name); await applyInit(url); // 只把库名摆出来:派生串与 secret 不逐字相同,Gitea 的日志掩码盖不住它 process.stdout.write(`本次跑的库:${name}\n`); const envFile = process.env['GITHUB_ENV']; if (envFile === undefined) { // 本地跑:没有 $GITHUB_ENV,把该导的那一行直接给出来(自己的终端,不存在日志外泄) process.stdout.write(`export DATABASE_URL='${url}'\n`); return; } appendFileSync(envFile, `DATABASE_URL=${url}\n`); } async function dbDrop(name) { const admin = await connect(adminUrl()); try { await admin.query(`drop database if exists "${name}" with (force)`); } finally { await admin.end(); } process.stdout.write(`已拆掉:${name}\n`); } async function dbProbe() { const url = process.env['DATABASE_URL']; if (url === undefined || url === '') die('DATABASE_URL 没设 —— 建库那一步该把它写进 $GITHUB_ENV'); let client; try { client = await connect(url); } catch (error) { die( `连不上库:${error instanceof Error ? error.message : String(error)}\n` + '集成测试会因此静默跳过,这一跑等于什么都没测 —— 先修库,不是放松断言。', ); } try { const result = await client.query('select version()'); process.stdout.write(`库连得上 ✔ ${result.rows[0].version}\n`); } finally { await client.end(); } } function assertIt(file) { if (file === undefined) die('用法:node tools/ci.mjs assert-it '); let report; try { report = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { die(`读不到 vitest 的 json 报告(${file}):${error instanceof Error ? error.message : String(error)}`); } const files = Array.isArray(report.testResults) ? report.testResults : []; const target = files.find((one) => typeof one.name === 'string' && one.name.endsWith(LEDGER_IT)); if (target === undefined) { die(`vitest 报告里没有 ${LEDGER_IT} —— 集成测试整个没被收集到,这一跑等于什么都没测`); } const cases = Array.isArray(target.assertionResults) ? target.assertionResults : []; const passed = cases.filter((one) => one.status === 'passed'); const skipped = cases.filter((one) => one.status !== 'passed' && one.status !== 'failed'); if (passed.length === 0 || skipped.length > 0) { process.stderr.write( `${LEDGER_IT}:${passed.length} 条通过、${skipped.length} 条被跳过。\n` + '集成测试被跳过 = 这一跑什么都没测(库没连上时那几条会退化成 it.skip,而退出码仍是 0)。\n' + '去看「建本次跑的一次性库」与「库真的连得上」两步,别改这道断言。\n', ); process.exit(1); } process.stdout.write(`集成测试真跑了 ✔ ${LEDGER_IT}:${passed.length} 条通过\n`); } /** 被 import 时不跑分发器。用 pathToFileURL 归一 —— 拼字符串那种写法遇软链会不成立(实测过) */ const isMain = process.argv[1] !== undefined && pathToFileURL(process.argv[1]).href === import.meta.url; const [command, argument] = process.argv.slice(2); if (isMain) switch (command) { case 'db-create': await dbCreate(checkName(argument)); break; case 'db-drop': await dbDrop(checkName(argument)); break; case 'db-probe': await dbProbe(); break; case 'assert-it': assertIt(argument); break; default: die('用法:node tools/ci.mjs [参数]'); }