Skip to content

feat: a Homebrew tap, because brew owns what it installs - #13

Merged
lntvan166 merged 2 commits into
mainfrom
feat/homebrew-tap
Aug 24, 2026
Merged

feat: a Homebrew tap, because brew owns what it installs#13
lntvan166 merged 2 commits into
mainfrom
feat/homebrew-tap

Conversation

@lntvan166

Copy link
Copy Markdown
Owner

brew install lntvan166/paddock/paddock — one command, plus a paddock update that refuses to fight brew over the same binary.

why a tap and not homebrew/core

Core is closed on two independent counts, neither of which is effort:

  • Notability — a self-submission by the repo owner needs 90 forks, 90 watchers or 225 stars (Package-Acceptance-Policy.md).
  • Self-update"Software that updates itself conflicts with Homebrew's version and upgrade management" (Acceptable-Formulae.md). That is paddock update, exactly.

Casks are not the escape hatch: "A rejection from homebrew/core does not by itself make the software eligible for homebrew/cask."

herdr, by contrast, is a core formula — source tarball, rust + zig build deps, bottled by Homebrew's CI, ~32k stars. That is the template if paddock ever qualifies. Recorded in docs/decisions.md so it isn't re-litigated.

the bug this would have shipped

The Homebrew prefix is user-writable, so paddock update's rename(2) over a keg succeeds. brew info paddock would then report a version that is no longer the bytes on disk, and the next brew upgrade would revert the operator — silently, both directions. The existing "installed by a package manager" hint only fires when rename fails, which under brew it never does.

So the guard is proactive: resolve realpath first (brew links <prefix>/bin at the keg, and process.execPath may return either), then refuse before the download rather than after 83MB. --check still works — it writes nothing — and names brew upgrade paddock instead of the command that now declines.

/Cellar/ is matched as a whole path segment: every prefix is covered including a custom one, while ~/Cellars/… and wine-cellar/ are not false positives.

the formula

  • Checksums come from the release's own SHA256SUMS, so formula and artifacts cannot disagree.
  • depends_on "herdr" — a tap formula may depend on a core one, so brew guarantees the thing paddock is useless without. No version constraint: the herdr check is directional and core never moves backwards.
  • Rendered by scripts/render-formula.ts from a template that declares its own platforms, so a platform change is a template edit and the script doesn't move. A missing asset throws naming all missing ones.
  • Pushed to the tap after the assets are attached — earlier, and the tap serves urls that 404.

verification

make check · make check-clean · make test1050 pass, 0 fail. 16 new tests, each watched fail first. Two caught real mistakes: a workflow comment containing the literal || true it forbids, and check-clean catching absolute home paths in test fixtures (content fixed, not the scanner).

Rendered formula verified ruby -c → Syntax OK, with correct per-platform digests.

needs, before this is useful

  • lntvan166/homebrew-paddock — created, public.
  • HOMEBREW_TAP_TOKEN — set. First exercised by the next release; the tap step runs after the upload, so a bad token fails the job after the binaries ship rather than silently.

known gap, deliberately not in this PR

ReleaseBanner.tsx still tells every operator to run paddock update, which now declines under brew. Fixing it needs a field through routes.tssrc/shared/types.ts → store → App.tsx → component — six files across the full dependency chain, including the one payload contract. Kept separate.

🤖 Generated with Claude Code

paddock ships pre-compiled binaries and self-updates. homebrew-core rejects
both — a formula must build from source, and "software that updates itself
conflicts with Homebrew's version and upgrade management" — and casks are not
the escape hatch, since core rejecting a CLI does not make it cask-eligible.
So distribution is a personal tap, one command:

    brew install lntvan166/paddock/paddock

Fully qualified, not `brew tap` then `brew install`: since Homebrew 6.0.0 a
non-official tap needs explicit trust, and the qualified name grants it for
this one formula in a single step.

`paddock update` now refuses inside a keg. This is not politeness. The
Homebrew prefix is USER-writable, so the rename(2) would have SUCCEEDED,
leaving `brew info paddock` reporting a version that is no longer the bytes on
disk and the next `brew upgrade` reverting the operator with neither side
saying anything. The existing "installed by a package manager" hint only fires
when rename FAILS, which under brew it does not — so the guard is proactive,
resolves realpath first (brew links <prefix>/bin at the keg, and execPath may
hand back either), and refuses before the download rather than after 83MB.
`--check` still reports, because it writes nothing, and names `brew upgrade
paddock` rather than the command that now declines.

`/Cellar/` is matched as a path SEGMENT, so every prefix is covered — including
a custom one, which enumerating the known three would have missed — while
`~/Cellars/…` is not a false positive.

The formula's checksums come from the release's own SHA256SUMS, so it cannot
disagree with the artifacts it points at, and it carries depends_on "herdr":
a tap formula may depend on a core one, so brew guarantees the thing paddock
is useless without. No version constraint, because the herdr check is
directional and core never moves backwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 24, 2026 06:20

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.

`ReleaseBanner` printed `paddock update` to everyone. Under Homebrew that
command now refuses by design, so the notice named an action that does not
work — the same defect as a mislabelled Approve button, which CLAUDE.md rules
out for exactly this reason. A control is labelled from what actually owns the
install, never guessed.

The install's owner is a fact the client cannot derive, so it rides the WS
envelope beside `latestKnown`: `managedBy: "homebrew" | null`. A union rather
than a boolean because the command differs per manager, so a second packager
adds a case instead of a second field.

`detectManagedBy` is where the realpath hop lives, extracted rather than
inlined at the composition root because a boot-time expression in index.ts
cannot be tested — and this one has a branch worth testing: brew leaves a
symlink in <prefix>/bin and `process.execPath` may hand back either that or
the keg. Resolved ONCE at boot, as a value rather than a getter, because the
path of a running executable cannot change and `update` refuses to swap it.

`managedBy` is REQUIRED on `HealthBody`, matching the reasoning already
recorded there for `latestKnown`: an optional field lets a later edit drop it
silently, and a phone would read the absence as "unmanaged" and print the
wrong command again. Making it required turned that into the type error that
found both wiring sites.

Verified end to end, not only in units: a binary compiled into a Cellar path
reports `managedBy: "homebrew"` on /api/health, refuses `update` through both
the keg path and the <prefix>/bin symlink, leaves the binary byte-identical
with no temp file behind, and still reports the new version under `--check`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lntvan166

Copy link
Copy Markdown
Owner Author

Pushed 5c85090 — closes the gap this PR's description flagged as deliberately out of scope.

ReleaseBanner printed paddock update to everyone, which under Homebrew is now the command that refuses. The install's owner rides the WS envelope beside latestKnown as managedBy: "homebrew" | null — a union rather than a boolean, so a second packager adds a case instead of a second field.

Two things worth noting for review:

  • detectManagedBy is extracted, not inlined at the composition root. A boot-time expression in index.ts can't be tested, and this one has a branch that deserves it: brew leaves a symlink in <prefix>/bin and process.execPath may return either that or the keg.
  • managedBy is required on HealthBody, matching the reasoning already recorded there for latestKnown. An optional field lets a later edit drop it silently, and the client would read the absence as "unmanaged" and print the wrong command again. Making it required is what turned this into a type error that located both wiring sites.

Verified end to end rather than in units alone — a binary compiled into a Cellar path:

$ paddock update                       # through the keg
paddock: 0.0.1 -> 0.8.4
paddock: this binary is managed by Homebrew (…/Cellar/paddock/0.0.1/bin/paddock)
paddock: run `brew upgrade paddock` instead
exit: 1

$ paddock update                       # through <prefix>/bin/paddock -> keg
…same, resolved to the keg path…       exit: 1

$ paddock update --check                # still reports, writes nothing
paddock: 0.0.1 -> 0.8.4
paddock: run `brew upgrade paddock` to install it
exit: 0

$ curl -s localhost/api/health | jq .managedBy
"homebrew"

Binary byte-identical afterwards, no .paddock.new left behind.

make check · make check-clean · make test1062 pass, 0 fail (was 1050).

@lntvan166
lntvan166 merged commit e0a08bf into main Aug 24, 2026
1 check passed
@lntvan166
lntvan166 deleted the feat/homebrew-tap branch August 24, 2026 06:32
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