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
18 changes: 18 additions & 0 deletions .github/workflows/create-release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
on: workflow_dispatch
name: Create a new release

jobs:
create-release:
runs-on: ubuntu-latest
permissions:
contents: write
if: ${{ (github.event.pusher.name != 'github action') && (github.ref == 'refs/heads/main') }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Semantic Release
uses: cycjimmy/semantic-release-action@v6
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
with:
branch: main
71 changes: 71 additions & 0 deletions .github/workflows/release-cli.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: Release rvmm CLI

on:
push:
release:
types:
- released
workflow_dispatch:

jobs:
build-macos:
name: Build, Sign and Notarize macOS Binary
runs-on: [self-hosted, macOS, ARM64]
permissions:
contents: write
defaults:
run:
working-directory: cli
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: cli/go.mod
check-latest: true

- name: Install dependencies
run: go mod download

- name: Run tests
run: go test ./...

- name: Import code signing certificates
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
p12-password: ${{ secrets.P12_PASSWORD }}

- name: Build and sign the binary
run: make build && ./scripts/sign.sh
env:
SIGNING_CERTIFICATE_NAME: ${{ secrets.SIGNING_CERTIFICATE_NAME }}

- name: Import installer signing certificate
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.INSTALLER_CERTIFICATE_BASE64 }}
p12-password: ${{ secrets.P12_PASSWORD }}
keychain: installer

- name: Package and notarize
if: github.event_name == 'release'
run: |
# Strip leading 'v' from semver tags (v1.2.3 -> 1.2.3) for pkg version
PKG_VERSION="${GITHUB_REF_NAME#v}"
export PKG_VERSION
./scripts/package-notarize.sh
env:
INSTALLER_SIGNING_CERTIFICATE_NAME: ${{ secrets.INSTALLER_SIGNING_CERTIFICATE_NAME }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PWD: ${{ secrets.APPLE_ID_PWD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}

- name: Upload pkg to release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v2
with:
files: cli/rvmm_macOS_arm64.pkg
token: ${{ secrets.GITHUB_TOKEN }}
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
rvmm
cli/bin/
*.pkg
*.yaml
*.log
*.log

# Keep CI workflows tracked despite the broad *.yaml ignore above
!.github/workflows/*.yaml
7 changes: 7 additions & 0 deletions .releaserc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/github"
]
}
52 changes: 40 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,23 @@ A tool for managing macOS virtual machines as GitHub Actions self-hosted runners
- `wget` - File downloads
- `packer` - VM image building

### Install from Release

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`.

### Build from Source

The CLI lives in the `cli/` directory:

```bash
git clone <repository-url>
cd macos-github-action-vm
go build -o rvmm main.go
cd macos-github-action-vm/cli
make build # outputs cli/bin/rvmm
```

## Configuration

Create a `rvmm.yaml` configuration file (see `assets/config.yaml.example` for a template):
Create a `rvmm.yaml` configuration file (see `cli/assets/config.yaml.example` for a template):

```yaml
github:
Expand Down Expand Up @@ -88,14 +94,27 @@ Run without arguments to start the interactive terminal UI:
The TUI provides menus for:

- **Setup**: Install required dependencies
- **Build**: Build VM image from IPSW
- **Build**: Build VM image from an external Packer template
- **Config**: Edit configuration
- **Run**: Start runners interactively
- **Images**: List local VM images
- **Daemon**: Install/manage runner daemon
- **Monitor Daemon**: Install/manage log monitoring daemon
- **View Logs**: Tail log files

### VM Image Templates

VM image templates are not bundled with the CLI — they are provided externally. When you select "Build VM image" in the TUI, you are prompted for the path to a Packer template (`.pkr.hcl`). The CLI runs `packer init` and `packer build` in the template's directory, so any supporting files (setup scripts, etc.) should live next to the template.

An example template is provided in [`guest/runner.pkr.hcl`](guest/runner.pkr.hcl):

```bash
rvmm # Open TUI
# Select "Build VM image" and enter e.g. /path/to/guest/runner.pkr.hcl
```

You can maintain your own templates in a separate repository and point the CLI at them.

### Headless Mode

#### Run Runner
Expand Down Expand Up @@ -213,14 +232,23 @@ In PostHog, you can:

## Architecture

- **internal/config**: Configuration management with Viper
- **internal/runner**: GitHub Actions runner logic and VM management
- **internal/daemon**: LaunchAgent/LaunchDaemon installation and management
- **internal/monitor**: Log file monitoring with tail-follow logic
- **internal/posthog**: PostHog API client for log event capture
- **internal/tui**: Bubble Tea terminal UI
- **assets**: Embedded templates and example configs
- **guest**: VM image building scripts (Packer)
- **cli**: The rvmm CLI (Go module)
- **cli/internal/config**: Configuration management with Viper
- **cli/internal/runner**: GitHub Actions runner logic and VM management
- **cli/internal/daemon**: LaunchAgent/LaunchDaemon installation and management
- **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/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

## Releases

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.

## Daemon Files

Expand Down
27 changes: 17 additions & 10 deletions Makefile → cli/Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
SHELL := /bin/bash

BINARY_NAME := rvmm
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)
Expand All @@ -11,7 +12,7 @@ LDFLAGS := -s -w \
-X github.com/rxtech-lab/rvmm/cmd.Commit=$(COMMIT) \
-X github.com/rxtech-lab/rvmm/cmd.BuildDate=$(BUILD_DATE)

.PHONY: deps build build-all install clean test
.PHONY: deps build build-all install package clean test

deps:
@echo "==> Fetching dependencies..."
Expand All @@ -24,27 +25,33 @@ build: deps
@echo " Version: $(VERSION)"
@echo " Commit: $(COMMIT)"
@echo " Date: $(BUILD_DATE)"
go build -ldflags "$(LDFLAGS)" -o "$(BINARY_NAME)" .
@echo "==> Built: $(BINARY_NAME)"
mkdir -p $(BUILD_DIR)
go build -ldflags "$(LDFLAGS)" -o "$(BUILD_DIR)/$(BINARY_NAME)" .
@echo "==> Built: $(BUILD_DIR)/$(BINARY_NAME)"

build-all: deps
@echo "==> Building for all platforms..."
mkdir -p $(BUILD_DIR)
@echo " Building darwin/arm64..."
GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o "$(BINARY_NAME)-darwin-arm64" .
GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o "$(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64" .
@echo " Building darwin/amd64..."
GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o "$(BINARY_NAME)-darwin-amd64" .
GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o "$(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64" .
@echo "==> Built binaries:"
ls -la "$(BINARY_NAME)"-darwin-*
ls -la "$(BUILD_DIR)/$(BINARY_NAME)"-darwin-*

install: deps
$(MAKE) build
install: build
@echo "==> Installing to /usr/local/bin..."
sudo cp "$(BINARY_NAME)" /usr/local/bin/
sudo cp "$(BUILD_DIR)/$(BINARY_NAME)" /usr/local/bin/
@echo "==> Installed: /usr/local/bin/$(BINARY_NAME)"

package: build
./scripts/sign.sh
./scripts/package-notarize.sh

clean:
@echo "==> Cleaning..."
rm -f "$(BINARY_NAME)" "$(BINARY_NAME)"-darwin-*
rm -rf $(BUILD_DIR)
rm -f $(BINARY_NAME)_macOS_*.pkg
@echo "==> Cleaned"

test:
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
28 changes: 0 additions & 28 deletions internal/setup/setup.go → cli/internal/setup/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,6 @@ func RunWithIO(log *zap.Logger, stdout, stderr io.Writer, stdin io.Reader) error
}
}

// Initialize Packer plugins (installs the tart plugin)
if err := initPacker(log, stdout, stderr); err != nil {
return fmt.Errorf("packer init failed: %w", err)
}

// Validate system
if err := validateSystem(log); err != nil {
log.Warn("System validation warnings", zap.Error(err))
Expand Down Expand Up @@ -194,29 +189,6 @@ func ensureTap(log *zap.Logger, tap string, stdout, stderr io.Writer) error {
return nil
}

func initPacker(log *zap.Logger, stdout, stderr io.Writer) error {
workingDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("get working directory: %w", err)
}

pkrFile := filepath.Join(workingDir, "guest", "runner.pkr.hcl")
if _, err := os.Stat(pkrFile); os.IsNotExist(err) {
log.Info("Packer file not found, skipping packer init", zap.String("path", pkrFile))
return nil
}

log.Info("Initializing Packer plugins")
cmd := exec.Command("packer", "init", pkrFile)
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("packer init %s failed: %w", pkrFile, err)
}
log.Info("Packer plugins initialized")
return nil
}

// CheckDependencies verifies all required tools are available
func CheckDependencies() error {
var missing []string
Expand Down
24 changes: 19 additions & 5 deletions internal/tui/app.go → cli/internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/charmbracelet/bubbles/list"
Expand Down Expand Up @@ -95,7 +96,7 @@ func newModel() model {
menu := newMenuModel(rootMenuEntries())

buildInput := textinput.New()
buildInput.Placeholder = "Press Enter to build runner"
buildInput.Placeholder = "/path/to/runner.pkr.hcl"
buildInput.CharLimit = 512
buildInput.Width = 50

Expand Down Expand Up @@ -296,10 +297,15 @@ func (m model) updateBuildPrompt(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.state = stateMenu
return m, nil
case "enter":
templatePath := strings.TrimSpace(m.buildInput.Value())
if templatePath == "" {
m.lastError = "packer template path is required"
return m, nil
}
m.state = stateMenu
m.busy = true
m.busyLabel = "Build VM image"
return m, tea.Batch(m.runBuildCmd(m.buildInput.Value()), m.spinner.Tick)
return m, tea.Batch(m.runBuildCmd(templatePath), m.spinner.Tick)
}

var cmd tea.Cmd
Expand Down Expand Up @@ -385,10 +391,18 @@ func (m model) runSetupCmd() tea.Cmd {
}
}

func (m model) runBuildCmd(ipsw string) tea.Cmd {
func (m model) runBuildCmd(templatePath string) tea.Cmd {
return func() tea.Msg {
guestDir := "guest"
if err := runCommandSeries(m.logWriter, guestDir, buildCommands(ipsw)...); err != nil {
info, err := os.Stat(templatePath)
if err != nil {
return taskDoneMsg{action: actionBuild, err: fmt.Errorf("packer template not found: %w", err)}
}
if info.IsDir() {
return taskDoneMsg{action: actionBuild, err: fmt.Errorf("packer template %q is a directory, expected a .pkr.hcl file", templatePath)}
}
templateDir := filepath.Dir(templatePath)
templateFile := filepath.Base(templatePath)
if err := runCommandSeries(m.logWriter, templateDir, buildCommands(templateFile)...); err != nil {
return taskDoneMsg{action: actionBuild, err: err}
}
return taskDoneMsg{action: actionBuild, err: nil}
Expand Down
6 changes: 3 additions & 3 deletions internal/tui/commands.go → cli/internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ func streamReader(writer io.Writer, reader io.Reader, wg *sync.WaitGroup) {
}
}

func buildCommands(ipsw string) []*exec.Cmd {
func buildCommands(templateFile string) []*exec.Cmd {
return []*exec.Cmd{
exec.Command("packer", "init", "runner.pkr.hcl"),
exec.Command("packer", "build", "runner.pkr.hcl"),
exec.Command("packer", "init", templateFile),
exec.Command("packer", "build", templateFile),
}
}

Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ func (buildMenuItem) Title() string {
}

func (buildMenuItem) Description() string {
return "Run Packer/Tart build for runner"
return "Run Packer/Tart build from an external template"
}

func (buildMenuItem) OnSelect(m *model) (tea.Model, tea.Cmd) {
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion internal/tui/views.go → cli/internal/tui/views.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func (m model) viewConfig() string {
}

func (m model) viewBuildPrompt() string {
return "Build VM image\n\n" + m.buildInput.View() + "\n\nEnter to start, Esc to cancel"
return "Build VM image\n\nPath to Packer template (.pkr.hcl):\n" + m.buildInput.View() + "\n\nEnter to start, Esc to cancel"
}

func (m model) viewPushPrompt() string {
Expand Down
File renamed without changes.
6 changes: 6 additions & 0 deletions cli/scripts/binaries.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/bin/bash

# Define the list of binaries to process
BINARIES=(
"rvmm"
)
Loading
Loading