发布产物怎么发:版本号范围、文件权限、校验和与压缩格式的判断
这篇按发布链条给判断顺序:版本号是兼容承诺而不是计数,权限是文件系统元数据所以会随打包方式丢失,摘要只证明字节一致、来源必须靠签名,压缩格式按下游平台选而不是按习惯。七节各给判据、代价与不可逆点,落到一次发布可以照做的动作顺序。
发布产物是什么:字节加上描述它的元数据
发布产物不是上传的那个文件,而是字节加上描述字节的元数据。字节指二进制本身;元数据包括版本号、目标平台与架构、文件权限、摘要、签名。判据是下游只拿到字节时能否复原出可用环境: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.0 | MINOR 与 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-- | 0644 | 6=4+2,4=4,4=4 | 普通数据文件与静态资源 |
| rwxr-xr-x | 0755 | 7=4+2+1,5=4+1 | 目录、安装与启动脚本 |
| rw------- | 0600 | 6=4+2,0,0 | 私钥、令牌、凭据 |
| rwx------ | 0700 | 7=4+2+1,0,0 | 私有构建目录 |
| rwxrwxrwt | 1777 | 三段皆 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 做下载校验,它已能构造选择前缀碰撞,等于没有校验。清单还必须覆盖每一个产物,漏掉的那个就是攻击面。
| 机制 | 位宽 | 能证明 | 不能证明 |
|---|---|---|---|
| CRC32 | 32 位 | 传输损坏 | 故意篡改 |
| MD5 | 128 位 | 无有效保证 | 碰撞可构造 |
| SHA-256 | 256 位 | 字节完全一致 | 字节出自谁手 |
| HMAC-SHA-256 | 256 位 | 持密钥者生成 | 密钥泄漏之后 |
| 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 缓存污染,成本只有一次下载;留痕记录版本号、提交哈希、构建环境、每个产物的摘要与签名,理由是半年后有人报某个版本装不上时,你能先确认他装的是不是你发的那份字节,而不是先花一天复现。真正的不可逆是清单公开之后,任何修正只能由新版本承载。
- 定版本号与预发布标识,先写进变更记录
- 在干净环境按锁文件构建,按平台分开产出
- 固定权限:可执行 0755、数据 0644、私钥 0600
- 打包并固定时间戳、顺序与属主,清掉 .DS_Store 与 ._ 文件
- 对每个产物算 SHA-256,汇总成一份清单
- 对清单签名,公钥走独立渠道分发
- 上传后重新下载,重算摘要与清单比对
- 把版本、提交哈希、环境与摘要记入发布记录
常见问题
- 版本号里的构建元数据能区分两次构建吗?
- 不能。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,两次打包才可能得到同一份字节。