Files
ClassHelper/scripts/version.mjs
T
LoveChengke 19c2aa07b2 feat(docs,scripts,build): 用 docfx 搭文档站并发布到 GitHub Pages,版本号收口成脚本迭代
文档站(`docs/` 就是 docfx 项目,发布在 https://lovechengke.github.io/ClassHelper/):

- 四章 + 常见问题 + 完整参考,共 44 页:快速上手(8 页)/ 应用帮助(10 页)/
  管理运维(6 页)/ 开发文档(11 页)。信息架构照 ClassIsland 文档站来:
  顶栏分章、左侧章节导航、右侧本页目录、上一页 / 下一页、站内搜索、深色主题跟随系统;
- 自定义模板覆盖(`templates/classhelper/`):把 docfx modern 模板那套英文 UI 文案
  (In this article / Improve this doc / Next…)换成中文,补 GitHub 入口与版本徽标,
  并加一层只调排版与配色的 `public/main.css`(不动 DOM 结构);
- 站点顶栏的版本徽标取自根 `package.json`(`docs/build.mjs` 用 `--metadata` 注入),
  不在文档里写第二份 —— 否则一定漂;
- 404 页单独生成:GitHub Pages 的项目页挂在 `/<仓库名>/` 子路径下,而浏览器是按**被请求的路径**
  解析页面里的相对链接的,所以 `<base>` 必须钉到站点根,由 `build.mjs`(本地 `./`)与
  workflow(CI 传 `/<仓库名>/`)分别注入;
- 构建前清空 `_site`:docfx 只覆盖不清理,删掉或改名的页面会以旧文件留在目录里,
  而 Pages 上传的是整个目录。

版本迭代(**每一次 releases,不是每一次提交**):

- 新增 `scripts/version.mjs`。这个仓库里同一个版本号散在 **10 个文件**里(5 份 `package.json`、
  `deploy/package.runtime.json`、两个 compose 的镜像 tag、插件 `manifest.yml`、以及插件 C# 里的
  `PluginVersion` 常量),手工改必然漏一处,而漏掉的那处**不会报错**,只在某个不常走的分支上
  表现为"版本号对不上"。现在收口成两条命令:`pnpm version:check`(逐处比对,不一致 exit 1)
  与 `pnpm version:bump patch|minor|major`(一次改写全部 10 处,任何一处匹配不到或匹配到多处
  就直接失败退出,不做尽力而为的部分改写);
- `AGENTS.md` 新增 §4.4「版本迭代(每一次 releases,不是每一次提交)」:写清版本号落在哪 10 处、
  完整发布命令序列(体检 → 验收 → `dist:all` → 哈希清单 → 推 master → `gh release create` →
  Linux 包由 CI 合并哈希行),以及"已发布的版本号不要复用"的理由。

其它:

- `.github/workflows/docs.yml`:推 master(`docs/**` / `package.json` / workflow 自身有变)即构建并
  部署到 Pages;构建带 `--check`,把 docfx 的警告当错误 —— 最常见的警告是"某个 `.md` 没被任何
  `toc.yml` 引用",那种页面有内容但没有侧边栏,属于必须修掉的疏漏;
- docs 目录重组后的引用同步:`production` / `mysql` / `linux-deploy` / `winisland-design-tokens`
  移到新位置后,AGENTS.md、`deploy/`、`scripts/`、客户端源码注释里的路径引用一并更新;
- 文档站只发布 `docs/` 这一棵树,页面里指向树外的相对链接(`../AGENTS.md` 等)改成 GitHub 绝对 URL,
  否则上线全是 404;
- 图标派生清单补上文档站模板里的 `logo.png` 与 `favicon.png`(`pnpm icons` 一并生成,
  仍然只有一个图形源 `build/classhelper.{png,ico}`);
- `docs/_site` 进 `.gitignore` / `.prettierignore` 与 ESLint ignores;
- 根脚本补 `version:check` / `version:bump` / `docs:build` / `docs:serve` / `docs:check`。
2026-10-06 16:27:44 +08:00

193 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 版本号的读写与一致性校验 —— `pnpm version:check` / `pnpm version:bump <级别>`。
*
* ## 为什么要这个脚本
*
* 这个仓库里**同一个版本号写在 10 个地方**(5 份 package.json、运行时常量、两个 compose 文件、
* 插件清单,以及插件 C# 源码里的一个 const)。手工改必然漏一处,而漏掉的那处**不会报错**,
* 只会在某个不常走的分支上表现为"版本号对不上":
*
* - `deploy/docker-compose*.yml` 镜像 tag 落后 → 服务器上跑的还是旧镜像;
* - `deploy/package.runtime.json` 容器 / 安装包里那份依赖清单的版本(人看,但会误导排查);
* - `manifest.yml` + `BridgeService.cs` ClassIsland 上显示的插件版本
* (清单里的 version 与实际上报的 PluginVersion 不一致时,
* Web 端「ClassIsland 联动」页显示的版本会对不上插件市场)。
*
* 因此:**改版本号的唯一入口是这个脚本**。它按表逐个改写,任何一处没匹配到(或匹配到多处)
* 就直接失败退出,不做"尽力而为"的部分改写。
*
* ## 用法
*
* node scripts/version.mjs # 检查(默认):列出各处版本,不一致则 exit 1
* node scripts/version.mjs --bump patch # 1.1.2 → 1.1.3
* node scripts/version.mjs --bump minor # 1.1.2 → 1.2.0
* node scripts/version.mjs --bump major # 1.1.2 → 2.0.0
* node scripts/version.mjs --set 1.2.0 # 显式指定(插件同时设为 1.2.0.0)
* node scripts/version.mjs --bump minor --dry # 只看会改哪些文件,不落盘
*
* 版本口径与完整发布流程见 docs/dev/release.md。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
/**
* `pattern` 一律写成**三个捕获组**:`(前缀)(版本号)(后缀)`。
* 前缀与后缀原样保留,中间的版本号被替换 —— 这样既能精确替换,也能取出来比对。
*/
const PRODUCT_TARGETS = [
{ file: 'package.json', pattern: /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/ },
{ file: 'packages/shared/package.json', pattern: /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/ },
{ file: 'packages/server/package.json', pattern: /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/ },
{ file: 'packages/web-admin/package.json', pattern: /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/ },
{ file: 'packages/desktop-client/package.json', pattern: /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/ },
{ file: 'deploy/package.runtime.json', pattern: /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/ },
{
file: 'deploy/docker-compose.yml',
pattern: /(classhelper-server:\$\{APP_VERSION:-)(\d+\.\d+\.\d+)(\})/,
},
{
file: 'deploy/docker-compose.sqlite.yml',
pattern: /(classhelper-server:\$\{APP_VERSION:-)(\d+\.\d+\.\d+)(\})/,
},
];
/**
* 插件版本(`x.y.z.0`)。第 4 段留给"只改插件、不动产品版本"的补丁发布;
* `--bump` / `--set` 一律把它归零,要单独发插件补丁时手工改这两处即可。
*/
const PLUGIN_TARGETS = [
{
file: 'packages/classisland-plugin/manifest.yml',
pattern: /^(version:[ \t]*)(\d+\.\d+\.\d+\.\d+)([ \t]*)$/m,
},
{
file: 'packages/classisland-plugin/src/Services/BridgeService.cs',
pattern: /(private const string PluginVersion = ")(\d+\.\d+\.\d+\.\d+)(";)/,
},
];
const ALL_TARGETS = [...PRODUCT_TARGETS, ...PLUGIN_TARGETS];
const PRODUCT_RE = /^\d+\.\d+\.\d+$/;
const PLUGIN_RE = /^\d+\.\d+\.\d+\.\d+$/;
function readTarget(target) {
const source = fs.readFileSync(path.join(root, target.file), 'utf8');
const flags = target.pattern.flags.includes('g') ? target.pattern.flags : `${target.pattern.flags}g`;
const hits = [...source.matchAll(new RegExp(target.pattern.source, flags))];
return { source, hits, global: new RegExp(target.pattern.source, flags) };
}
/** 取出某个文件当前声明的版本号;没匹配到或匹配到多处都返回 null。 */
function readVersion(target) {
const { hits } = readTarget(target);
return hits.length === 1 ? hits[0][2] : null;
}
/** 改写一处。缺失或重复命中都算失败 —— 静默漏改比直接报错危险得多。 */
function writeVersion(target, next, { dry }) {
const { source, hits, global } = readTarget(target);
if (hits.length !== 1) {
throw new Error(
`${target.file} 里匹配到 ${hits.length} 处版本号(应为 1 处)。` +
' 模板被改过?请同步 scripts/version.mjs 里的 pattern 表。',
);
}
if (!dry) {
const replaced = source.replace(global, (_m, prefix, _old, suffix) => `${prefix}${next}${suffix}`);
fs.writeFileSync(path.join(root, target.file), replaced, 'utf8');
}
return `${target.file} ${hits[0][2]} → ${next}`;
}
function bumpProduct(current, level) {
const [major, minor, patch] = current.split('.').map(Number);
if (level === 'major') return `${major + 1}.0.0`;
if (level === 'minor') return `${major}.${minor + 1}.0`;
if (level === 'patch') return `${major}.${minor}.${patch + 1}`;
throw new Error(`未知的级别 ${level}(可选:major / minor / patch,或用 --set x.y.z)`);
}
/** 检查模式:逐处列出,并给出不一致清单。 */
function check(product) {
console.log(`产品版本(单一来源:根 package.json):${product}\n`);
const width = Math.max(...ALL_TARGETS.map((target) => target.file.length));
const problems = [];
for (const target of ALL_TARGETS) {
const version = readVersion(target);
if (!version) {
console.log(`?? ${target.file.padEnd(width)} (未匹配到版本号)`);
problems.push(`${target.file}:匹配不到版本号`);
continue;
}
// 插件版本是四段,只校验前三段;第 4 段允许不为 0(插件专属补丁号)
const expected = PLUGIN_RE.test(version) ? `${product}.0` : product;
const ok = PLUGIN_RE.test(version)
? version.split('.').slice(0, 3).join('.') === product
: version === expected;
console.log(`${ok ? ' ' : '!!'} ${target.file.padEnd(width)} ${version}`);
if (!ok) problems.push(`${target.file}:${version} ≠ ${expected}`);
}
if (problems.length) {
console.error('\n版本号不一致:');
for (const problem of problems) console.error(` - ${problem}`);
console.error('\n对齐方式:pnpm version:check 之后用 pnpm version:bump patch 统一改写。');
process.exit(1);
}
console.log('\n全部一致 ✅(插件第 4 段是插件专属补丁号,允许不为 0)');
}
function apply(next, { dry, product }) {
const pluginNext = `${next}.0`;
const lines = [];
for (const target of PRODUCT_TARGETS) lines.push(writeVersion(target, next, { dry }));
for (const target of PLUGIN_TARGETS) lines.push(writeVersion(target, pluginNext, { dry }));
console.log(
`${dry ? '[dry] 将改写' : '已改写'} ${product} → ${next}(插件 ${product}.0 → ${pluginNext}):\n`,
);
for (const line of lines) console.log(` ${line}`);
console.log('\n接着按 docs/dev/release.md 走发布流程;GitHub Release 的 tag(v' + next + ')也要对齐。');
}
function main() {
const args = process.argv.slice(2);
const dry = args.includes('--dry');
const bumpIndex = args.indexOf('--bump');
const setIndex = args.indexOf('--set');
const product = readVersion(PRODUCT_TARGETS[0]); // 根 package.json = 单一来源
if (!product || !PRODUCT_RE.test(product)) {
throw new Error('读不到根 package.json 的版本号(或它不是 x.y.z 形式)');
}
let next = null;
if (bumpIndex >= 0) {
const level = args[bumpIndex + 1];
if (!level) throw new Error('--bump 后面要跟 patch / minor / major');
next = bumpProduct(product, level);
} else if (setIndex >= 0) {
const value = args[setIndex + 1];
if (!value) throw new Error('--set 后面要跟 x.y.z');
if (!PRODUCT_RE.test(value)) throw new Error(`--set 的取值 ${value} 不是 x.y.z 形式`);
next = value;
}
if (!next) check(product);
else apply(next, { dry, product });
}
try {
main();
} catch (error) {
console.error(`[version] ${error.message}`);
process.exit(1);
}