Automated, repeatable, credential-free macOS VM images for Firefox CI.
Built with Packer + Tart + Puppet, orchestrated through GitHub Actions, and distributed via an OCI registry. This system produces fully-configured macOS VMs that mirror bare-metal hardware workers β dynamic hostnames, Taskcluster integration, and Puppet-managed configuration β without ever baking a worker secret into the image or exposing one to the build runner.
- Builds golden images from Apple IPSW files using Packer.
- Configures them with Puppet (using a non-secret fake vault at build time).
- Stores them in an OCI registry (anonymous pull, authenticated push).
- Deploys them on Puppet-managed Tart worker hosts.
- Injects real credentials at VM boot β the host, not the image, supplies the worker's Taskcluster vault.
Key features:
- π Credential-free images β no worker secret is ever baked in or present on the build runner.
- π§© Host-mediated secret injection β the MDM-enrolled Tart host fetches the per-pool vault and shares it into each VM at launch.
- π·οΈ Dynamic hostnames β VMs self-configure a unique identity from their MAC address.
- π¦ OCI distribution β images tagged
prod-latest/prod-{sha}, anon-pull / auth-push. - π€ Worker automation β Puppet-managed Tart hosts deploy and keep VMs running.
This repo was the subject of Bug 2049579: an earlier version ran fork-PR code on a non-ephemeral self-hosted macOS runner that also held plaintext Taskcluster worker vaults. The build has since been re-architected so that neither the runner nor the image ever holds a worker secret. The controls below are load-bearing β do not regress them:
- No fork-PR execution on the self-hosted runner. The build triggers on
pushtomainandworkflow_dispatchonly β neverpull_request. A fork PR must not be able to run code on the build host. - Credential-free images. Every build uses the non-secret
mac/tester15/vault-fake.yaml. Real worker credentials are never copied onto the runner or baked into the image. - Host-mediated credential injection. Real credentials reach a VM only at first boot, supplied by the Tart host (see below). The image ships with no usable secret.
- Authenticated registry push. The OCI registry allows anonymous pull
(tester hosts) but requires authentication to push. The build logs in with
a
buildercredential from a GitHub Actions secret before pushing.
If you change the workflow triggers, the vault handling, or the registry auth, you are touching the security boundary β get it reviewed as such.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β GitHub Actions runner (self-hosted) β
β push to main / workflow_dispatch β
β β
β ββββββββββββββββββββ ββββββββββββββββββββββββββ β
β β Build β β Push to OCI registry β β
β β (fake vault, β β β (auth: builder secret) β β
β β credential-free) β β β β
β ββββββββββββββββββββ ββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β OCI Registry (anon pull / auth push) β
β β
β sequoia-tester: prod-latest , prod-{sha} β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β (host pulls image)
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Tart Worker Host (MDM-enrolled) β
β β
β 1. holds a broker client cert (via MDM) β
β 2. fetches the per-pool vault over mTLS β
β 3. shares it into each VM at launch: β
β tart run --dir=vault:<dir>:ro β
β β
β βββββββββββββββββββ βββββββββββββββββββ β
β β credential-free β β credential-free β β
β β VM #1 β β VM #2 β β
β βββββββββββββββββββ βββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β (VM first boot: reads the shared vault,
β runs puppet, worker registers)
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Taskcluster β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The image is credential-free; the host is the trust anchor (its MDM
identity authorizes the vault fetch). See ronin_puppet's profiles::tart
(inject_vault / vault_role) for the host side, and vault-inject.sh for the
guest side.
macos-vms/
βββ .github/workflows/
β βββ build-mac.yaml # CI/CD pipeline (push-to-main / dispatch)
βββ scripts/ # build-host operational tooling
β βββ setup-build-runner.sh # reproducible runner (re)install
β βββ build-runner-maintenance.sh # hourly tart prune + low-disk alert
β βββ com.mozilla.build-runner-maintenance.plist
βββ mac/tester15/ # the tester VM image
βββ builder.sh # 4-phase build orchestrator
βββ create-base.pkr.hcl # Phase 1: base macOS from IPSW
βββ disable-sip.pkr.hcl # Phase 2: disable SIP (guest)
βββ puppet-setup-phase1.pkr.hcl # Phase 3: puppet run 1
βββ puppet-setup-phase2.pkr.hcl # Phase 4: puppet run 2 + first-boot hooks
βββ set_hostname.sh # dynamic hostname (LaunchDaemon)
βββ com.mozilla.sethostname.plist
βββ vault-inject.sh # FIRST-BOOT: read host-shared vault β puppet
βββ com.mozilla.vault-inject.plist
βββ vault-fake.yaml # non-secret build-time vault (safe to commit)
brew tap hashicorp/tap cirruslabs/cli
brew install hashicorp/tap/packer cirruslabs/cli/tart ansibleAlways builds credential-free with the fake vault β no secrets required:
cd mac/tester15
./builder.shThe build runs only on push to main (paths under mac/tester15/** or
the workflow) and on manual workflow_dispatch. There is intentionally no
pull_request trigger (see Security Model). Every run:
- builds credential-free with
vault-fake.yaml; - logs in to the registry with the
buildersecret and pushessequoia-tester:prod-{sha}+sequoia-tester:prod-latest.
builder.sh runs four Packer phases:
Installs macOS (Sequoia) from an IPSW and drives Setup Assistant via automated
VNC keystrokes (creates the admin account, enables SSH + Screen Sharing).
This phase is the slowest and, because it relies on timed blind keystrokes, the
most flake-prone β an SSH-timeout here is usually the Setup Assistant
automation drifting out of sync, not a real failure. Re-run first.
Boots the guest into Recovery and runs csrutil disable. This affects the VM
image only, not any host.
Sets the puppet role gecko_t_osx_1500_m_vms, installs Rosetta 2, Xcode CLT,
and the Puppet agent, temporarily disables the TCC/SafariDriver/pipconf bits
that need a reboot, and runs the first apply β all using the fake vault.
Re-enables the deferred bits, runs the final apply, and installs (but does not
load) the first-boot hooks: the dynamic-hostname LaunchDaemon and the
vault-inject LaunchDaemon that supplies real credentials on the deployed
VM. Any build-time vault is removed so the image ships credential-free.
The image contains no worker credential. On a deployed VM's first boot:
- The Tart host (MDM-enrolled) fetches the per-pool vault from the RelOps
secret broker over mTLS, using a client certificate delivered to the host by
MDM. The host launches the VM with the vault shared in read-only:
tart run --dir=vault:<dir>:ro β¦(seeronin_puppetprofiles::tart). - Inside the VM,
com.mozilla.vault-injectrunsvault-inject.sh, which reads/Volumes/My Shared Files/vault/vault.yaml, writes it to/var/root/vault.yaml, and runsrun-puppet.sh. - Puppet regenerates the worker config with the real
worker.access_token/worker.client_id, and the worker registers with Taskcluster.
The secret exists only inside the ephemeral VM at runtime β never on the build runner, never in the image.
Tart worker hosts are Puppet-managed (roles_profiles::roles::tart_worker /
profiles::tart), which installs Tart, deploys the worker LaunchDaemon(s), and β
with tart.inject_vault: true β the launch wrapper (tart-run-vm.sh) that
fetches the per-pool vault over mTLS using the host's SCEP cert and shares it
into each VM (tart run --dir=vault:β¦). On macOS 15 puppet does not
pull/clone the image (manage_image: false) β tart pull needs a GUI/Local-
Network context β so image lifecycle is handled by the update script below.
Apple licensing caps a host at 2 VMs.
tart.oci_tag in the host's role data selects the image β pin a specific
prod-<sha> for a controlled fleet, or track the moving prod-latest. Rolling
it out runs /usr/local/bin/tart-update-vms.sh on the host, which:
- pulls the selected image,
- rolls one worker slot at a time so the other keeps serving,
- drains each slot first β waits for its worker to finish its current task (via the Taskcluster public API, no creds) before recreating it,
- recreates the VM and restarts its worker unit (daemon- and agent-aware).
Each recreated VM self-configures on first boot: set_hostname β vault-inject
reads the injected vault β puppet regenerates the worker config with the real
credentials β a one-time auto-reboot so generic-worker starts on those creds
β the worker registers and claims tasks. (Validated end-to-end on m4-235:
mac-f4a3ef built β injected β registered β ran a live mochitest-chrome.)
Driving the per-host update across the fleet β batched to respect the MDC1 network, with TC quarantine for a true drain β is the on-network reprovision runner's job, optionally triggered and observed from Hangar (same pattern as the reprovision action; Hangar shows image drift + fires the roll, the runner does the work). The per-host script above is the safe primitive that layer calls.
VMs self-configure a stable, unique hostname on first boot:
set_hostname.shruns at boot via LaunchDaemon.- It derives
mac-{last-3-MAC-octets}from the primary interface. - It updates the Taskcluster worker config so the worker registers under that unique identity.
This avoids hostname/worker-id collisions between VMs cloned from one image.
prod-latestβ most recentmainbuildprod-{sha}β a specificmaincommit
The registry is anonymous-pull, authenticated-push. Pulls (tester hosts)
need no credential; pushes (the build) authenticate as builder using the
REGISTRY_PUSH_PASSWORD GitHub Actions secret.
The build runs on a self-hosted runner (a Mac). Because that host is
long-lived, scripts/ keeps it reproducible and healthy:
setup-build-runner.sh <registration-token>β one command to (re)install the GitHub Actions runner and the maintenance daemon. The token is a runtime argument; nothing secret is committed.build-runner-maintenance.sh+ LaunchDaemon β hourlytart prune(only when no build VM is running) plus a low-disk email alert. The build host's historical failure mode was silently filling its disk.
See scripts/README.md for details.
Phase 1 (create-base) times out waiting for SSH. Usually the Setup Assistant VNC automation drifted; re-run the build. Persistent failures point at host load or a macOS layout change in the boot-command tab-navigation.
A deployed VM won't register with Taskcluster. Check the injection chain:
on the host, confirm inject_vault is on and the vault fetch succeeded; in the
VM, check /var/log/vault-inject.{out,err} and that /var/root/vault.yaml
existed before the puppet run.
VMs won't start on a worker host.
sudo launchctl list | grep mozilla
tail -f /var/log/tartworker-1.outCan't pull from the OCI registry.
curl http://<registry>/v2/ # anon pull is allowed
tart pull --insecure <registry>/sequoia-tester:prod-latest(Pull failures are usually connectivity; push failures are usually auth β pushes
require the builder credential.)
Thanks to APFS copy-on-write, a cloned VM does not immediately claim its full provisioned disk β only writes to the clone consume new space, which also makes cloning near-instant. Per-VM: 4 CPU / 8 GB RAM / 100 GB provisioned disk; max 2 VMs per physical host (Apple license).
- Create a feature branch and test locally with
./builder.sh(credential-free). - Open a PR for review. PRs do not trigger a build on the self-hosted runner by design (Bug 2049579) β validate locally.
- Merge to
mainβ triggers the credential-free prod build β image pushed to the registry β deployed to Tart hosts via Puppet.
Mozilla Public License 2.0
Built with β€οΈ by Mozilla RelOps