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
39 changes: 39 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,42 @@ jobs:
else
gh release create "$GITHUB_REF_NAME" --generate-notes --verify-tag out/*
fi

# The tap comes LAST. The formula's urls point at the assets attached
# above, so a tap published any earlier serves a formula whose downloads
# 404 — and had that upload then failed, one advertising a release that
# never existed.
- name: Check out the Homebrew tap
uses: actions/checkout@v4
with:
repository: lntvan166/homebrew-paddock
# github.token is scoped to THIS repository. Pushing to the tap with
# it fails 403 at the very end of an otherwise green release, so the
# tap gets its own fine-grained token, contents:write on that repo
# and nothing else.
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: tap

- name: Render and push the formula
run: |
set -eu
VERSION="${GITHUB_REF_NAME#v}"
mkdir -p tap/Formula
bun run scripts/render-formula.ts "$VERSION" out/SHA256SUMS tap/Formula/paddock.rb
cd tap
git add Formula/paddock.rb
# Re-running a tag renders byte-identical output. That is not a
# failure, but `git commit` with nothing staged exits 1 and would
# fail the job on a legitimate re-run — so the no-op is named
# explicitly. Suppressing the exit code instead would hide a real
# failure here too, which is the thing CLAUDE.md forbids.
if git diff --cached --quiet; then
echo "formula already current for $VERSION — nothing to push"
else
# A literal identity, not an address: this repository is public and
# CLAUDE.md forbids committing email addresses. git does not
# require user.email to be one.
git -c user.name=paddock-release -c user.email=paddock-release \
commit -m "paddock $VERSION"
git push
fi
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ build/
paddock
paddock-*

# Release staging: the compiled binaries, their SHA256SUMS, and the rendered
# Homebrew formula (`make formula`). Built by the release workflow in CI and
# reproducible locally; nothing here is source.
out/

# Generated by scripts/gen-embedded.ts (`make embed`). Vite content-hashes
# asset names, so a committed copy would silently drift from the bundle it
# claims to describe.
Expand Down
10 changes: 9 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export GID := $(shell id -g)
TAG := $(firstword $(shell git tag --points-at HEAD))
VERSION := $(if $(TAG),$(TAG:v%=%),0.0.0-dev)

.PHONY: dev types icons check check-clean embed build-web test build up down logs restart
.PHONY: dev types icons check check-clean embed build-web test build formula up down logs restart

# A real directory target, deliberately NOT in .PHONY: make compares its mtime
# against package.json and bun.lock, so this installs on a fresh clone or after
Expand Down Expand Up @@ -91,6 +91,14 @@ build: check check-clean test
--define 'process.env.PADDOCK_VERSION="$(VERSION)"' \
src/server/index.ts --outfile paddock

# Renders the tap formula so it can be eyeballed without cutting a release.
# The release workflow runs the same script against the real SHA256SUMS; point
# this at any sums file: make formula SUMS=/tmp/SHA256SUMS
SUMS ?= out/SHA256SUMS
FORMULA_OUT ?= out/paddock.rb
formula:
bun run scripts/render-formula.ts $(VERSION) $(SUMS) $(FORMULA_OUT)

up:
docker compose up -d --build

Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,19 @@ Installs to `~/.local/bin/paddock`, no `sudo`, checksum verified before
anything is written · [read it first](https://lntvan166.github.io/paddock/install.sh)
· [binaries](https://github.com/lntvan166/paddock/releases)

Or with Homebrew, which pulls in herdr as a dependency:

```bash
brew install lntvan166/paddock/paddock
```

One command — the fully-qualified name taps and trusts this single formula.
Homebrew 6.0.0 requires explicit trust for a non-official tap, so a bare
`brew install paddock` cannot reach a tap; that name belongs to
`homebrew/core`, which paddock does not qualify for (`docs/decisions.md`).
Homebrew then owns the install, so upgrade with `brew upgrade paddock` —
`paddock update` detects the keg and declines rather than desyncing it.

### herdr version

paddock talks to herdr over herdr's own socket protocol. This release is built
Expand Down
34 changes: 34 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -534,3 +534,37 @@ session does not silently re-litigate them.
way a failed key press or reply already is, the affordance is left in
place for a retry, and the cursor is left exactly where it was so the
retry asks for the same page rather than skipping ahead.

19. **Homebrew ships from a personal tap, not `homebrew/core`, and `paddock
update` refuses under it.** Core is closed to paddock on two independent
counts, and neither is a matter of effort. Notability: a self-submission
by the repository 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`) — which is `paddock update`, exactly. The
obvious escape hatch is closed by name: casks are for pre-built
distributions, and *"Open-source command-line-only software normally
belongs in homebrew/core as a formula built from source… A rejection from
homebrew/core does not by itself make the software eligible for
homebrew/cask."*

herdr, by contrast, IS a core formula — built from a source tarball with
`rust` and `zig` as build deps, bottled by Homebrew's own CI, at ~32k
stars. That is the template if paddock ever qualifies: source build, no
self-update. It is also why the tap formula carries `depends_on "herdr"` —
a tap formula may depend on a core one, so brew can guarantee the thing
paddock is useless without. No version constraint, because paddock's herdr
check is directional and core never moves backwards.

The bare name `paddock` is free in both core and cask and is deliberately
left unclaimed elsewhere, so a future core submission can still have it.
Until then the install is `brew install lntvan166/paddock/paddock`: since
Homebrew 6.0.0 a non-official tap needs explicit trust, and the
fully-qualified form grants it for that one formula in a single command.

Under brew, `paddock update` refuses rather than warning-and-proceeding.
Warning and proceeding would leave `brew info` lying about what is
installed and let the next `brew upgrade` silently revert the operator —
and disabling self-update is a precondition for core anyway, so a clean
refusal is the same direction the project would have to move regardless.

1 change: 1 addition & 0 deletions docs/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ one, recorded here so they are not reintroduced.
| A test passes locally and fails in CI with "Attempted to assign to readonly property" | Bun runs every test file in ONE process, and `tests/support/dom.ts` makes globals readonly — so whether `globalThis.window = …` works depends on which file ran first. Adding test files changes that order | Fake a global with `Object.defineProperty`, restore its real descriptor, and put the setup INSIDE the `try` so a partial fake still unwinds |
| One test fails and takes an unrelated test in another file with it | Globals faked before the `try`, so a throw skipped the restore and the next DOM file rendered against a two-property `window` | Setup inside the `try`; the restore is what must be unconditional |
| `make test` fails about one run in twenty, looking like a timer flake | A test picked its port by arithmetic on `performance.now()` within a range that contains a real listener — paddock's own default port, on the machine of anyone running paddock | `tests/support/port.ts` asks the OS for a free port; a range that "looks unused" is a guess about someone else's machine |
| `brew info paddock` reports a version that is not the bytes on disk | The Homebrew prefix is USER-owned, so `paddock update`'s `rename(2)` over a keg SUCCEEDS. The existing "installed by a package manager" hint only fires when rename FAILS, so nothing was said — and the next `brew upgrade` reverted the operator's update without either side mentioning it | `update` resolves `realpath(selfPath)` and refuses when a `/Cellar/` segment is present, naming `brew upgrade paddock`. Refused BEFORE the download, and matched as a path SEGMENT so `~/Cellars/…` is not a false positive. `--check` still reports, because it writes nothing — and names the brew command, not `paddock update` |

## herdr protocol specifics

Expand Down
74 changes: 74 additions & 0 deletions packaging/homebrew/paddock.rb.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Rendered by scripts/render-formula.ts and pushed to the tap by
# .github/workflows/release.yml. The copy in the tap repository is GENERATED —
# edit this template, never that file, or the next release overwrites the fix.
#
# This is a tap formula, not a homebrew-core one. Core requires a build from
# source and rejects software that updates itself; see docs/decisions.md.
class Paddock < Formula
desc "Watch and answer your coding agents from your phone"
homepage "https://github.com/lntvan166/paddock"
version "{{version}}"
license "MIT"

# paddock reads herdr's own socket protocol and does nothing without it, so
# the dependency is real rather than a convenience. herdr is in
# homebrew-core, and a tap formula may depend on a core formula (the reverse
# is what Homebrew forbids), so brew can guarantee herdr is present instead
# of `paddock doctor` reporting its absence after the install.
#
# No version constraint, deliberately: paddock's herdr check is directional
# (README) — a NEWER herdr is accepted, only an older one is refused. Core
# never moves backwards, so tracking whatever it ships stays correct.
depends_on "herdr"

on_macos do
on_arm do
url "https://github.com/lntvan166/paddock/releases/download/v{{version}}/paddock-macos-aarch64"
sha256 "{{sha256:paddock-macos-aarch64}}"
end
on_intel do
url "https://github.com/lntvan166/paddock/releases/download/v{{version}}/paddock-macos-x86_64"
sha256 "{{sha256:paddock-macos-x86_64}}"
end
end

on_linux do
on_arm do
url "https://github.com/lntvan166/paddock/releases/download/v{{version}}/paddock-linux-aarch64"
sha256 "{{sha256:paddock-linux-aarch64}}"
end
on_intel do
url "https://github.com/lntvan166/paddock/releases/download/v{{version}}/paddock-linux-x86_64"
sha256 "{{sha256:paddock-linux-x86_64}}"
end
end

livecheck do
url :stable
strategy :github_latest
end

def install
# The release assets are bare binaries, not archives, so Homebrew stages
# each one under its own platform-specific name. Exactly one is present.
asset = Dir["paddock-*"].first
odie "no paddock binary in the staged download" if asset.nil?
bin.install asset => "paddock"
end

def caveats
<<~CAVEAT
Homebrew owns this install, so `paddock update` will decline and send you
back here. Upgrade with:
brew upgrade paddock
CAVEAT
end

test do
# The failure this catches has happened here before: a binary that reports
# 0.0.0-dev because a build-time define never reached it. Every test in the
# suite stayed green while every released binary was unupdatable.
ENV["PADDOCK_NO_UPDATE_CHECK"] = "1"
assert_match version.to_s, shell_output("#{bin}/paddock --version")
end
end
66 changes: 66 additions & 0 deletions scripts/render-formula.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Renders `packaging/homebrew/paddock.rb.tmpl` into the Homebrew formula the
* tap repository serves.
*
* The checksums come from the release's own SHA256SUMS rather than being
* recomputed here, so the formula and the published artifacts cannot disagree:
* there is one source for both, and it is the one `install.sh` and
* `paddock update` already verify against.
*
* The template names its own platforms. This renderer resolves whatever
* `{{sha256:<asset>}}` placeholders it finds, so adding or dropping a platform
* is a template edit and nothing here changes.
*/

/** Parses `sha256sum` output: `<hex> <name>`, or `<hex> *<name>` in binary mode. */
function digests(sums: string): Map<string, string> {
const out = new Map<string, string>();
for (const line of sums.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const [digest, ...rest] = trimmed.split(/\s+/);
const asset = rest.join(" ").replace(/^\*/, "");
if (digest && asset) out.set(asset, digest);
}
return out;
}

export function renderFormula(tmpl: string, version: string, sums: string): string {
const known = digests(sums);
const missing: string[] = [];

const out = tmpl
.replaceAll("{{version}}", version)
.replace(/\{\{sha256:([^}]+)\}\}/g, (_match, asset: string) => {
const digest = known.get(asset);
if (!digest) {
// Collected rather than thrown at the first miss, so a release that
// published none of its assets reports all four instead of sending
// whoever is debugging it round the loop once per platform.
missing.push(asset);
return "";
}
return digest;
});

if (missing.length > 0) {
throw new Error(
`render-formula: not listed in SHA256SUMS: ${missing.sort().join(", ")}`,
);
}
return out;
}

// Run as a program only when invoked directly, so the tests above can import
// the renderer without it trying to read a release's files.
if (import.meta.main) {
const [version, sumsPath, outPath] = process.argv.slice(2);
if (!version || !sumsPath || !outPath) {
console.error("usage: bun run scripts/render-formula.ts <version> <SHA256SUMS> <out.rb>");
process.exit(2);
}
const tmpl = await Bun.file("packaging/homebrew/paddock.rb.tmpl").text();
const sums = await Bun.file(sumsPath).text();
await Bun.write(outPath, renderFormula(tmpl, version.replace(/^v/, ""), sums));
console.log(`render-formula: wrote ${outPath} for ${version}`);
}
17 changes: 15 additions & 2 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { preflight, tunnelHint } from "@server/tunnel/preflight";
import { runTunnel } from "@server/tunnel/run";
import { VERSION } from "@server/version";
import { runDoctor } from "@server/doctor";
import { runUpdate } from "@server/update";
import { detectManagedBy, runUpdate } from "@server/update";
import { noUpdateCheckRequested, scheduleUpdateChecks } from "@server/update-check";
import { say, warn } from "@server/term";
import { BootLog } from "@server/boot-log";
Expand Down Expand Up @@ -293,7 +293,19 @@ const updateChecks = scheduleUpdateChecks(
},
);

const hub = new Hub({ build: currentBuildId, latestKnown: () => latestKnown });
/**
* Which package manager owns this binary, resolved ONCE at boot.
*
* A constant, unlike `latestKnown`: the path of a running executable does not
* change, and `update` refuses inside a keg so it will not be swapped
* underneath either. It rides the WS envelope because the client cannot know
* which upgrade command applies — `paddock update` declines under Homebrew,
* so a banner naming it there would label the notice with an action that
* refuses.
*/
const managedBy = await detectManagedBy(process.execPath);

const hub = new Hub({ build: currentBuildId, latestKnown: () => latestKnown, managedBy });

const settings = new SettingsStore(defaultConfigDir());
await settings.load();
Expand Down Expand Up @@ -576,6 +588,7 @@ const appDeps = {
lastNotifyError: notifier.lastError,
version: VERSION,
latestKnown,
managedBy,
herdrProtocol: DEMO ? HERDR_PROTOCOL : herdrProtocol,
// Read from the supervisor rather than cached here, for the same reason
// herdrConnected reads the stream: a copy can go stale and then lie.
Expand Down
12 changes: 11 additions & 1 deletion src/server/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { EMBEDDED } from "@server/embedded";
import { allowWrite, hostOf, refusalReason } from "@server/origin";
import { warn } from "@server/term";
import type { JournalReader } from "@server/journal/read";
import { isNavKey, type NotifyTrigger, type SettingsPatch } from "@shared/types";
import { isNavKey, type ManagedBy, type NotifyTrigger, type SettingsPatch } from "@shared/types";
import { diffScreens, digestOf } from "@shared/screen";
import type { HerdrAgentSession } from "@shared/herdr-api";

Expand Down Expand Up @@ -58,6 +58,16 @@ export interface HealthBody {
* field from `health()` must be a type error, not a silently missing key.
*/
latestKnown: string | null;
/**
* The package manager that owns this install, or null for the ordinary case.
*
* Exposed because the UPGRADE COMMAND depends on it: `paddock update`
* refuses inside a Homebrew keg, so anything telling an operator to run it
* there is wrong. Required rather than optional, for the same reason as
* `latestKnown` above — a future edit to `health()` that drops it must be a
* type error, not a silently missing key a phone then reads as "unmanaged".
*/
managedBy: ManagedBy | null;
/**
* The protocol the LIVE herdr reports, or null before it has answered.
*
Expand Down
Loading
Loading