Files
ESP32S3_vt/scripts/version.mjs
T
sloray 896b21fe44
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s
feat(client): RMIC::2026 界面重做、可计算的版本机制与固件版本联动
- 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: 补充界面语言、版本机制与协议通告布局的说明
2026-10-19 02:40:08 +08:00

422 lines
13 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
/**
* 版本号与改动日志的计算器。
*
* # 为什么要有它
*
* 版本号与改动日志原来都是**手写**的:改一次代码要记得去改三处版本号,再想想这次该升哪
* 一位,最后在 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("提示:工作区有未提交的改动,它们不会计入上面这份日志。");
}