Files
nodeagent/scripts/lib/version-write.mjs
T
jkooe bce11514a9 fix(release): 发版脚本漏改 Cargo 清单导致版本号分叉
release.mjs patch --dry-run 实测:只改 8 个 package.json + 1 个 tauri.conf.json,而
apps/console/src-tauri/{Cargo.toml,Cargo.lock} 里**同样存着 2.0.2** —— 发版后必然滞留,
与 tauri.conf.json 悄悄分叉,且**不报错**(Tauri 打包以 tauri.conf.json 为准)。

- 版本写入抽成 scripts/lib/version-write.mjs(纯函数、可测),release.mjs 调用它。
  抽出来的理由:埋在脚本里时单测只能断言「源码里出现过某个字符串」—— 那种守卫注入
  故障都不会变红(铁律:守卫要落在最终产物字节上)。
- Cargo.toml **只在 [package] 段**改(非 package 段里也可能有独占一行的 version = "…");
  Cargo.lock **按 [[package]] 块 + crate 名定位**(全文盲替换会改坏 thiserror 2.0.21
  这类依赖版本)。
- release.mjs 的「脏文件白名单」改为取自**实际改写结果**(changed),不再手写文件名列表 ——
  手写的那份一旦与写入逻辑漂移,要么拦下本该放行的(发版卡死),要么放过不该放行的
  (静默推走改动)。
- 新增 tests/unit/version-write.test.mjs(9 条):在临时目录铺**真实文件**并 git init/git add
  (versionTargets 走 git ls-files,不真进索引就会假绿),跑完读回文件断言;另含一条
  **仓库版本号一致性守卫**,发版漏项会直接变红。
- 注入 4 处故障实测:编排漏 Cargo / Cargo.toml 全文盲替换 / Cargo.lock 按位置盲改 /
  仓库版本分叉 —— 全部变红,还原后全绿。
2026-10-13 17:16:46 +08:00

183 lines
6.9 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.
/**
* 版本号写入 —— 发版脚本与测试**共用**的唯一实现。
*
* 为什么单独成模块(2026-10-13):
* `scripts/release.mjs` 原先只覆盖 `package.json` + `tauri.conf.json`,
* 但 `apps/console/src-tauri/Cargo.toml` 与 `Cargo.lock` 里**同样存着版本号**。
* 发版后这两处会**滞留旧版本**,与 `tauri.conf.json` 悄悄分叉 ——
* 而分叉不报错(Tauri 打包以 `tauri.conf.json` 为准),属于最难发现的那类漂移。
*
* 为什么把它从脚本里抽出来:
* 铁律「守卫要落在**最终产物字节**上」。若版本写入逻辑埋在 `release.mjs` 里,
* 单测只能断言「源码里出现过某个字符串」—— 那种守卫注入故障都不会变红。
* 抽成纯函数后,测试可以对**临时目录里的真实文件**跑一遍再断言文件内容。
*/
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
/** 拿 `git ls-files` 的结果(只认已入库文件 —— 未入库的构建产物不该被改版本号)。 */
function lsFiles(root, pattern) {
try {
return execFileSync('git', ['ls-files', pattern], { cwd: root, encoding: 'utf8' })
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
} catch {
return [];
}
}
/**
* 枚举「存着版本号、需要一起改」的文件。
* Cargo 相关文件用 `optional: true` 标记:没有 Rust 侧时不该让发版直接失败。
*/
export function versionTargets(root = process.cwd()) {
const pkgFiles = ['package.json'];
for (const f of lsFiles(root, '*package.json')) {
if (f === 'package.json' || f.includes('node_modules')) continue;
pkgFiles.push(f);
}
return {
pkgFiles,
tauriConfFiles: lsFiles(root, '*src-tauri/tauri.conf.json'),
cargoTomlFiles: lsFiles(root, '*src-tauri/Cargo.toml'),
cargoLockFiles: lsFiles(root, '*src-tauri/Cargo.lock'),
};
}
/**
* 改 `Cargo.toml` 的版本号 —— **只在 `[package]` 段内**改。
*
* 为什么要限定段落:`Cargo.toml` 里 `version = "..."` 这个形状还出现在别处
* (例如 `[dependencies]` 里 `some-crate = { version = "1" }` 是行内的,
* 但某些写法会把 `version = "..."` 单独成行)。不做限定就成了「全文盲替换」,
* 会改坏依赖约束 —— 那比不改更糟。
*/
export function setCargoTomlVersion(text, version, label = 'Cargo.toml') {
const lines = text.split('\n');
let inPackage = false;
let done = false;
for (let i = 0; i < lines.length; i += 1) {
const trimmed = lines[i].trim();
if (trimmed.startsWith('[')) {
inPackage = trimmed === '[package]';
continue;
}
if (!inPackage || done) continue;
if (/^version\s*=/.test(trimmed)) {
lines[i] = lines[i].replace(/^(\s*version\s*=\s*")[^"]*(".*)$/, `$1${version}$2`);
done = true;
}
}
if (!done) throw new Error(`${label} 的 [package] 段里找不到 version 行`);
return lines.join('\n');
}
/** 从 `Cargo.toml` 的 `[package]` 段读 crate 名(用于去 `Cargo.lock` 里定位同一个包)。 */
export function readCargoPackageName(text) {
const lines = text.split('\n');
let inPackage = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('[')) {
inPackage = trimmed === '[package]';
continue;
}
if (!inPackage) continue;
const m = /^name\s*=\s*"([^"]*)"$/.exec(trimmed);
if (m) return m[1];
}
return null;
}
/**
* 改 `Cargo.lock` 里**指定 crate** 的版本号。
*
* `Cargo.lock` 是「按 `[[package]]` 分块、块内 `name` 后跟 `version`」的结构,
* 且全文散布着**大量同名依赖版本**(`thiserror 2.0.21`、`proc-macro-crate 2.0.2` …)。
* 直接 `replace('2.0.2', ...)` 会把依赖版本一起改坏 —— 所以必须按块定位。
* 找不到该包时返回原文本(未构建过的 checkout 里可能没有),不报错。
*/
export function setCargoLockVersion(text, pkgName, version) {
const lines = text.split('\n');
let currentName = null;
let done = false;
for (let i = 0; i < lines.length; i += 1) {
const trimmed = lines[i].trim();
if (trimmed === '[[package]]') {
currentName = null;
continue;
}
const nameMatch = /^name\s*=\s*"([^"]*)"$/.exec(trimmed);
if (nameMatch) {
currentName = nameMatch[1];
continue;
}
if (currentName === pkgName && !done && /^version\s*=/.test(trimmed)) {
lines[i] = lines[i].replace(/^(\s*version\s*=\s*")[^"]*(".*)$/, `$1${version}$2`);
done = true;
}
}
return { text: lines.join('\n'), changed: done };
}
/**
* 把版本号写到**全部**存放它的地方。
*
* @returns {{ changed: Array<{file:string, before:string, kind:string}>, skipped: string[] }}
*/
export function writeVersionEverywhere(root, version, { dryRun = false } = {}) {
const targets = versionTargets(root);
const changed = [];
const skipped = [];
// ---- JSON 类:package.json 与 tauri.conf.json 同构,写回时保持 2 空格缩进 ----
for (const rel of [...targets.pkgFiles, ...targets.tauriConfFiles]) {
const abs = join(root, rel);
let parsed;
try {
parsed = JSON.parse(readFileSync(abs, 'utf8'));
} catch {
skipped.push(`${rel}(读不出 JSON)`);
continue;
}
if (!parsed.version) {
skipped.push(`${rel}(无 version 字段)`);
continue;
}
changed.push({ file: rel, before: parsed.version, kind: 'json' });
if (dryRun) continue;
parsed.version = version;
writeFileSync(abs, `${JSON.stringify(parsed, null, 2)}\n`);
}
// ---- Cargo.toml + Cargo.lock:成对处理(lock 里要按 crate 名定位) ----
for (const rel of targets.cargoTomlFiles) {
const abs = join(root, rel);
const text = readFileSync(abs, 'utf8');
const before = /\[package\][\s\S]*?^version\s*=\s*"([^"]*)"/m.exec(text)?.[1];
const crateName = readCargoPackageName(text);
changed.push({ file: rel, before: before ?? '?', kind: 'cargo-toml' });
if (!dryRun) writeFileSync(abs, setCargoTomlVersion(text, version, rel));
if (!crateName) continue;
const lockRel = targets.cargoLockFiles.find((f) => f.startsWith(rel.slice(0, rel.length - 'Cargo.toml'.length)));
if (!lockRel) continue;
const lockAbs = join(root, lockRel);
const lockText = readFileSync(lockAbs, 'utf8');
const lockBefore = new RegExp(`name\\s*=\\s*"${crateName}"\\nversion\\s*=\\s*"([^"]*)"`).exec(lockText)?.[1];
if (lockBefore === undefined) {
skipped.push(`${lockRel}(未找到 crate ${crateName},可能尚未构建)`);
continue;
}
changed.push({ file: lockRel, before: lockBefore, kind: 'cargo-lock' });
if (dryRun) continue;
const result = setCargoLockVersion(lockText, crateName, version);
if (result.changed) writeFileSync(lockAbs, result.text);
}
return { changed, skipped };
}