- feat(client): 界面换成墨黑蓝底 + 单支红的矩形语言,切角全部去掉,仅装甲条保留平行四边形 - feat(client): 自托管 HarmonyOS Sans SC 与 JetBrains Mono,中文与拉丁数字逐字回退 - feat(client): 新增启动序列、首启门、用户端 HUD、裁判端控制台、设置抽屉与固件维护页 - feat(client): 裁判端中缝与下方控制台可拖,鼠标落在哪一路下方就看哪一路 - feat(client): 设置抽屉常驻实时预览,与画面共用同一份准星绘制代码 - feat(client): 动效改用 Material 3 的缓动曲线与时长,逐条替换掉自造的贝塞尔 - feat(version): 版本号与 CHANGELOG 由 scripts/version.mjs 从 git 历史算出,不再手写 - feat(firmware): Hello 报文携带语义化版本、构建号与自报协议号,负载向后兼容 - feat(client): 固件版本显示在绑定列表与裁判端每一格;协议不匹配时给出具体原因 - feat(firmware): 固件包版本与客户端同源,都从 version.json 读 - fix(client): getCurrentWindow() 在没有 Tauri 注入时同步抛异常,浏览器预览整棵树被卸载 - fix(client): 启动序列的淡出 effect 清掉了自己刚设的 setTimeout,界面永远停在启动页 - fix(client): 设置抽屉左侧导航的锚点没挂到 section 上,且用错了坐标系 - fix(protocol): Hello 改用宽松帧头解析,让协议不匹配的板子能被解释而不是静默消失 - fix(build): Windows 上 bindgen 加载不了 libclang 的同目录依赖,构建脚本补上搜索路径 - fix(build): esp-idf-sys 在长路径下拒绝构建,固件构建改用短的目标目录 - build(client): 队标图标,含光学尺寸分级与手工拼的多尺寸 ICO - build(ci): 守门 job 增加版本一致性检查,产物名改为 rmic-2026-* - docs: 补充界面语言、版本机制与协议通告布局的说明
422 lines
13 KiB
JavaScript
422 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 版本号与改动日志的计算器。
|
||
*
|
||
* # 为什么要有它
|
||
*
|
||
* 版本号与改动日志原来都是**手写**的:改一次代码要记得去改三处版本号,再想想这次该升哪
|
||
* 一位,最后在 CHANGELOG 里补一段。这三件事都会忘,而忘了之后没有任何东西会报错——
|
||
* 典型表现是装包里写着 0.1.0,实际是两周后的代码。
|
||
*
|
||
* 现在它们都从 **git 历史**算出来:
|
||
*
|
||
* - 版本号 = 上一个 tag + 这之间提交的语义(conventional commits);
|
||
* - 改动日志 = 同一批提交按类型分组;
|
||
* - 构建号 = `git rev-list --count HEAD`,单调递增,无冲突。
|
||
*
|
||
* 于是"这次该升哪一位"不再是一个判断,而是一次计算。
|
||
*
|
||
* # 用法
|
||
*
|
||
* node scripts/version.mjs --write # 计算并写回所有派生文件
|
||
* node scripts/version.mjs --check # 只校验:有未归档的提交就退 1
|
||
* node scripts/version.mjs --print # 把算出来的 version.json 打到 stdout
|
||
*
|
||
* # 约定
|
||
*
|
||
* 提交标题用 Conventional Commits:`type(scope): 说明`,可加 `!` 表示破坏性改动,
|
||
* 或在正文里写 `BREAKING CHANGE:`。规则:
|
||
*
|
||
* | 有 breaking | major |
|
||
* | 有 feat | minor |
|
||
* | 有 fix / perf | patch |
|
||
* | 其它 | 不动版本,只加构建号 |
|
||
*
|
||
* 正文里以 `- ` 开头、且本身写成 `type(scope): 说明` 的行,会被当作**额外的条目**收进
|
||
* 同一份日志。这样一次 squash 提交也能把几件事分别归类,而不必为了日志好看去拆提交。
|
||
*/
|
||
|
||
import { execFileSync } from "node:child_process";
|
||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||
import { dirname, join, resolve } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||
|
||
/** 各类提交在日志里的标题与顺序。没列出的类型归入「其它」。 */
|
||
const GROUPS = [
|
||
["feat", "新增"],
|
||
["fix", "修复"],
|
||
["perf", "性能"],
|
||
["refactor", "重构"],
|
||
["build", "构建"],
|
||
["ci", "CI"],
|
||
["docs", "文档"],
|
||
["test", "测试"],
|
||
["style", "样式"],
|
||
["revert", "回滚"],
|
||
["chore", "杂项"],
|
||
["other", "其它"],
|
||
];
|
||
|
||
const TYPE_ORDER = GROUPS.map(([type]) => type);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// git
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function git(args, { allowFail = false } = {}) {
|
||
try {
|
||
return execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim();
|
||
} catch (err) {
|
||
if (allowFail) return "";
|
||
throw new Error(`git ${args.join(" ")} 失败:${err.message}`);
|
||
}
|
||
}
|
||
|
||
/** 最近的一个 `v*` 标签;没有标签时返回 null(第一次发布)。 */
|
||
function lastTag() {
|
||
const tag = git(["describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"], {
|
||
allowFail: true,
|
||
});
|
||
return tag || null;
|
||
}
|
||
|
||
/** `v1.2.3` → `[1,2,3]`。 */
|
||
function parseTag(tag) {
|
||
const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag ?? "");
|
||
if (!match) return [0, 0, 0];
|
||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||
}
|
||
|
||
/**
|
||
* 取 `since..HEAD` 的提交。
|
||
*
|
||
* 用 `%x1f` / `%x1e` 作分隔符:它们不会出现在提交信息里,比按空行切更稳。
|
||
*/
|
||
function commitsSince(since) {
|
||
const range = since ? `${since}..HEAD` : "HEAD";
|
||
const raw = git(["log", range, "--no-merges", "--pretty=format:%H%x1f%s%x1f%b%x1e"]);
|
||
if (!raw) return [];
|
||
|
||
return raw
|
||
.split("\x1e")
|
||
.map((chunk) => chunk.trim())
|
||
.filter(Boolean)
|
||
.map((chunk) => {
|
||
const [hash, subject, body] = chunk.split("\x1f");
|
||
return { hash, subject: subject ?? "", body: body ?? "" };
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 解析
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const SUBJECT = /^(?<type>[a-z]+)(?:\((?<scope>[^)]+)\))?(?<bang>!)?:\s*(?<text>.+)$/;
|
||
|
||
/**
|
||
* 把一条提交拆成一个主条目 + 正文里的追加条目。
|
||
*
|
||
* 正文里的追加条目必须自己写成 `type(scope): 说明`,否则不猜——猜错的日志比没有更糟。
|
||
*/
|
||
function entriesOf(commit) {
|
||
const head = SUBJECT.exec(commit.subject);
|
||
const breaking = /(^|\n)BREAKING[ -]CHANGE:/.test(commit.body) || Boolean(head?.groups?.bang);
|
||
|
||
const entries = [];
|
||
if (head) {
|
||
entries.push({
|
||
type: head.groups.type,
|
||
scope: head.groups.scope ?? null,
|
||
text: head.groups.text.trim(),
|
||
breaking,
|
||
});
|
||
} else {
|
||
entries.push({ type: "other", scope: null, text: commit.subject, breaking });
|
||
}
|
||
|
||
for (const line of commit.body.split("\n")) {
|
||
const bullet = /^\s*[-*]\s+(.+)$/.exec(line);
|
||
if (!bullet) continue;
|
||
const nested = SUBJECT.exec(bullet[1].trim());
|
||
if (!nested) continue;
|
||
entries.push({
|
||
type: nested.groups.type,
|
||
scope: nested.groups.scope ?? null,
|
||
text: nested.groups.text.trim(),
|
||
breaking: Boolean(nested.groups.bang),
|
||
});
|
||
}
|
||
|
||
return entries;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 计算
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function bump(base, entries) {
|
||
if (entries.some((entry) => entry.breaking)) return [base[0] + 1, 0, 0];
|
||
if (entries.some((entry) => entry.type === "feat")) return [base[0], base[1] + 1, 0];
|
||
if (entries.some((entry) => entry.type === "fix" || entry.type === "perf")) {
|
||
return [base[0], base[1], base[2] + 1];
|
||
}
|
||
return base;
|
||
}
|
||
|
||
function compute() {
|
||
const tag = lastTag();
|
||
const base = parseTag(tag);
|
||
const commits = commitsSince(tag);
|
||
const entries = commits.flatMap(entriesOf);
|
||
const version = bump(base, entries);
|
||
const build = Number(git(["rev-list", "--count", "HEAD"]) || 0);
|
||
|
||
return {
|
||
tag,
|
||
base,
|
||
commits,
|
||
entries,
|
||
version,
|
||
build,
|
||
dirty: git(["status", "--porcelain"]).length > 0,
|
||
};
|
||
}
|
||
|
||
function metaOf(result) {
|
||
const [major, minor, patch] = result.version;
|
||
return {
|
||
version: `${major}.${minor}.${patch}`,
|
||
major,
|
||
minor,
|
||
patch,
|
||
build: result.build,
|
||
protocol: protocolVersion(),
|
||
// 生成日期取提交日期而不是"现在":同一次发布重跑两次,产物必须逐字节相同,
|
||
// 否则每次 --check 都会报一个并不存在的差异。
|
||
date: git(["log", "-1", "--pretty=format:%cs"]),
|
||
};
|
||
}
|
||
|
||
/** 线路协议版本以 protocol crate 里的常量为准,不从别处猜。 */
|
||
function protocolVersion() {
|
||
const source = readFileSync(join(ROOT, "protocol/src/lib.rs"), "utf8");
|
||
const match = /pub const PROTOCOL_VERSION: u16 = (\d+);/.exec(source);
|
||
if (!match) throw new Error("protocol/src/lib.rs 里找不到 PROTOCOL_VERSION");
|
||
return Number(match[1]);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 渲染
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function groupEntries(entries) {
|
||
const buckets = new Map(GROUPS.map(([type]) => [type, []]));
|
||
for (const entry of entries) {
|
||
const bucket = buckets.get(entry.type) ?? buckets.get("other");
|
||
bucket.push(entry);
|
||
}
|
||
return [...buckets.entries()]
|
||
.filter(([, list]) => list.length > 0)
|
||
.sort((a, b) => TYPE_ORDER.indexOf(a[0]) - TYPE_ORDER.indexOf(b[0]));
|
||
}
|
||
|
||
function renderChangelog(meta, result) {
|
||
const lines = [];
|
||
lines.push("# 改动记录");
|
||
lines.push("");
|
||
lines.push(
|
||
"> 本文件由 `scripts/version.mjs` 依据 git 历史生成,**请勿手改**。",
|
||
);
|
||
lines.push("> 提交信息遵循 Conventional Commits,一条一个条目;要求见该脚本的文件头。");
|
||
lines.push("> 建立这套机制之前的人工整理记录保存在 [`docs/CHANGELOG-legacy.md`](docs/CHANGELOG-legacy.md)。");
|
||
lines.push("");
|
||
|
||
if (result.entries.length === 0) {
|
||
lines.push(`## v${meta.version} — ${meta.date}`);
|
||
lines.push("");
|
||
lines.push("(自上一个发布以来没有面向使用者的改动。)");
|
||
lines.push("");
|
||
return lines.join("\n");
|
||
}
|
||
|
||
const span = result.tag ? `${result.tag} → v${meta.version}` : `初始 → v${meta.version}`;
|
||
lines.push(`## v${meta.version} — ${meta.date}`);
|
||
lines.push("");
|
||
lines.push(
|
||
`构建号 **${meta.build}** · 线路协议 **${meta.protocol}** · ` +
|
||
`${result.commits.length} 个提交(${span})`,
|
||
);
|
||
lines.push("");
|
||
|
||
|
||
for (const [type, list] of groupEntries(result.entries)) {
|
||
const heading = GROUPS.find(([key]) => key === type)?.[1] ?? "其它";
|
||
lines.push(`### ${heading}`);
|
||
lines.push("");
|
||
for (const entry of list) {
|
||
const scope = entry.scope ? `**${entry.scope}** · ` : "";
|
||
const bang = entry.breaking ? "(破坏性)" : "";
|
||
lines.push(`- ${scope}${entry.text}${bang}`);
|
||
}
|
||
lines.push("");
|
||
}
|
||
|
||
return lines.join("\n");
|
||
}
|
||
|
||
function renderJson(meta, result) {
|
||
return (
|
||
JSON.stringify(
|
||
{
|
||
...meta,
|
||
baseline: result.tag,
|
||
commits: result.commits.length,
|
||
groups: Object.fromEntries(
|
||
groupEntries(result.entries).map(([type, list]) => [
|
||
type,
|
||
list.map((entry) => ({
|
||
scope: entry.scope,
|
||
text: entry.text,
|
||
breaking: entry.breaking,
|
||
})),
|
||
]),
|
||
),
|
||
},
|
||
null,
|
||
2,
|
||
) + "\n"
|
||
);
|
||
}
|
||
|
||
function renderTs(meta) {
|
||
return `/**
|
||
* 构建信息。**由 \`scripts/version.mjs\` 生成,请勿手改。**
|
||
*
|
||
* 版本号与构建号都从 git 历史算出来(见该脚本的文件头),所以界面显示的一定就是
|
||
* 这一份二进制的来源,不会出现"装包里写着 0.1.0,其实是两周后的代码"。
|
||
*/
|
||
|
||
export const BUILD = {
|
||
version: "${meta.version}",
|
||
major: ${meta.major},
|
||
minor: ${meta.minor},
|
||
patch: ${meta.patch},
|
||
/** git 提交数,单调递增。 */
|
||
build: ${meta.build},
|
||
/** 线路协议版本。与固件自报的那个数直接比对。 */
|
||
protocol: ${meta.protocol},
|
||
/** 生成时的提交日期(\`YYYY-MM-DD\`)。 */
|
||
date: "${meta.date}",
|
||
} as const;
|
||
|
||
/** \`"0.2.0+57"\` —— 界面上放得下的那一行。 */
|
||
export const VERSION_LABEL = \`v\${BUILD.version}+\${BUILD.build}\`;
|
||
`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 写回
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function writeIfChanged(path, content) {
|
||
const full = join(ROOT, path);
|
||
if (existsSync(full) && readFileSync(full, "utf8") === content) {
|
||
return false;
|
||
}
|
||
writeFileSync(full, content, "utf8");
|
||
return true;
|
||
}
|
||
|
||
/** 把版本写进 JSON 文件,保留原有排版(这些文件是给人看也给人改的)。 */
|
||
function syncJsonVersion(path, version) {
|
||
const full = join(ROOT, path);
|
||
if (!existsSync(full)) return false;
|
||
const source = readFileSync(full, "utf8");
|
||
const next = source.replace(/("version"\s*:\s*)"[^"]*"/, `$1"${version}"`);
|
||
if (next === source) return false;
|
||
writeFileSync(full, next, "utf8");
|
||
return true;
|
||
}
|
||
|
||
function write(meta, result) {
|
||
const changed = [];
|
||
const put = (path, content) => {
|
||
if (writeIfChanged(path, content)) changed.push(path);
|
||
};
|
||
|
||
put(
|
||
"version.json",
|
||
JSON.stringify(
|
||
{
|
||
...meta,
|
||
baseline: result.tag,
|
||
note: "由 scripts/version.mjs 从 git 历史算出,请勿手改。",
|
||
},
|
||
null,
|
||
2,
|
||
) + "\n",
|
||
);
|
||
put("CHANGELOG.md", renderChangelog(meta, result));
|
||
put("CHANGELOG.json", renderJson(meta, result));
|
||
put("desktop/src/lib/version.ts", renderTs(meta));
|
||
|
||
if (syncJsonVersion("desktop/package.json", meta.version)) changed.push("desktop/package.json");
|
||
if (syncJsonVersion("desktop/src-tauri/tauri.conf.json", meta.version)) {
|
||
changed.push("desktop/src-tauri/tauri.conf.json");
|
||
}
|
||
|
||
return changed;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 入口
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const argv = new Set(process.argv.slice(2));
|
||
const result = compute();
|
||
const meta = metaOf(result);
|
||
|
||
if (argv.has("--print")) {
|
||
process.stdout.write(JSON.stringify(meta, null, 2) + "\n");
|
||
process.exit(0);
|
||
}
|
||
|
||
if (argv.has("--check")) {
|
||
const committed = existsSync(join(ROOT, "version.json"))
|
||
? JSON.parse(readFileSync(join(ROOT, "version.json"), "utf8"))
|
||
: null;
|
||
|
||
if (!committed) {
|
||
console.error("version.json 不存在。先跑一次 `node scripts/version.mjs --write`。");
|
||
process.exit(1);
|
||
}
|
||
|
||
if (committed.version !== meta.version || committed.build !== meta.build) {
|
||
console.error(
|
||
`版本已过期:仓库里写的是 v${committed.version}+${committed.build},` +
|
||
`按 git 历史算出来应该是 v${meta.version}+${meta.build}。\n` +
|
||
"跑 `node scripts/version.mjs --write` 再提交。",
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log(`版本一致:v${meta.version}+${meta.build}`);
|
||
process.exit(0);
|
||
}
|
||
|
||
const changed = write(meta, result);
|
||
console.log(
|
||
`v${meta.version}+${meta.build}(协议 ${meta.protocol},基线 ${result.tag ?? "无"},` +
|
||
`${result.commits.length} 个提交,${result.entries.length} 个条目)`,
|
||
);
|
||
if (changed.length > 0) {
|
||
console.log("已更新:" + changed.join("、"));
|
||
} else {
|
||
console.log("所有派生文件都是最新的。");
|
||
}
|
||
if (result.dirty) {
|
||
console.log("提示:工作区有未提交的改动,它们不会计入上面这份日志。");
|
||
}
|