DEVTOOLBOX / GUIDES 本篇

发布产物怎么发:版本号范围、文件权限、校验和与压缩格式的判断

这篇按发布链条给判断顺序:版本号是兼容承诺而不是计数,权限是文件系统元数据所以会随打包方式丢失,摘要只证明字节一致、来源必须靠签名,压缩格式按下游平台选而不是按习惯。七节各给判据、代价与不可逆点,落到一次发布可以照做的动作顺序。

内容复核 10 分钟阅读

发布产物是什么:字节加上描述它的元数据

发布产物不是上传的那个文件,而是字节加上描述字节的元数据。字节指二进制本身;元数据包括版本号、目标平台与架构、文件权限、摘要、签名。判据是下游只拿到字节时能否复原出可用环境:tar.gz 解出来是 0644 而二进制需要 0755,或者 Windows 用户不知道该装哪个架构包,元数据缺失就和字节损坏一样致命。所以第一件事是决定元数据放包内还是包外。

包内元数据随文件流动、不会分离,代价是改一次就要重打包并重新签名;包外清单可以独立签名与分发,代价是会与字节脱钩,必须在下发流程里强制校验,否则清单和包各自漂移也没人发现。矩阵还是单包取决于安装端能否选择:含原生二进制就必须按平台拆,纯字节码或静态资源才可以合成一个;矩阵的代价是构建与签名次数乘以平台数,漏签一个平台就多一处无法验证。

不可逆点在公开那一刻。包被镜像、被写进别人的 lockfile、被 CI 缓存命中之后就没有真正的撤回,删除只让新请求返回 404,已经流出去的摘要仍指向那份字节。两个看起来正常其实错的默认值:版本号升了而字节没变,同一份代码因此有两个摘要却共用一个版本号;用 git tag 代替产物,tag 指向源码,重新构建的字节通常不同,编译器版本、路径、时间戳都会改变输出。

元数据通常的载体丢失后的症状补回代价
版本号文件名与清单无法区分两次发布只能重新发布
平台与架构文件名后缀装到错误的 CPU需要拆成矩阵
文件权限tar 头、zip 外部属性解压后不可执行文档里补 chmod
摘要与签名并列的 .sha256 与 .sig无法验证完整性需要可信通道

元数据丢一项,字节就少一层可用性;包外元数据必须在下发流程里强制校验

版本号:SemVer 范围表达式静默放行了哪些升级

SemVer 2.0.0 规定三段 MAJOR.MINOR.PATCH:不兼容变更加 MAJOR,向下兼容的新增加 MINOR,向下兼容的修复加 PATCH。它真正约束的不是数字,而是范围内可以发生什么。范围表达式把这层约束翻译成可自动求值的区间,代价是每一次自动升级都是一次未经审阅的变更。判据只有一条:这个依赖的破坏性变更会不会在你不知情的时刻进入构建。

插入符与波浪号正好卡在这条线上。^1.2.3 展开为 >=1.2.3 且 <2.0.0,允许 MINOR 与 PATCH 自动上来;~1.2.3 只允许 PATCH,上界是 1.3.0。真正的坑在 0.x:^0.2.3 的上界是 0.3.0,因为 0.x 被视为尚无兼容承诺,插入符退化成了波浪号;^0.0.3 更窄,只到 0.0.4。所以「用插入符就是安全的」对 0.x 不成立,而 0.x 恰恰是新库最常见的形态。范围只划定允许的窗口,锁文件才记录这次装的具体版本,两者的分工要先说清。

另外三条常被忽略。预发布版本不在普通范围的解集里:1.2.4-beta.1 不满足 ^1.2.3,除非范围本身写了预发布。构建元数据 1.2.3+build.7 在优先级比较中被忽略,它与 1.2.3+build.9 相等,拿它区分两次构建是错的。优先级规则里数字标识按数值比较、字母数字标识按 ASCII 比较,所以 1.0.0-beta.11 高于 1.0.0-beta.2,而 1.0.0-alpha 低于 1.0.0-alpha.1。

写法展开后的区间静默放行的升级
^1.2.3>=1.2.3 且 <2.0.0MINOR 与 PATCH
^0.2.3>=0.2.3 且 <0.3.0仅 PATCH
~1.2.3>=1.2.3 且 <1.3.0仅 PATCH
1.2.3 - 2.3.4>=1.2.3 且 <=2.3.4可以跨到 2.x
* 或 latest任意版本含破坏性变更
1.2.3精确等于无

0.x 段是插入符失效的地方,也是新库最集中的地方

文件权限:644 与 755、umask,以及压缩包为什么会丢它

Unix 权限是九个位加三个特殊位,按所有者、组、其他人三段读,每段三位 rwx,读 4、写 2、执行 1。0644 即 rw-r--r--,普通数据文件;0755 即 rwxr-xr-x,目录与可执行文件;0600 即 rw-------,私钥与凭据。目录上的 x 不是可执行而是可进入,缺了它连目录里的文件名都列不出来,rw 齐全也没有意义。

新建文件的权限不是程序写死的,而是请求模式与 umask 相与的结果。进程请求 0666,umask 022 清掉组和其他人的写位,得到 0644;请求 0777 得到 0755。判据是:umask 022 适合共享机器上的常规项目,umask 077 让新建文件为 0600、目录为 0700,适合任何可能落盘密钥的构建机。特殊位单独看:setuid 04000、setgid 02000、sticky 01000,/tmp 的 1777 就是 sticky 在起作用,它让用户只能删除自己的文件。

不可逆点出现在传输环节:权限不是内容的属性,而是文件系统元数据,一旦被不保存它的容器承载就永久丢失,事后无法从字节里推出原始模式。误用集中在两处。一是用 777 解决权限不够,把写权限交给所有本地用户;二是从 Windows 打出来的 zip 解压后一律 0644,安装脚本因此不可执行,而错误信息通常只说找不到命令。tar 保存模式与符号链接,zip 是否保存取决于打包工具写的字段,这一点在第 5、6 节展开。

模式八进制逐位相加何时该用
rw-r--r--06446=4+2,4=4,4=4普通数据文件与静态资源
rwxr-xr-x07557=4+2+1,5=4+1目录、安装与启动脚本
rw-------06006=4+2,0,0私钥、令牌、凭据
rwx------07007=4+2+1,0,0私有构建目录
rwxrwxrwt1777三段皆 7,再加 sticky共享临时目录

umask 022 与 077 的差别只有一处:其他人的那三位是否被留下

校验和:公布摘要能证明什么、不能证明什么

摘要能证明的是你手上的字节与发布者算过的那份相同,前提是摘要本身来自一条独立可信的通道。这条前提被忽略时,校验和就退化成一个装饰。判据很干脆:摘要与产物若来自同一个 HTTPS 响应、同一台服务器、同一次镜像同步,攻击者改包时顺手改摘要,两者永远一致,验证百分之百通过却什么都没证明。

位宽决定能对抗什么。CRC32 只有 32 位,随机内容的碰撞门槛在约 2 的 16 次方、六万多次,而且它线性、可构造,能发现传输损坏,挡不住故意篡改;gzip(RFC 1952)的尾部就是这样一个 CRC32 加 ISIZE,后者是原始长度对 2 的 32 次方取模,连截断都能发现,却不构成任何来源证明。SHA-256 输出 256 位、32 字节,十六进制是 64 个字符,碰撞门槛约 2 的 128 次方,适合做内容指纹。

来源要靠签名,不靠更长的摘要。发布者用私钥对摘要清单签名(minisign、cosign 或 GPG),下游用经独立渠道拿到的公钥验证,信任链才闭合;把公钥和签名放在同一个下载页,等于把锁和钥匙挂在同一扇门上。误用还有两处:把摘要截到 8 个十六进制字符当校验值,只剩 32 位;用 MD5 做下载校验,它已能构造选择前缀碰撞,等于没有校验。清单还必须覆盖每一个产物,漏掉的那个就是攻击面。

机制位宽能证明不能证明
CRC3232 位传输损坏故意篡改
MD5128 位无有效保证碰撞可构造
SHA-256256 位字节完全一致字节出自谁手
HMAC-SHA-256256 位持密钥者生成密钥泄漏之后
Ed25519 签名约 128 位安全强度来源与完整性私钥被盗之后

位宽只决定伪造的难度,决定信任方向的是密钥在哪里、公钥怎么来的

压缩:按平台选格式,而不是按习惯

先分清归档与压缩。gzip(RFC 1952)只压一个字节流,不打包多文件、不保存权限与所有者,解压后的文件名来自包头且可以用 -n 去掉;tar 只打包不压缩;zip 同时做归档与逐成员压缩,并把一部分 Unix 模式塞进外部属性字段。选格式的判据是下游用什么解:Linux 与 macOS 的构建产物用 tar.gz 或 tar.zst,因为 tar 保存模式、所有者、符号链接与 mtime;交给 Windows 或普通用户的用 zip,因为系统自带解压。

压缩级别是可推导的取舍。gzip 级别 1 到 9,命令行默认 6;级别越高压缩比提升越慢、耗时线性增长,9 相对 6 通常只多几个百分点,构建流水线上没有理由用它。对已压缩内容(jpeg、png、mp4、zip 内部成员)再压,收益接近零,代价是时间加一道解压步骤。若目标只是减少传输字节,在 HTTP 层用 br 或 zstd 比在产物里换格式更有效,因为那一步不改变发布物本身。

看起来正常其实错的做法有三个:用 gzip 打包多个文件,结果只进了一个流,解出来还得再解 tar;用 zip 分发 Linux 二进制,权限字段常被写成 0,解压后一律 0644;用 tar 打包时不加前缀目录,解压时几十个文件直接倒进当前目录。不可逆的是形式选择:换格式就要换解压工具,下游的旧脚本不会跟着改,这个代价必须写进发版说明,而不是留给用户排查。

格式保存权限保存 mtime何时选
tar.gz是是,秒级Linux 与 macOS 构建产物
tar.zst是是,秒级同上,更看重解压速度
zip仅当外部属性有值2 秒粒度,本地时间Windows 与普通用户
gzip 单文件否是,可用 -n 去掉单个日志或字节流
tar 不压缩是是,秒级需要随机读取成员

权限这一列决定 Linux 产物能不能直接跑,其余各列只影响体积与可比性

压缩包卫生:时间戳、路径前缀与可复现构建

同一个源码树打两次包,字节通常不同,因为归档把打包时刻写进了头。tar 记录每个成员的 mtime、uid、gid、模式,并常按 readdir 顺序写入,顺序在不同文件系统上并不一致;gzip 头里有 4 字节 MTIME 与可选的原文件名;zip 的 DOS 时间戳只有 2 秒粒度,默认写本地时间。可复现构建的做法是固定这些输入:把 SOURCE_DATE_EPOCH 设为发布提交的时间,用 --mtime 与 --sort=name 固定时间与顺序,用 --owner=0 --group=0 --numeric-owner 固定属主,gzip 加 -n 去掉时间与文件名。

路径前缀决定解压后的形状。带顶层目录的归档解压成一个目录,缺点是这个目录名里常带版本号,下游必须知道版本才能写路径;不带前缀的归档把内容倒进当前目录,简单,但会覆盖同名文件。zip 没有 strip 选项,tar 有 --strip-components。判据是:产物要被人手工解压时带前缀,要被脚本安装时用固定目录名并在文档里给出唯一解压命令,比让用户猜更省事。

安全上要清两类条目。绝对路径与以 .. 开头的路径会在解压时逃出目标目录,实现里必须先规范化再校验前缀,不能只信任打包者;指向归档外的符号链接同样能把写操作引到别处,tar 用 --no-absolute-names 收窄。另外要清掉 macOS 产生的 .DS_Store 与 ._ 资源叉文件,它们对下游无用却会改变摘要与体积,让同一份源码在不同机器上永远算不出同一个值。

输入默认行为固定方式
成员顺序依 readdir 返回顺序tar --sort=name
mtime写入打包时刻SOURCE_DATE_EPOCH、--mtime
属主与属组打包者账户的 uid 与 gid--owner=0 --group=0 --numeric-owner
gzip 包头写入时间与原文件名gzip -n
顶层前缀随调用目录变化用固定目录名打包

五处输入全部固定,两次打包才会得到同一份字节;只固定时间是常见的不完整做法

发一次版本的固定动作顺序

顺序的价值在于每一步都作用在前一步产出的字节上,而不是作用在意图上。先改版本号再构建、先构建再算摘要、先算摘要再签名、先签名再上传,这条链条一旦倒置,发布的字节与被验证的字节就不再是同一份,失败点会推迟到用户机器上才暴露,而那时你已经无法区分是自己发错了还是对方装错了。

各步的判据与代价。版本号按改动性质加,不按工作量加,未完成的版本用 1.4.0-rc.1 这样的预发布标识而不是提前占用 1.4.0;构建放在干净容器里做,依赖锁文件必须提交,多平台矩阵并发跑,代价是构建时间乘以平台数;摘要对每个产物单独算,写进一份清单并随包发布;签名签清单而不是逐个签产物,因为逐个签会让漏签一个文件无从检测。

最后两步最容易被省:验证与留痕。上传后重新下载一份,重算摘要并与清单比对,能发现上传截断与 CDN 缓存污染,成本只有一次下载;留痕记录版本号、提交哈希、构建环境、每个产物的摘要与签名,理由是半年后有人报某个版本装不上时,你能先确认他装的是不是你发的那份字节,而不是先花一天复现。真正的不可逆是清单公开之后,任何修正只能由新版本承载。

常见问题

版本号里的构建元数据能区分两次构建吗?
不能。SemVer 2.0.0 规定构建元数据在优先级比较中被忽略,1.2.3+build.7 与 1.2.3+build.9 相等。要区分构建请用摘要与发布记录,不要指望版本号后缀。
为什么 zip 解压后二进制不能执行?
zip 的权限存在外部属性字段,Windows 打包工具常写成 0,解压端只能按默认 0644 还原。改成 tar.gz 分发,或在安装脚本里显式 chmod 0755。
公布了 SHA-256 就算验证过吗?
不算。摘要与包若同源,篡改可以同时改两者。要证明来源必须签名,并把公钥经独立渠道分发,否则只能发现传输损坏,发现不了替换。
gzip 能打包整个目录吗?
不能。RFC 1952 的 gzip 只压单个字节流,没有成员表。目录先用 tar 打包再 gzip,或直接用 zip。默认级别 6,改到 9 通常只多几个百分点。
压缩包里的时间戳为什么每次都不同?
归档默认把打包时刻写进头里,tar 还记录属主 uid 与成员顺序。设 SOURCE_DATE_EPOCH、加 --sort=name 与 --owner=0 --group=0、gzip 加 -n,两次打包才可能得到同一份字节。

动手试试