#!/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 = /^(?[a-z]+)(?:\((?[^)]+)\))?(?!)?:\s*(?.+)$/; /** * 把一条提交拆成一个主条目 + 正文里的追加条目。 * * 正文里的追加条目必须自己写成 `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("提示:工作区有未提交的改动,它们不会计入上面这份日志。"); }