Skip to content
Closed
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
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true

[*.py]
indent_size = 4
indent_style = space

[{*.hcl,*.pkl,*.yaml,*.yml,mise.toml}]
indent_size = 2
indent_style = space
47 changes: 18 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# hisaac-ci

Ansible-first automation for provisioning macOS CI agents, with Tart VM support for local iteration.
Ansible-first automation for provisioning macOS CI agents using Tart for VM management.

## Overview

This repository provisions macOS hosts (VMs or physical machines) using Ansible roles and playbooks.
This repository provisions macOS Tart VMs using Ansible roles and playbooks.

The current active stack is:

- Root-level Ansible project (`ansible.cfg`, `inventory/`, `playbooks/`, `roles/`)
- Dynamic Tart inventory (`inventory/tart_inventory.py`) for VM IP discovery at runtime
- Dynamic Tart inventory (`inventory/macos_inventory.py`) for VM IP discovery at runtime
- Role-scoped reference scripts in `scripts/` used as implementation references and helpers
- Toolchain and workflow orchestration via `mise`

Expand All @@ -20,13 +20,11 @@ The current active stack is:
├── ansible.cfg
├── requirements.yml
├── inventory/
│ ├── tart_inventory.py
│ ├── macos_inventory.py
│ └── group_vars/
│ ├── macos_agents.yml
│ ├── macos_15_agents.yml
│ ├── macos_26_agents.yml
│ ├── tart_agents.yml
│ └── orka_agents.yml
│ └── macos_26_agents.yml
├── playbooks/
│ └── provision.yml
├── roles/
Expand All @@ -36,7 +34,6 @@ The current active stack is:
│ ├── ssh_config/
│ ├── system_config/
│ ├── auth_config/
│ ├── homebrew/
│ └── xcode/
├── scripts/
│ ├── xcode/
Expand Down Expand Up @@ -81,18 +78,15 @@ Run commands from the repository root.

## Inventory and Variables

`ansible.cfg` points to `inventory/tart_inventory.py` as the default inventory.
Pass `-i inventory/macos_inventory.py` and set `VM_NAME` to the Tart VM to provision.

- `inventory/tart_inventory.py` discovers VM IPs via `tart ip <vm_name>`
- Hosts are grouped into:
- `macos_agents` (parent)
- `tart_agents` (runtime Tart VM group)
- version-specific groups like `macos_15_agents` and `macos_26_agents`
- `inventory/macos_inventory.py` discovers the VM IP via `tart ip <vm_name>`
- The VM belongs directly to `macos_agents`
- The playbook loads version-specific configuration after detecting the guest macOS version

Primary variable files:

- `inventory/group_vars/macos_agents.yml`: shared settings (admin credentials, Homebrew base packages, Dock config)
- `inventory/group_vars/tart_agents.yml`: Tart-specific packages (`tart-guest-agent`)
- `inventory/group_vars/macos_agents.yml`: agent settings (admin credentials, Homebrew packages including `tart-guest-agent`, the `openai/tools` tap, and Dock config)
- `inventory/group_vars/macos_15_agents.yml` and `inventory/group_vars/macos_26_agents.yml`: Xcode/runtime/simulator presets

## Xcode Cache Inputs
Expand All @@ -104,15 +98,15 @@ The `xcode` role installs all `*.xip` files found in the admin user's Downloads
Run the full playbook:

```bash
ansible-playbook playbooks/provision.yml
VM_NAME=macos:26 ansible-playbook playbooks/provision.yml -i inventory/macos_inventory.py
```

Or run common role slices with tags:

```bash
ansible-playbook playbooks/provision.yml --tags xcode
ansible-playbook playbooks/provision.yml --tags homebrew
ansible-playbook playbooks/provision.yml --tags system_config
VM_NAME=macos:26 ansible-playbook playbooks/provision.yml -i inventory/macos_inventory.py --tags xcode
VM_NAME=macos:26 ansible-playbook playbooks/provision.yml -i inventory/macos_inventory.py --tags homebrew
VM_NAME=macos:26 ansible-playbook playbooks/provision.yml -i inventory/macos_inventory.py --tags system_config
```

Playbook role order in `playbooks/provision.yml` is intentional:
Expand All @@ -123,7 +117,7 @@ Playbook role order in `playbooks/provision.yml` is intentional:
4. `ssh_config`
5. `system_config`
6. `auth_config`
7. `homebrew`
7. `geerlingguy.mac.homebrew`
8. `geerlingguy.mac.dock`
9. `xcode`
10. Spotlight re-index task
Expand All @@ -133,15 +127,10 @@ Playbook role order in `playbooks/provision.yml` is intentional:

`mise` includes helper tasks for local Tart loops:

- `mise run run-tart-vms`: starts configured VMs and waits for SSH readiness
- `mise run provision`: verifies Xcode/simruntime cache inputs, starts VMs, then runs `ansible-playbook playbooks/provision.yml`
- `mise run shutdown-tart-vms`: shuts down hosts in `tart_agents`
- `mise run refresh-tart-vms`: recreates local Tart VM clones from frozen images

Adjust VM names in:
- `mise run run-tart-vm macos:26`: starts the named Tart VM and waits for SSH readiness
- `mise run provision macos:26`: starts the named Tart VM, then runs the provisioning playbook

- `inventory/tart_inventory.py` (`VMS` list)
- `mise.toml` (`tasks.run-tart-vms` and `tasks.refresh-tart-vms`)
Replace `macos:26` with your local Tart VM name.

## Linting and Formatting

Expand Down
7 changes: 7 additions & 0 deletions docs/github-actions-runners-macos-vm-bootstrapping.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# macOS VM Bootstrapping: How GitHub Actions Runner Images Work

> **Provenance:** Local research summary of the macOS image-building scripts in
> [`actions/runner-images`](https://github.com/actions/runner-images), not an upstream document or a provisioning guide for this repository.
>
> **Last content update:** May 22, 2026, in local commit
> [`a19b31b9c25a`](https://github.com/hisaac/ci/commit/a19b31b9c25adf7c15685c4bdedef8b4a79339ea).
> The original upstream revision was not recorded.

This document explains how macOS virtual machines are bootstrapped for GitHub Actions runners. The VMs are not truly headless — they boot into a full GUI session — but are configured so that no human interaction is ever required. This enables unattended UI automation, Xcode testing, and job provisioning.

---
Expand Down
4 changes: 4 additions & 0 deletions inventory/group_vars/macos_agents.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ macos_update_enabled: true

homebrew_upgrade_all_packages: true

homebrew_taps:
- openai/tools

homebrew_installed_packages:
- bash
- curl
Expand All @@ -23,6 +26,7 @@ homebrew_installed_packages:
- git-lfs
- mise
- python
- tart-guest-agent
- unxip
- xcodes

Expand Down
7 changes: 0 additions & 7 deletions inventory/group_vars/orka_agents.yml

This file was deleted.

8 changes: 0 additions & 8 deletions inventory/group_vars/tart_agents.yml

This file was deleted.

45 changes: 8 additions & 37 deletions inventory/macos_inventory.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
#!/usr/bin/env python3
"""
Dynamic inventory script for macOS VMs (Tart or Orka).
Dynamic inventory script for Tart VMs.

Set VM_PROVIDER to 'tart' or 'orka', and VM_NAME to the VM name.
Set VM_NAME to the VM name.

Usage (Ansible calls these automatically):
VM_PROVIDER=tart VM_NAME=macos:26 ./macos_inventory.py --list
VM_PROVIDER=orka VM_NAME=orka-agent-thmpm ./macos_inventory.py --list
VM_PROVIDER=tart VM_NAME=macos:26 ./macos_inventory.py --host <vm_name>
VM_NAME=macos:26 ./macos_inventory.py --list
./macos_inventory.py --host macos:26
"""

import json
Expand All @@ -29,54 +28,26 @@ def get_tart_hostvars(vm_name: str) -> dict | None:
return {"ansible_host": ip} if ip else None


def get_orka_hostvars(vm_name: str) -> dict | None:
result = subprocess.run(
["orka3", "vm", "list", "-o", "json"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
print(f"Error: orka3 vm list failed: {result.stderr}", file=sys.stderr)
return None
for vm in json.loads(result.stdout):
if vm.get("name") == vm_name and vm.get("status") == "Running":
return {"ansible_host": vm["ip"], "ansible_port": vm["ssh"]}
return None


def list_inventory() -> dict:
provider = os.environ.get("VM_PROVIDER", "").strip().lower()
vm_name = os.environ.get("VM_NAME", "").strip()

if not provider:
print("Error: VM_PROVIDER environment variable is not set (tart or orka)", file=sys.stderr)
sys.exit(1)
if provider not in ("tart", "orka"):
print(f"Error: VM_PROVIDER must be 'tart' or 'orka', got '{provider}'", file=sys.stderr)
sys.exit(1)
if not vm_name:
print("Error: VM_NAME environment variable is not set", file=sys.stderr)
sys.exit(1)

get_hostvars = get_tart_hostvars if provider == "tart" else get_orka_hostvars
hostvars = get_hostvars(vm_name)
hostvars = get_tart_hostvars(vm_name)
if not hostvars:
print(f"Error: could not find running VM '{vm_name}'", file=sys.stderr)
sys.exit(1)

provider_group = f"{provider}_agents"
return {
"macos_agents": {"children": [provider_group]},
provider_group: {"hosts": [vm_name]},
"macos_agents": {"hosts": [vm_name]},
"_meta": {"hostvars": {vm_name: hostvars}},
}


def host_vars(vm_name: str) -> dict:
provider = os.environ.get("VM_PROVIDER", "").strip().lower()
get_hostvars = get_tart_hostvars if provider == "tart" else get_orka_hostvars
return get_hostvars(vm_name) or {}
return get_tart_hostvars(vm_name) or {}


if __name__ == "__main__":
Expand All @@ -87,7 +58,7 @@ def host_vars(vm_name: str) -> dict:
print(json.dumps(host_vars(sys.argv[idx + 1])))
else:
print(
"Usage: VM_PROVIDER=<tart|orka> VM_NAME=<name> macos_inventory.py [--list | --host <name>]",
"Usage: VM_NAME=<name> macos_inventory.py [--list | --host <name>]",
file=sys.stderr,
)
sys.exit(1)
19 changes: 2 additions & 17 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,25 +65,10 @@ until nc -z "$ip" 22 &>/dev/null; do sleep 2; done
echo "$vm is ready"
'''

[tasks.refresh-orka-vm]
usage = '''
arg "<vm>" help="Name of the Orka VM to delete and recreate"
arg "<vm_config>" help="Name of the VM Config to recreate the VM with"
'''
run = [
"orka3 vm delete {{ usage.vm }} || true",
"orka3 vm deploy {{ usage.vm }} --config {{ usage.vm_config }}",
]

[tasks.provision-orka]
usage = 'arg "<vm>" help="Name of the Orka VM to provision"'
env = { VM_PROVIDER = "orka" }
run = 'VM_NAME="{{ usage.vm }}" ansible-playbook playbooks/provision.yml -i inventory/macos_inventory.py'

[tasks.provision-tart]
[tasks.provision]
usage = 'arg "<vm>" help="Name of the Tart VM to provision"'
depends = ["run-tart-vm {{ usage.vm }}"]
env = { VM_PROVIDER = "tart" }

run = 'VM_NAME="{{ usage.vm }}" ansible-playbook playbooks/provision.yml -i inventory/macos_inventory.py'

[tasks.upd]
Expand Down
2 changes: 1 addition & 1 deletion playbooks/provision.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@
hosts: macos_agents
tags: [homebrew]
roles:
- role: homebrew
- role: geerlingguy.mac.homebrew

- name: Configure git
hosts: macos_agents
Expand Down
9 changes: 5 additions & 4 deletions roles/auth_config/files/kcpassword.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,27 @@
def encode_data(passwd):
# The magic 11 bytes - these are just repeated
# 0x7D 0x89 0x52 0x23 0xD2 0xBC 0xDD 0xEA 0xA3 0xB9 0x1F
key = [125,137,82,35,210,188,221,234,163,185,31]
key = [125, 137, 82, 35, 210, 188, 221, 234, 163, 185, 31]
key_len = len(key)

passwd = [ord(x) for x in list(passwd)]
# pad passwd length out to an even multiple of key length
r = len(passwd) % key_len
if len(passwd) == 11:
passwd += [0]
elif (r > 0):
elif r > 0:
passwd = passwd + [0] * (key_len - r)

for j in range(len(passwd)):
passwd[j] = passwd[j] ^ key[j % key_len]

return bytearray(passwd)


if __name__ == "__main__":
runner_pwd_encoded = encode_data(sys.argv[1])
fd = os.open('/etc/kcpassword', os.O_WRONLY | os.O_CREAT, 0o600)
file = os.fdopen(fd, 'wb')
fd = os.open("/etc/kcpassword", os.O_WRONLY | os.O_CREAT, 0o600)
file = os.fdopen(fd, "wb")
file.truncate(0)
file.write(runner_pwd_encoded)
file.close()
Expand Down
3 changes: 0 additions & 3 deletions roles/homebrew/defaults/main.yml

This file was deleted.

9 changes: 0 additions & 9 deletions roles/homebrew/tasks/main.yml

This file was deleted.

2 changes: 0 additions & 2 deletions roles/system_config/defaults/main.yml

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
# Inspired by:
# https://github.com/actions/runner-images/blob/main/images/macos/scripts/build/configure-hostname.sh

prefix="{{ system_config_hostname_prefix | default('Mac') }}"
name="${prefix}-$(python3 -c 'from time import time; print(int(round(time() * 1000)))')"
name="tart-$(/usr/bin/uuidgen)"
scutil --set HostName "${name}.local"
scutil --set LocalHostName "${name}"
scutil --set ComputerName "${name}.local"
6 changes: 3 additions & 3 deletions roles/system_config/tasks/hostname.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Install a LaunchDaemon that generates a unique hostname on each boot.
# Since many VMs are cloned from the same base image and run simultaneously,
# they'd all share the same hostname causing Bonjour/mDNS conflicts.
# A millisecond-precision timestamp ensures uniqueness without external coordination.
# A random UUID avoids hostname collisions without external coordination.
#
# Inspired by:
# https://github.com/actions/runner-images/blob/main/images/macos/scripts/build/configure-hostname.sh
Expand All @@ -15,8 +15,8 @@
become: true

- name: Install dynamic hostname script
ansible.builtin.template:
src: hostname/set-dynamic-hostname.sh.j2
ansible.builtin.copy:
src: hostname/set-dynamic-hostname.sh
dest: /usr/local/bin/set-dynamic-hostname.sh
owner: root
mode: "0755"
Expand Down