Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 43 additions & 5 deletions docs/system-packages/brew.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ into the canonical Homebrew prefix — `/opt/homebrew` on arm64 macOS,
formulae.brew.sh API, resolves the runtime dependency closure, downloads
prebuilt bottles from ghcr.io (verifying sha256 checksums), and performs the
same relocation, code-signing, and linking work `brew` does when pouring a
bottle. mise never shells out to `brew`.
bottle. Formulae without a usable bottle are built from source, also without
Homebrew (see [Source formulae](#source-formulae)). mise never shells out to
`brew`.

This exists because shared-library packages — postgres, ffmpeg, imagemagick,
php — fundamentally can't be served by mise's per-project backends like
Expand All @@ -32,8 +34,9 @@ what makes them work.
| Linux arm64 | `/home/linuxbrew/.linuxbrew` |

Intel macs are not supported — the `brew` manager reports itself unavailable
there. On Linux, formulae without a bottle for your architecture fail with a
clear error (arm64 Linux bottles exist for most but not all of homebrew/core).
there. On Linux, formulae without a bottle for your architecture (arm64
Linux bottles exist for most but not all of homebrew/core) are built from
source instead.

## The prefix

Expand Down Expand Up @@ -82,6 +85,39 @@ For each formula in the dependency closure (dependencies first):
[keg-only](https://docs.brew.sh/FAQ#what-does-keg-only-mean) formulae get
the `opt` link but are not linked into the prefix, same as brew.

## Source formulae

A few formulae have no bottle at all (source-only formulae), and some have
bottles for other platforms but not yours. mise builds those from source —
still without Homebrew:

1. **Ruby** — a formula is Ruby code, so mise provisions a mise-managed
ruby through its normal tool machinery (precompiled, fast; respects your
configured ruby if you have one).
2. **Formula** — the formula's `.rb` is downloaded from homebrew/core,
pinned to the exact commit the API metadata was generated from and
verified against the API's sha256 for it.
3. **Source** — the stable source archive is downloaded and verified
against the API's sha256.
4. **Build deps** — the formula's build dependencies (cmake, pkgconf, ...)
are added to the install closure and poured as regular bottles first.
5. **Build** — mise evaluates the formula with its own Formula-DSL shim and
runs `def install` against the canonical prefix, with `PATH`,
`PKG_CONFIG_PATH`, and compiler flags pointing at the dependency kegs.
The keg gets the same brew-compatible receipt as a poured bottle, with
`poured_from_bottle: false` — exactly how brew marks its own source
builds.

The shim implements the commonly-used subset of the formula DSL
(configure/cmake/meson-style builds, resources, patches, the standard path
and environment helpers). Formulae that use parts of the DSL the shim
doesn't cover — language-specific helpers like `virtualenv_install_with_resources`,
VCS downloads, and similar — fail with a clear `formula uses ...` error
rather than miscompiling silently.

Source builds need a working toolchain (Xcode Command Line Tools on macOS,
gcc/make on Linux), exactly as they would under plain Homebrew.

## Upgrades

`mise system upgrade` re-resolves the configured formulae against the
Expand All @@ -97,8 +133,10 @@ operation.
implemented.
- **No taps.** Third-party taps are Ruby code that requires Homebrew to
evaluate; only homebrew/core is supported.
- **No source builds.** Formulae without a bottle for your platform fail
with a clear error.
- **Source builds cover the common formula shapes.** mise's formula shim
implements the widely-used subset of the DSL (see
[Source formulae](#source-formulae)); formulae that reach beyond it fail
with a clear error naming the unsupported feature.
- **Use canonical formula names.** `postgresql@17` is a formula name, not a
mise version pin — the API's current stable version decides what gets
installed. Aliases (`postgres`) install correctly but `mise system status`
Expand Down
34 changes: 34 additions & 0 deletions e2e/cli/test_system_install_brew_source_slow
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash

# Source-build pipeline test: builds GNU hello from source with mise's
# formula shim (mise-managed ruby, no Homebrew installed). Uses a throwaway
# prefix, so no root is needed. Requires network and a C toolchain.
if [[ "$(uname)" != "Linux" ]] || ! command -v cc >/dev/null 2>&1; then
exit 0
fi
case "$(uname -m)" in
x86_64 | aarch64) ;;
*) exit 0 ;;
esac

export MISE_SYSTEM_BREW_PREFIX="$HOME/brew-source-prefix"
# hello has bottles everywhere; force it down the source-build path
export MISE_SYSTEM_BREW_FORCE_SOURCE=hello
Comment thread
coderabbitai[bot] marked this conversation as resolved.

cat <<EOF >mise.toml
[system.packages]
"brew:hello" = "latest"
EOF

assert_contains "mise system status" "missing"
assert_contains "mise system install --dry-run" "build hello/"

mise system install --yes
assert_contains "mise system status" "installed"

# the built binary runs from the prefix and the keg carries a receipt
assert_contains "$MISE_SYSTEM_BREW_PREFIX/bin/hello --greeting=mise" "mise"
assert_contains "cat $MISE_SYSTEM_BREW_PREFIX/Cellar/hello/*/INSTALL_RECEIPT.json" '"poured_from_bottle":false'

# idempotent
assert_contains "mise system install --yes 2>&1" "already"
2 changes: 1 addition & 1 deletion src/system/packages/apt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ fn parse_dpkg_query(output: &str, requests: &[PackageRequest]) -> Vec<PackageSta
.collect()
}

#[async_trait]
#[async_trait(?Send)]
impl SystemPackageManager for AptManager {
fn name(&self) -> &'static str {
"apt"
Expand Down
48 changes: 48 additions & 0 deletions src/system/packages/brew/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,42 @@ pub struct Formula {
/// runtime dependencies (formula names)
#[serde(default)]
pub dependencies: Vec<String>,
/// build-time-only dependencies — needed for source builds, not pours
#[serde(default)]
pub build_dependencies: Vec<String>,
#[serde(default)]
pub bottle: HashMap<String, BottleSpec>,
/// per-bottle-tag overrides (e.g. different dependencies on some platforms)
#[serde(default)]
pub variations: HashMap<String, Variation>,
/// source download specs keyed by spec name ("stable")
#[serde(default)]
pub urls: HashMap<String, SourceUrl>,
/// formula .rb location in homebrew/core (e.g. "Formula/h/hello.rb")
#[serde(default)]
pub ruby_source_path: Option<String>,
#[serde(default)]
pub ruby_source_checksum: Option<RubySourceChecksum>,
/// homebrew/core commit this API snapshot was generated from
#[serde(default)]
pub tap_git_head: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct SourceUrl {
pub url: String,
/// sha256 of the source archive; absent for VCS sources
#[serde(default)]
pub checksum: Option<String>,
/// non-default download strategy (":git", ":svn", ...) — unsupported
#[serde(default)]
pub using: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct RubySourceChecksum {
#[serde(default)]
pub sha256: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
Expand All @@ -53,6 +84,8 @@ pub struct BottleFile {
pub struct Variation {
#[serde(default)]
pub dependencies: Option<Vec<String>>,
#[serde(default)]
pub build_dependencies: Option<Vec<String>>,
}

impl Formula {
Expand Down Expand Up @@ -80,9 +113,24 @@ impl Formula {
&self.dependencies
}

/// build-time dependencies for the given bottle tag, applying `variations`
pub fn build_dependencies_for(&self, tag: &str) -> &[String] {
if let Some(v) = self.variations.get(tag)
&& let Some(deps) = &v.build_dependencies
{
return deps;
}
&self.build_dependencies
}

pub fn bottle_files(&self) -> Option<&HashMap<String, BottleFile>> {
self.bottle.get("stable").map(|b| &b.files)
}

/// the stable source archive spec, when present
pub fn stable_url(&self) -> Option<&SourceUrl> {
self.urls.get("stable")
}
}

/// Fetch formula metadata by name (or alias — brew's API redirects aliases
Expand Down
120 changes: 86 additions & 34 deletions src/system/packages/brew/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@
//! (/opt/homebrew on arm64 macOS, /home/linuxbrew/.linuxbrew on Linux) —
//! fetching metadata from formulae.brew.sh, downloading bottles from
//! ghcr.io, and doing the same relocation/codesigning work `brew` does at
//! pour time. mise never shells out to brew; the receipts it writes are
//! brew-compatible, so a real Homebrew sees mise-poured kegs as its own.
//! pour time. mise never shells out to brew to pour a bottle; the receipts
//! it writes are brew-compatible, so a real Homebrew sees mise-poured kegs
//! as its own.
//!
//! Scope: formulae only. Taps require evaluating Ruby and are unsupported;
//! casks and services are not implemented.
//! Formulae without a usable bottle are built from source, still without
//! Homebrew: mise provisions a mise-managed ruby and evaluates the formula
//! with its own Formula-DSL shim (see source.rs and shim.rb).
//!
//! Scope: formulae only. Casks and services are not implemented; taps are
//! unsupported (only homebrew/core formulae are served by the API).

use async_trait::async_trait;
use eyre::bail;
Expand All @@ -26,6 +31,7 @@ mod pour;
mod prefix;
mod relocate;
mod resolve;
mod source;
mod state;
mod tag;

Expand Down Expand Up @@ -72,19 +78,38 @@ impl BrewManager {
info!("brew: all formulae already poured");
return Ok(());
}
// formulae without a usable bottle are built from source by
// evaluating their Ruby with mise's formula shim; reject the ones
// the builder can't handle before any work happens
let source_builds: Vec<_> = to_pour
.iter()
.filter(|rf| !source::has_bottle(&rf.formula))
.collect();
for rf in &source_builds {
source::check_buildable(&rf.formula)?;
}
if opts.dry_run {
prefix::bootstrap(true)?;
for rf in &to_pour {
miseprintln!(
"pour {}/{} ({})",
rf.formula.name,
rf.formula.pkg_version()?,
if rf.on_request {
"requested"
} else {
"dependency"
},
);
let origin = if rf.on_request {
"requested"
} else {
"dependency"
};
if source::has_bottle(&rf.formula) {
miseprintln!(
"pour {}/{} ({origin})",
rf.formula.name,
rf.formula.pkg_version()?,
);
} else {
miseprintln!(
"build {}/{} from source ({origin}, {})",
rf.formula.name,
rf.formula.pkg_version()?,
source::missing_bottle_reason(&rf.formula),
);
}
}
return Ok(());
}
Expand All @@ -97,41 +122,68 @@ impl BrewManager {
}
prefix::bootstrap(false)?;
prefix::setup_linux_runtime()?;
if !source_builds.is_empty() {
info!(
"brew: building from source (no bottle for this machine): {}",
source_builds
.iter()
.map(|rf| rf.formula.name.clone())
.collect::<Vec<_>>()
.join(", "),
);
}
let mpr = MultiProgressReport::get();
// overall [cur/total] header above the per-formula clx jobs, same as
// tool installs (no-op when only one formula is being installed)
mpr.init_footer(false, "install", to_pour.len());
let mut ledger = state::Ledger::load();
for rf in &to_pour {
let name = &rf.formula.name;
let pkg_version = rf.formula.pkg_version()?;
let Some(files) = rf.formula.bottle_files() else {
bail!("{name} has no bottles (source-only formulae are unsupported)");
let pr: Box<dyn SingleReport> = mpr.add(&format!("brew:{name}"));
// branch on the same predicate the upfront classification used
let bottle = if source::has_bottle(&rf.formula) {
rf.formula.bottle_files().and_then(tag::select)
} else {
None
};
let Some((tag, bottle)) = tag::select(files) else {
bail!(
"{name} has no bottle for this machine (available: {})",
files.keys().cloned().collect::<Vec<_>>().join(", "),
);
let installed = match bottle {
Some((tag, bottle)) => {
async {
let tarball =
fetch::fetch_bottle(name, &pkg_version, bottle, Some(&*pr)).await?;
pour::pour(rf, &tag, bottle, &tarball, &closure, &*pr).await?;
Ok(pkg_version.clone())
}
.await
}
None => source::build(rf, &closure, &*pr)
.await
.map(|()| pkg_version.clone()),
};
let pr: Box<dyn SingleReport> = mpr.add(&format!("brew:{name}"));
let poured = async {
let tarball = fetch::fetch_bottle(name, &pkg_version, bottle, Some(&*pr)).await?;
pour::pour(rf, &tag, bottle, &tarball, &closure, &*pr).await
}
.await;
if let Err(err) = poured {
pr.finish_with_icon("failed".to_string(), ProgressIcon::Error);
return Err(err);
}
ledger.record(name, &pkg_version, rf.on_request);
let version = match installed {
Ok(version) => version,
Err(err) => {
pr.finish_with_icon("failed".to_string(), ProgressIcon::Error);
// render the final progress state so the error that
// propagates from here isn't masked by live jobs
mpr.footer_finish();
return Err(err);
}
};
ledger.record(name, &version, rf.on_request);
ledger.save()?;
pr.finish_with_message(pkg_version.clone());
pr.finish_with_message(version);
mpr.footer_inc(1);
}
mpr.footer_finish();
// a glibc poured in this run repoints <prefix>/lib/ld.so at it
prefix::setup_linux_runtime()?;
Ok(())
}
}

#[async_trait]
#[async_trait(?Send)]
impl SystemPackageManager for BrewManager {
fn name(&self) -> &'static str {
"brew"
Expand Down
Loading
Loading