tart-executor is an app to launch ephemeral virtual machines on demand from Github action jobs using Tart.
tart-router is an optional app to handle routing Github actions to multiple macOS computers running tart-executor.
First install Tart if not already installed on all macOS computers running VMs:
brew install cirruslabs/cli/tartInstall tart-executor on all macOS computers that will run VMs:
brew tap bludesign/tart
brew install bludesign/tart/tart-executorCreate config file in home directory named tart-executor.yaml
# Host name for metrics and logs (requried)
hostname: server
# Total CPU capacity used for aggregate VM scheduling (required)
cpuLimit: 10
# Total memory capacity used for aggregate VM scheduling, in binary MB (required)
totalMemory: 16384
# Optional logging endpoint URL to send NDJSON via HTTP POST (optional)
loggingEndpoint: http://10.0.1.50:8080/logs
# Optional bearer token protecting the management API at /api/v1/* (optional)
# When set, /api/v1/* (except /health, /openapi.yaml, /docs) requires the header
# Authorization: Bearer <token>. When omitted the management API is open.
apiToken: change-me-secret
tart:
# Custom home folder to use for Tart if you use an external drive you will need to allow access when the dialog shows up (optional)
homeFolder: /Volumes/Files/tart
# Run VMs in headless mode (optional)
isHeadless: true
# Array of OCI registries that use insecure pulls (optional)
insecureDomains:
- 10.0.1.100
# Max VMs to run at a time. This will not work higher then 2 because of Apple's EULA and Virtualization.Framework limit (required)
numberOfVirtualMachines: 2
# SSH username and password for Tart's images this is admin for username and password
ssh:
username: admin
password: admin
# Default CPU count to use per VM recommend setting this to machine CPU count/2. If not set will use jobs label if set or images default (optional)
defaultCpu: 5
# Default memory to use per VM. If not set will use jobs label if set or images default (optional)
defaultMemory: 6144
github:
# organization or repo (required)
runnerScope: organization
# Required for organization runner scope leave out for repo scope
organizationName: bludesign
# Required for repo runner scope leave out for organization scope
ownerName: bludesign
# Required for repo runner scope leave out for organization scope
repositoryName: tart
# Github app ID (required)
appId: 1234567
# Path to private key for github app (required)
privateKey: /Users/server/private-key.pem
# Scanning GitHub for jobs that are queued but have no runner (optional, on by default).
# Powers GET /api/v1/github/queued-jobs. Requires the GitHub App to have Actions: read.
scan:
# Set to false to disable scanning entirely; the endpoint then answers 503 (optional)
enabled: true
# How long a scan result is served before refreshing in the background (optional)
cacheSeconds: 30
# How long the repository list is reused before being re-fetched (optional)
repositoryCacheSeconds: 600
# Most repositories to visit in one scan, most-recently-pushed first (optional)
maxRepositories: 200
# Requests in flight at once. Raising this risks GitHub's secondary rate limits (optional)
maxConcurrentRequests: 4
# Workflow runs fetched per repository, newest first (optional)
runsPerRepository: 50
# Wall-clock budget for one scan in seconds (optional)
timeoutSeconds: 20
# Abort the scan when fewer than this many GitHub API requests remain (optional)
rateLimitFloor: 200
# Only scan these owner/repo names. Leave out to scan every installed repository (optional)
includeRepositories:
- bludesign/tart
# Never scan these owner/repo names (optional)
excludeRepositories:
- bludesign/archive
runner:
# Runner label to look for in Github action job labels to create VM (required)
labels: tartelet
webhook:
# Port to listen for webhook calls (required)
port: 3250
# URL for tart-router no trailing slash only needed if using tart-router
routerUrl: http://10.0.4.1:3251Run by typing tart-executor into terminal.
Optionally if running VMs on multiple macOS computers install tart-router on one computer. Make sure you are aware of the licensing limits for Tart. This can be run on a computer that is also running tart-executor as long as the webhook port is different or a seperate computer:
brew tap bludesign/tart
brew install bludesign/tart/tart-routerCreate config file in home directory named tart-router.yaml
# Host name for metrics and logs (requried)
hostname: macbook-router
# Runner label to look for in Github action job labels should match the tart-executors (required)
labels: tartelet
# Port to listen on for Github webhook calls (required)
port: 3251
# Optional logging endpoint URL to send NDJSON via HTTP POST (optional)
loggingEndpoint: http://10.0.1.50:8080/logs
# Optional bearer token protecting the management API at /api/v1/* (optional)
# When set, /api/v1/* (except /health, /openapi.yaml, /docs) requires the header
# Authorization: Bearer <token>. When omitted the management API is open.
apiToken: change-me-secret
# Array of tart-executor hosts to forward Github action jobs to (required)
hosts:
# Hostname set in config for tart-executor (required)
- hostname: server-1
# URL for tart-executor no trailing slash (required)
url: http://127.0.0.1:3250
# Priority to send jobs to host (required)
priority: 0
# Per-job CPU maximum. Aggregate capacity comes from the executor's cpuLimit (optional)
cpuLimit: 5
# Per-job memory maximum. Aggregate capacity comes from the executor's totalMemory (optional)
memoryLimit: 8192
- hostname: server-2
url: http://10.0.4.2:3250
priority: 1
- hostname: server-3
url: http://10.0.4.3:3250
priority: 2Run by typing tart-router into terminal.
The router treats each host's cpuLimit and memoryLimit as per-job maximums. Before
dispatching, it also checks the executor's live aggregate CPU and memory usage and its virtual
machine count. A job without an explicit cpu: or memory: label uses the executor's configured
default for scheduling when available. Unknown resource values retain the previous behavior and
do not block dispatch. For example, a 10-CPU / 28672-MB executor can use router per-job limits of
cpuLimit: 10 and memoryLimit: 28672: a 7-CPU / 24576-MB job can share the host with a
2-CPU / 4096-MB job, while another 7-CPU / 24576-MB job waits for resources.
tart-executor will automatically register the virtual machine as a runner on a GitHub organization or on a specified repository. In order to do this it must be configured with the relevant credentials. Follow the steps below to add the credentials to tart-executor. You can reuse the credentials from the GitHub App on all host machines running tart-executor.
- Create a GitHub App on your organization or account. This can be done by under "Developer settings" in your organization's or your personal settings or by following this link when creating the GitHub app on a personal account or the following link when creating the GitHub app on an organization: https://github.com/organizations/{YOUR_ORGANIZATION_NAME}/settings/apps. Remember to change the link to include the name of your organization if needed.
- When creating the GitHub App, the required permissions depend on whether you are creating the app for an organization or a personal account. 3. 3. Organizations should enable the Organization:
ActionsandSelf-hostedrunners (Read and write) permission and personal accounts should enable the Repository:Actions,AdministrationandMetadata(Read and write) permissions. - Under subscribe to events check
Workflow job. - Set the
Webhook URLto the URL that is reachable by Github that goes to the webhook server ontart-executorortart-routerif using the router. - After creating the app install the GitHub app on your organization.
- Select "Generate a private key".
tart-executorwill use this to send authorized requests to the API. The generated key should automatically be downloaded. - Transfer the generated private key to your
tart-executormachines and set the path in the config.
GET /api/v1/github/queued-jobs additionally needs the repository permission Actions (Read), on top of the permissions above. Metadata read is already implicit and covers the repository listing.
If you are adding this to an App that already exists, GitHub treats it as a permission update: request it in the App's settings, then an organization owner has to approve it on the installation. Until they do, every scan is denied with Resource not accessible by integration, and the endpoint reports that in warnings (it returns 200 with an empty jobs array, so read the warnings rather than concluding the queue is empty).
Occasionally GitHub reports a job as queued but no VM ever starts for it β a webhook delivery was missed, or the executor lost the job. The management API can find and fix these without any GitHub credentials of your own:
# What is GitHub waiting on? `matching=all` includes jobs this executor cannot run, each
# annotated with matchesRunnerLabels and why.
curl -s -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:3250/api/v1/github/queued-jobs?matching=all' | jq# Start a VM for one. Pass the job's labels exactly as the scan reported them.
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"id":123456,"labels":["tartelet","ghcr.io/cirruslabs/macos-sequoia-xcode:latest"]}' \
http://127.0.0.1:3250/api/v1/jobs/dispatch | jqSend it to the router instead (http://127.0.0.1:3251/api/v1/jobs/dispatch) to have it placed on whichever executor has capacity, optionally pinning one with "host": "server-1".
Two things to know about the labels: they are passed to the runner's --labels verbatim, so reordering or dropping any of them (including cpu:/memory:) registers a runner GitHub is not waiting on β the VM boots, idles, and the job stays queued. And force bypasses the already-tracked and capacity guards (deliberately over-committing past numberOfVirtualMachines), but never the guard against a VM that already exists for that job id; cancel the job first if you really want to replace its machine.
You can use a brew service to run the executor and router in the background and on startup:
brew services start tart-executor
brew services start tart-router
The first time you run tart-executor on a host machine, macOS shows a popup asking you to allow local network access (and, when a job attaches a disk, removable-volume access). Grant it once: you will probably need to cancel the in-flight job and check GitHub settings to make sure there are no leftover runners, then restart tart-executor and rerun the job. On startup tart-executor automatically removes leftover temporary VMs (named tart-executor-*) from a previous run.
Release binaries are signed with a stable Developer ID (com.BluDesign.TartExecutor / com.BluDesign.TartRouter, team B88BRQ88KH) and notarized by Apple, so this grant is tied to a code-signing identity that stays constant across releases. You only need to allow local network / removable storage access once β the permission now persists through brew upgrade instead of being requested again on every update.
Both tart-executor and tart-router expose a JSON management/debugging API under /api/v1 on the same port they already listen on (the executor's webhook.port, e.g. 3250, and the router's port, e.g. 3251). Use it to inspect and control a running instance: list and stop jobs, manage Tart VMs and images, and view effective settings, status, and health.
Set apiToken in the config to require a bearer token. When set, every /api/v1/* endpoint except /health, /openapi.yaml, and /docs requires the header Authorization: Bearer <token>. When apiToken is omitted the API is open, matching the existing endpoints which rely on private-network placement.
Each service serves its own OpenAPI 3.1 spec and a browsable Redoc page:
- Spec:
GET /api/v1/openapi.yaml - Docs: open
http://<host>:<port>/api/v1/docsin a browser
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/health |
Liveness and version |
| GET | /api/v1/status |
Job counts and capacity |
| GET | /api/v1/settings |
Effective settings (secrets redacted) |
| GET | /api/v1/jobs |
List jobs (optional state query: pending, in_progress, active) |
| GET | /api/v1/jobs/{id} |
Job detail |
| POST | /api/v1/jobs/{id}/cancel |
Cancel one job by id |
| POST | /api/v1/jobs/cancel |
Cancel by labels (body {"labels":["..."]}) |
| POST | /api/v1/jobs/cancel-all |
Cancel all active jobs |
| POST | /api/v1/jobs/dispatch |
Start a VM for a job (body {"id":123,"labels":["..."],"force":false}) |
| GET | /api/v1/github/queued-jobs |
GitHub Actions jobs waiting for a runner (?matching=all, ?refresh=true) |
| GET | /api/v1/vms |
List local Tart VMs / images |
| GET | /api/v1/vms/{name} |
VM detail including IP |
| DELETE | /api/v1/vms/{name} |
Delete a VM |
| POST | /api/v1/images/pull |
Pull an image (body {"name":"...","isInsecure":false}) |
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/health |
Liveness and version |
| GET | /api/v1/status |
Router queue and capacity |
| GET | /api/v1/settings |
Effective settings |
| GET | /api/v1/jobs |
List tracked jobs |
| GET | /api/v1/jobs/{id} |
Job detail |
| POST | /api/v1/jobs/{id}/cancel |
Cancel one job (and on its host) |
| POST | /api/v1/jobs/cancel |
Cancel by labels across all hosts |
| POST | /api/v1/jobs/dispatch |
Queue and place a job (body {"id":123,"labels":["..."],"host":null,"force":false}) |
| GET | /api/v1/hosts |
List executors and their last-polled status |
| GET | /api/v1/hosts/{hostname} |
Executor detail |
| POST | /api/v1/hosts/refresh |
Force an immediate status re-poll |
Example:
curl -H "Authorization: Bearer <token>" http://127.0.0.1:3250/api/v1/jobs
curl -X POST -H "Authorization: Bearer <token>" http://127.0.0.1:3250/api/v1/jobs/42/cancelFor jobs that you want tart-executor to handle set the labels on a job as the label you have set in the config for example tartelet and then the image that you would like to use for example ghcr.io/cirruslabs/macos-sequoia-xcode:latest so in the action config you would have: runs-on: [tartelet, "ghcr.io/cirruslabs/macos-sequoia-xcode:latest"] when a new job runs tart-executor will get a callback and check if the labels in the job. If the labels contains the label set in config it handles creating a VM for that job. The lifecycle of a GitHub Actions runner managed by tart-executor is as follows:
tart-executoruses Tart to pull a virtual machine which will update the image if needed.tart-executoruses Tart to clone a virtual machin to a temporary image.- The virtual machine is booted.
- After the machine is booted, a setup script is being run. The script downloads the newest version of GitHub's runner application and registers the runner on the GitHub organization.
- The runner listens for a job and executes it.
- After executing the job, the runner automatically removes itself from the GitHub organization.
- The virtual machine is shutdown.
tart-executoruses Tart to delete the temporary virtual machine.
You can also set memory and CPU labels on jobs to override the number of CPUs or memory the VM will have that is created for that job. If you don't set these tart-executor will use the default set in the config or if no default is set the amount set on the VM image when it was created. For example:
runs-on: [tartelet, "ghcr.io/cirruslabs/macos-sequoia-xcode:latest", "memory:24576", "cpu:6"]
From the time a job is started to a VM being booted and ready to start that job is around 25 seconds. The performace of the Tart VM depends on the CPUs and memory set for that VM.
Pull requests with bugfixes and new features are much appreciated. We are happy to review PRs and merge them once they are ready.
We use SwiftLint to ensure uniformity in the code. Install SwiftLint using Homebrew by running the following command in your terminal.
brew install swiftlint