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
23 changes: 23 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
106 changes: 90 additions & 16 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<token>/<version>/`, 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
Expand Down
58 changes: 52 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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`:
Expand All @@ -339,10 +387,8 @@ xattr -d com.apple.quarantine /opt/homebrew/bin/openshell-driver-applecontainer
openshell-driver-applecontainer setup
```

<!-- TODO when public: enable the Homebrew tap section in .goreleaser.yaml and document
`brew install` here (a private tap is useless). -->
<!-- Future: sign releases (cosign) so the installer can verify authenticity, not just
integrity. -->
integrity — and so the Homebrew cask no longer has to strip the quarantine bit. -->

## Contributing

Expand Down
84 changes: 58 additions & 26 deletions cmd/openshell-driver-applecontainer/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <prefix>/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 {
Expand 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
// <brew-prefix>/Caskroom/<token>/<version>/<binary>, symlinked into
// <brew-prefix>/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
Expand All @@ -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
Expand Down
Loading