Skip to content
/ sand Public

Self-hosted Github Runner on ephemeral macOS VMs on Apple Silicon

License

Notifications You must be signed in to change notification settings

khoi/sand

Repository files navigation

sand

Self-hosted macOS CI Runners powered by Tart - Apple's Virtualization framework.

Requirements

  • macOS 15+ running on Apple Silicon machines.
  • Tart installed and available in PATH
  • sand uses tart. it helps understanding tart before using sand (https://tart.run/quick-start/)

Caveats

macOS DHCP leases last 24 hours by default, causing IP exhaustion if you run more than ~253 VMs per day. To reduce lease time to 10 minutes:

sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.InternetSharing.default.plist bootpd -dict DHCPLeaseTimeSecs -int 600

Install

brew tap khoi/sand
brew install sand

Usage

sand run --config config.yml
sand destroy --config config.yml
sand run --dry-run --config config.yml

Local test suite

To run the local bash e2e tests (no CI):

./Tests/run

These tests spin up real VMs and require tart, ssh, and sshpass on your machine. See Tests/README.md for environment overrides (image, timeout, SSH creds, etc).

Start up on boot

To make sand run on boot, u can leverage launchctl as an option

  1. Create a LaunchAgent plist at ~/Library/LaunchAgents/io.khoi.sand.plist:
<?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>io.khoi.sand</string>
  <key>ProgramArguments</key>
  <array>
    <string>/opt/homebrew/bin/sand</string>
    <string>run</string>
    <string>--config</string>
    <string>/Users/yourname/sand.yml</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key>
    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
  </dict>
  <key>WorkingDirectory</key>
  <string>/Users/yourname</string>
  <key>KeepAlive</key>
  <true/>
  <key>RunAtLoad</key>
  <true/>
  <key>StandardOutPath</key>
  <string>/Users/yourname/Library/Logs/sand.launchd.out.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/yourname/Library/Logs/sand.launchd.err.log</string>
</dict>
</plist>
  1. Load it (modern launchctl):
launchctl enable gui/$(id -u)/com.khoi.sand
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.khoi.sand.plist
launchctl kickstart -k gui/$(id -u)/com.khoi.sand
launchctl print gui/$(id -u)/com.khoi.sand

Logs

sand logs to macOS default logging system using os_log. To see the log

log show --predicate "subsystem == \"sand\"" --last 1h --info --debug
log stream --predicate 'subsystem == "sand"' --debug --info --style compact --color always

You can also write logs to a file:

sand run --config config.yml --log-file /tmp/sand.log

Configuration

Create a config.yml and run the CLI with --config.

GitHub Actions setup

  1. Create a GitHub App and grant Self-hosted runners permission set to Read & Write at the organization level. https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app
  2. Install the app on the organization or the specific repository you want to run against.
  3. Download the private key and set appId, organization, repository (optional), and privateKeyPath in your config.

GitHub Actions runner provisioner

runners:
  - name: runner-1
    vm:
      source:
        type: oci
        image: ghcr.io/cirruslabs/macos-runner:tahoe
      cache:
        host: ~/.cache/sand/actions-runner
        name: sand-cache
    provisioner:
      type: github
      config:
        appId: 123456
        organization: my-org
        repository: my-repo
        privateKeyPath: ~/my-app.private-key.pem
        runnerName: runner-1
    healthCheck:
      command: "pgrep -fl /Users/admin/actions-runner/run.sh"
      interval: 30
      delay: 60

To enable runner caching, set vm.cache. The GitHub provisioner reuses the Actions runner archive from that mount between restarts; on a cache miss it downloads the tarball and stores it in the mounted directory. On macOS guests, the cache directory resolves to /Volumes/My Shared Files/<name> (with name acting as the share name).

Sand resolves the latest Actions runner version at runtime via the GitHub API and uses that version for both the download URL and the cache filename. If version resolution fails and a cache directory is available, sand falls back to the newest cached runner tarball it can find.

Common pitfalls:

  • vm.cache.host must be a directory (missing paths are created; file paths are rejected).
  • vm.cache is ignored unless the provisioner type is github.
  • Linux runner cache requires virtiofs support in the guest. The default Ubuntu images from cirruslabs do not provide virtiofs, so cache mounts on Ubuntu are not supported.

Custom provisioner script

runners:
  - name: runner-1
    vm:
      source:
        type: oci
        image: "ghcr.io/cirruslabs/ubuntu:latest"
      hardware:
        ramGb: 4
      ssh:
        user: admin
        password: admin
        port: 22
    provisioner:
      type: script
      config:
        run: |
          echo "Hello World" && sleep 10
    healthCheck:
      command: "true"

If healthCheck is omitted, sand runs echo healthcheck every 30s after a 60s delay.

Full configurations keys can be found at fixtures/sample_full_config.yml or fixtures/sample_on_prod.yml

Acknowledgements