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
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env node
/**
* CI 的四件小事(只在 Gitea Actions 里用,本地开发不需要):
*
* db-create <jcai_ci_*> 在常驻 PG 上建一个一次性库、灌 db/init(四个 schema),
* 连接串写进 $GITHUB_ENV 的 DATABASE_URL
* db-drop <jcai_ci_*> 拆掉它
* db-probe 拿 DATABASE_URL 探一次 —— 连不上就红
* assert-it <vitest.json> 断言 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 <vitest.json>');
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 <db-create|db-drop|db-probe|assert-it> [参数]');
}