Library/Homebrew/plans/relocatable-bottles.md
Every Homebrew/core bottle pours at every prefix under 65 characters long.
cellar :any nor cellar :any_skip_relocation. The
padded-prefix scheme below handles most of them; the residual classes in
"Completeness" each get their own strategy so that none is left behind
silently. Exceptions must be named, justified in the formula and counted.This is a living document: edit or remove steps as they are implemented so it always describes the remaining work.
End each implementation commit body with this exact line:
This change is part of [`plans/relocatable-bottles.md`](https://github.com/Homebrew/brew/blob/HEAD/Library/Homebrew/plans/relocatable-bottles.md)
Any benchmark quoted in a commit message must be the full hyperfine
output from brew benchmark (using its --exec mode for bespoke
workloads), never hand-summarised numbers.
Changes to homebrew/core (re-marks, formula fixes, sweeps) use one commit
per formula with the subject <formula>: <change>, never one commit
spanning many formulae.
Pull requests must always fill in the repository's pull request
template (.github/PULL_REQUEST_TEMPLATE.md), never bypass it
(e.g. with gh pr create --fill).
| Tag | :any_skip_relocation | :any | pinned |
|---|---|---|---|
arm64_tahoe (8,369 bottles) | 4,598 (54.9%) | 2,745 (32.8%) | 1,026 (12.3%) |
x86_64_linux (8,401 bottles) | 4,891 (58.2%) | 2,341 (27.9%) | 1,169 (13.9%) |
The 1,121 :all bottles are counted in :any_skip_relocation: they are all
marked that way and apply on every platform. 1,328 formulae carry a pinned
bottle on at least one tag and 892 are pinned on both arm64_tahoe and
x86_64_linux, so causes are mostly inherent rather than
platform-specific. Six formulae still gate pouring on
pour_bottle? only_if: :default_prefix.
Per-bottle rates understate the problem, because an install only avoids
source builds when the formula's entire recursive runtime dependency closure
pours. On arm64_tahoe, although 87.7% of bottles are individually
relocatable, only 56.0% of formulae (4,688 of 8,369) have a fully
relocatable closure; 43.9% contain at least one pinned bottle. A few hub
formulae do most of the poisoning: openssl@3 alone appears in the closure
of 2,152 formulae, followed by gettext (1,325), glib (963),
[email protected] (867), libx11 (855) and fontconfig (831). Fixing only
those six would lift closure coverage to 70.9%, and the top twenty to
76.4%, which is why per-formula fixes alone plateau and the value of this
plan is convex: it concentrates near 100%, where whole dependency trees
become pourable, and every pinned formula left continues to poison its
entire dependent subtree.
Bottles are stored with @@HOMEBREW_PREFIX@@-style placeholders in text
files, libtool files, Mach-O load commands and ELF RPATHs/interpreters
(keg_relocate.rb, extend/os/{mac,linux}/keg_relocate.rb). After
placeholdering, brew bottle scans the keg (dev-cmd/bottle.rb); any
surviving prefix/cellar/library reference or absolute symlink into the prefix
pins the bottle to its build cellar. Surviving references are almost always
raw C strings compiled into binaries, because text replacement deliberately
touches only text files.
Raw C strings are the only length-limited content at pour time:
-headerpad_max_install_names (shims/super/cc), and install-name edits
happen in-process via ruby-macho.Keg#relocate_build_prefix (keg_relocate.rb) implements that NUL-padded
patching and re-signs patched Mach-O files, and
BottleSpecification#compatible_locations? allows pouring a pinned bottle
into an equal-or-shorter prefix by default. The documented
HOMEBREW_NO_RELOCATE_BUILD_PREFIX variable disables this. History:
default-on was added in #12534 (December 2021), reverted twice, then gated
behind a positive variable in #13217 after #13209 reported hard failures at
a prefix longer than the bottled one. The length check now prevents that
case and the positive variable was removed when default-on was restored.
Prior art: conda-forge builds packages under a long placeholder prefix (up
to 255 characters) and rewrites it at install time, including NUL-padded
replacement inside binaries, and Spack's padded_length configuration pads
install tree paths for the same reason so its build caches relocate. Both
validate the padded-prefix approach at scale and are useful references for
edge cases (e.g. prefixes inside length-prefixed data).
Replaying the exact keg_contain? logic over the contents of 17 pinned and
16 relocatable published bottles:
wget2's localedir, graphviz's
plugin directory, [email protected]'s framework prefix..dynstr bytes: Meson's install-time RPATH rewrite
overwrites the build RPATH without clearing the rest of the old string
(intentional upstream; Nix carries a patch), and patchelf leaves the old
table behind when growing one, so the checker counts the corpse
(harfbuzz, shared-mime-info; the 224-formula glib/gobject
introspection cluster on Linux looks identical). Functionally these are
false positives, since addressed by scanning ELF files by structure
(design decision 11)..o artefacts (about 29 npm
formulae).abseil replays clean under the current checker yet CI pinned it:
since explained and fixed (phantom resolved-linkage matches, Phase 1
item 4). A bottle's cellar lives in the formula, not the tarball, so
provably clean bottles can be re-marked cellar :any without
rebuilding.padded_prefix: true, while formulae retain the tag's default
cellar. Every embedded C string then carries 64 bytes of patchable
material, so bottles patch down to any prefix up to 64 bytes, including
the defaults. Clients must understand the tab marker before the first
padded bottles are published, so the brew release precedes the
infrastructure cutover.
64 rather than conda's 255 because CI runs brew test at the padded
prefix, where Unix socket paths (sun_path is 104 bytes) and shebang
limits punish very long prefixes.:any and :any_skip_relocation bottles
(84 to 88% of the catalogue) are placeholder-based and prefix-independent,
so they pour exactly as today. Only pinned bottles start patching (1 to 2
files typically) plus re-signing at pour. Re-signing preserves
entitlements, requirements, flags and runtime metadata
(codesign_patched_binary passes --preserve-metadata).built_prefix in its tab; absence means the tag's default prefix, so
tabs for today's bottles are unchanged. The literal string, not a
name/version pair, so the constant can change without a mapping. A
canonical padded build also records padded_prefix: true there.HOMEBREW_NO_RELOCATE_BUILD_PREFIX is the documented escape hatch,
retained through the soak and padded migration.strings-based checker, which counted every prefix byte
sequence in a file as a pin: a wrongly pinned bottle forces source
builds for every non-default-prefix user and poisons its whole
dependent subtree, whereas a wrongly relocatable one surfaces as a
per-formula bug report with a trivial fix and is caught by the Phase 4
validation sweep and the test-bot relocated-pour test. Concretely, ELF
files are scanned by structure rather than as a whole: only the
interpreter the loader uses, the dynamic strings the loader references
and the contents of ordinary sections count; bytes outside every
section and unreferenced entries in loader-owned string tables never
do. Other file types keep the whole-file scan.Each pinned bottle flipped to cellar :any pours at any prefix with no
length limit and no new machinery.
abseil class was pinned
by file_linked_libraries resolving @rpath/@loader_path load
commands against the live keg at bottle time, turning relocatable
linkage into absolute build-prefix paths; the checker now reads raw
load-command names. Text matches never recorded it because googletest
include-path strings hit the ignores filters, which also explains
why only arm64 macOS pinned: /usr/local/include/... never byte-matched
Intel's /usr/local/opt and /usr/local/Cellar search strings and
Linux has no linkage check. Remaining: once the fix is deployed to CI,
batch re-mark provably clean pins cellar :any with no rebuild
(sha256 unchanged) in homebrew/core, starting from the 127 formulae
pinned on all arm64 macOS tags yet :any on x86_64_linux (e.g.
abseil, boost, binutils, aws-sdk-cpp).Whole dependency closures now pour at prefixes up to the bottled default
length (13 bytes arm64 macOS, 26 Linux), covering the hub formulae
(openssl@3, gettext, glib, [email protected]) that Phase 1 cannot.
Relocation is the default for equal-or-shorter prefixes and
HOMEBREW_NO_RELOCATE_BUILD_PREFIX provides a documented escape hatch.
This exercises the already-published catalogue across more real custom
prefixes before Homebrew commits to padded production builds.
During a soak of at least a few months, Homebrew/brew CI pairs its existing default-prefix test-bot jobs with padded-prefix jobs on x86_64 Linux, arm64 Linux and arm64 macOS. Both variants run the same source-build, bottle, reinstall, linkage and formula-test workflow; the padded jobs neither publish nor upload their bottles.
All remaining homebrew/brew and individual-formula fixes (the brew side of Phase 3, the upstream hub track and the Completeness items) come before anything that touches homebrew/core CI or runs at catalogue scale.
Client foundations completed August 2026: canonical 64-byte prefixes exist, padded eligibility remains in tab or manifest metadata only and bottle selection uses that manifest data. Begin the production steps below only after Phase 2 has soaked for at least a few months.
Extend the paired test-bot jobs to cover a pinned plus path-length-sensitive sample and representative pinned dependency closures at the candidate 64-byte prefixes on all three target platforms.
test-bot: build changed formulae at the padded prefix, pour each fresh
bottle into a scratch short prefix, then run brew linkage --test and
brew test.
Before cutover, version-gate symbolic cellar and padded_prefix metadata
consumers, release supporting clients and ensure the marker remains
per-platform rather than merging into :all. Resolve socket, shebang and
build-path failures, audit scanner blind spots, decide the glibc strategy and
retire or justify pour_bottle? only_if: :default_prefix gates.
Infra cutover in order: x86_64 Linux runners first (no SIP, biggest pinned share), arm64 Linux second, arm64 macOS third.
Natural version-bump and autobump churn rebottles most formulae at 64;
a dashboard tracks the 64-built share per platform from built_prefix.
Dependency-ordered forced sweep for stragglers, prioritised by poisoned
dependent count (openssl@3, gettext, glib, [email protected], libx11
and fontconfig first), then remaining pinned formulae: old pinned
bottles cannot pour into the padded CI prefix, so they force CI
source-build fallbacks until rebottled, whereas old relocatable bottles
pour there immediately (placeholder relocation has no length limit).
Old pinned bottles keep pouring unchanged at default prefixes
throughout.
Validation sweep, in homebrew/core CI (workflow_dispatch) once the
stages above are done: pour every pinned bottle into a scratch prefix
shorter than the bottled one on each target platform with patching
enabled, confirm each tab records relocated_build_prefix, run
brew linkage --test and brew test, and report a per-formula table;
this is the functional catch-all for scanner blind spots. Not before:
it is a mass run whose results change with every rebottle.
HOMEBREW_NO_RELOCATE_BUILD_PREFIX; update docs/Bottles.md,
docs/Installation.md and homebrew/install messaging with the single
global contract.Independent of the phases above and prioritised by poisoned dependent
count, because these help prefixes beyond 64 bytes and remove formulae from
the patching path entirely: prefer executable-relative lookups upstream,
environment fallbacks or moving baked-in paths into generated text files
(which relocate for free). Biggest heads: openssl@3, gettext, glib,
[email protected], libx11, fontconfig, then node, perl, ruby, php,
mysql, postgresql@x, ffmpeg, llvm, gcc, ghc, openjdk.
The padded-prefix scheme alone does not cover every non-:any bottle. Each
residual class below is a goal with its own strategy, tracked to zero or to
a named, formula-annotated exception:
brew bottle rewrites
absolute symlinks into the prefix or cellar as relative ones
(functionally identical) and pouring retargets any left in older
bottles from the prefix they were built for.protodesc_cold/.bun sections, e.g.
bun): these keep raw RPATH strings and auto-pin today. Strategy: the
NUL-padded string patcher handles them at pour. A shortened NUL-padded
.dynstr entry is valid (the loader reads to the first NUL), and under
padded builds the replacement always fits. Verified with bun in the
Phase 4 validation sweep.strings-based scanning cannot see. Under
padded builds these become a correctness risk for default-prefix users
too, since an unseen string never gets patched. Strategy: bottle-time
audit of known container formats with warnings; the Phase 4
validation sweep and the test-bot relocated-pour test are the functional
catch-all; findings become per-formula fixes. NUL padding inside a
length-prefixed field can also corrupt rare formats, which the same
validation catches.pour_bottle? only_if: :default_prefix (apptainer, composer, fish,
ocaml, ocaml@4, screenfetch): these refuse to pour at non-default
prefixes regardless of cellar. Strategy: re-test each under patching and
retire or convert the gates during Phase 4.An audit should enforce the end state: a formula whose fresh bottle is neither relocatable nor patchable must carry an in-formula annotation naming the reason, so the exceptions list stays short, visible and countable.
brew install at any prefix up to 64 bytes never falls back to source
because of relocation on the target platforms.brew benchmark at implementation: 1.67x faster end-to-end on a
5,000-file synthetic keg, relocation cost itself dropping from ~320ms to
~1ms), with gains accruing as bottles are rebottled with metadata.