Testing Workflows
This guide collects the platform-specific testing workflows that used to live in the main README.md.
Use the cross-platform matrix in the root README for a quick support overview, then use the sections below for concrete commands.
Unified VM Workflow
Section titled “Unified VM Workflow”Most VM-backed test flows follow the same lifecycle from the repository root:
sailwright build <osname> [--type <type>] [--arch <arch>]sailwright create <osname> [--type <type>] [--arch <arch>]sailwright start <osname> [--type <type>] [--arch <arch>]sailwright provision <osname> [--type <type>] [--arch <arch>] --checksailwright provision <osname> [--type <type>] [--arch <arch>]sailwright stop <osname> [--type <type>] [--arch <arch>]sailwright destroy <osname> [--type <type>] [--arch <arch>]buildcreates or refreshes the reusable VM artifact.createcreates the managed VM target from that artifact.startstarts an existing created VM when it is stopped.provisionruns the Ansible workflow against the running target.stopshuts the VM down without deleting it.destroyremoves the managed VM target.
Depending on the backend, the initial boot may happen during create or require a small host-specific step. After a VM has been created, use start whenever you want to boot it again.
Use the list subcommands to see what your current host supports:
sailwright build listsailwright create listsailwright start listsailwright provision listsailwright stop listsailwright destroy listUse --help on the root command or any subcommand to inspect supported flags and usage details:
sailwright --helpsailwright create --helpsailwright start --helpsailwright provision --helpOCI Build Artifact Registry Workflow
Section titled “OCI Build Artifact Registry Workflow”Final build artifacts can be pushed to, and pulled from, an OCI registry. The commands use the same target vocabulary as the VM workflow and a Docker-like reference argument:
sailwright push listsailwright pull list
sailwright push localhost:5000/sailwright/ubuntu-server-amd64:qemu \ --plain-http \ --os ubuntu \ --type server \ --arch amd64 \ --host-os linux
sailwright pull localhost:5000/sailwright/ubuntu-server-amd64:qemu \ --plain-http \ --os ubuntu \ --type server \ --arch amd64 \ --host-os linuxThe OCI client reads Docker credentials by default, so docker login works for
authenticated registries. Use --username, --password-stdin, or
--access-token when you want command-specific credentials.
The Linux GitHub Actions build workflow publishes completed Ubuntu artifacts to
GitHub Container Registry on pushes to main and on manual workflow_dispatch
runs started from main. Artifacts are stored under
ghcr.io/<owner>/ubuntu-24 with tags using the
<type>-<arch>-<hostos>-build schema, for example:
ghcr.io/csautter/ubuntu-24:server-amd64-linux-buildghcr.io/csautter/ubuntu-24:desktop-arm64-linux-buildWindows VM build artifacts are intentionally not published.
For HTTPS registries with an internal or self-signed certificate, prefer adding
the registry CA with --ca-file /path/to/ca.pem. For short-lived testing
against a registry you already trust, --insecure-skip-tls-verify disables TLS
certificate verification.
For a local zot registry, use --plain-http. To run the opt-in integration test
against that registry:
DEV_ALCHEMY_OCI_INTEGRATION_REF=localhost:5000/sailwright/integration:oci \DEV_ALCHEMY_OCI_INTEGRATION_PLAIN_HTTP=true \go test ./pkg/oci -run TestOCIRegistryPushPullIntegration -count=1The pushed artifact is an ordinary OCI artifact, so it can also be inspected with ORAS:
oras manifest fetch --plain-http localhost:5000/sailwright/ubuntu-server-amd64:qemuSystem-Agnostic Docker Workflow
Section titled “System-Agnostic Docker Workflow”Local Host Provisioning
Section titled “Local Host Provisioning”Use the shared local wrapper when you want to apply the playbook to the current machine instead of a managed VM:
sailwright provision local --checksailwright provision local --proto ssh --checksailwright provision local --playbook ./playbooks/setup.yml --checksailwright provision localFor platform defaults, configured playbook resolution, advanced flags, and Windows cleanup behavior, use Local Provisioning.
Ubuntu role tests on Linux, WSL, Windows, or macOS
Section titled “Ubuntu role tests on Linux, WSL, Windows, or macOS”Use the provided Docker Compose setup to run the Ubuntu-focused Ansible playbook inside a container:
docker compose -f deployments/docker-compose/ansible/docker-compose.yml upClean up afterwards with:
docker compose -f deployments/docker-compose/ansible/docker-compose.yml downLinux Host Workflows
Section titled “Linux Host Workflows”Ubuntu on Linux with QEMU/KVM and virt-manager
Section titled “Ubuntu on Linux with QEMU/KVM and virt-manager”Install host dependencies first:
sailwright installsailwright install installs the libvirt and QEMU host packages, but some
distributions still require one manual host step before the managed VM can
boot: enable libvirt’s default network, or grant ACL/group access to the
system libvirt image directory if the daemon runs guests as a different user.
Build and create the Ubuntu VM:
arch=amd64 # or arm64type=desktop # or serversailwright build ubuntu --arch "$arch" --type "$type"sailwright create ubuntu --arch "$arch" --type "$type"By default the Linux create/start/stop/destroy workflow uses the libvirt system
connection (qemu:///system) so the VM attaches to libvirt’s standard NAT
network and gets outbound internet access in the common case. Managed QCOW2
disks default to /var/tmp/dev-alchemy/libvirt/images, which avoids requiring a
pre-created root-owned image directory. Sailwright creates managed image
directories with mode 0750; if your system libvirt daemon runs guests as a
different user, grant access explicitly with a libvirt storage pool, group
ownership/ACLs on DEV_ALCHEMY_LIBVIRT_IMAGE_DIR, or use the session connection
below.
When a VM uses a named libvirt network, Sailwright preflights it before defining or starting the domain. With the default system URI this checks:
virsh --connect qemu:///system net-info defaultIf the default network exists but is inactive, enable it with:
sudo virsh --connect qemu:///system net-start defaultsudo virsh --connect qemu:///system net-autostart defaultIf you prefer the rootless libvirt user session instead, set:
export DEV_ALCHEMY_LIBVIRT_URI=qemu:///session# Optional when you want a custom storage location for the session connection:export DEV_ALCHEMY_LIBVIRT_IMAGE_DIR="$HOME/.local/share/dev-alchemy/libvirt/images"The built-in session path uses libvirt user-mode networking, so it does not
require the default NAT network. If a named network is configured for a session
URI later, the same preflight is run against qemu:///session.
If you prefer the traditional system libvirt image location instead, set:
export DEV_ALCHEMY_LIBVIRT_URI=qemu:///systemexport DEV_ALCHEMY_LIBVIRT_IMAGE_DIR=/var/lib/libvirt/images/dev-alchemyYou can then boot the created VM either from virt-manager or from the CLI:
sailwright start ubuntu --arch "$arch" --type "$type"sailwright provision ubuntu --arch "$arch" --type "$type" --checksailwright provision ubuntu --arch "$arch" --type "$type"sailwright stop ubuntu --arch "$arch" --type "$type"sailwright destroy ubuntu --arch "$arch" --type "$type"The provision wrapper discovers the libvirt guest IP with virsh domifaddr
and runs ansible-playbook with an inline SSH inventory target. Optional
Ubuntu provisioning overrides can be set in .env or the process environment
using LIBVIRT_UBUNTU_ANSIBLE_*:
LIBVIRT_UBUNTU_ANSIBLE_USER=packerLIBVIRT_UBUNTU_ANSIBLE_PASSWORD=P@ssw0rd!LIBVIRT_UBUNTU_ANSIBLE_BECOME_PASSWORD=P@ssw0rd!# Optional (defaults shown):LIBVIRT_UBUNTU_ANSIBLE_CONNECTION=sshLIBVIRT_UBUNTU_ANSIBLE_SSH_COMMON_ARGS=-o StrictHostKeyChecking=no -o ServerAliveInterval=10 -o ServerAliveCountMax=3 -o ControlMaster=no -o ControlPersist=noLIBVIRT_UBUNTU_ANSIBLE_SSH_TIMEOUT=120LIBVIRT_UBUNTU_ANSIBLE_SSH_RETRIES=3Related guide:
Windows on Linux with QEMU/KVM and virt-manager
Section titled “Windows on Linux with QEMU/KVM and virt-manager”Install host dependencies first:
sailwright installBuild and create the Windows 11 VM:
arch=amd64 # or arm64sailwright build windows11 --arch "$arch" --headlesssailwright create windows11 --arch "$arch"You can then boot the created VM either from virt-manager or from the CLI:
sailwright start windows11 --arch "$arch"The lifecycle uses the same libvirt defaults as Ubuntu on Linux: qemu:///system
and managed disks under /var/tmp/dev-alchemy/libvirt/images unless
DEV_ALCHEMY_LIBVIRT_URI or DEV_ALCHEMY_LIBVIRT_IMAGE_DIR is set.
If /dev/kvm is unavailable or unreliable, set
DEV_ALCHEMY_QEMU_FORCE_SOFTWARE_EMULATION=1 to bypass KVM probing and force
QEMU TCG software emulation.
Set WinRM credentials in project-root .env or the process environment:
LIBVIRT_WINDOWS_ANSIBLE_USER=AdministratorLIBVIRT_WINDOWS_ANSIBLE_PASSWORD=your-secure-password# Optional (defaults shown):LIBVIRT_WINDOWS_ANSIBLE_CONNECTION=winrmLIBVIRT_WINDOWS_ANSIBLE_WINRM_TRANSPORT=basicLIBVIRT_WINDOWS_ANSIBLE_PORT=5985Provision it from the repository root:
sailwright provision windows11 --arch "$arch" --checksailwright provision windows11 --arch "$arch"sailwright stop windows11 --arch "$arch"sailwright destroy windows11 --arch "$arch"The provision wrapper discovers the libvirt guest IP with virsh domifaddr
and runs ansible-playbook with an inline WinRM inventory target.
Related guide:
Windows Host Workflows
Section titled “Windows Host Workflows”Ubuntu on Windows with Hyper-V
Section titled “Ubuntu on Windows with Hyper-V”Install host dependencies first:
sailwright.exe installBuild the Ubuntu artifact:
# serversailwright.exe build ubuntu --type server --arch amd64# desktopsailwright.exe build ubuntu --type desktop --arch amd64Create the VM:
$env:VAGRANT_HYPERV_SWITCH = "Default Switch"sailwright.exe create ubuntu --type server --arch amd64# or desktopsailwright.exe create ubuntu --type desktop --arch amd64Provision it:
sailwright.exe provision ubuntu --type server --arch amd64 --checksailwright.exe provision ubuntu --type server --arch amd64The command discovers the VM IP automatically and runs Ansible through the Windows/Cygwin wrapper.
Optional Ubuntu provisioning overrides can be set in .env using HYPERV_UBUNTU_ANSIBLE_*.
Related guides:
Windows on Windows with Docker Desktop
Section titled “Windows on Windows with Docker Desktop”Use Docker Desktop with Windows containers enabled:
docker compose -f deployments/docker-compose/ansible-windows/docker-compose.yml upClean up afterwards with:
docker compose -f deployments/docker-compose/ansible-windows/docker-compose.yml downMore details:
Windows on Windows with Hyper-V
Section titled “Windows on Windows with Hyper-V”Install host dependencies first:
sailwright.exe installYou will need a Windows ISO for the build. You can download one manually from Microsoft or use:
Build details live here:
After the VM is running, provision it from the repository root:
sailwright.exe provision windows11 --arch amd64 --checksailwright.exe provision windows11 --arch amd64Set WinRM credentials in project-root .env or the process environment:
HYPERV_WINDOWS_ANSIBLE_USER=AdministratorHYPERV_WINDOWS_ANSIBLE_PASSWORD=your-secure-password# Optional (defaults shown):HYPERV_WINDOWS_ANSIBLE_CONNECTION=winrmHYPERV_WINDOWS_ANSIBLE_WINRM_TRANSPORT=basicHYPERV_WINDOWS_ANSIBLE_PORT=5985Optional shell path overrides for the Windows/Cygwin wrapper:
$env:CYGWIN_BASH_PATH = "C:\tools\cygwin\bin\bash.exe"# or, if you prefer setting the Cygwin terminal path:$env:CYGWIN_TERMINAL_PATH = "C:\tools\cygwin\bin\mintty.exe"Path resolution precedence for provisioning:
CYGWIN_BASH_PATHCYGWIN_TERMINAL_PATHwhenCYGWIN_BASH_PATHis unset- Auto-detect
C:\tools\cygwin\bin\bash.exe - Auto-detect
C:\cygwin64\bin\bash.exe
If CYGWIN_TERMINAL_PATH points to mintty.exe, provisioning resolves it to the sibling bash.exe.
macOS Host Workflows
Section titled “macOS Host Workflows”macOS on macOS with Tart
Section titled “macOS on macOS with Tart”Use the provided script:
./scripts/macos/test-ansible-macos.shThe script runs the Ansible playbook against a temporary Tart VM. Tart project:
By default, Sailwright uses the Tart image’s development credentials for Ansible access (admin / admin).
Override them in .env if needed:
TART_MACOS_ANSIBLE_USER=adminTART_MACOS_ANSIBLE_PASSWORD=adminTART_MACOS_ANSIBLE_BECOME_PASSWORD defaults to TART_MACOS_ANSIBLE_PASSWORD when unset.
Clean up afterwards with:
tart delete sequoia-baseWindows on macOS with UTM
Section titled “Windows on macOS with UTM”Install host dependencies first:
sailwright installBuild and create the Windows 11 VM:
# arm64 requires sudo to create a custom .iso file for automated installation.# sudo rights are evaluated at runtime, so you can run the build command without sudo and it will ask for sudo rights only if needed.arch=arm64 # or amd64# sudo sailwright build windows11 --arch $arch --headlesssailwright build windows11 --arch $arch --headless# `--headless` applies to `build`, not `create`.sailwright create windows11 --arch $archOpen UTM and start the created VM.
Set WinRM credentials in project-root .env or the process environment:
UTM_WINDOWS_ANSIBLE_USER=AdministratorUTM_WINDOWS_ANSIBLE_PASSWORD=your-secure-password# Optional (defaults shown):UTM_WINDOWS_ANSIBLE_CONNECTION=winrmUTM_WINDOWS_ANSIBLE_WINRM_TRANSPORT=basicUTM_WINDOWS_ANSIBLE_PORT=5985Provision it from the repository root:
sailwright provision windows11 --arch $arch --checksailwright provision windows11 --arch $archThe wrapper discovers the VM IP automatically from the generated UTM config and arp -a, then runs ansible-playbook with an inline inventory target.
On macOS it also sets OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES for the Ansible process automatically.
If you need to inspect the discovered IP manually:
bash ./deployments/utm/determine-vm-ip-address.sh --arch $arch --os windows11Related guide:
Newly built Windows images install a dedicated WinRM firewall rule for TCP 5985 on all network profiles, so later NIC or network-profile changes should not break reachability.
Older images may still need their network switched to Private or an equivalent firewall rule added manually.
Ubuntu on macOS with UTM
Section titled “Ubuntu on macOS with UTM”Install host dependencies first:
sailwright installBuild and create the Ubuntu VM:
arch=arm64 # or amd64type=desktop # or serversailwright build ubuntu --arch $arch --type $typesailwright create ubuntu --arch $arch --type $typeOpen UTM and start the created VM.
Set Ubuntu SSH credentials in project-root .env or the process environment:
UTM_UBUNTU_ANSIBLE_USER=packerUTM_UBUNTU_ANSIBLE_PASSWORD=P@ssw0rd!UTM_UBUNTU_ANSIBLE_BECOME_PASSWORD=P@ssw0rd!# Optional (defaults shown):UTM_UBUNTU_ANSIBLE_CONNECTION=sshUTM_UBUNTU_ANSIBLE_SSH_COMMON_ARGS=-o StrictHostKeyChecking=no -o ServerAliveInterval=10 -o ServerAliveCountMax=3 -o ControlMaster=no -o ControlPersist=noUTM_UBUNTU_ANSIBLE_SSH_TIMEOUT=120UTM_UBUNTU_ANSIBLE_SSH_RETRIES=3Provision it from the repository root:
sailwright provision ubuntu --type $type --arch $arch --checksailwright provision ubuntu --type $type --arch $archThe wrapper discovers the VM IP automatically from the generated UTM config and arp -a, then runs ansible-playbook with an inline inventory target.
On macOS it also sets OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES for the Ansible process automatically.
If you need to inspect the discovered IP manually:
bash ./deployments/utm/determine-vm-ip-address.sh --arch $arch --os "ubuntu-$type"