CI run 38380306200(提交 73ed06f)Windows 单元测试 319/321,macOS 全绿。
① 仓库内全部版本号互一致 → 报 undefined apps/console/src-tauri/Cargo.lock
根因:actions/checkout 在 Windows 默认 core.autocrlf=true → CRLF,
而 Cargo.lock 的版本提取把 \n 写死。**同一隐患也在生产代码里**:
version-write.mjs 另有一条 name…\nversion… 正则读旧值,CRLF 下返回
undefined → 把 Cargo.lock 记入 skipped 并跳过 → 发版后滞留旧版本
(正是本模块要消灭的分叉,且不报错)。
修法:读旧值收敛到 readCargoTomlVersion / setCargoLockVersion().before
单一真源,删掉重复正则;新增 splitLineEol() 拆「正文+行尾」后改写
(不能写 ("".*)$ 兜行尾 —— JS 的 . 不匹配 \r)。
② W-01 根因守卫 → 把唯一真源自己判成违规(util\quote.ts)
根因:file.slice(dir.length+1) 在 Windows 得到反斜杠路径,与白名单
的 util/quote.ts 比不中 → 守卫自己错。
修法:新增 relPosix() 归一成 /。
新增 4 条守卫:Cargo.lock / Cargo.toml 的 CRLF 纯函数用例、
CRLF 工作区上真跑 writeVersionEverywhere 再读回文件、
以及用 path.win32 形态字符串在 macOS 上直接测 Windows 分支。
注入故障实测 4 条如期变红;test:unit 325/325(321 + 4)。
239 lines
9.9 KiB
JavaScript
239 lines
9.9 KiB
JavaScript
/**
|
||
* 版本号写入 —— 发版脚本与测试**共用**的唯一实现。
|
||
*
|
||
* 为什么单独成模块(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` 里,
|
||
* 单测只能断言「源码里出现过某个字符串」—— 那种守卫注入故障都不会变红。
|
||
* 抽成纯函数后,测试可以对**临时目录里的真实文件**跑一遍再断言文件内容。
|
||
*
|
||
* ⚠️ 换行符纪律(2026-10-13 Windows CI 实证后补):
|
||
* Windows 上 `actions/checkout` 默认 `core.autocrlf=true`,检出的文本是 **CRLF**。
|
||
* 因此本模块内**一律不得**对原文跑把 `\n` 写死的正则 —— 那种写法在 CRLF 检出上会
|
||
* **静默返回 `undefined`**,而调用方普遍把它当成「没这个字段」直接跳过:不报错、
|
||
* 只是悄悄没改(正是本模块要消灭的那类漂移)。
|
||
* 统一做法:
|
||
* ① **读**值一律 `split('\n')` 后对 `line.trim()` 匹配(`\r` 被 `trim()` 吃掉);
|
||
* ② **写**值先用 `splitLineEol()` 把行尾拆下来、改完正文再拼回去。
|
||
* (不能偷懒写 `(".*)$` 兜行尾 —— JS 的 `.` **不匹配 `\r`**,那样照样失配。)
|
||
* 两条合起来保证:LF / CRLF 两种检出**结果一致**,且写回**逐字节保真**。
|
||
*/
|
||
|
||
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'),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 把一行拆成「正文 + 行尾」—— 行尾可能是 `\r`(CRLF 检出)或空串。
|
||
*
|
||
* ⚠️ 为什么必须拆:JS 里 `.` **不匹配 `\r`**(`\r` 与 `\n` 同属行终止符),
|
||
* 所以 `^(\s*version\s*=\s*")[^"]*(".*)$` 这类写法在 `version = "2.0.3"\r`
|
||
* 上会**直接失配** —— 行没改成、`done` 也没置位,全程不报错。
|
||
* 2026-10-13 本地复现(CRLF 文本喂给纯函数)即命中此处。
|
||
*/
|
||
function splitLineEol(line) {
|
||
return line.endsWith('\r') ? [line.slice(0, -1), '\r'] : [line, ''];
|
||
}
|
||
|
||
/**
|
||
* 改 `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)) {
|
||
const [body, eol] = splitLineEol(lines[i]);
|
||
const m = /^(\s*version\s*=\s*")([^"]*)(".*)$/.exec(body);
|
||
if (m) {
|
||
lines[i] = `${m[1]}${version}${m[3]}${eol}`;
|
||
done = true;
|
||
}
|
||
}
|
||
}
|
||
if (!done) throw new Error(`${label} 的 [package] 段里找不到 version 行`);
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/** 从 `Cargo.toml` 的 `[package]` 段读版本号(读不到返回 `undefined`)。 */
|
||
export function readCargoTomlVersion(text) {
|
||
return packageSectionValue(text, 'version');
|
||
}
|
||
|
||
/**
|
||
* 在 `[package]` 段内取某个 `key = "值"` 的值。
|
||
*
|
||
* ⚠️ 一律走 `line.trim()` 再匹配(而不是对原文跑带 `$` / `\n` 的正则)——
|
||
* **这样换行符就与结果无关**:CRLF 的 `\r` 被 `trim()` 吃掉,
|
||
* LF / CRLF 两种检出得到同一结果。2026-10-13 Windows CI 实证:
|
||
* 写死 `\n` 的提取在 CRLF 检出上**静默返回 undefined**,
|
||
* 而调用方把它当成「没有这个字段」直接跳过 —— 不报错、只是悄悄没改。
|
||
*/
|
||
function packageSectionValue(text, key) {
|
||
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 = new RegExp(`^${key}\\s*=\\s*"([^"]*)"$`).exec(trimmed);
|
||
if (m) return m[1];
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/** 从 `Cargo.toml` 的 `[package]` 段读 crate 名(用于去 `Cargo.lock` 里定位同一个包)。 */
|
||
export function readCargoPackageName(text) {
|
||
return packageSectionValue(text, 'name') ?? 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;
|
||
let before;
|
||
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)) {
|
||
// 先拆掉行尾再改正文,改完把原行尾接回去 → CRLF 检出下换行符**逐字节保真**,
|
||
// 不会把 Windows 工作区悄悄转成 LF(那会污染整个文件的 diff)。
|
||
const [body, eol] = splitLineEol(lines[i]);
|
||
const m = /^(\s*version\s*=\s*")([^"]*)(".*)$/.exec(body);
|
||
if (m) {
|
||
before = m[2];
|
||
lines[i] = `${m[1]}${version}${m[3]}${eol}`;
|
||
done = true;
|
||
}
|
||
}
|
||
}
|
||
return { text: lines.join('\n'), changed: done, before };
|
||
}
|
||
|
||
/**
|
||
* 把版本号写到**全部**存放它的地方。
|
||
*
|
||
* @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 名定位) ----
|
||
//
|
||
// ⚠️ 「读旧值」与「写新值」**必须共用同一份实现**(`readCargoTomlVersion` /
|
||
// `setCargoLockVersion`)。早先这里另写了一条 `name…\nversion…` 的正则读旧值,
|
||
// 那与写入实现是**两份会各自漂移的解析**:2026-10-13 Windows CI 实证,
|
||
// CRLF 检出上那条正则返回 `undefined` → 本函数把它当成「没找到该 crate」记入
|
||
// `skipped` 并**跳过 Cargo.lock**,发版后该文件滞留旧版本 ——
|
||
// 正是本模块要消灭的那类分叉,而且**不报错**。
|
||
for (const rel of targets.cargoTomlFiles) {
|
||
const abs = join(root, rel);
|
||
const text = readFileSync(abs, 'utf8');
|
||
const crateName = readCargoPackageName(text);
|
||
changed.push({ file: rel, before: readCargoTomlVersion(text) ?? '?', 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 lockRes = setCargoLockVersion(readFileSync(lockAbs, 'utf8'), crateName, version);
|
||
if (lockRes.before === undefined) {
|
||
skipped.push(`${lockRel}(未找到 crate ${crateName},可能尚未构建)`);
|
||
continue;
|
||
}
|
||
changed.push({ file: lockRel, before: lockRes.before, kind: 'cargo-lock' });
|
||
if (!dryRun && lockRes.changed) writeFileSync(lockAbs, lockRes.text);
|
||
}
|
||
|
||
return { changed, skipped };
|
||
}
|