Files
ESP32S3_vt/scripts/version.mjs
T
sloray d9a27b69f3
构建客户端 / 检查(类型 + 单元测试) (push) Waiting to run
构建客户端 / Linux x64(deb + AppImage) (push) Blocked by required conditions
构建客户端 / Windows x64(NSIS 安装包) (push) Blocked by required conditions
构建客户端 / macOS 通用(.app + .dmg) (push) Blocked by required conditions
fix(ci): 版本校验在浅克隆下必然误报
- fix(ci): checkout 改成 fetch-depth: 0——没有标签、提交数恒为 1 时,
  version.mjs --check 算出来的版本一定是 0.0.0+1,于是每次推送都红
- fix(version): CHANGELOG 按标签区间重算全部历史,不再只渲染当前版本,
  否则每次发布都会把上一版那一段覆盖掉
- fix(build): 固件包文件名用算出来的版本,不再用 BUILD-INFO.json 里那套旧编号
2026-10-19 02:42:53 +08:00

496 lines
16 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*` 标签,按版本号从旧到新。 */
function allTags() {
const raw = git(["tag", "--list", "v*", "--sort=version:refname"], { allowFail: true });
if (!raw) return [];
return raw.split("\n").map((line) => line.trim()).filter(Boolean);
}
/** `from..to` 之间的提交;`from` 为 null 表示从头开始。 */
function commitsBetween(from, to) {
const range = from ? `${from}..${to}` : to;
const raw = git(["log", range, "--no-merges", "--pretty=format:%H%x1f%s%x1f%b%x1e"], {
allowFail: true,
});
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 ?? "" };
});
}
/**
* 逐个标签还原历史发布。
*
* 改动日志必须是**可重复计算**的:同一次 `--write` 无论跑多少遍,输出都要一样。
* 所以旧版本不从文件里读回来,而是按标签区间重新算一遍——文件只是它的投影。
*/
function history() {
const tags = allTags();
const releases = [];
let previous = null;
for (const tag of tags) {
const commits = commitsBetween(previous, tag);
releases.push({
tag,
title: tag,
date: git(["log", "-1", "--pretty=format:%cs", tag], { allowFail: true }),
commits,
entries: commits.flatMap(entriesOf),
});
previous = tag;
}
return releases;
}
/** 最近的一个 `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,
// 历史发布按标签区间重新算,渲染时用。
releases: history(),
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 renderSection(title, date, meta, commits, entries, span) {
const lines = [];
lines.push(`## ${title} — ${date}`);
lines.push("");
if (entries.length === 0) {
lines.push("(这一版没有面向使用者的改动。)");
lines.push("");
return lines;
}
const stat = [`${commits.length} 个提交${span ? `(${span})` : ""}`];
if (meta) stat.unshift(`构建号 **${meta.build}**`, `线路协议 **${meta.protocol}**`);
lines.push(stat.join(" · "));
lines.push("");
for (const [type, list] of groupEntries(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;
}
/**
* 整份改动日志:当前这次要发布的 + 所有历史标签。
*
* 历史部分是从标签区间**重新算**出来的,不是把上一版的文件内容搬过来。这样跑多少遍
* `--write` 结果都一样,也不会因为某次忘了提交生成物就把历史吃掉。
*/
function renderChangelog(meta, result, releases) {
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("");
// HEAD 正好在最新标签上时(刚发布完),当前这一节没有内容,而同一个版本会在下面的
// 历史里出现一次。跳过它,免得同一版出现两个标题。
const hasCurrent = result.entries.length > 0 || releases.length === 0;
if (hasCurrent) {
const span = result.tag ? `${result.tag} → v${meta.version}` : `初始 → v${meta.version}`;
lines.push(
...renderSection(`v${meta.version}`, meta.date, meta, result.commits, result.entries, span),
);
}
for (const release of [...releases].reverse()) {
lines.push(...renderSection(release.title, release.date, null, release.commits, release.entries, ""));
}
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, result.releases));
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("提示:工作区有未提交的改动,它们不会计入上面这份日志。");
}