diff --git a/.github/workflows/release-cli.yaml b/.github/workflows/release-cli.yaml
index b63f168..5129a40 100644
--- a/.github/workflows/release-cli.yaml
+++ b/.github/workflows/release-cli.yaml
@@ -23,8 +23,9 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
- go-version-file: cli/go.mod
+ go-version: '1.26.x'
check-latest: true
+ cache-dependency-path: cli/go.sum
- name: Install dependencies
run: go mod download
@@ -39,9 +40,20 @@ jobs:
p12-password: ${{ secrets.P12_PASSWORD }}
- name: Build and sign the binary
- run: make build && ./scripts/sign.sh
+ run: |
+ if [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then
+ VERSION="${GITHUB_REF_NAME}"
+ else
+ VERSION="v0.0.0-${GITHUB_SHA:0:12}"
+ fi
+ make build VERSION="${VERSION}" COMMIT="${GITHUB_SHA:0:12}" TEAM_ID="${APPLE_TEAM_ID}"
+ ./scripts/sign.sh
env:
SIGNING_CERTIFICATE_NAME: ${{ secrets.SIGNING_CERTIFICATE_NAME }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+
+ - name: Verify Mach-O UUID
+ run: otool -l bin/rvmm | grep -q 'cmd LC_UUID'
- name: Import installer signing certificate
uses: apple-actions/import-codesign-certs@v3
@@ -67,5 +79,7 @@ jobs:
if: github.event_name == 'release'
uses: softprops/action-gh-release@v2
with:
- files: cli/rvmm_macOS_arm64.pkg
+ files: |
+ cli/rvmm_macOS_arm64.pkg
+ cli/rvmm_macOS_arm64.pkg.sha256
token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/upload-docs.yaml b/.github/workflows/upload-docs.yaml
new file mode 100644
index 0000000..de8a4c1
--- /dev/null
+++ b/.github/workflows/upload-docs.yaml
@@ -0,0 +1,29 @@
+name: Upload docs to autopilot
+
+on:
+ push:
+ branches: ["main"]
+ paths:
+ - "docs/**"
+ - "scripts/upload_docs.py"
+ - ".github/workflows/upload-docs.yaml"
+ workflow_dispatch:
+
+concurrency:
+ group: upload-docs
+ cancel-in-progress: false
+
+jobs:
+ upload:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.x"
+ - name: Upload docs
+ env:
+ DOCS_ENDPOINT: https://autopilot.rxlab.app
+ DOCS_REPOSITORY_ID: rxtech-lab/macos-github-action-vm
+ DOCS_UPLOAD_TOKEN: ${{ secrets.DOCS_UPLOAD_TOKEN }}
+ run: python scripts/upload_docs.py
diff --git a/.gitignore b/.gitignore
index 0772845..39ee08a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
rvmm
cli/bin/
*.pkg
+*.pkg.sha256
*.yaml
*.log
diff --git a/README.md b/README.md
index 5f76e5b..67cfbb9 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ A tool for managing macOS virtual machines as GitHub Actions self-hosted runners
- **Log Monitoring**: Send VM logs to PostHog for centralized monitoring across multiple machines
- **Interactive TUI**: User-friendly terminal interface for all operations
- **Headless Mode**: Run in background via LaunchAgent/LaunchDaemon
+- **Automatic Updates**: Install verified GitHub releases silently through a dedicated root updater
## Installation
@@ -26,6 +27,10 @@ A tool for managing macOS virtual machines as GitHub Actions self-hosted runners
Download the notarized `rvmm_macOS_arm64.pkg` installer from the [GitHub Releases](../../releases) page and install it. The binary is installed to `/usr/local/bin/rvmm`.
+The package also installs `lab.rxtech.rvmm.updater`, a narrowly scoped root LaunchDaemon. The initial package installation requires administrator approval. After that, the updater checks for a new published GitHub release at boot and every six hours, verifies the SHA-256 checksum, Developer ID Installer signature, Apple team, and notarization, then installs it without prompting.
+
+Select **Update rvmm** in the TUI to request an immediate background check. Installing an update does not terminate the currently running RVMM process or active VM work; the new binary is used on the next safe service start.
+
### Build from Source
The CLI lives in the `cli/` directory:
@@ -239,6 +244,7 @@ In PostHog, you can:
- **cli/internal/monitor**: Log file monitoring with tail-follow logic
- **cli/internal/posthog**: PostHog API client for log event capture
- **cli/internal/tui**: Bubble Tea terminal UI
+ - **cli/internal/updater**: GitHub release discovery, verified downloads, updater state, and package installation
- **cli/assets**: Embedded plist template and example config
- **cli/scripts**: Build, signing, and notarization scripts for releases
- **guest**: Example external Packer template for building the runner VM image
@@ -248,7 +254,7 @@ In PostHog, you can:
Releases are automated via GitHub Actions:
1. The "Create a new release" workflow (`create-release.yaml`) runs semantic-release to tag and create a GitHub release.
-2. The "Release rvmm CLI" workflow (`release-cli.yaml`) triggers on the release event: it builds the CLI, signs the binary, packages it as a `.pkg` installer, notarizes it with Apple, and uploads `rvmm_macOS_arm64.pkg` to the GitHub release.
+2. The "Release rvmm CLI" workflow (`release-cli.yaml`) triggers on the release event: it stamps the tag into the binary, signs it, verifies its Mach-O UUID, packages and notarizes it, and uploads `rvmm_macOS_arm64.pkg` plus its SHA-256 checksum.
## Daemon Files
@@ -264,6 +270,15 @@ After installation, daemons create the following files:
- Plist: `${daemon.plist_path}` with `.monitor` suffix
- Logs: `${working_directory}/monitor_stdout.log`, `${working_directory}/monitor_stderr.log`
+### Automatic Updater
+
+- Plist: `/Library/LaunchDaemons/lab.rxtech.rvmm.updater.plist`
+- Status: `/Library/Application Support/RVMM/Updater/status.json`
+- Requests: `/Library/Application Support/RVMM/Updater/requests/`
+- Logs: `/Library/Logs/rvmm-updater.log`
+
+The updater is intentionally separate from the runner daemon. Only verified package installation runs as root; Tart and GitHub Actions runner operations continue under `daemon.user`.
+
## Troubleshooting
### Runner Daemon Issues
diff --git a/cli/Makefile b/cli/Makefile
index f3707dd..bea91f8 100644
--- a/cli/Makefile
+++ b/cli/Makefile
@@ -6,11 +6,13 @@ BUILD_DIR := ./bin
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
+TEAM_ID ?=
LDFLAGS := -s -w \
- -X github.com/rxtech-lab/rvmm/cmd.Version=$(VERSION) \
- -X github.com/rxtech-lab/rvmm/cmd.Commit=$(COMMIT) \
- -X github.com/rxtech-lab/rvmm/cmd.BuildDate=$(BUILD_DATE)
+ -X github.com/rxtech-lab/rvmm/internal/buildinfo.Version=$(VERSION) \
+ -X github.com/rxtech-lab/rvmm/internal/buildinfo.Commit=$(COMMIT) \
+ -X github.com/rxtech-lab/rvmm/internal/buildinfo.BuildDate=$(BUILD_DATE) \
+ -X github.com/rxtech-lab/rvmm/internal/buildinfo.TeamID=$(TEAM_ID)
.PHONY: deps build build-all install package clean test
@@ -52,6 +54,7 @@ clean:
@echo "==> Cleaning..."
rm -rf $(BUILD_DIR)
rm -f $(BINARY_NAME)_macOS_*.pkg
+ rm -f $(BINARY_NAME)_macOS_*.pkg.sha256
@echo "==> Cleaned"
test:
diff --git a/cli/assets/lab.rxtech.rvmm.updater.plist b/cli/assets/lab.rxtech.rvmm.updater.plist
new file mode 100644
index 0000000..9e23537
--- /dev/null
+++ b/cli/assets/lab.rxtech.rvmm.updater.plist
@@ -0,0 +1,30 @@
+
+
+
+
+ Label
+ lab.rxtech.rvmm.updater
+ ProgramArguments
+
+ /usr/local/bin/rvmm
+ update-worker
+
+ RunAtLoad
+
+ StartInterval
+ 21600
+ QueueDirectories
+
+ /Library/Application Support/RVMM/Updater/requests
+
+
+ ProcessType
+ Background
+ ThrottleInterval
+ 60
+ StandardOutPath
+ /Library/Logs/rvmm-updater.log
+ StandardErrorPath
+ /Library/Logs/rvmm-updater.log
+
+
diff --git a/cli/internal/buildinfo/buildinfo.go b/cli/internal/buildinfo/buildinfo.go
new file mode 100644
index 0000000..5de223c
--- /dev/null
+++ b/cli/internal/buildinfo/buildinfo.go
@@ -0,0 +1,9 @@
+package buildinfo
+
+// These values are replaced by linker flags in release builds.
+var (
+ Version = "dev"
+ Commit = "none"
+ BuildDate = "unknown"
+ TeamID = ""
+)
diff --git a/cli/internal/tui/app.go b/cli/internal/tui/app.go
index 8e32570..0c2f440 100644
--- a/cli/internal/tui/app.go
+++ b/cli/internal/tui/app.go
@@ -17,6 +17,7 @@ import (
"github.com/rxtech-lab/rvmm/internal/daemon"
"github.com/rxtech-lab/rvmm/internal/runner"
"github.com/rxtech-lab/rvmm/internal/setup"
+ "github.com/rxtech-lab/rvmm/internal/updater"
"go.uber.org/zap"
)
@@ -48,6 +49,7 @@ const (
actionMonitorDaemonUninstall
actionMonitorDaemonStatus
actionViewLogs
+ actionUpdateRequest
actionQuit
)
@@ -81,7 +83,10 @@ type model struct {
windowWidth int
windowHeight int
lastError string
+ lastMessage string
lastLogLine string
+ updateStatus updater.Status
+ updaterReady bool
}
func Run() {
@@ -134,11 +139,17 @@ func newModel() model {
if logErr != nil {
m.lastError = logErr.Error()
}
+ if status, err := updater.ReadStatus(updater.StatusPath); err == nil {
+ m.updateStatus = status
+ m.updaterReady = true
+ } else if !errors.Is(err, os.ErrNotExist) {
+ m.updaterReady = true
+ }
return m
}
func (m model) Init() tea.Cmd {
- return tickLogTail(m.logPath)
+ return tea.Batch(tickLogTail(m.logPath), tickUpdaterStatus())
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -173,6 +184,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.lastLogLine = msg.line
}
return m, tickLogTail(m.logPath)
+ case updaterStatusMsg:
+ m.updateStatus = msg.status
+ m.updaterReady = msg.installed
+ return m, tickUpdaterStatus()
case taskDoneMsg:
m.busy = false
m.busyLabel = ""
@@ -182,8 +197,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if msg.err != nil {
m.lastError = msg.err.Error()
+ m.lastMessage = ""
} else {
m.lastError = ""
+ if msg.action == actionUpdateRequest {
+ m.lastMessage = "Update requested; installation will continue in the background."
+ }
}
return m, nil
}
@@ -464,6 +483,13 @@ func (m model) runListImagesCmd() tea.Cmd {
}
}
+func (m model) runUpdateRequestCmd() tea.Cmd {
+ return func() tea.Msg {
+ err := updater.RequestUpdate(updater.RequestDirectory)
+ return taskDoneMsg{action: actionUpdateRequest, err: err}
+ }
+}
+
func (m model) runDaemonCmd(action actionType) tea.Cmd {
return func() tea.Msg {
cfg, err := loadConfig(m.configPath)
diff --git a/cli/internal/tui/menu_root.go b/cli/internal/tui/menu_root.go
index 7e535c4..5d2bd8c 100644
--- a/cli/internal/tui/menu_root.go
+++ b/cli/internal/tui/menu_root.go
@@ -10,6 +10,7 @@ func rootMenuEntries() []menuEntry {
daemonMenuItem{},
monitorDaemonMenuItem{},
viewLogsMenuItem{},
+ updateMenuItem{},
quitMenuItem{},
}
}
diff --git a/cli/internal/tui/menu_update.go b/cli/internal/tui/menu_update.go
new file mode 100644
index 0000000..1eb67e0
--- /dev/null
+++ b/cli/internal/tui/menu_update.go
@@ -0,0 +1,19 @@
+package tui
+
+import tea "github.com/charmbracelet/bubbletea"
+
+type updateMenuItem struct{}
+
+func (updateMenuItem) Title() string {
+ return "Update rvmm"
+}
+
+func (updateMenuItem) Description() string {
+ return "Request an immediate signed update in the background"
+}
+
+func (updateMenuItem) OnSelect(m *model) (tea.Model, tea.Cmd) {
+ m.busy = true
+ m.busyLabel = "Request update"
+ return *m, tea.Batch(m.runUpdateRequestCmd(), m.spinner.Tick)
+}
diff --git a/cli/internal/tui/menu_update_test.go b/cli/internal/tui/menu_update_test.go
new file mode 100644
index 0000000..b118165
--- /dev/null
+++ b/cli/internal/tui/menu_update_test.go
@@ -0,0 +1,25 @@
+package tui
+
+import (
+ "testing"
+
+ "github.com/rxtech-lab/rvmm/internal/updater"
+)
+
+func TestRootMenuContainsUpdate(t *testing.T) {
+ t.Parallel()
+ for _, entry := range rootMenuEntries() {
+ if entry.Title() == "Update rvmm" {
+ return
+ }
+ }
+ t.Fatal("root menu does not contain Update rvmm")
+}
+
+func TestFormatUpdaterStatus(t *testing.T) {
+ t.Parallel()
+ got := formatUpdaterStatus(updater.Status{State: "installed", LatestVersion: "v1.1.0", RestartNeeded: true}, true)
+ if got != "installed (v1.1.0) - restart pending" {
+ t.Fatalf("formatUpdaterStatus() = %q", got)
+ }
+}
diff --git a/cli/internal/tui/updater_status.go b/cli/internal/tui/updater_status.go
new file mode 100644
index 0000000..1338c4b
--- /dev/null
+++ b/cli/internal/tui/updater_status.go
@@ -0,0 +1,42 @@
+package tui
+
+import (
+ "errors"
+ "os"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/rxtech-lab/rvmm/internal/updater"
+)
+
+type updaterStatusMsg struct {
+ status updater.Status
+ installed bool
+}
+
+func tickUpdaterStatus() tea.Cmd {
+ return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
+ status, err := updater.ReadStatus(updater.StatusPath)
+ if err != nil {
+ return updaterStatusMsg{installed: !errors.Is(err, os.ErrNotExist)}
+ }
+ return updaterStatusMsg{status: status, installed: true}
+ })
+}
+
+func formatUpdaterStatus(status updater.Status, installed bool) string {
+ if !installed {
+ return "not installed"
+ }
+ if status.State == "" {
+ return "waiting for first check"
+ }
+ result := status.State
+ if status.LatestVersion != "" {
+ result += " (" + status.LatestVersion + ")"
+ }
+ if status.RestartNeeded {
+ result += " - restart pending"
+ }
+ return result
+}
diff --git a/cli/internal/tui/views.go b/cli/internal/tui/views.go
index 6c64480..7e0d874 100644
--- a/cli/internal/tui/views.go
+++ b/cli/internal/tui/views.go
@@ -34,13 +34,18 @@ func (m model) viewMenu() string {
if m.lastError != "" {
lastError = "\n\nLast error: " + m.lastError
}
+ lastMessage := ""
+ if m.lastMessage != "" {
+ lastMessage = "\n\n" + m.lastMessage
+ }
+ updaterLine := "Updater: " + formatUpdaterStatus(m.updateStatus, m.updaterReady)
header := headerView("RVMM", status, latest)
tips := "enter=select s=stop runner q=quit"
if len(m.menuStack) > 0 {
tips = "enter=select esc=back s=stop runner q=quit"
}
- return fmt.Sprintf("%s\n\n%s\n\n%s%s\n\nTips: %s", header, m.menu.View(), logLine, lastError, tips)
+ return fmt.Sprintf("%s\n\n%s\n\n%s\n%s%s%s\n\nTips: %s", header, m.menu.View(), logLine, updaterLine, lastMessage, lastError, tips)
}
func (m model) viewConfig() string {
diff --git a/cli/internal/updater/client.go b/cli/internal/updater/client.go
new file mode 100644
index 0000000..77f3cc2
--- /dev/null
+++ b/cli/internal/updater/client.go
@@ -0,0 +1,254 @@
+package updater
+
+import (
+ "bufio"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+const (
+ DefaultReleaseURL = "https://api.github.com/repos/rxtech-lab/macos-github-action-vm/releases/latest"
+ PackageAssetName = "rvmm_macOS_arm64.pkg"
+ ChecksumAssetName = PackageAssetName + ".sha256"
+ maxReleaseBodySize = 2 << 20
+ maxChecksumSize = 16 << 10
+ maxPackageSize = 512 << 20
+)
+
+type ReleaseAsset struct {
+ Name string `json:"name"`
+ BrowserDownloadURL string `json:"browser_download_url"`
+ Size int64 `json:"size"`
+}
+
+type githubRelease struct {
+ TagName string `json:"tag_name"`
+ Draft bool `json:"draft"`
+ Prerelease bool `json:"prerelease"`
+ Assets []ReleaseAsset `json:"assets"`
+}
+
+type Update struct {
+ CurrentVersion string
+ LatestVersion string
+ Package ReleaseAsset
+ Checksum ReleaseAsset
+ Available bool
+}
+
+type ReleaseClient interface {
+ Check(context.Context, string) (Update, error)
+ Download(context.Context, Update, string) error
+}
+
+type Client struct {
+ ReleaseURL string
+ HTTPClient *http.Client
+ UserAgent string
+}
+
+func NewClient(version string) *Client {
+ return &Client{
+ ReleaseURL: DefaultReleaseURL,
+ HTTPClient: &http.Client{
+ Timeout: 2 * time.Minute,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 10 {
+ return errors.New("too many redirects")
+ }
+ if req.URL.Scheme != "https" || !allowedDownloadHost(req.URL.Hostname()) {
+ return fmt.Errorf("refusing redirect to %s", req.URL.String())
+ }
+ return nil
+ },
+ },
+ UserAgent: "rvmm/" + version,
+ }
+}
+
+func (c *Client) Check(ctx context.Context, currentVersion string) (Update, error) {
+ if _, err := parseVersion(currentVersion); err != nil {
+ return Update{}, fmt.Errorf("current build is not a release version: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.ReleaseURL, nil)
+ if err != nil {
+ return Update{}, fmt.Errorf("create release request: %w", err)
+ }
+ req.Header.Set("Accept", "application/vnd.github+json")
+ req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
+ req.Header.Set("User-Agent", c.UserAgent)
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return Update{}, fmt.Errorf("fetch latest release: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ return Update{}, fmt.Errorf("fetch latest release: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+
+ var release githubRelease
+ decoder := json.NewDecoder(io.LimitReader(resp.Body, maxReleaseBodySize))
+ if err := decoder.Decode(&release); err != nil {
+ return Update{}, fmt.Errorf("decode latest release: %w", err)
+ }
+ if release.Draft || release.Prerelease {
+ return Update{}, errors.New("latest release must be a published full release")
+ }
+ if _, err := parseVersion(release.TagName); err != nil {
+ return Update{}, fmt.Errorf("latest release tag: %w", err)
+ }
+
+ comparison, err := compareVersions(release.TagName, currentVersion)
+ if err != nil {
+ return Update{}, fmt.Errorf("compare versions: %w", err)
+ }
+ result := Update{CurrentVersion: currentVersion, LatestVersion: release.TagName, Available: comparison > 0}
+ if !result.Available {
+ return result, nil
+ }
+
+ for _, asset := range release.Assets {
+ switch asset.Name {
+ case PackageAssetName:
+ result.Package = asset
+ case ChecksumAssetName:
+ result.Checksum = asset
+ }
+ }
+ if err := validateAsset(result.Package, PackageAssetName, maxPackageSize); err != nil {
+ return Update{}, err
+ }
+ if err := validateAsset(result.Checksum, ChecksumAssetName, maxChecksumSize); err != nil {
+ return Update{}, err
+ }
+ return result, nil
+}
+
+func (c *Client) Download(ctx context.Context, update Update, destination string) error {
+ expectedChecksum, err := c.fetchChecksum(ctx, update.Checksum.BrowserDownloadURL)
+ if err != nil {
+ return err
+ }
+
+ if err := validateDownloadURL(update.Package.BrowserDownloadURL); err != nil {
+ return err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, update.Package.BrowserDownloadURL, nil)
+ if err != nil {
+ return fmt.Errorf("create package request: %w", err)
+ }
+ req.Header.Set("User-Agent", c.UserAgent)
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("download package: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("download package: status %d", resp.StatusCode)
+ }
+
+ if err := os.MkdirAll(filepath.Dir(destination), 0700); err != nil {
+ return fmt.Errorf("create download directory: %w", err)
+ }
+ temporary := destination + ".part"
+ file, err := os.OpenFile(temporary, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
+ if err != nil {
+ return fmt.Errorf("create package file: %w", err)
+ }
+ hash := sha256.New()
+ written, copyErr := io.Copy(io.MultiWriter(file, hash), io.LimitReader(resp.Body, maxPackageSize+1))
+ closeErr := file.Close()
+ if copyErr != nil {
+ _ = os.Remove(temporary)
+ return fmt.Errorf("write package: %w", copyErr)
+ }
+ if closeErr != nil {
+ _ = os.Remove(temporary)
+ return fmt.Errorf("close package: %w", closeErr)
+ }
+ if written > maxPackageSize || (update.Package.Size > 0 && written != update.Package.Size) {
+ _ = os.Remove(temporary)
+ return fmt.Errorf("package size mismatch: downloaded %d bytes, release reports %d", written, update.Package.Size)
+ }
+ actualChecksum := hex.EncodeToString(hash.Sum(nil))
+ if !strings.EqualFold(actualChecksum, expectedChecksum) {
+ _ = os.Remove(temporary)
+ return fmt.Errorf("package checksum mismatch: got %s, expected %s", actualChecksum, expectedChecksum)
+ }
+ if err := os.Rename(temporary, destination); err != nil {
+ _ = os.Remove(temporary)
+ return fmt.Errorf("finalize package: %w", err)
+ }
+ return nil
+}
+
+func (c *Client) fetchChecksum(ctx context.Context, checksumURL string) (string, error) {
+ if err := validateDownloadURL(checksumURL); err != nil {
+ return "", err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, checksumURL, nil)
+ if err != nil {
+ return "", fmt.Errorf("create checksum request: %w", err)
+ }
+ req.Header.Set("User-Agent", c.UserAgent)
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("download checksum: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("download checksum: status %d", resp.StatusCode)
+ }
+
+ scanner := bufio.NewScanner(io.LimitReader(resp.Body, maxChecksumSize))
+ if !scanner.Scan() {
+ return "", errors.New("checksum asset is empty")
+ }
+ fields := strings.Fields(scanner.Text())
+ if len(fields) == 0 || len(fields[0]) != sha256.Size*2 {
+ return "", errors.New("checksum asset does not contain a SHA-256 digest")
+ }
+ if _, err := hex.DecodeString(fields[0]); err != nil {
+ return "", fmt.Errorf("invalid SHA-256 digest: %w", err)
+ }
+ return fields[0], nil
+}
+
+func validateAsset(asset ReleaseAsset, name string, maximumSize int64) error {
+ if asset.Name != name || asset.BrowserDownloadURL == "" {
+ return fmt.Errorf("release is missing required asset %s", name)
+ }
+ if asset.Size <= 0 || asset.Size > maximumSize {
+ return fmt.Errorf("release asset %s has invalid size %d", name, asset.Size)
+ }
+ return validateDownloadURL(asset.BrowserDownloadURL)
+}
+
+func validateDownloadURL(rawURL string) error {
+ parsed, err := url.Parse(rawURL)
+ if err != nil {
+ return fmt.Errorf("invalid release asset URL: %w", err)
+ }
+ if parsed.Scheme != "https" || !allowedDownloadHost(parsed.Hostname()) {
+ return fmt.Errorf("refusing release asset URL %s", rawURL)
+ }
+ return nil
+}
+
+func allowedDownloadHost(host string) bool {
+ return host == "github.com" || host == "objects.githubusercontent.com" || host == "release-assets.githubusercontent.com" || host == "github-releases.githubusercontent.com"
+}
diff --git a/cli/internal/updater/client_test.go b/cli/internal/updater/client_test.go
new file mode 100644
index 0000000..1f18c5f
--- /dev/null
+++ b/cli/internal/updater/client_test.go
@@ -0,0 +1,112 @@
+package updater
+
+import (
+ "context"
+ "crypto/sha256"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
+ return f(request)
+}
+
+func response(status int, body string) *http.Response {
+ return &http.Response{
+ StatusCode: status,
+ Body: io.NopCloser(strings.NewReader(body)),
+ Header: make(http.Header),
+ }
+}
+
+func TestClientCheckFindsNewerRelease(t *testing.T) {
+ t.Parallel()
+ body := `{
+ "tag_name":"v1.2.0",
+ "draft":false,
+ "prerelease":false,
+ "assets":[
+ {"name":"rvmm_macOS_arm64.pkg","browser_download_url":"https://github.com/rxtech-lab/macos-github-action-vm/releases/download/v1.2.0/rvmm_macOS_arm64.pkg","size":42},
+ {"name":"rvmm_macOS_arm64.pkg.sha256","browser_download_url":"https://github.com/rxtech-lab/macos-github-action-vm/releases/download/v1.2.0/rvmm_macOS_arm64.pkg.sha256","size":80}
+ ]
+ }`
+ client := NewClient("v1.1.0")
+ client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return response(http.StatusOK, body), nil
+ })
+
+ update, err := client.Check(context.Background(), "v1.1.0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !update.Available || update.LatestVersion != "v1.2.0" {
+ t.Fatalf("unexpected update: %+v", update)
+ }
+}
+
+func TestClientCheckRequiresChecksumAsset(t *testing.T) {
+ t.Parallel()
+ body := `{"tag_name":"v1.2.0","assets":[{"name":"rvmm_macOS_arm64.pkg","browser_download_url":"https://github.com/example.pkg","size":42}]}`
+ client := NewClient("v1.1.0")
+ client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return response(http.StatusOK, body), nil
+ })
+ if _, err := client.Check(context.Background(), "v1.1.0"); err == nil || !strings.Contains(err.Error(), ChecksumAssetName) {
+ t.Fatalf("Check() error = %v, want missing checksum", err)
+ }
+}
+
+func TestClientDownloadVerifiesChecksum(t *testing.T) {
+ t.Parallel()
+ packageBody := "signed package contents"
+ digest := fmt.Sprintf("%x", sha256.Sum256([]byte(packageBody)))
+ client := NewClient("v1.1.0")
+ client.HTTPClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ if strings.HasSuffix(request.URL.Path, ".sha256") {
+ return response(http.StatusOK, digest+" "+PackageAssetName+"\n"), nil
+ }
+ return response(http.StatusOK, packageBody), nil
+ })
+ update := Update{
+ Available: true,
+ Package: ReleaseAsset{Name: PackageAssetName, BrowserDownloadURL: "https://github.com/package", Size: int64(len(packageBody))},
+ Checksum: ReleaseAsset{Name: ChecksumAssetName, BrowserDownloadURL: "https://github.com/package.sha256", Size: 80},
+ }
+ destination := filepath.Join(t.TempDir(), PackageAssetName)
+ if err := client.Download(context.Background(), update, destination); err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(data) != packageBody {
+ t.Fatalf("downloaded %q, want %q", data, packageBody)
+ }
+}
+
+func TestClientDownloadRejectsChecksumMismatch(t *testing.T) {
+ t.Parallel()
+ client := NewClient("v1.1.0")
+ client.HTTPClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ if strings.HasSuffix(request.URL.Path, ".sha256") {
+ return response(http.StatusOK, strings.Repeat("0", 64)), nil
+ }
+ return response(http.StatusOK, "package"), nil
+ })
+ update := Update{
+ Package: ReleaseAsset{Name: PackageAssetName, BrowserDownloadURL: "https://github.com/package", Size: 7},
+ Checksum: ReleaseAsset{Name: ChecksumAssetName, BrowserDownloadURL: "https://github.com/package.sha256", Size: 64},
+ }
+ err := client.Download(context.Background(), update, filepath.Join(t.TempDir(), PackageAssetName))
+ if err == nil || !strings.Contains(err.Error(), "checksum mismatch") {
+ t.Fatalf("Download() error = %v, want checksum mismatch", err)
+ }
+}
diff --git a/cli/internal/updater/state.go b/cli/internal/updater/state.go
new file mode 100644
index 0000000..7d3fec9
--- /dev/null
+++ b/cli/internal/updater/state.go
@@ -0,0 +1,87 @@
+package updater
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+const (
+ StateDirectory = "/Library/Application Support/RVMM/Updater"
+ RequestDirectory = StateDirectory + "/requests"
+ StatusPath = StateDirectory + "/status.json"
+ LockPath = StateDirectory + "/update.lock"
+)
+
+type Status struct {
+ State string `json:"state"`
+ CurrentVersion string `json:"current_version,omitempty"`
+ LatestVersion string `json:"latest_version,omitempty"`
+ Message string `json:"message,omitempty"`
+ RestartNeeded bool `json:"restart_needed,omitempty"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+func ReadStatus(path string) (Status, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return Status{}, err
+ }
+ var status Status
+ if err := json.Unmarshal(data, &status); err != nil {
+ return Status{}, fmt.Errorf("decode updater status: %w", err)
+ }
+ return status, nil
+}
+
+func writeStatus(path string, status Status) error {
+ status.UpdatedAt = time.Now().UTC()
+ data, err := json.MarshalIndent(status, "", " ")
+ if err != nil {
+ return fmt.Errorf("encode updater status: %w", err)
+ }
+ data = append(data, '\n')
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ return fmt.Errorf("create updater state directory: %w", err)
+ }
+ temporary, err := os.CreateTemp(filepath.Dir(path), ".status-*")
+ if err != nil {
+ return fmt.Errorf("create updater status: %w", err)
+ }
+ temporaryPath := temporary.Name()
+ defer os.Remove(temporaryPath)
+ if err := temporary.Chmod(0644); err != nil {
+ temporary.Close()
+ return err
+ }
+ if _, err := temporary.Write(data); err != nil {
+ temporary.Close()
+ return err
+ }
+ if err := temporary.Close(); err != nil {
+ return err
+ }
+ return os.Rename(temporaryPath, path)
+}
+
+func RequestUpdate(directory string) error {
+ info, err := os.Stat(directory)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return errors.New("background updater is not installed; install the latest RVMM package once with administrator approval")
+ }
+ return fmt.Errorf("inspect updater request directory: %w", err)
+ }
+ if !info.IsDir() {
+ return errors.New("updater request path is not a directory")
+ }
+ name := fmt.Sprintf("request-%d-%d", time.Now().UnixNano(), os.Getpid())
+ file, err := os.OpenFile(filepath.Join(directory, name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
+ if err != nil {
+ return fmt.Errorf("queue update request: %w", err)
+ }
+ return file.Close()
+}
diff --git a/cli/internal/updater/state_test.go b/cli/internal/updater/state_test.go
new file mode 100644
index 0000000..14bcef0
--- /dev/null
+++ b/cli/internal/updater/state_test.go
@@ -0,0 +1,38 @@
+package updater
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestRequestUpdateCreatesQueueEntry(t *testing.T) {
+ t.Parallel()
+ directory := t.TempDir()
+ if err := RequestUpdate(directory); err != nil {
+ t.Fatal(err)
+ }
+ entries, err := os.ReadDir(directory)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 1 {
+ t.Fatalf("got %d request entries, want 1", len(entries))
+ }
+}
+
+func TestStatusRoundTrip(t *testing.T) {
+ t.Parallel()
+ path := filepath.Join(t.TempDir(), "status.json")
+ want := Status{State: "installed", CurrentVersion: "v1.0.0", LatestVersion: "v1.1.0", RestartNeeded: true}
+ if err := writeStatus(path, want); err != nil {
+ t.Fatal(err)
+ }
+ got, err := ReadStatus(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.State != want.State || got.LatestVersion != want.LatestVersion || !got.RestartNeeded {
+ t.Fatalf("ReadStatus() = %+v, want %+v", got, want)
+ }
+}
diff --git a/cli/internal/updater/verify.go b/cli/internal/updater/verify.go
new file mode 100644
index 0000000..19ef2a3
--- /dev/null
+++ b/cli/internal/updater/verify.go
@@ -0,0 +1,39 @@
+package updater
+
+import (
+ "context"
+ "fmt"
+ "os/exec"
+ "strings"
+)
+
+func VerifyPackage(ctx context.Context, packagePath, teamID string) error {
+ if teamID == "" {
+ return fmt.Errorf("release build does not contain an expected Apple team ID")
+ }
+ output, err := exec.CommandContext(ctx, "/usr/sbin/pkgutil", "--check-signature", packagePath).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("verify package signature: %w: %s", err, strings.TrimSpace(string(output)))
+ }
+ if !signatureContainsTeamID(string(output), teamID) {
+ return fmt.Errorf("package is not signed by expected Apple team %s", teamID)
+ }
+
+ output, err = exec.CommandContext(ctx, "/usr/sbin/spctl", "--assess", "--type", "install", "--verbose=2", packagePath).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("verify package trust: %w: %s", err, strings.TrimSpace(string(output)))
+ }
+ return nil
+}
+
+func signatureContainsTeamID(output, teamID string) bool {
+ return strings.Contains(output, "("+teamID+")") || strings.Contains(output, "TeamIdentifier="+teamID)
+}
+
+func InstallPackage(ctx context.Context, packagePath string) error {
+ output, err := exec.CommandContext(ctx, "/usr/sbin/installer", "-pkg", packagePath, "-target", "/").CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("install package: %w: %s", err, strings.TrimSpace(string(output)))
+ }
+ return nil
+}
diff --git a/cli/internal/updater/verify_test.go b/cli/internal/updater/verify_test.go
new file mode 100644
index 0000000..d89eac9
--- /dev/null
+++ b/cli/internal/updater/verify_test.go
@@ -0,0 +1,13 @@
+package updater
+
+import "testing"
+
+func TestSignatureContainsTeamID(t *testing.T) {
+ t.Parallel()
+ if !signatureContainsTeamID("Developer ID Installer: Example Corp (ABC123XYZ)", "ABC123XYZ") {
+ t.Fatal("expected team ID to be recognized")
+ }
+ if signatureContainsTeamID("Developer ID Installer: Other Corp (OTHERTEAM)", "ABC123XYZ") {
+ t.Fatal("unexpected signer accepted")
+ }
+}
diff --git a/cli/internal/updater/version.go b/cli/internal/updater/version.go
new file mode 100644
index 0000000..7bd2187
--- /dev/null
+++ b/cli/internal/updater/version.go
@@ -0,0 +1,158 @@
+package updater
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+type semanticVersion struct {
+ major uint64
+ minor uint64
+ patch uint64
+ prerelease []string
+}
+
+func parseVersion(value string) (semanticVersion, error) {
+ value = strings.TrimSpace(value)
+ value = strings.TrimPrefix(value, "v")
+ if buildIndex := strings.IndexByte(value, '+'); buildIndex >= 0 {
+ value = value[:buildIndex]
+ }
+
+ core := value
+ var prerelease []string
+ if prereleaseIndex := strings.IndexByte(value, '-'); prereleaseIndex >= 0 {
+ core = value[:prereleaseIndex]
+ pre := value[prereleaseIndex+1:]
+ if pre == "" {
+ return semanticVersion{}, fmt.Errorf("invalid semantic version %q", value)
+ }
+ prerelease = strings.Split(pre, ".")
+ }
+
+ parts := strings.Split(core, ".")
+ if len(parts) != 3 {
+ return semanticVersion{}, fmt.Errorf("invalid semantic version %q", value)
+ }
+ values := make([]uint64, 3)
+ for i, part := range parts {
+ if part == "" || (len(part) > 1 && part[0] == '0') {
+ return semanticVersion{}, fmt.Errorf("invalid semantic version %q", value)
+ }
+ parsed, err := strconv.ParseUint(part, 10, 64)
+ if err != nil {
+ return semanticVersion{}, fmt.Errorf("invalid semantic version %q: %w", value, err)
+ }
+ values[i] = parsed
+ }
+
+ for _, identifier := range prerelease {
+ if identifier == "" {
+ return semanticVersion{}, fmt.Errorf("invalid semantic version %q", value)
+ }
+ for _, r := range identifier {
+ if !(r >= '0' && r <= '9') && !(r >= 'A' && r <= 'Z') && !(r >= 'a' && r <= 'z') && r != '-' {
+ return semanticVersion{}, fmt.Errorf("invalid semantic version %q", value)
+ }
+ }
+ if isNumeric(identifier) && len(identifier) > 1 && identifier[0] == '0' {
+ return semanticVersion{}, fmt.Errorf("invalid semantic version %q", value)
+ }
+ }
+
+ return semanticVersion{major: values[0], minor: values[1], patch: values[2], prerelease: prerelease}, nil
+}
+
+func compareVersions(left, right string) (int, error) {
+ l, err := parseVersion(left)
+ if err != nil {
+ return 0, err
+ }
+ r, err := parseVersion(right)
+ if err != nil {
+ return 0, err
+ }
+
+ if result := compareUint64(l.major, r.major); result != 0 {
+ return result, nil
+ }
+ if result := compareUint64(l.minor, r.minor); result != 0 {
+ return result, nil
+ }
+ if result := compareUint64(l.patch, r.patch); result != 0 {
+ return result, nil
+ }
+ return comparePrerelease(l.prerelease, r.prerelease), nil
+}
+
+func compareUint64(left, right uint64) int {
+ if left < right {
+ return -1
+ }
+ if left > right {
+ return 1
+ }
+ return 0
+}
+
+func comparePrerelease(left, right []string) int {
+ if len(left) == 0 && len(right) == 0 {
+ return 0
+ }
+ if len(left) == 0 {
+ return 1
+ }
+ if len(right) == 0 {
+ return -1
+ }
+
+ for i := 0; i < len(left) && i < len(right); i++ {
+ if left[i] == right[i] {
+ continue
+ }
+ leftNumeric := isNumeric(left[i])
+ rightNumeric := isNumeric(right[i])
+ switch {
+ case leftNumeric && rightNumeric:
+ if len(left[i]) < len(right[i]) {
+ return -1
+ }
+ if len(left[i]) > len(right[i]) {
+ return 1
+ }
+ if left[i] < right[i] {
+ return -1
+ }
+ return 1
+ case leftNumeric:
+ return -1
+ case rightNumeric:
+ return 1
+ case left[i] < right[i]:
+ return -1
+ default:
+ return 1
+ }
+ }
+
+ if len(left) < len(right) {
+ return -1
+ }
+ if len(left) > len(right) {
+ return 1
+ }
+ return 0
+}
+
+func isNumeric(value string) bool {
+ if value == "" {
+ return false
+ }
+ for _, r := range value {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return true
+}
diff --git a/cli/internal/updater/version_test.go b/cli/internal/updater/version_test.go
new file mode 100644
index 0000000..f710b58
--- /dev/null
+++ b/cli/internal/updater/version_test.go
@@ -0,0 +1,41 @@
+package updater
+
+import "testing"
+
+func TestCompareVersions(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ left string
+ right string
+ want int
+ }{
+ {"v1.2.3", "1.2.3", 0},
+ {"v1.2.4", "v1.2.3", 1},
+ {"v2.0.0", "v1.99.99", 1},
+ {"v1.2.3-beta.2", "v1.2.3-beta.11", -1},
+ {"v1.2.3", "v1.2.3-rc.1", 1},
+ {"v1.2.3+build.2", "v1.2.3+build.1", 0},
+ }
+ for _, test := range tests {
+ test := test
+ t.Run(test.left+"_"+test.right, func(t *testing.T) {
+ t.Parallel()
+ got, err := compareVersions(test.left, test.right)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != test.want {
+ t.Fatalf("compareVersions(%q, %q) = %d, want %d", test.left, test.right, got, test.want)
+ }
+ })
+ }
+}
+
+func TestParseVersionRejectsInvalidValues(t *testing.T) {
+ t.Parallel()
+ for _, value := range []string{"dev", "1.2", "1.02.3", "1.2.3-", "1.2.3-01"} {
+ if _, err := parseVersion(value); err == nil {
+ t.Errorf("parseVersion(%q) succeeded, want error", value)
+ }
+ }
+}
diff --git a/cli/internal/updater/worker.go b/cli/internal/updater/worker.go
new file mode 100644
index 0000000..aba330d
--- /dev/null
+++ b/cli/internal/updater/worker.go
@@ -0,0 +1,131 @@
+package updater
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+ "syscall"
+ "time"
+)
+
+type Worker struct {
+ CurrentVersion string
+ TeamID string
+ Client ReleaseClient
+ StatusPath string
+ LockPath string
+ RequestDir string
+ Verify func(context.Context, string, string) error
+ Install func(context.Context, string) error
+ IsRoot func() bool
+ IsSupported func() bool
+}
+
+func NewWorker(currentVersion, teamID string) *Worker {
+ return &Worker{
+ CurrentVersion: currentVersion,
+ TeamID: teamID,
+ Client: NewClient(currentVersion),
+ StatusPath: StatusPath,
+ LockPath: LockPath,
+ RequestDir: RequestDirectory,
+ Verify: VerifyPackage,
+ Install: InstallPackage,
+ IsRoot: func() bool { return os.Geteuid() == 0 },
+ IsSupported: func() bool { return runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" },
+ }
+}
+
+func (w *Worker) Run(ctx context.Context) error {
+ if !w.IsRoot() {
+ return errors.New("update worker must run as root")
+ }
+ if !w.IsSupported() {
+ return fmt.Errorf("automatic updates are only supported on macOS arm64")
+ }
+ if err := os.MkdirAll(filepath.Dir(w.LockPath), 0755); err != nil {
+ return fmt.Errorf("create updater state directory: %w", err)
+ }
+ lock, err := os.OpenFile(w.LockPath, os.O_CREATE|os.O_RDWR, 0600)
+ if err != nil {
+ return fmt.Errorf("open updater lock: %w", err)
+ }
+ defer lock.Close()
+ if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
+ return errors.New("another update worker is already running")
+ }
+ defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
+ defer w.consumeRequests()
+ pendingRestart := false
+ if previous, err := ReadStatus(w.StatusPath); err == nil {
+ pendingRestart = previous.RestartNeeded
+ }
+
+ setStatus := func(state, latest, message string, restart bool) {
+ _ = writeStatus(w.StatusPath, Status{
+ State: state,
+ CurrentVersion: w.CurrentVersion,
+ LatestVersion: latest,
+ Message: message,
+ RestartNeeded: restart,
+ })
+ }
+ setStatus("checking", "", "Checking GitHub for updates", pendingRestart)
+
+ update, err := w.Client.Check(ctx, w.CurrentVersion)
+ if err != nil {
+ setStatus("error", "", err.Error(), pendingRestart)
+ return err
+ }
+ if !update.Available {
+ message := "RVMM is up to date"
+ if pendingRestart {
+ message = "RVMM is up to date; service restart is still pending"
+ }
+ setStatus("up_to_date", update.LatestVersion, message, pendingRestart)
+ return nil
+ }
+
+ directory, err := os.MkdirTemp("/private/tmp", "rvmm-update-*")
+ if err != nil {
+ setStatus("error", update.LatestVersion, err.Error(), pendingRestart)
+ return fmt.Errorf("create update directory: %w", err)
+ }
+ defer os.RemoveAll(directory)
+ packagePath := filepath.Join(directory, PackageAssetName)
+
+ setStatus("downloading", update.LatestVersion, "Downloading signed update", pendingRestart)
+ if err := w.Client.Download(ctx, update, packagePath); err != nil {
+ setStatus("error", update.LatestVersion, err.Error(), pendingRestart)
+ return err
+ }
+ setStatus("verifying", update.LatestVersion, "Verifying package signature and notarization", pendingRestart)
+ if err := w.Verify(ctx, packagePath, w.TeamID); err != nil {
+ setStatus("error", update.LatestVersion, err.Error(), pendingRestart)
+ return err
+ }
+ setStatus("installing", update.LatestVersion, "Installing update", pendingRestart)
+ installCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
+ defer cancel()
+ if err := w.Install(installCtx, packagePath); err != nil {
+ setStatus("error", update.LatestVersion, err.Error(), pendingRestart)
+ return err
+ }
+ setStatus("installed", update.LatestVersion, "Update installed; active VM work was not interrupted", true)
+ return nil
+}
+
+func (w *Worker) consumeRequests() {
+ entries, err := os.ReadDir(w.RequestDir)
+ if err != nil {
+ return
+ }
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ _ = os.Remove(filepath.Join(w.RequestDir, entry.Name()))
+ }
+ }
+}
diff --git a/cli/internal/updater/worker_test.go b/cli/internal/updater/worker_test.go
new file mode 100644
index 0000000..cae6fdd
--- /dev/null
+++ b/cli/internal/updater/worker_test.go
@@ -0,0 +1,105 @@
+package updater
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+type fakeReleaseClient struct {
+ update Update
+ downloaded bool
+}
+
+func (f *fakeReleaseClient) Check(context.Context, string) (Update, error) {
+ return f.update, nil
+}
+
+func (f *fakeReleaseClient) Download(_ context.Context, _ Update, destination string) error {
+ f.downloaded = true
+ return os.WriteFile(destination, []byte("package"), 0600)
+}
+
+func TestWorkerInstallsAvailableUpdate(t *testing.T) {
+ t.Parallel()
+ directory := t.TempDir()
+ requests := filepath.Join(directory, "requests")
+ if err := os.Mkdir(requests, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(requests, "request"), nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ fakeClient := &fakeReleaseClient{update: Update{Available: true, LatestVersion: "v1.1.0"}}
+ verified := false
+ installed := false
+ worker := &Worker{
+ CurrentVersion: "v1.0.0",
+ TeamID: "TEAMID",
+ Client: fakeClient,
+ StatusPath: filepath.Join(directory, "status.json"),
+ LockPath: filepath.Join(directory, "update.lock"),
+ RequestDir: requests,
+ Verify: func(context.Context, string, string) error {
+ verified = true
+ return nil
+ },
+ Install: func(context.Context, string) error {
+ installed = true
+ return nil
+ },
+ IsRoot: func() bool { return true },
+ IsSupported: func() bool { return true },
+ }
+ if err := worker.Run(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if !fakeClient.downloaded || !verified || !installed {
+ t.Fatalf("downloaded=%v verified=%v installed=%v", fakeClient.downloaded, verified, installed)
+ }
+ status, err := ReadStatus(worker.StatusPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if status.State != "installed" || !status.RestartNeeded {
+ t.Fatalf("unexpected status: %+v", status)
+ }
+ entries, err := os.ReadDir(requests)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 0 {
+ t.Fatalf("request queue was not consumed: %v", entries)
+ }
+}
+
+func TestWorkerPreservesPendingRestartWhenUpToDate(t *testing.T) {
+ t.Parallel()
+ directory := t.TempDir()
+ statusPath := filepath.Join(directory, "status.json")
+ if err := writeStatus(statusPath, Status{State: "installed", LatestVersion: "v1.1.0", RestartNeeded: true}); err != nil {
+ t.Fatal(err)
+ }
+ worker := &Worker{
+ CurrentVersion: "v1.1.0",
+ Client: &fakeReleaseClient{update: Update{Available: false, LatestVersion: "v1.1.0"}},
+ StatusPath: statusPath,
+ LockPath: filepath.Join(directory, "update.lock"),
+ RequestDir: filepath.Join(directory, "requests"),
+ Verify: func(context.Context, string, string) error { return nil },
+ Install: func(context.Context, string) error { return nil },
+ IsRoot: func() bool { return true },
+ IsSupported: func() bool { return true },
+ }
+ if err := worker.Run(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ status, err := ReadStatus(statusPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !status.RestartNeeded {
+ t.Fatalf("pending restart was cleared: %+v", status)
+ }
+}
diff --git a/cli/main.go b/cli/main.go
index 71b3944..e56df7e 100644
--- a/cli/main.go
+++ b/cli/main.go
@@ -10,11 +10,13 @@ import (
"sync"
"syscall"
+ "github.com/rxtech-lab/rvmm/internal/buildinfo"
"github.com/rxtech-lab/rvmm/internal/config"
"github.com/rxtech-lab/rvmm/internal/monitor"
"github.com/rxtech-lab/rvmm/internal/posthog"
"github.com/rxtech-lab/rvmm/internal/runner"
"github.com/rxtech-lab/rvmm/internal/tui"
+ "github.com/rxtech-lab/rvmm/internal/updater"
"go.uber.org/zap"
)
@@ -27,9 +29,29 @@ func main() {
monitorHeadless()
return
}
+ if len(os.Args) > 1 && os.Args[1] == "update-worker" {
+ updateWorker()
+ return
+ }
tui.Run()
}
+func updateWorker() {
+ logger, err := zap.NewProduction()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "failed to create logger: %v\n", err)
+ os.Exit(1)
+ }
+ defer logger.Sync()
+
+ worker := updater.NewWorker(buildinfo.Version, buildinfo.TeamID)
+ if err := worker.Run(context.Background()); err != nil {
+ logger.Error("Automatic update failed", zap.Error(err))
+ os.Exit(1)
+ }
+ logger.Info("Automatic update check completed")
+}
+
func runHeadless() {
fs := flag.NewFlagSet("run", flag.ExitOnError)
configPath := fs.String("config", "", "path to config file")
diff --git a/cli/scripts/package-notarize.sh b/cli/scripts/package-notarize.sh
index b237b09..274594e 100755
--- a/cli/scripts/package-notarize.sh
+++ b/cli/scripts/package-notarize.sh
@@ -2,6 +2,8 @@
set -e
+export COPYFILE_DISABLE=1
+
if [ -z "${INSTALLER_SIGNING_CERTIFICATE_NAME}" ]; then
echo "Error: INSTALLER_SIGNING_CERTIFICATE_NAME is not set"
exit 1
@@ -17,10 +19,24 @@ source "$(dirname "$0")/binaries.sh"
PKG_FILE="${PKG_FILE:-rvmm_macOS_arm64.pkg}"
PKG_VERSION="${PKG_VERSION:-1.0}"
PKG_IDENTIFIER="${PKG_IDENTIFIER:-lab.rxtech.rvmm}"
-TMP_DIR="tmp_pkg_build"
+TMP_DIR="$(/usr/bin/mktemp -d /private/tmp/rvmm-pkg-root.XXXXXX)"
+PKG_SCRIPTS_DIR="$(/usr/bin/mktemp -d /private/tmp/rvmm-pkg-scripts.XXXXXX)"
+
+cleanup() {
+ /bin/rm -rf "${TMP_DIR}" "${PKG_SCRIPTS_DIR}"
+}
+trap cleanup EXIT
echo "Creating package structure"
mkdir -p "${TMP_DIR}/usr/local/bin"
+mkdir -p "${TMP_DIR}/Library/LaunchDaemons"
+mkdir -p "${TMP_DIR}/Library/Application Support/RVMM/Updater/requests"
+mkdir -p "${PKG_SCRIPTS_DIR}"
+chmod 0770 "${TMP_DIR}/Library/Application Support/RVMM/Updater/requests"
+cp "assets/lab.rxtech.rvmm.updater.plist" "${TMP_DIR}/Library/LaunchDaemons/"
+chmod 0644 "${TMP_DIR}/Library/LaunchDaemons/lab.rxtech.rvmm.updater.plist"
+cp "scripts/pkg-scripts/postinstall" "${PKG_SCRIPTS_DIR}/postinstall"
+chmod 0755 "${PKG_SCRIPTS_DIR}/postinstall"
for binary in "${BINARIES[@]}"; do
BINARY_PATH="bin/${binary}"
@@ -34,20 +50,26 @@ for binary in "${BINARIES[@]}"; do
cp "${BINARY_PATH}" "${TMP_DIR}/usr/local/bin/"
done
+# Avoid packaging macOS metadata sidecars such as ._rvmm.
+/usr/bin/xattr -cr "${TMP_DIR}"
+
echo "Building pkg installer (version ${PKG_VERSION})"
pkgbuild --root "${TMP_DIR}" \
+ --ownership recommended \
--identifier "${PKG_IDENTIFIER}" \
--version "${PKG_VERSION}" \
--sign "${INSTALLER_SIGNING_CERTIFICATE_NAME}" \
+ --scripts "${PKG_SCRIPTS_DIR}" \
--install-location "/" \
"${PKG_FILE}"
-rm -rf "${TMP_DIR}"
-
echo "Submitting for notarization"
xcrun notarytool submit "${PKG_FILE}" --verbose --apple-id "${APPLE_ID}" --team-id "${APPLE_TEAM_ID}" --password "${APPLE_ID_PWD}" --wait
echo "Stapling notarization ticket"
xcrun stapler staple -v "${PKG_FILE}"
+echo "Writing SHA-256 checksum"
+/usr/bin/shasum -a 256 "${PKG_FILE}" > "${PKG_FILE}.sha256"
+
echo "Package created, signed, notarized and stapled successfully: ${PKG_FILE}"
diff --git a/cli/scripts/pkg-scripts/postinstall b/cli/scripts/pkg-scripts/postinstall
new file mode 100755
index 0000000..403960c
--- /dev/null
+++ b/cli/scripts/pkg-scripts/postinstall
@@ -0,0 +1,23 @@
+#!/bin/bash
+
+set -euo pipefail
+
+TARGET_VOLUME="${3:-/}"
+STATE_DIR="${TARGET_VOLUME%/}/Library/Application Support/RVMM/Updater"
+REQUEST_DIR="${STATE_DIR}/requests"
+PLIST_PATH="${TARGET_VOLUME%/}/Library/LaunchDaemons/lab.rxtech.rvmm.updater.plist"
+LABEL="lab.rxtech.rvmm.updater"
+
+/usr/bin/install -d -o root -g wheel -m 0755 "${STATE_DIR}"
+/usr/bin/install -d -o root -g admin -m 0770 "${REQUEST_DIR}"
+/usr/sbin/chown root:wheel "${PLIST_PATH}"
+/bin/chmod 0644 "${PLIST_PATH}"
+
+if [ "${TARGET_VOLUME}" = "/" ]; then
+ /bin/launchctl enable "system/${LABEL}" || true
+ if ! /bin/launchctl print "system/${LABEL}" >/dev/null 2>&1; then
+ /bin/launchctl bootstrap system "${PLIST_PATH}"
+ fi
+fi
+
+exit 0
diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md
new file mode 100644
index 0000000..46a048e
--- /dev/null
+++ b/docs/architecture/overview.md
@@ -0,0 +1,63 @@
+---
+slug: architecture/overview
+title: RVMM Architecture Overview
+description: Components and runtime flow of the macOS GitHub Actions runner VM manager
+---
+
+# RVMM architecture overview
+
+RVMM is a Go command-line application for running ephemeral GitHub Actions self-hosted runners inside Tart virtual machines on Apple Silicon Macs. It combines host setup, image management, runner registration, bounded VM concurrency, launchd integration, and optional PostHog log forwarding.
+
+## Repository layout
+
+| Area | Responsibility |
+| --- | --- |
+| `cli/main.go` | Selects the interactive TUI, headless runner, or headless monitor entry point. |
+| `cli/internal/config` | Loads `rvmm.yaml`, applies defaults, and validates required settings. |
+| `cli/internal/runner` | Obtains GitHub registration tokens and manages Tart VM, SSH, and ephemeral runner lifecycles. |
+| `cli/internal/tui` | Implements the Bubble Tea menus for setup, configuration, image operations, runners, daemons, and logs. |
+| `cli/internal/daemon` | Installs, removes, and inspects runner and monitor launchd jobs. |
+| `cli/internal/monitor` | Tails runner stdout and stderr files. |
+| `cli/internal/posthog` | Sends new log lines to PostHog capture endpoints. |
+| `cli/internal/setup` | Installs or validates host dependencies and creates a sample configuration. |
+| `cli/assets` | Embeds the launchd plist template and example configuration in the binary. |
+| `guest/runner.pkr.hcl` | Provides an example Packer template for a Tart runner image. |
+| `.github/workflows` | Creates releases and builds, signs, notarizes, and publishes the macOS installer. |
+
+## Runtime modes
+
+Running `rvmm` without arguments opens the interactive TUI. The TUI calls the same config, setup, runner, daemon, and image-management packages used by headless operation.
+
+`rvmm run -config rvmm.yaml` starts the runner loop. `rvmm monitor -config rvmm.yaml` starts two log tailers for the configured working directory and requires `posthog.enabled: true`.
+
+## Runner lifecycle
+
+1. Load and validate configuration.
+2. Verify that `tart`, `sshpass`, `wget`, and `packer` are available.
+3. Log in to the OCI registry when credentials are configured.
+4. Reuse a local Tart image or pull the configured image from the registry.
+5. Create `options.max_concurrent_runners` worker slots.
+6. For each slot, request a short-lived registration token from GitHub.
+7. Clone the base image to an instance named `_` and boot it without graphics.
+8. Wait for the guest IP and SSH, then configure an ephemeral GitHub Actions runner in the guest.
+9. Run one Actions job, stop the VM, and delete the instance during cleanup.
+10. Return the slot to the pool and repeat until the context is cancelled or the shutdown flag exists.
+
+Each worker owns its own `VMManager`, while the GitHub client is shared. The base image is initialized once before workers begin.
+
+## VM image construction
+
+The example Packer template uses the Tart plugin and starts from a Cirrus Labs macOS image. It downloads the latest arm64 GitHub Actions runner into the guest, installs Apple certificate authorities, selects Xcode, accepts its license, completes first-launch setup, and downloads available platforms and simulator runtimes.
+
+The CLI accepts external `.pkr.hcl` templates, runs `packer init`, and then runs `packer build` from the selected template's directory. Supporting provisioner files therefore stay beside the template.
+
+## Background services and observability
+
+The runner can be installed as a system LaunchDaemon or user LaunchAgent depending on `daemon.plist_path`. Its stdout and stderr are written under `options.working_directory`.
+
+The optional monitor runs as a separate LaunchAgent. It polls both files, starts at their current end, detects truncation, and forwards subsequent non-empty lines as `mac_ci_log_line` PostHog events identified by `posthog.machine_label`.
+
+## Release flow
+
+The manual semantic-release workflow creates a GitHub release from `main`. The release workflow runs on the self-hosted macOS ARM64 runner, executes Go tests, builds and signs `rvmm`, creates and notarizes a macOS installer package for release events, and uploads the package to the GitHub release.
+
diff --git a/docs/code/go.md b/docs/code/go.md
new file mode 100644
index 0000000..0843577
--- /dev/null
+++ b/docs/code/go.md
@@ -0,0 +1,129 @@
+---
+slug: code/go-packages
+title: Go Package Reference
+description: Go package index for the RVMM command-line application
+---
+
+# Go package reference
+
+This index is derived from `go doc` for module `github.com/rxtech-lab/rvmm` using Go 1.22. Run `go doc -all ` from `cli/` for full declarations and field documentation.
+
+## `github.com/rxtech-lab/rvmm`
+
+The executable package selects the Bubble Tea TUI by default. It also exposes the `run` and `monitor` command modes through the process entry point.
+
+## `assets`
+
+Embedded build assets:
+
+```text
+var ConfigExample []byte
+var EkidenPlist []byte
+```
+
+## `internal/config`
+
+Loads, defaults, and validates the YAML configuration.
+
+```text
+type Config struct { ... }
+ func Load(configPath string) (*Config, error)
+type DaemonConfig struct { ... }
+type GitHubConfig struct { ... }
+type OptionsConfig struct { ... }
+type PostHogConfig struct { ... }
+type RegistryConfig struct { ... }
+type VMConfig struct { ... }
+func (c *Config) Validate() error
+```
+
+## `internal/daemon`
+
+Manages launchd jobs for the runner and log monitor.
+
+```text
+func Install(log *zap.Logger, cfg *config.Config, configPath string, out io.Writer) error
+func InstallMonitor(log *zap.Logger, cfg *config.Config, configPath string, out io.Writer) error
+func IsRunning(cfg *config.Config) (bool, error)
+func Status(log *zap.Logger, cfg *config.Config, out io.Writer) error
+func StatusMonitor(log *zap.Logger, cfg *config.Config, out io.Writer) error
+func Uninstall(log *zap.Logger, cfg *config.Config, out io.Writer) error
+func UninstallMonitor(log *zap.Logger, cfg *config.Config, out io.Writer) error
+type PlistData struct { ... }
+```
+
+## `internal/monitor`
+
+Polls a log file and sends new lines to PostHog.
+
+```text
+type LogTailer struct { ... }
+ func NewLogTailer(filePath string, logType string, posthog *posthog.Client, log *zap.Logger) *LogTailer
+ func (t *LogTailer) Start(ctx context.Context) error
+```
+
+## `internal/posthog`
+
+Builds and sends single or batched PostHog capture requests.
+
+```text
+type CaptureRequest struct { ... }
+type Client struct { ... }
+ func NewClient(cfg *config.PostHogConfig, log *zap.Logger) *Client
+ func (c *Client) CaptureLogEvent(logType string, logLine string) error
+ func (c *Client) CaptureLogEventBatch(logType string, logLines []string) error
+type Event struct { ... }
+```
+
+## `internal/runner`
+
+Coordinates registration tokens, Tart instances, SSH, and ephemeral runner workers.
+
+```text
+func Run(ctx context.Context, log *zap.Logger, cfg *config.Config) error
+type GitHubClient struct { ... }
+ func NewGitHubClient(cfg *config.Config, log *zap.Logger) *GitHubClient
+ func (g *GitHubClient) GetRegistrationToken() (string, error)
+type RegistrationTokenResponse struct { ... }
+type SSHClient struct { ... }
+ func NewSSHClient(cfg *config.Config, log *zap.Logger) *SSHClient
+ func (s *SSHClient) ConfigureRunner(ctx context.Context, ip, token, runnerName string) error
+ func (s *SSHClient) Execute(ctx context.Context, ip, command string, showOutput bool) error
+ func (s *SSHClient) ExecuteWithOutput(ctx context.Context, ip, command string) (string, error)
+ func (s *SSHClient) RunRunner(ctx context.Context, ip string) error
+ func (s *SSHClient) WaitForSSH(ctx context.Context, ip string) error
+type VMManager struct { ... }
+ func NewVMManager(cfg *config.Config, log *zap.Logger) *VMManager
+ func (v *VMManager) Cleanup(ctx context.Context, instanceName string)
+ func (v *VMManager) Clone(ctx context.Context, instanceName string) error
+ func (v *VMManager) Delete(ctx context.Context, instanceName string) error
+ func (v *VMManager) GetCachePath() string
+ func (v *VMManager) GetRegistryPath() string
+ func (v *VMManager) ImageExists(ctx context.Context) (bool, error)
+ func (v *VMManager) Login(ctx context.Context) error
+ func (v *VMManager) PullImage(ctx context.Context) error
+ func (v *VMManager) Start(ctx context.Context, instanceName string) (*exec.Cmd, error)
+ func (v *VMManager) Stop(ctx context.Context, instanceName string) error
+ func (v *VMManager) WaitForIP(ctx context.Context, instanceName string) (string, error)
+```
+
+## `internal/setup`
+
+Installs and validates host dependencies and creates the sample configuration.
+
+```text
+var RequiredPackages = []string { ... }
+var RequiredTools = []string { ... }
+func CheckDependencies() error
+func Run(log *zap.Logger) error
+func RunWithIO(log *zap.Logger, stdout, stderr io.Writer, stdin io.Reader) error
+```
+
+## `internal/tui`
+
+Implements the Bubble Tea interface and delegates work to the setup, runner, daemon, and image-management packages.
+
+```text
+func Run()
+```
+
diff --git a/docs/operations/configuration.md b/docs/operations/configuration.md
new file mode 100644
index 0000000..6e0f3f6
--- /dev/null
+++ b/docs/operations/configuration.md
@@ -0,0 +1,96 @@
+---
+slug: operations/configuration
+title: RVMM Configuration and Operations
+description: Configure, run, monitor, and release RVMM
+---
+
+# RVMM configuration and operations
+
+RVMM reads YAML from the path passed with `-config`. Without an explicit path it searches for `rvmm.yaml` in the current directory, `$HOME/.rvmm`, and `/etc/rvmm`. The interactive setup flow writes an example `rvmm.yaml` in the current directory if one does not already exist.
+
+## Host requirements
+
+RVMM is intended for macOS on Apple Silicon with hardware virtualization enabled. Runtime and image-building operations use:
+
+- `tart` for VM images and instances
+- `sshpass` for guest automation
+- `wget` for downloads
+- `packer` from `hashicorp/tap` for image templates
+
+The TUI Setup action can install Homebrew and these packages.
+
+## Configuration sections
+
+### `github`
+
+| Field | Meaning |
+| --- | --- |
+| `api_token` | GitHub token used to request runner registration tokens. |
+| `registration_endpoint` | Organization or repository runner registration-token endpoint. |
+| `runner_url` | Organization or repository URL passed to the guest runner configuration. |
+| `runner_name` | Base runner name; the worker slot is appended at runtime. |
+| `runner_labels` | Labels registered on each ephemeral runner. |
+| `runner_group` | Optional organization or enterprise runner group. |
+
+`api_token`, `registration_endpoint`, and `runner_url` are required.
+
+### `vm`
+
+`username` and `password` are the guest SSH credentials and are required. `display` controls the Tart display configuration applied before boot and defaults to `3840x2160`.
+
+### `registry`
+
+`image_name` is required and can name a local Tart image or an OCI image. Set `url`, `username`, and `password` when RVMM must authenticate and pull from a registry. If `image_name` already starts with the configured registry URL, RVMM avoids adding the prefix twice.
+
+### `options`
+
+| Field | Default | Meaning |
+| --- | --- | --- |
+| `truncate_size` | empty | Optional target size used when resizing a pulled image. |
+| `log_file` | `runner.log` | TUI log path. |
+| `max_concurrent_runners` | `1` | Number of VM worker slots; must be at least one. |
+| `shutdown_flag_file` | `.shutdown` | Stops new work and waits for active workers when this file exists. |
+| `working_directory` | `/Users/admin/vm` | Runner logs and launchd working directory. |
+
+### `daemon`
+
+`label` identifies the launchd job, `plist_path` selects a system LaunchDaemon or user LaunchAgent location, and `user` is written into the generated plist. Installing under `/Library/LaunchDaemons` generally requires elevated filesystem permissions.
+
+### `posthog`
+
+Set `enabled: true`, then provide `api_key`, `host`, and a unique `machine_label` to forward runner logs. These values are validated only when monitoring is enabled.
+
+## Common commands
+
+```bash
+# Build and test the CLI
+cd cli
+make build
+make test
+
+# Run interactively
+./bin/rvmm
+
+# Run ephemeral workers in the foreground
+./bin/rvmm run -config /path/to/rvmm.yaml
+
+# Forward new runner logs to PostHog
+./bin/rvmm monitor -config /path/to/rvmm.yaml
+
+# Build the example guest image
+cd ../guest
+packer init runner.pkr.hcl
+packer build runner.pkr.hcl
+```
+
+## Operational checks
+
+- Confirm the image is visible with `tart list` before starting workers.
+- Confirm the guest credentials match the Packer image and that SSH is reachable.
+- Confirm the GitHub token can call the configured registration endpoint.
+- Check the configured launchd domain with `launchctl print system/