Skip to content

feat(brew): Brew — Homebrew formulae and casks as a v2 resource (ENG-3738) - #33

Merged
Ryan Zhu (underthestars-zhy) merged 2 commits into
ryan/eng-3749-astrolabe-homebrew-itself-as-a-v2-resourcefrom
ryan/eng-3738-astrolabe-brew-as-a-v2-resource
Nov 1, 2026
Merged

Ryan Zhu (underthestars-zhy) merged 2 commits into
ryan/eng-3749-astrolabe-homebrew-itself-as-a-v2-resourcefrom
ryan/eng-3738-astrolabe-brew-as-a-v2-resource

Conversation

@underthestars-zhy

@underthestars-zhy Ryan Zhu (underthestars-zhy) commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Implements ENG-3738. Stacked on #32 (ENG-3749), which is stacked on #31, #30, #29, #28, #26 and #25 (next).

What

v2 could install CLT (ENG-3739) and Homebrew itself (ENG-3749), but not a single formula or cask, and most farm setup is Brew(...). This ports v1's Brew onto @Resource, @Monitor, and the Homebrew facts and lock from #32:

var body: some Component {
    CommandLineTools()                                   // Homebrew waits for it
    Homebrew(owner: "ops")                               // installs Homebrew and keeps it owned by ops

    Brew("wget")                                         // install if missing
    Brew("firefox", type: .cask)                         // a cask
    Brew("tailscale", version: .atLeast("1.102.0"))      // brew upgrade while below
    Brew("cloudflare/cloudflare/cloudflared")            // tap-qualified: brew taps it
}
You do Astrolabe does
Add Brew("wget") brew install --formula wget as Homebrew's owner, once Homebrew is on the Mac
Remove it from body brew uninstall --formula wget, also after a daemon restart (from records.json)
.installed → .atLeast("1.25") brew upgrade only if below 1.25. Never uninstalls
"cloudflared" → "cloudflare/cloudflare/cloudflared" Same id: nothing is uninstalled, and the rollout finds it installed
.formula → .cask (same name) A new id: install the cask, uninstall the formula
Someone runs brew uninstall wget Within about 60 s, brew install again
Another admin runs brew install jq Homebrew re-owns the prefix. Nothing is uninstalled
Clean Mac, no Homebrew(owner:) Every Brew waits silently
Homebrew deleted Removals find nothing to remove. Homebrew reinstalls, then every Brew installs again
  • Gated on \.homebrew.isInstalled, never on one owner. remove really uninstalls, and anything not declared is removed. Gating on owners.count == 1 would uninstall every package whenever another admin runs brew install. isInstalled goes false only when bin/brew is gone, and then the Cellar usually is too, so remove finds nothing.
  • Drift repair through @Monitor. A new fact, \.homebrew.packages, scans Cellar, Caskroom and opt every 60 s with readdir/readlink (no brew, no lock). Each BrewPackage monitors only its own Bool (\.homebrew[satisfies: …], internal), so installing one package never reruns the others. This replaces v1's 15 s loop().
  • .latest(checkEvery:) is dropped. Time passing never changes a hash. A follow-up adds an available fact that body pins into .atLeast.
  • \.homebrew.packages is public, so app code can @Fact it, e.g. to declare something only once a binary is installed.

How it runs brew

  • rollout (BrewInstaller.ensure):
    1. Check the Mac without the lock. Satisfied: return, with no lock and no brew.
    2. An uncomparable version (HEAD-…, latest, a v1.2 floor) throws and never upgrades, as in v1.
    3. Under HomebrewContext.lock: Task.checkCancellation(), check again, then resolve the owner from system.owners() (never HomebrewReads.owners(), which takes the same lock).
    4. brew install|upgrade --formula|--cask <declared name> as the owner, streamed to the log, then verify against the Cellar.
  • remove (BrewInstaller.uninstall): same shape, with brew uninstall --formula|--cask <short name>, only if it's installed. It uses only what the id is built from, since the snapshot it gets isn't necessarily the last success.
  • Owners: [] throws homebrewMissing. Several owners, root, or a uid with no account throw homebrewNotReady, retried (60 s → 1 h) once Homebrew re-owns.
  • The lock is FIFO and shared with install.sh, re-owns and the owners read, so brew never runs during an install and never two at once. Brew never touches the CLT lock. The timeout is 4 h, mostly for waiting on the lock: on first boot every package queues behind the others.

Changes

  • BuiltIn/Brew/Brew.swift: the public Brew component (PackageType, Version) and the internal BrewPackage (astrolabe.brew, id brew:<type>:<short name>; the hash covers name, type and minimum).
  • BrewFact.swift: \.homebrew.packages, the [satisfies:] subscript, and the public BrewPackages.
  • BrewInstaller.swift: BrewName, BrewCheck, ensure, uninstall, BrewError.
  • BrewSystem.swift: BrewScan, plus packages() and brew(_:as:) on InstallScriptHomebrew.
    • Installed means at least one non-empty version directory. Dot-names and non-directories are skipped, and an entry that vanishes mid-scan counts as gone.
    • A missing (or non-directory) Cellar/Caskroom reads as empty. Any other I/O error throws, so a hiccup never reads as "nothing installed".
    • A formula's version is the opt link target, otherwise the highest keg.
  • BrewVersion.swift: v1's, verbatim.
  • HomebrewSystem gains packages() and brew(_:as:). Also HomebrewReads.packages(), and comment updates on the lock and on HomebrewFacts.
  • Examples/BrewEngine reads packages from /etc/astrolabe-brew-packages (wget, cask firefox, tailscale >= 1.102.0).
  • README: a paragraph in the v2 section.

Tests

swift test on this Mac: 335 tests. Everything passes except storageClientConcurrentWritesPreserveAllKeys (.helperNotFound), which also fails on main. BrewTests passed in 8 runs in a row.

BrewTests (63), over FakeHomebrewSystem (now one package map, plus brew that suspends, so overlapping runs would show) and FakeCLTSystem:

  • Resource: ids, tap qualification, the minimum moves the hash not the id, formula vs cask, the monitor stays out of the hash, the snapshot round-trips, and in a pass the monitor reads only packages.
  • Scan on a temp prefix: v1's cases, plus stray files at each level, missing roots, and unreadable directories throwing.
  • BrewVersion: v1's cases, verbatim. check: every case, and the subscript agrees with it.
  • ensure / uninstall: exact call order; no lock when satisfied or absent; upgrade; still below the floor; uncomparable; each owner case; brew failure and a brew that didn't take; casks; tap names; cancelled while waiting; maxRunning == 1.
  • Engine:
    • CLT, then Homebrew, then Brew, with no operation.failed;
    • no Homebrew(), on a clean Mac and on an existing one;
    • drift: reinstalled, then exactly one follow-up;
    • dropped from body, and across a restart;
    • a new floor upgrades;
    • tap qualification runs no brew;
    • mixed owners are re-owned and nothing is uninstalled;
    • a deleted Homebrew comes back with no uninstall;
    • a rollout superseded while waiting on the lock runs no brew;
    • the dry run.

Removing the lock from ensure makes three of these fail (maxRunning, cancelled-while-waiting, superseded), so they do check serialization.

Also checked: BrewScan on this Mac's real /opt/homebrew (read-only) agrees with brew list on all 227 formulae and 9 casks, in about 50 ms.

Not yet verified

The farm-Mac steps need root: every Astrolabe binary refuses to start without it, --dry-run included. On a farm Mac or a clean VM, after swift build -c release:

  1. Write the owner to /etc/astrolabe-homebrew-owner and the list to /etc/astrolabe-brew-packages (temp file, then sudo mv).
  2. sudo .build/release/BrewEngine --dry-run, then sudo .build/release/BrewEngine.
  3. Check the Cellar/Caskroom ownership, a hand-run brew as-brew-user uninstall wget (back within 60 s), removing a line, adding tailscale >= 1.102.0, and another admin's brew install jq.

Known gap (accepted)

If bin/brew is gone while the Cellar stays (a partial deletion, or a read in the instant brew update rewrites bin/brew), isInstalled reads false and every BrewPackage is removed, then reinstalled after Homebrew repairs it. It's rare and limited to one Mac. A Held(until:) structural freeze would close it if it ever shows up.

ENG-3738's description predates @Monitor and Homebrew(owner:); I'll update it when this lands.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

…3738)

v2 could install CLT and Homebrew itself, but not a single formula or
cask, and most farm setup is Brew(...). Brew ports v1's Brew onto
@resource, @monitor, and the Homebrew facts and lock:

    CommandLineTools()
    Homebrew(owner: "ops")
    Brew("wget")
    Brew("firefox", type: .cask)
    Brew("tailscale", version: .atLeast("1.102.0"))

- Brew is a Component that declares BrewPackage (astrolabe.brew) once
  \.homebrew.isInstalled reads true. Gated on isInstalled, never on one
  owner: remove really uninstalls, and mixed owners (another admin's
  brew install) would otherwise hide, and uninstall, every package.
- The id is brew:<type>:<short name>, so tap-qualifying a name keeps the
  id and never uninstalls. The hash covers name, type and minimum: a new
  .atLeast floor upgrades in place.
- rollout: check the Mac without the lock (satisfied → no lock, no
  brew); else under HomebrewContext.lock, check again, resolve the one
  owner from system.owners(), brew install|upgrade --formula|--cask
  <name> as the owner, and verify. An uncomparable version throws and
  never upgrades. [] owners → homebrewMissing; mixed, root or #uid →
  homebrewNotReady, retried once Homebrew re-owns.
- remove: brew uninstall --formula|--cask <short name>, only if it's
  there. Uses only what the id is built from.
- \.homebrew.packages scans Cellar, Caskroom and opt every 60 s with
  readdir and readlink, no brew and no lock. Throws only on real I/O
  errors. Each BrewPackage monitors \.homebrew[satisfies:], its own Bool,
  so a hand-run brew uninstall or a downgrade is undone within a minute.
  This replaces v1's 15 s loop.
- .latest(checkEvery:) is dropped: time passing never changes a hash. A
  follow-up adds an available fact that body pins into .atLeast.
- HomebrewSystem gains packages() and brew(_:as:). BrewVersion is v1's,
  verbatim.
- Examples/BrewEngine reads packages from /etc/astrolabe-brew-packages.
- Tests: ids, hashes and the snapshot; the scan on a temp prefix; v1's
  version cases; check and the subscript; ensure and uninstall over the
  fake (call order, no lock when satisfied, upgrade, floors, owners,
  failures, casks, taps, cancellation, one brew at a time); and the
  engine: CLT then Homebrew then Brew with no failures, no Homebrew(),
  drift, dropped from body and across a restart, a new floor, tap
  qualification, mixed owners, a deleted Homebrew, a superseded rollout
  waiting on the lock, and the dry run. FakeHomebrewSystem keeps one
  package map and runs brew.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 15, 2026 01:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Team
  • Run ID: 390412bf-ef68-4321-a7c1-bc367e345337

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

…e' into ryan/eng-3738-astrolabe-brew-as-a-v2-resource
@underthestars-zhy
Ryan Zhu (underthestars-zhy) merged commit 6b21452 into ryan/eng-3749-astrolabe-homebrew-itself-as-a-v2-resource Nov 1, 2026
1 check passed

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

Open in Capy

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants