diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a634c8..a5f3b16 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,9 +22,32 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod + # GITHUB_TOKEN is scoped to this repository and cannot commit the cask to + # the tap, so GoReleaser pushes it over SSH with a deploy key that has + # write access to vyncint/homebrew-tap and nothing else. It wants a path, + # not the key material, hence staging it in a file first. + - name: Stage the Homebrew tap deploy key + env: + HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }} + run: | + set -euo pipefail + if [ -z "${HOMEBREW_TAP_DEPLOY_KEY}" ]; then + echo "::error::HOMEBREW_TAP_DEPLOY_KEY is not set; the Homebrew cask cannot be published" >&2 + exit 1 + fi + install -d -m 700 "${RUNNER_TEMP}/homebrew-tap" + key="${RUNNER_TEMP}/homebrew-tap/id_ed25519" + # printf, not echo: the secret is multi-line and ssh requires the + # trailing newline that GitHub strips from secrets. + printf '%s\n' "${HOMEBREW_TAP_DEPLOY_KEY}" > "${key}" + chmod 600 "${key}" + echo "HOMEBREW_TAP_DEPLOY_KEY_PATH=${key}" >> "${GITHUB_ENV}" - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: version: "~> v2" args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Remove the staged deploy key + if: always() + run: rm -rf "${RUNNER_TEMP}/homebrew-tap" diff --git a/.goreleaser.yaml b/.goreleaser.yaml index b041523..525e32d 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -56,22 +56,96 @@ changelog: release: prerelease: auto footer: | - Unsigned binary: after download, run - `xattr -d com.apple.quarantine openshell-driver-applecontainer`. + Install: `brew install vyncint/tap/openshell-driver-applecontainer`, or the + one-line installer (which also installs the prerequisites): + `curl -LsSf https://raw.githubusercontent.com/vyncint/openshell-driver-applecontainer/main/install.sh | sh`. + + Unpacking the archive by hand instead? The binary is unsigned — run + `xattr -d com.apple.quarantine openshell-driver-applecontainer` after extracting. Compatibility: OpenShell v0.0.96, apple/container 1.2.0, Apple silicon macOS 26. -# TODO when the repository goes public: enable the Homebrew tap (a private -# tap is useless — brew cannot fetch from private release assets without -# extra credentials). -# -# brews: -# - repository: -# owner: vyncint -# name: homebrew-tap -# directory: Formula -# homepage: https://github.com/vyncint/openshell-driver-applecontainer -# description: OpenShell compute driver backed by apple/container micro-VMs -# license: Apache-2.0 -# install: | -# bin.install "openshell-driver-applecontainer" +# Published to https://github.com/vyncint/homebrew-tap as a cask, not a formula: +# Homebrew treats pre-built binaries as casks, and a cask handles the quarantine +# bit on our unsigned binary natively. The tap is pushed over SSH with a +# write-scoped deploy key, because the workflow's GITHUB_TOKEN is scoped to this +# repository and cannot commit to the tap. +homebrew_casks: + - name: openshell-driver-applecontainer + ids: + - default + repository: + owner: vyncint + name: homebrew-tap + branch: main + git: + url: "git@github.com:vyncint/homebrew-tap.git" + private_key: "{{ .Env.HOMEBREW_TAP_DEPLOY_KEY_PATH }}" + # Explicit so the push does not depend on the runner's ssh defaults or + # on github.com already being in its known_hosts. + ssh_command: "ssh -i {{ .Env.HOMEBREW_TAP_DEPLOY_KEY_PATH }} -F /dev/null -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" + directory: Casks + homepage: https://github.com/vyncint/openshell-driver-applecontainer + description: OpenShell compute driver backed by apple/container micro-VMs + license: Apache-2.0 + # Pre-releases stay off the tap: `brew upgrade` must never move a user onto one. + skip_upload: auto + commit_author: + name: Vyncint Ng + email: vyncint@users.noreply.github.com + commit_msg_template: "chore: openshell-driver-applecontainer {{ .Tag }}" + binaries: + - openshell-driver-applecontainer + # The gateway this driver plugs into. Homebrew only auto-taps names given on + # the command line, never a dependency's, so on a host without the + # nvidia/openshell tap this warns and installs nothing instead of resolving + # — hence the two-name install in the caveats. The declaration still earns + # its keep once the tap is present: brew then installs the gateway with the + # driver and warns before removing it out from under one. + # apple/container cannot be declared at all — it ships as a signed .pkg + # outside Homebrew. + dependencies: + - formula: nvidia/openshell/openshell + # apple/container is Apple-silicon-only, so the driver is too. Without this + # an Intel Mac fails deep in the download with "no such artifact" instead of + # a clear message. + custom_block: | + depends_on arch: :arm64 + hooks: + post: + # Our release binary is unsigned, and Homebrew quarantines everything it + # downloads — Gatekeeper would refuse to execute it. Strip the attribute + # from the staged binary, as the cask cannot opt out of quarantine. + install: | + staged = "#{staged_path}/openshell-driver-applecontainer" + if system_command("/usr/bin/xattr", args: ["-h"]).exit_status.zero? + system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", staged] + end + uninstall: + # Stop and unload the launchd agent `setup` installed, before the binary + # it points at disappears. + launchctl: + - local.openshell-driver-applecontainer + # `brew uninstall --zap` territory: state a plain uninstall must not touch. + # Kept alphabetical — `brew style` enforces it. + zap: + trash: + - "~/.local/state/openshell-applecontainer" + - "~/Library/LaunchAgents/local.openshell-driver-applecontainer.plist" + - "~/Library/Logs/openshell-driver-applecontainer.log" + caveats: | + If OpenShell itself was not installed alongside this driver, install it + by name so Homebrew taps NVIDIA's tap for you: + + brew install nvidia/openshell/openshell + + apple/container is required too, and is not available through Homebrew. + Take it from https://github.com/apple/container/releases (signed .pkg). + + Then wire up the stack — launchd service, gateway configuration, vmnet + network, images. Idempotent, so re-run it any time to repair: + + openshell-driver-applecontainer setup + + Re-run `setup` after every `brew upgrade` too: it restarts the launchd + service onto the new binary. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ce8996..694aecc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Added + +- **Homebrew install**: + `brew install nvidia/openshell/openshell vyncint/tap/openshell-driver-applecontainer`. Every + release now publishes a cask to [vyncint/homebrew-tap](https://github.com/vyncint/homebrew-tap) — + a cask rather than a formula because Homebrew treats pre-built binaries that way, and because a + cask can strip the quarantine bit from our unsigned binary. It unloads the driver's launchd agent + on `brew uninstall` and removes the driver's state, plist and log on `--zap`; pre-releases are not + published to the tap. OpenShell is declared as a dependency but is worth naming on the command + line anyway, since Homebrew auto-taps only the names it is given and would otherwise resolve the + dependency to nothing on a host without the `nvidia/openshell` tap. apple/container stays outside + Homebrew's reach entirely (signed `.pkg`) and is covered by the caveats. +- `install.sh` and the manual path are unchanged and still the way to install everything — + including apple/container — in one step. + +### Fixed + +- `update` no longer corrupts a Homebrew installation. It resolves symlinks to find the running + binary, which for a cask points inside `Caskroom///`, so replacing it in place + left Homebrew convinced it still had the version it staged (and a later `brew upgrade` or + `uninstall` acting on the wrong files). It now detects a cask install, delegates to + `brew upgrade --cask`, and re-runs `setup` through the Homebrew symlink rather than the + now-replaced version directory. `--version` is rejected for cask installs, which can only track + the tap's latest release. + ## [0.2.7] - 2026-08-05 ### Fixed diff --git a/README.md b/README.md index e5c550c..7aff4d2 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,13 @@ offers to install anything missing, then downloads the driver (verifying its che curl -LsSf https://raw.githubusercontent.com/vyncint/openshell-driver-applecontainer/main/install.sh | sh ``` -> This URL and the release downloads it fetches work once the repository is public. While it is -> private, use the manual path below. +Or, if you would rather Homebrew owned the driver — see [Homebrew](#homebrew) for what it does +and does not install: + +```sh +brew install nvidia/openshell/openshell vyncint/tap/openshell-driver-applecontainer +openshell-driver-applecontainer setup +``` Then use OpenShell normally: @@ -63,6 +68,49 @@ apple/container uninstaller: the bare command touches only the driver; `-d`/`--d removes its data (`-k`/`--keep-data` is the default); `--all` also removes the prerequisites (apple/container's uninstaller needs `sudo`). `uninstall` remains as an alias for `cleanup`. +On a Homebrew install `update` does not replace the binary itself — it would overwrite the +Caskroom behind Homebrew's back — and runs `brew upgrade --cask` instead, then re-runs `setup` +as usual. `--version` is refused there, because a cask only ever tracks the tap's latest +release; uninstall the cask and use `install.sh` if you need to pin one. + +### Homebrew + +```sh +brew install nvidia/openshell/openshell vyncint/tap/openshell-driver-applecontainer +openshell-driver-applecontainer setup +``` + +Name OpenShell explicitly like that even though the cask declares it as a dependency: Homebrew +only auto-taps names given on the command line, never a dependency's, so on a host that has not +tapped `nvidia/openshell` the dependency warns and resolves to nothing. Naming both installs +both, from a single command, on any host. + +Homebrew does *not* install **apple/container** at all — it ships as a signed `.pkg` outside +Homebrew, so take it from [its releases](https://github.com/apple/container/releases), or let +`install.sh` do it. The driver binary is unsigned, so the cask strips the quarantine bit on +install. + +`setup` is still yours to run: Homebrew places the binary, `setup` wires the launchd service, +gateway configuration, vmnet network and images. Re-run it after every `brew upgrade` so the +service restarts on the new binary. + +```sh +brew upgrade --cask openshell-driver-applecontainer && openshell-driver-applecontainer setup +brew uninstall --cask openshell-driver-applecontainer # also unloads the launchd agent +brew uninstall --zap --cask openshell-driver-applecontainer # also removes driver state and logs +``` + +Already installed with `install.sh` and switching over? Homebrew refuses to link over the +existing binary (`It seems there is already a Binary at …`), so remove it first — the driver's +own data and the gateway wiring are untouched, and `setup` restores the service afterwards: + +```sh +openshell-driver-applecontainer cleanup # stop the service (keeps data) +rm -f "$(brew --prefix)/bin/openshell-driver-applecontainer" +brew install vyncint/tap/openshell-driver-applecontainer # OpenShell is already installed +openshell-driver-applecontainer setup +``` + ### Manual install Prerequisites: an Apple-silicon Mac (macOS 26+), [apple/container](https://github.com/apple/container) @@ -326,7 +374,7 @@ openshell-driver-applecontainer update --all --openshell-version 0.0.97 --contai ## Install from a release (manual) -The one-line installer above does this for you. To do it by hand: download the `darwin_arm64` +The one-line installer and the Homebrew cask both do this for you. To do it by hand: download the `darwin_arm64` archive from a [release](https://github.com/vyncint/openshell-driver-applecontainer/releases), verify its checksum against `checksums.txt`, unpack into `/opt/homebrew/bin`, clear the quarantine bit (unsigned binary), then run `setup`: @@ -339,10 +387,8 @@ xattr -d com.apple.quarantine /opt/homebrew/bin/openshell-driver-applecontainer openshell-driver-applecontainer setup ``` - + integrity — and so the Homebrew cask no longer has to strip the quarantine bit. --> ## Contributing diff --git a/cmd/openshell-driver-applecontainer/update.go b/cmd/openshell-driver-applecontainer/update.go index caf9e4d..153af56 100644 --- a/cmd/openshell-driver-applecontainer/update.go +++ b/cmd/openshell-driver-applecontainer/update.go @@ -45,22 +45,48 @@ func runUpdate(args []string) int { } log := newLogger("info") - want := *targetVersion - if want == "" { - latest, err := latestReleaseTag(updateRepo) - if err != nil { - log.Error("update: could not determine the latest release; pass --version", "err", err) - return 1 - } - want = latest + self, err := currentBinaryPath() + if err != nil { + log.Error("update: locate the running binary", "err", err) + return 1 } + // Re-running setup goes through the launch path, not the resolved one: a + // brew upgrade replaces the Caskroom version directory, so the resolved + // path is gone by the time setup runs. The symlink in /bin is not. + setupPath := self - log.Info("update: driver", "current", version, "target", want) - if want == version { - log.Info("update: already on the requested version; re-applying setup", "version", version) - } else if err := selfUpdate(log, want); err != nil { - log.Error("update failed", "err", err) - return 1 + if cask, brewManaged := homebrewCask(self); brewManaged { + if *targetVersion != "" { + log.Error("update: this install is managed by Homebrew, which only tracks the tap's latest release. "+ + "To pin a version, remove it (`brew uninstall --cask "+cask+"`) and install with install.sh", + "requested", *targetVersion) + return 2 + } + log.Info("update: this install is managed by Homebrew; upgrading through brew", "cask", cask) + if err := streamCmd("brew", "upgrade", "--cask", cask); err != nil { + log.Warn("brew upgrade failed (the cask may already be current)", "cask", cask, "err", err) + } + if p, execErr := os.Executable(); execErr == nil { + setupPath = p + } + } else { + want := *targetVersion + if want == "" { + latest, err := latestReleaseTag(updateRepo) + if err != nil { + log.Error("update: could not determine the latest release; pass --version", "err", err) + return 1 + } + want = latest + } + + log.Info("update: driver", "current", version, "target", want) + if want == version { + log.Info("update: already on the requested version; re-applying setup", "version", version) + } else if err := selfUpdate(log, self, want); err != nil { + log.Error("update failed", "err", err) + return 1 + } } if *all { @@ -71,22 +97,32 @@ func runUpdate(args []string) int { log.Info("update: skipping setup (--no-setup); run `" + updateBinaryName + " setup` to restart the service") return 0 } - self, err := currentBinaryPath() - if err != nil { - log.Error("update: locate binary for re-setup", "err", err) - return 1 - } log.Info("update: re-running setup to restart the service on the new binary") - if err := streamCmd(self, "setup"); err != nil { + if err := streamCmd(setupPath, "setup"); err != nil { log.Error("update: setup after update failed; run `"+updateBinaryName+" setup` yourself", "err", err) return 1 } return 0 } +// homebrewCask reports whether binPath is a binary staged by a Homebrew cask, +// and the cask's token. Cask artifacts live at +// /Caskroom///, symlinked into +// /bin — so replacing that file in place would leave Homebrew +// believing it still has the version it staged. +func homebrewCask(binPath string) (string, bool) { + parts := strings.Split(filepath.ToSlash(binPath), "/") + for i, p := range parts { + if p == "Caskroom" && i+1 < len(parts) && parts[i+1] != "" { + return parts[i+1], true + } + } + return "", false +} + // selfUpdate downloads release `version`, verifies its checksum, and replaces -// the running binary in place. -func selfUpdate(log *slog.Logger, version string) error { +// binPath (the running binary) in place. +func selfUpdate(log *slog.Logger, binPath, version string) error { tmp, err := os.MkdirTemp("", "oshl-ac-update-") if err != nil { return err @@ -110,10 +146,6 @@ func selfUpdate(log *slog.Logger, version string) error { } log.Info("update: checksum verified") - binPath, err := currentBinaryPath() - if err != nil { - return err - } extracted := filepath.Join(tmp, updateBinaryName) if err := extractBinaryFromTarGz(archivePath, updateBinaryName, extracted); err != nil { return err diff --git a/cmd/openshell-driver-applecontainer/update_test.go b/cmd/openshell-driver-applecontainer/update_test.go index 0299504..8bfb004 100644 --- a/cmd/openshell-driver-applecontainer/update_test.go +++ b/cmd/openshell-driver-applecontainer/update_test.go @@ -21,6 +21,31 @@ func TestReleaseArchiveName(t *testing.T) { } } +func TestHomebrewCask(t *testing.T) { + cases := []struct { + path string + cask string + brew bool + }{ + {"/opt/homebrew/Caskroom/openshell-driver-applecontainer/0.2.8/openshell-driver-applecontainer", + "openshell-driver-applecontainer", true}, + // Intel prefix, and a version directory that itself looks like a path. + {"/usr/local/Caskroom/some-tool/1.2.3_1/some-tool", "some-tool", true}, + // Installed by install.sh: a real file in the brew prefix, not a cask. + {"/opt/homebrew/bin/openshell-driver-applecontainer", "", false}, + {"/usr/local/bin/openshell-driver-applecontainer", "", false}, + // A directory merely named Caskroom, with nothing under it. + {"/tmp/Caskroom", "", false}, + {"", "", false}, + } + for _, tc := range cases { + cask, brew := homebrewCask(tc.path) + if brew != tc.brew || cask != tc.cask { + t.Errorf("homebrewCask(%q) = (%q, %v), want (%q, %v)", tc.path, cask, brew, tc.cask, tc.brew) + } + } +} + // makeTarGz writes a gzipped tar of name->content and returns its path. func makeTarGz(t *testing.T, dir string, entries map[string][]byte) string { t.Helper() diff --git a/install.sh b/install.sh index 543cde0..3a52e86 100755 --- a/install.sh +++ b/install.sh @@ -8,6 +8,10 @@ # OpenShell) and offers to install any that are missing, then downloads the # driver release (verifying its checksum) and optionally runs `setup`. # +# There is also `brew install vyncint/tap/openshell-driver-applecontainer`, +# which installs the driver and OpenShell but not apple/container. This script +# is the only path that installs everything. +# # Environment / flags: # -y, --yes assume "yes" to every prompt (non-interactive) # --no-setup install the binary but do not run `setup` @@ -181,6 +185,26 @@ resolve_version() { [ -n "$VERSION" ] || err "could not determine the latest release; pass --version" } +# check_not_brew_managed refuses to clobber a Homebrew cask install. install(1) +# replaces brew's symlink with a real file rather than writing through it, so +# Homebrew would still believe it owned the binary while PATH resolved to a +# different one — and `brew upgrade` would silently stop taking effect. +check_not_brew_managed() { + target="$PREFIX/bin/$BINARY" + [ -L "$target" ] || return 0 + case "$(readlink "$target")" in + */Caskroom/*) ;; + *) return 0 ;; + esac + err "$target is managed by Homebrew. + + To upgrade it, stay with Homebrew: + brew upgrade --cask $BINARY && $BINARY setup + + To switch to this installer, remove the cask first: + brew uninstall --cask $BINARY" +} + install_driver() { need curl || err "curl is required" need shasum || err "shasum is required" @@ -230,6 +254,7 @@ maybe_setup() { main() { parse_args "$@" check_platform + check_not_brew_managed check_homebrew check_container check_openshell