- fix(ci): checkout 改成 fetch-depth: 0——没有标签、提交数恒为 1 时, version.mjs --check 算出来的版本一定是 0.0.0+1,于是每次推送都红 - fix(version): CHANGELOG 按标签区间重算全部历史,不再只渲染当前版本, 否则每次发布都会把上一版那一段覆盖掉 - fix(build): 固件包文件名用算出来的版本,不再用 BUILD-INFO.json 里那套旧编号
496 lines
16 KiB
JavaScript
496 lines
16 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*` 标签,按版本号从旧到新。 */
|
||
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("提示:工作区有未提交的改动,它们不会计入上面这份日志。");
|
||
}
|