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
20 changes: 17 additions & 3 deletions .github/workflows/release-cli.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 }}
29 changes: 29 additions & 0 deletions .github/workflows/upload-docs.yaml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
rvmm
cli/bin/
*.pkg
*.pkg.sha256
*.yaml
*.log

Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down
9 changes: 6 additions & 3 deletions cli/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
30 changes: 30 additions & 0 deletions cli/assets/lab.rxtech.rvmm.updater.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>lab.rxtech.rvmm.updater</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/rvmm</string>
<string>update-worker</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>21600</integer>
<key>QueueDirectories</key>
<dict>
<key>/Library/Application Support/RVMM/Updater/requests</key>
<true/>
</dict>
<key>ProcessType</key>
<string>Background</string>
<key>ThrottleInterval</key>
<integer>60</integer>
<key>StandardOutPath</key>
<string>/Library/Logs/rvmm-updater.log</string>
<key>StandardErrorPath</key>
<string>/Library/Logs/rvmm-updater.log</string>
</dict>
</plist>
9 changes: 9 additions & 0 deletions cli/internal/buildinfo/buildinfo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package buildinfo

// These values are replaced by linker flags in release builds.
var (
Version = "dev"
Commit = "none"
BuildDate = "unknown"
TeamID = ""
)
28 changes: 27 additions & 1 deletion cli/internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -48,6 +49,7 @@ const (
actionMonitorDaemonUninstall
actionMonitorDaemonStatus
actionViewLogs
actionUpdateRequest
actionQuit
)

Expand Down Expand Up @@ -81,7 +83,10 @@ type model struct {
windowWidth int
windowHeight int
lastError string
lastMessage string
lastLogLine string
updateStatus updater.Status
updaterReady bool
}

func Run() {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = ""
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions cli/internal/tui/menu_root.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ func rootMenuEntries() []menuEntry {
daemonMenuItem{},
monitorDaemonMenuItem{},
viewLogsMenuItem{},
updateMenuItem{},
quitMenuItem{},
}
}
19 changes: 19 additions & 0 deletions cli/internal/tui/menu_update.go
Original file line number Diff line number Diff line change
@@ -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)
}
25 changes: 25 additions & 0 deletions cli/internal/tui/menu_update_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
42 changes: 42 additions & 0 deletions cli/internal/tui/updater_status.go
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 6 additions & 1 deletion cli/internal/tui/views.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading