ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Homebrew 可复现构建指南:SOURCE_DATE_EPOCH、确定性 gzip 压缩与 Bottle 占位符重定位机制

Homebrew 可复现构建指南:SOURCE_DATE_EPOCH、确定性 gzip 压缩与 Bottle 占位符重定位机制 Homebrew 可复现构建指南SOURCE_DATE_EPOCH、确定性 gzip 压缩与 Bottle 占位符重定位机制【免费下载链接】brew The Package Manager for Everywhere项目地址: https://gitcode.com/GitHub_Trending/br/brew本文基于 Homebrew 官方文档 Reproducible-Builds 展开系统讲解 Homebrew 构建环境为实现可复现构建reproducible builds所设计的三大机制固定构建时间SOURCE_DATE_EPOCH与time方法、可复现 gzip 压缩助手Utils::Gzip.compress以及 Bottle 构建时的路径占位符替换relocatability。读完本文你将能够在编写 Formula 时避免产物中出现时间戳和构建机专属路径并理解brew bottle底层如何保证同一源码在不同构建机上产出比特级一致的产物。一、目标与总体思路Homebrew 的构建环境以“尽可能实现可复现构建”为设计目标相同源码、相同构建流程应当产出可重复验证的构建产物。导致产物在重复构建之间产生差异的常见因素中有两类由构建环境直接引入构建时间许多构建工具会把“构建发生的时刻”写入或记录到产物中例如编译进二进制的日期字符串、gzip 头中的 mtime 字段构建路径一些 Formula 或构建工具会把构建机上的绝对路径写进配置文件或二进制例如 Homebrew 前缀、Cellar 路径导致产物无法在其他前缀或机器上通用。此外构建产物中若包含压缩文件如 man 页的.gz不同构建机上的gzip实现与默认行为也会引入差异。Homebrew 分别用三套机制应对在构建环境中注入固定的SOURCE_DATE_EPOCH、提供Utils::Gzip确定性压缩助手、以及在 Bottle 阶段自动执行前缀占位符替换。下文逐节展开。二、构建时间SOURCE_DATE_EPOCH 与time方法2.1 问题嵌入构建时间的产物一些构建工具Makefile、configure 脚本、打包器等会把构建时刻写入产物。如果两次构建发生在不同时刻即使源码完全相同产物字节也会不同。可复现构建社区对此的通用对策是使用SOURCE_DATE_EPOCH环境变量一个以 Unix 纪元秒为单位的时间戳供消费它的构建工具用来替代“当前时间”。2.2 Homebrew 的默认处理Homebrew 构建环境会自动设置SOURCE_DATE_EPOCH。从构建入口 build.rb 可以看到在 Formula 构建阶段注入的环境变量包括with_env( # For head builds, HOMEBREW_FORMULA_PREFIX should include the commit, # which is not known until after the formula has been staged. HOMEBREW_FORMULA_PREFIX: formula.prefix, # https://reproducible-builds.org/docs/build-path/ HOMEBREW_FORMULA_BUILDPATH: formula.buildpath, # https://reproducible-builds.org/docs/source-date-epoch/ SOURCE_DATE_EPOCH: formula.source_modified_time.to_i.to_s, # Avoid make getting confused about timestamps. TZ: UTC0, ) do几个关键事实SOURCE_DATE_EPOCH取值为formula.source_modified_time的整型时间戳即源码的最后修改时间而不是构建发起时间。这样同一份源码在任何机器上构建注入的时间都一致时区被固定为TZUTC0避免 Make 等工具因时区差异产生时间戳抖动同时注入HOMEBREW_FORMULA_BUILDPATH供消费构建路径变量的工具使用使构建路径也可预测。source_modified_time来自各下载策略。以 Git 为例git_download_strategy.rb 通过读取提交时间得到# Returns the most recent modified time for all files in the current working directory after stage. def source_modified_time result system_command(git, args: [--git-dir, git_dir, show, -s, --format%cD], env: local_git_env, print_stderr: false) raise Failed to read the Git commit time:\n#{result.stderr} unless result.success? Time.parse(result.stdout) end其他 VCSMercurial、Subversion 等在各自的download_strategy实现中有对应的source_modified_timePyPI 策略还会与文件Last-Modified取最大值。也就是说对于以版本控制方式获取的源码可复现构建的时间锚点就是最后一次提交的时刻与谁、在何时触发构建无关。2.3 需要手动设置时间时的写法在某些情况下Formula 作者需要显式地把时间传给上游构建系统例如make DATE...。此时不应使用 Ruby 的Time.now而应使用 Formula 提供的time方法——它返回一个与SOURCE_DATE_EPOCH完全一致的 RubyTime对象。formula.rb 中的实现# Creates a new Time object for use in the formula as the build time. sig { returns(Time) } def time if ENV[SOURCE_DATE_EPOCH].present? Time.at(ENV[SOURCE_DATE_EPOCH].to_i).utc else Time.now.utc end end当SOURCE_DATE_EPOCH存在时Homebrew 构建环境中恒存在time返回以该纪元秒构造的 UTC 时间只有脱离构建环境例如单独调试时才回退到Time.now.utc。典型用法如下——用time.iso8601或按需time.strftime把时间格式化为上游要求的格式def install system make, install, VERSION#{version}, DATE#{time.iso8601}, PREFIX#{prefix} end官方文档的建议当上游构建接口要求格式化时间戳时使用time.iso8601或time.strftime。这样时间既固定于源码状态又满足上游的字符串格式要求。三、可复现的 gzip 压缩Utils::Gzip.compress3.1 问题构建机gzip引入不可复现输出部分 Formula 在构建过程中会生成 gzip 压缩文件例如压缩 man 页或其他数据文件。构建机可能提供不同的gzip实现而gzip默认会记录被压缩文件的修改时间gzip 格式头中的 mtime 字段该值通常随构建时刻变化。因此直接依赖构建机上的gzip命令产物中往往会包含每次构建都不同的字节破坏可复现性。3.2 使用Utils::Gzip.compressHomebrew 为此提供Utils::Gzip.compress助手函数接受一个或多个待压缩路径把压缩结果与原文件并排放置并加.gz后缀与gzip工具行为一致同时返回Pathname对象数组可直接交给其他方法消费。文档给出的两个标准用法def install system make, install man1.install Utils::Gzip.compress(mycommand.1) enddef install system make, install (pkgshare/data).install Utils::Gzip.compress(*Dir[#{buildpath}/path/to/some/folder/contents/*]) end文档的要点是一次传一个或多个路径给同一个助手而不是直接调用宿主机的gzip。3.3 源码级实现utils/gzip.rbUtils::Gzip的核心是两个类方法def self.compress(*paths, reproducible: true, mtime: ENV[SOURCE_DATE_EPOCH].to_i)默认reproducible: true逐个调用compress_with_options做纯 Ruby 的确定性压缩传入reproducible: false时回退到SystemCommand.safe_system gzip, path即宿主gzip此时不可复现仅保留接口兼容性。compress_with_options的关键细节GZIP_BUFFER_SIZE T.let(64 * 1024, Integer) # 与 Apple gzip 使用的 zlib 缓冲一致 def self.compress_with_options(path, mtime: ENV[SOURCE_DATE_EPOCH].to_i, orig_name: File.basename(path), output: #{path}.gz) if mtime.to_i 0 odebug Setting mtime 1 to avoid zlib gem bug and unsigned integer cast when mtime 0. mtime 1 end File.open(path, rb) do |fp| gz Zlib::GzipWriter.open(output) gz.mtime mtime gz.orig_name orig_name gz.write(fp.read(GZIP_BUFFER_SIZE)) until fp.eof? ensure gz.close end FileUtils.rm_f path Pathname.new(output) end从源码结构看它做了几件保证字节确定性的小事mtime 默认取SOURCE_DATE_EPOCH即与构建环境注入的源码时间锚点一致而不是当前时刻orig_name 默认为原文件 basenamegzip 头里的原文件名也是可复现的若原文件路径不可预测可显式传入orig_namemtime 0时改写为1源码注释说明这是规避 Ruby zlib gem 对 mtime0 处理不当的已知问题以及防止 gzip 对负数做无符号转换的隐患固定 64 KiB 的 zlib 缓冲大小与 Applegzip使用的参数对齐确保压缩流本身确定压缩完成后删除原文件FileUtils.rm_f path并返回输出Pathname。3.4 测试用例用固定校验和验证确定性Library/Homebrew/test/utils/gzip_spec.rb 用“相同输入 相同 mtime ⇒ 相同 SHA256”的方式直接验证了确定性。例如在SOURCE_DATE_EPOCH23456环境下压缩三个内容为Hello world的文件it creates reproducible gz files from input files with SOURCE_DATE_EPOCH as mtime do ENV[SOURCE_DATE_EPOCH] 23456 expected_checksums %w[ d5e0cc3259b1eb61d93ee5a30d41aef4a382c1cf2b759719c289f625e27b915c 068657725bca5f9c2bc62bc6bf679eb63786e92d16cae575dee2fd9787a338f3 e566e9fdaf9aa2a7c9501f9845fed1b70669bfa679b0de609e3b63f99988784d ] # ... results described_class.compress(*files) 3.times do |n| expect(Digest::SHA256.hexdigest(File.read(results[n]))).to eq(expected_checksums[n]) end end测试断言输出文件的 SHA256 精确等于硬编码值——这意味着只要输入、SOURCE_DATE_EPOCH相同任何机器上跑出来的.gz字节完全一致。3.5 仓库内的真实应用Bottle 打包本身Utils::Gzip最典型的生产级用法就是 Bottle 打包流程。在 dev-cmd/bottle.rb 中brew bottle以“先 tar 再 gzip”的方式生成可复现的 Bottle# Tar then gzip for reproducible bottles. # GNU tar fails to create a bottle if modification time is unsigned integer # (i.e. before 1970) time_at_epoch Time.at(1) tab_source_modified_time [time_at_epoch, tab.source_modified_time].max tar_mtime tab_source_modified_time.strftime(%Y-%m-%d %H:%M:%S) tar, tar_args setup_tar_and_args!(tar_mtime, default_tar: formula.name gnu-tar) SystemCommand.safe_system tar, --create, --numeric-owner, *tar_args, --file, tar_path, #{formula.name}/#{formula.pkg_version} # Set filename as it affects the tarball checksum. relocatable_tar_path #{formula}-bottle.tar mv tar_path, relocatable_tar_path # Use gzip, faster to compress than bzip2, faster to uncompress than bzip2 # or an uncompressed tarball (and more bandwidth friendly). Utils::Gzip.compress_with_options(relocatable_tar_path, mtime: tab.source_modified_time, orig_name: relocatable_tar_path, output: bottle_path)可以看到这里把“可复现”推到了极致tar 的 mtime 固定为源码修改时间与Time.at(1)取 max 以规避 1970 年前无符号时间戳问题并加--numeric-owner消除属主信息差异tar 文件名被统一改名为#{formula}-bottle.tar因为 gzip 头中的orig_name会影响最终校验和最后用Utils::Gzip.compress_with_options以tab.source_modified_time作为 mtime 压缩成.bottle.tar.gz。也就是说Bottle 文件本身的可复现性就是构建在这套 gzip 助手与固定的时间锚点之上。四、可重定位Relocatability路径占位符替换4.1 问题构建机路径被记录进产物一些 Formula 或构建工具会在配置文件或二进制中记录构建环境的路径例如 Homebrew 前缀HOMEBREW_PREFIX和 Cellar 路径。若这些路径原样留在 Bottle 中那么该 Bottle 就只能在“相同路径”的机器上工作。4.2 占位符机制构建可分发的 Bottle 时Homebrew 会扫描构建产物把常见的 Homebrew 路径替换为占位符安装pourBottle 时再把占位符展开为用户机器上的真实路径。占位符定义在 keg_relocate.rbPREFIX_PLACEHOLDER HOMEBREW_PREFIX CELLAR_PLACEHOLDER HOMEBREW_CELLAR REPOSITORY_PLACEHOLDER HOMEBREW_REPOSITORY LIBRARY_PLACEHOLDER HOMEBREW_LIBRARY PERL_PLACEHOLDER HOMEBREW_PERL JAVA_PLACEHOLDER HOMEBREW_JAVA替换映射由prepare_relocation_to_placeholders构建keg_relocate.rb#L157-L183除前缀、Cellar 外还覆盖 Homebrew 仓库路径、Library 路径、Perl shebang 与 OpenJDK 路径等当HOMEBREW_PREFIX/usr/local时还会启用一组“选择性替换”/usr/local/opt、/usr/local/Caskroom、/usr/local/etc/...等以避免误替换系统路径。反向映射prepare_relocation_to_locations则在安装时把占位符还原为用户机器上的实际路径。具体流程Bottle 时dev-cmd/bottle.rb 中调用keg.replace_locations_with_placeholders对文本文件、libtool 文件及链接信息执行替换并把被改动的文件记录进 Bottle 元数据tab安装时调用replace_placeholders_with_locations将HOMEBREW_PREFIX等占位符展开为端用户机器上的前缀与 Cellar 路径。4.3 收益与“零成本”特性这套机制带来两个直接收益与官方文档一致非默认前缀可用Bottle 可以被 Homebrew 安装在非默认前缀的用户直接使用而无需源码构建跨平台比特级一致当两台构建机唯一的差异是 Homebrew 的安装位置时产出的 Bottle 是比特级bit-for-bit相同的。并且如官方文档所述这一搜索替换过程自动发生Formula 作者无需做任何额外操作即可使用。4.4 需要注意的边界占位符替换作用于文本文件、libtool 文件以及动态链接信息如果构建工具把前缀路径以裸 C 字符串形式编译进二进制例如sysconfdir、数据目录等常量纯文本替换无法覆盖Bottle 会被检查器判定为不可重定位并“钉”在构建 Cellar 上。从 plans/relocatable-bottles.md 的统计可以看到被钉住的 Bottle 绝大多数正是这类“编译进二进制的自身前缀/Cellar 字符串”。对 Formula 作者的实用含义是能由构建系统生成路径的优先让它写成可被替换的文本/链接条目而不是硬编码进代码常量该计划文档同时描述了 Homebrew 在推进中的更长线方案安装期对已发布的“钉住” Bottle 做前缀补丁、以及 64 字节填充前缀构建等属于对本文占位符机制的纵深补充可结合 Bottles 文档 一并阅读。五、Formula 作者检查清单把上述机制汇总为一份可操作的自查清单时间不要在install/资源块中使用Time.now、date等“当前时刻”需要把时间传给上游构建接口时用time.iso8601或time.strftime如DATE#{time.iso8601}。构建环境已保证SOURCE_DATE_EPOCH等于源码修改时间见 build.rb压缩构建中需要生成.gz文件时一律改用Utils::Gzip.compress单文件或*Dir[...]多文件均可不要直接system gzip确需宿主gzip行为时才考虑reproducible: false见 utils/gzip.rb路径避免把HOMEBREW_PREFIX/Cellar 绝对路径以常量形式编进二进制文本配置与链接信息会自动做占位符替换无需手动处理但二进制内嵌字符串可能导致 Bottle 不可重定位验证本地可用brew bottle生成 Bottle 并检查 tab 元数据中relocatable/pinned状态gzip 助手的行为可参考 test/utils/gzip_spec.rb 中的固定校验和断言方式自行比对产物哈希。六、小结Homebrew 的可复现构建由三条相互独立的机制支撑以SOURCE_DATE_EPOCH源码修改时间为锚点的构建时间固定、以Utils::Gzip.compress为核心的确定性 gzip 压缩、以及 Bottle 阶段自动执行的HOMEBREW_PREFIX类占位符替换。三者分别消除了时间戳、压缩头 mtime 和构建机路径这三类最常见的不可复现来源且全部对 Formula 作者透明或以极小的 API 使用成本接入——这正是同一份源码能够稳定产出可校验、可跨前缀复用的 Bottle 的基础。【免费下载链接】brew The Package Manager for Everywhere项目地址: https://gitcode.com/GitHub_Trending/br/brew创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表