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 按位置盲改 /
  仓库版本分叉 —— 全部变红,还原后全绿。
This commit is contained in:
jkooe committed 2026-10-13 17:16:46 +08:00
1 parent 530bb4fca7
commit bce11514a9
3 files changed
+428 -33

No files matched your search

+182
View File
@@ -0,0 +1,182 @@
/**
* 版本号写入 —— 发版脚本与测试**共用**的唯一实现。
*
* 为什么单独成模块(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 };
}
+21 -33
View File
@@ -19,6 +19,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { checkZipArch } from './lib/zip-pe-arch.mjs';
import { writeVersionEverywhere } from './lib/version-write.mjs';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const argv = process.argv.slice(2);
@@ -98,41 +99,25 @@ const remote = sh('git', ['remote', 'get-url', 'origin'])
const repoPath = remote.replace(/^https:\/\/github\.com\//, '');
if (!/^[\w.-]+\/[\w.-]+$/.test(repoPath)) fail(`无法从 remote 推断 GitHub 仓库: ${remote}`);
// ---------- ② 改版本(单一版本源:根 + 各 workspace + Tauri 清单)----------
const pkgFiles = ['package.json'];
for (const f of sh('git', ['ls-files', '*package.json']).split('\n').filter(Boolean)) {
if (f === 'package.json' || f.includes('node_modules')) continue;
pkgFiles.push(f);
}
// Tauri 的版本号不在 package.json 里,另存于 src-tauri/tauri.conf.json,
// 且它才是最终打进 .app / 安装包的版本。漏掉会「包里是旧版本号」,故一并纳入。
const tauriConfFiles = sh('git', ['ls-files', '*src-tauri/tauri.conf.json'])
.split('\n')
.filter(Boolean);
// ---------- ② 改版本(单一版本源:根 + 各 workspace + Tauri/Cargo 清单)----------
// 2026-10-13:原先这里手写两段 for 循环,只覆盖 package.json + tauri.conf.json,
// 于是 src-tauri/{Cargo.toml,Cargo.lock} 里的版本号在发版后会**滞留**(dry-run 实证:8+1 处,
// Cargo 两处漏网),与 tauri.conf.json 悄悄分叉 —— 不报错,属最难发现的漂移。
// 现收敛进 lib/version-write.mjs(纯函数),由 tests/unit/version-write.test.mjs 对**真实文件**断言。
const { changed, skipped } = writeVersionEverywhere(root, version, { dryRun });
if (dryRun) {
// dry-run 绝不改文件 —— 演练就该是无副作用的
console.log(`(dry-run)将把 ${pkgFiles.length} 个 package.json 的 version 改为 ${version}`);
if (tauriConfFiles.length) {
console.log(`(dry-run)以及 ${tauriConfFiles.length} 个 tauri.conf.json:${tauriConfFiles.join(', ')}`);
}
// dry-run 绝不改文件 —— 演练就该是无副作用的(writeVersionEverywhere 已按 dryRun 只读)
const byKind = (k) => changed.filter((c) => c.kind === k).map((c) => c.file);
console.log(`(dry-run)将把 ${changed.length} 处版本号改为 ${version}:`);
for (const rel of byKind('json')) console.log(` ${rel}`);
for (const rel of byKind('cargo-toml')) console.log(` ${rel}`);
for (const rel of byKind('cargo-lock')) console.log(` ${rel}`);
for (const s of skipped) console.log(` (跳过)${s}`);
console.log('(dry-run)将执行:构建 → pack:win → pack:mac → 生成资产 → 提交打标签 → gh release create');
process.exit(0);
}
for (const f of pkgFiles) {
const p = join(root, f);
const j = JSON.parse(readFileSync(p, 'utf8'));
if (!j.version) continue;
j.version = version;
writeFileSync(p, JSON.stringify(j, null, 2) + '\n');
}
for (const f of tauriConfFiles) {
const p = join(root, f);
const j = JSON.parse(readFileSync(p, 'utf8'));
if (!j.version) continue;
j.version = version;
writeFileSync(p, JSON.stringify(j, null, 2) + '\n');
}
console.log(`✓ 版本号已写入 ${pkgFiles.length} 个 package.json + ${tauriConfFiles.length} 个 tauri.conf.json`);
for (const s of skipped) console.warn(`(跳过)${s}`);
console.log(`✓ 版本号已写入 ${changed.length} 处(package.json + tauri.conf.json + Cargo 清单)`);
// ---------- ③ 构建 + 打包 ----------
console.log('=== 构建 ===');
@@ -261,9 +246,12 @@ if (untracked.length > 0) {
// 同样会提交已修改的跟踪文件,而它们不在第一层的视野里。
// 真例:`pnpm install` 把某个应用的依赖写进了跟踪中的根 pnpm-lock.yaml
// (+1401 行),发版时静默推走,第一层闸门完全不响。
// 故此处用「脏文件白名单」:除脚本自己改的 package.json 外,一律不许有别处改动。
// 故此处用「脏文件白名单」:除脚本自己改的版本号文件外,一律不许有别处改动。
// 正常发版应在**干净工作区**上开始,所以这条不该有例外。
const allowedDirty = new Set([...pkgFiles, ...tauriConfFiles]);
// ⚠️ 白名单**必须**取自 writeVersionEverywhere 的实际改写清单(changed),
// 而不是重新手写一份文件名列表 —— 手写的那份一旦与写入逻辑漂移,
// 要么把该放行的拦下来(发版卡死),要么把不该放行的放过去(静默推走改动)。
const allowedDirty = new Set(changed.map((c) => c.file));
const dirty = sh('git', ['diff', '--name-only', 'HEAD'])
.split('\n')
.filter(Boolean);
+225
View File
@@ -0,0 +1,225 @@
/**
* 版本号写入(`scripts/lib/version-write.mjs`)的行为守卫 + 发版漂移守卫。
*
* ⚠️ 背景(2026-10-13):`scripts/release.mjs` 原先只改 `package.json` + `tauri.conf.json`,
* 于是 `apps/console/src-tauri/{Cargo.toml,Cargo.lock}` 里的版本号**发版后会滞留**。
* `release.mjs patch --dry-run` 实证:报「8 个 package.json + 1 个 tauri.conf.json」,
* Cargo 两处**完全不在视野里** —— 而它们同样写着 2.0.2,会与 tauri.conf.json 悄悄分叉。
* 分叉不报错(Tauri 打包以 tauri.conf.json 为准)→ 属最难发现的漂移。
*
* 为什么这样测(铁律「守卫要落在最终产物字节上」):
* 首版若断言「release.mjs 源码里出现过 Cargo.toml 字样」,注入故障都不会变红。
* 本文件改为:在临时目录里铺**真实文件**(含 git init + git add,让 `git ls-files` 真的工作),
* 跑一遍写入,再**读回文件内容**断言 —— 这是能被注入故障打红的守卫。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
readCargoPackageName,
setCargoLockVersion,
setCargoTomlVersion,
versionTargets,
writeVersionEverywhere,
} from '../../scripts/lib/version-write.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, '..', '..');
// ---------------------------------------------------------------------------
// ① 纯函数:Cargo.toml
// ---------------------------------------------------------------------------
test('Cargo.toml:只改 [package] 段的 version', () => {
const src = [
'[package]',
'name = "nodeagent-console"',
'version = "2.0.2"',
'edition = "2021"',
'',
'[build-dependencies]',
'tauri-build = { version = "2", features = [] }',
'',
// 关键陷阱:非 [package] 段里**同样存在** `version = "2.0.2"` 这种独占一行的写法。
// 不做段落限定的话,全文盲替换会把它一起改坏(TOML 合法,改完不报错)。
'[package.metadata.deb]',
'version = "2.0.2"',
'',
].join('\n');
const out = setCargoTomlVersion(src, '9.9.9', 'Cargo.toml');
assert.match(out, /\[package\]\nname = "nodeagent-console"\nversion = "9\.9\.9"\n/);
assert.equal(out.match(/version = "2\.0\.2"/g)?.length, 1, '非 [package] 段的那处必须原样保留');
assert.equal(out.match(/version = "9\.9\.9"/g)?.length, 1, '被改的只能有一处');
});
test('Cargo.toml:缺 [package] version 时大声失败,不静默跳过', () => {
assert.throws(() => setCargoTomlVersion('[package]\nname = "x"\n', '9.9.9', 'Cargo.toml'), /找不到 version 行/);
});
test('Cargo.toml:读到 crate 名(用于去 Cargo.lock 里定位同名包)', () => {
assert.equal(readCargoPackageName('[package]\nname = "nodeagent-console"\nversion = "2.0.2"\n'), 'nodeagent-console');
assert.equal(readCargoPackageName('[dependencies]\nname = "not-this"\n'), null);
});
// ---------------------------------------------------------------------------
// ② 纯函数:Cargo.lock
// ---------------------------------------------------------------------------
test('Cargo.lock:只改指定 crate 的版本,同名依赖版本一律不动', () => {
const src = [
'version = 4',
'',
'[[package]]',
'name = "nodeagent-console"',
'version = "2.0.2"',
'dependencies = [',
' "thiserror",',
']',
'',
'[[package]]',
'name = "proc-macro-crate"',
'version = "2.0.2"',
'',
'[[package]]',
'name = "statuses"',
'version = "2.0.2"',
'',
].join('\n');
const { text, changed } = setCargoLockVersion(src, 'nodeagent-console', '9.9.9');
assert.equal(changed, true);
assert.match(text, /name = "nodeagent-console"\nversion = "9\.9\.9"/);
assert.equal(
text.match(/version = "2\.0\.2"/g)?.length,
2,
'两个同名版本的依赖(proc-macro-crate / statuses)必须原样保留',
);
// 顶部那个 `version = 4`(lock 格式版本)也不能被动
assert.match(text, /^version = 4\n/);
});
test('Cargo.lock:找不到该 crate 时返回原文并报 changed=false(未构建的 checkout)', () => {
const { text, changed } = setCargoLockVersion('[[package]]\nname = "other"\nversion = "1.0.0"\n', 'nodeagent-console', '9.9.9');
assert.equal(changed, false);
assert.match(text, /name = "other"\nversion = "1\.0\.0"/);
});
// ---------------------------------------------------------------------------
// ③ 编排:对临时目录里的**真实文件**跑一遍
// ---------------------------------------------------------------------------
function scaffold() {
const dir = mkdtempSync(join(tmpdir(), 'na-version-'));
const tauriDir = join(dir, 'apps', 'console', 'src-tauri');
mkdirSync(tauriDir, { recursive: true });
writeFileSync(join(dir, 'package.json'), `${JSON.stringify({ name: 'nodeagent', version: '2.0.2' }, null, 2)}\n`);
writeFileSync(join(tauriDir, 'tauri.conf.json'), `${JSON.stringify({ productName: 'x', version: '2.0.2' }, null, 2)}\n`);
writeFileSync(
join(tauriDir, 'Cargo.toml'),
['[package]', 'name = "nodeagent-console"', 'version = "2.0.2"', 'edition = "2021"', ''].join('\n'),
);
writeFileSync(
join(tauriDir, 'Cargo.lock'),
[
'version = 4',
'',
'[[package]]',
'name = "nodeagent-console"',
'version = "2.0.2"',
'',
'[[package]]',
'name = "statuses"',
'version = "2.0.2"',
'',
].join('\n'),
);
// 必须真的进一次 git 索引 —— `versionTargets` 走 `git ls-files`,
// 只认「已入库文件」,否则临时目录里它会返回空集,测试就会假绿。
execFileSync('git', ['init', '-q'], { cwd: dir });
execFileSync('git', ['add', '-A'], { cwd: dir });
return dir;
}
test('四处版本号一起改:package.json + tauri.conf.json + Cargo.toml + Cargo.lock', () => {
const dir = scaffold();
const res = writeVersionEverywhere(dir, '9.9.9');
const files = res.changed.map((c) => c.file);
assert.deepEqual(files.sort(), [
'apps/console/src-tauri/Cargo.lock',
'apps/console/src-tauri/Cargo.toml',
'apps/console/src-tauri/tauri.conf.json',
'package.json',
].sort());
assert.equal(res.skipped.length, 0, `不该有跳过项:${res.skipped.join(', ')}`);
assert.equal(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).version, '9.9.9');
assert.equal(JSON.parse(readFileSync(join(dir, 'apps/console/src-tauri/tauri.conf.json'), 'utf8')).version, '9.9.9');
assert.match(readFileSync(join(dir, 'apps/console/src-tauri/Cargo.toml'), 'utf8'), /version = "9\.9\.9"/);
const lock = readFileSync(join(dir, 'apps/console/src-tauri/Cargo.lock'), 'utf8');
assert.match(lock, /name = "nodeagent-console"\nversion = "9\.9\.9"/);
assert.match(lock, /name = "statuses"\nversion = "2\.0\.2"/, '依赖版本不得被连带改掉');
// JSON 写回后仍要是「2 空格缩进 + 尾换行」(否则会污染 diff)
const rawPkg = readFileSync(join(dir, 'package.json'), 'utf8');
assert.ok(rawPkg.endsWith('}\n') && rawPkg.includes('\n "version"'), 'JSON 格式应与既有文件一致');
});
test('dry-run 一个字节都不改', () => {
const dir = scaffold();
const before = readFileSync(join(dir, 'apps/console/src-tauri/Cargo.toml'), 'utf8');
const res = writeVersionEverywhere(dir, '9.9.9', { dryRun: true });
assert.equal(res.changed.length, 4);
assert.equal(readFileSync(join(dir, 'apps/console/src-tauri/Cargo.toml'), 'utf8'), before);
assert.match(readFileSync(join(dir, 'package.json'), 'utf8'), /"version": "2\.0\.2"/);
});
// ---------------------------------------------------------------------------
// ④ 发版漂移守卫:真实仓库里 11 处版本号必须互一致
// ---------------------------------------------------------------------------
test('仓库内全部版本号互一致(发版漏项会在这里变红)', () => {
const t = versionTargets(repoRoot);
// 防「枚举失效 → 空集通过」的假绿:数量不对就直接失败
assert.equal(t.pkgFiles.length, 8, `package.json 应枚举到 8 个,实际 ${t.pkgFiles.length}`);
assert.equal(t.tauriConfFiles.length, 1, 'tauri.conf.json 应枚举到 1 个');
assert.equal(t.cargoTomlFiles.length, 1, 'Cargo.toml 应枚举到 1 个');
assert.equal(t.cargoLockFiles.length, 1, 'Cargo.lock 应枚举到 1 个');
const versions = new Map();
for (const f of [...t.pkgFiles, ...t.tauriConfFiles]) {
versions.set(f, JSON.parse(readFileSync(join(repoRoot, f), 'utf8')).version);
}
for (const f of t.cargoTomlFiles) {
const m = /\[package\][\s\S]*?^version\s*=\s*"([^"]*)"/m.exec(readFileSync(join(repoRoot, f), 'utf8'));
versions.set(f, m?.[1]);
}
for (const f of t.cargoLockFiles) {
const m = /name\s*=\s*"nodeagent-console"\nversion\s*=\s*"([^"]*)"/.exec(readFileSync(join(repoRoot, f), 'utf8'));
versions.set(f, m?.[1]);
}
const distinct = new Set(versions.values());
const dump = [...versions].map(([f, v]) => ` ${v} ${f}`).join('\n');
assert.equal(distinct.size, 1, `版本号出现分叉(发版时漏改了某个清单):\n${dump}`);
assert.ok(!distinct.has(undefined), `有清单读不出版本号:\n${dump}`);
});
test('发版脚本用「实际改写清单」做脏文件白名单,不得回退成手写文件名列表', () => {
const src = readFileSync(join(repoRoot, 'scripts', 'release.mjs'), 'utf8');
assert.match(src, /writeVersionEverywhere/, 'release.mjs 必须走 lib/version-write.mjs');
assert.match(
src,
/const allowedDirty = new Set\(changed\.map/,
'白名单必须取自写入结果(手写列表会与写入逻辑漂移)',
);
assert.doesNotMatch(
src,
/allowedDirty = new Set\(\[\.\.\.pkgFiles/,
'不得再引用已删除的 pkgFiles/tauriConfFiles 变量',
);
});