发版流水线的第一步是打包:dist/ 压成 zip,算个 SHA-256,放进 release/。

十几行 Node 的事,却出过两次很有代表性的问题。

坑一:「时好时坏」其实是「取决于谁来跑」

打包调的是 PowerShell 的 Compress-Archive。某天它开始报错:

无法加载文件 C:\program files\windowsapps\microsoft.powershell_7...\Microsoft.PowerShell.Archive.psm1,
因为在此系统上禁止运行脚本

注意路径里的 microsoft.powershell_7。调用的是 powershell.exe(5.1),加载的却是 PowerShell 7 的模块。

原因是子进程继承了调用方终端的 PSModulePath。从 pwsh(7)打开的终端里,这个变量带着 PS7 的模块目录;5.1 顺着它找到了 7 的 .psm1,而这台机器的执行策略全是 Undefined → 默认 Restricted → 禁止加载。

最迷惑的是:在 pwsh 里直接跑同一条命令是好的(pwsh 自己是 RemoteSigned)。于是它看起来时好时坏。

别急着判「偶发」。 很多「偶发」其实是「取决于谁来调用、从哪个环境继承了什么」。先比较一下成功时和失败时的父进程。

修法:-ExecutionPolicy Bypass(只作用于那个子进程),并依次尝试 pwsh → powershell。

坑二:修坑一时,压出了一个装不上的包

修坑一的时候,我顺手把两条命令拼成了一个 -Command 字符串:

// ✗ 拼成一个字符串
execFileSync('powershell', ['-Command', `Import-Module ...; Compress-Archive -Path "${dist}\\*" ...`]);

${dist}\\* 里的反斜杠被 PowerShell 的命令解析再吃了一层,glob 失效。于是压进去的不是「dist 里的内容」,而是**dist 目录本身**:

正确的包:manifest.json app.js icons/…
坏的包: dist/manifest.json dist/app.js dist/icons/…

Chrome 和安装脚本都要求 manifest.json 在根上。这个包谁都装不了。

而打包脚本一声不吭地「成功」了——还打印了体积和 SHA-256。

正确的写法是给 -Command 传多个 argv 元素,别自己拼:

const psArgs = [
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command',
// ⚠ 保持为独立 argv 元素,别 join
'Import-Module', 'Microsoft.PowerShell.Archive', '-ErrorAction', 'Stop', ';',
`Compress-Archive -Path "${dist}\\*" -DestinationPath "${zipPath}" -Force`,
];

压完要开包验根

这种错误不会自己暴露。它要等到包已经传上服务器、公告和群通知都发完了、用户把它拖进浏览器报「清单文件缺失或不可读取」——才被发现。

所以打包后加了一步开包验根。不需要解压,也不需要依赖:读 zip 文件尾部的中央目录,列出所有条目名,看 manifest.json 在不在根上。

scripts/verify-zip-root.mjs
import { readFileSync } from 'node:fs';
/** 读 zip 中央目录,确认 manifest.json 在根。零依赖,毫秒级。 */
export function verifyZipRoot(zipFile) {
const buf = readFileSync(zipFile);
// EOCD(中央目录结束记录)签名 0x06054b50,从尾部往前找
// (它后面可能跟一段注释,最长 65535 字节)
let eocd = -1;
for (let i = buf.length - 22; i >= 0 && i > buf.length - 66_000; i--) {
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
}
if (eocd < 0) throw new Error(`${zipFile} 不是合法 zip(找不到 EOCD)`);
const total = buf.readUInt16LE(eocd + 10);
let off = buf.readUInt32LE(eocd + 16); // 中央目录起始偏移
const names = [];
for (let i = 0; i < total; i++) {
if (buf.readUInt32LE(off) !== 0x02014b50) break; // 中央目录文件头签名
const nameLen = buf.readUInt16LE(off + 28);
const extraLen = buf.readUInt16LE(off + 30);
const commentLen = buf.readUInt16LE(off + 32);
names.push(buf.subarray(off + 46, off + 46 + nameLen).toString('utf8'));
off += 46 + nameLen + extraLen + commentLen;
}
// 规范要求分隔符是 '/',但有的工具写 '\',两种都认
const flat = names.map((n) => n.replace(/\\/g, '/'));
if (!flat.includes('manifest.json')) {
const nested = flat.find((n) => n.endsWith('/manifest.json'));
throw new Error(`manifest.json 不在 zip 根目录${nested ? `(实际在 ${nested})` : ''}`);
}
return flat.length;
}

拿两个真实的包验过:

✓ good.zip: 3 个条目,manifest.json 在根
✗ bad.zip: manifest.json 不在 zip 根目录(实际在 dist/manifest.json)

(坏包是用 Compress-Archive -Path dist 压的,好包是 -Path dist\*——就差一个 \*。)

一个会「静默成功」的步骤后面,必须跟一个会「大声失败」的检查。 这个检查越便宜越好——读文件尾部几十 KB,毫秒级,没有理由不加。

图标:永远从最大的原稿往下生成

另一件事是图标。

某次发现工具栏上我们的图标比别的扩展明显小一圈。量了一下:原图四周有大片透明留白,内容只占画布纵向 58%,缩到 16px 后机器人实际只有大约 9px 高。

于是写了个生成脚本,做两件事:

① 按 alpha 通道裁掉留白。 找出所有不透明像素的边界框,长边贴边(只留 3% 呼吸位),短边居中。Chrome 工具栏自己不加内边距,留白留多了就是白白变小。

② 从尽可能大的原稿降采样,每个尺寸补一点锐化。

const SIZES = [
{ n: 16, sharpen: 1.0 }, // 越小越需要锐化,抵消重采样的软化
{ n: 24, sharpen: 0.9 },
{ n: 32, sharpen: 0.7 },
{ n: 48, sharpen: 0.4 },
{ n: 128, sharpen: 0 }, // 降采样后本就锐利
];

锐化系数是肉眼比对调出来的,所以脚本带 --dry-run,改之前先出图看看。

别拿 128px 当源放大

这条是踩过的:有一次手头没有原稿,从 public/icons/icon128.png 放大重导了一版——肉眼可见地糊。

更麻烦的是,那张 128 实际内容只有 98×74(剩下全是留白),从它放大相当于把一张 98 像素的图拉伸。

规矩于是很简单:

  • 原稿(这里是一张 500×500 的 PNG)放在一个不会被打包的目录里——放 public/ 会被原样拷进包,182KB 纯属白带;
  • public/icons/ 下的所有尺寸都是产物,别手改,也别只替换其中一两个;
  • 换图标 = 换原稿 + 重跑脚本。

manifest 里还有一处配套的:工具栏图标要显式给 16/24/32 三档。不写的话 Chrome 拿最近的尺寸(32)缩到 16,高 DPI 屏还要再缩放一次,线条会发虚。

action: {
default_icon: { 16: 'icons/icon16.png', 24: 'icons/icon24.png', 32: 'icons/icon32.png' },
},

这两件事看着毫不相干,但它们有同一个形状:产物看起来是对的。

zip 打出来了、有体积、有哈希;图标生成了、尺寸对、文件在。只有真的「装上去」「放上工具栏」那一刻,问题才出现——而那时它已经到了用户手里。

所以关于构建产物,我现在只信两种东西:原稿,和对产物本身做的检查。中间那些「脚本说它成功了」都不算。