From d9ff74a50fccd543f748acd50cc7fcec78099e83 Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 20 Jul 2026 23:47:03 +0800 Subject: [PATCH 1/3] Document cross-architecture container support --- .../hosted-runner-docker-architecture.yml | 162 ++++++++++++++++++ README.md | 5 +- SUPPORT.md | 18 ++ docs/configuration.md | 2 +- docs/providers/tart.md | 11 +- docs/providers/wsl.md | 2 + docs/troubleshooting.md | 87 ++++++++++ 7 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/hosted-runner-docker-architecture.yml create mode 100644 SUPPORT.md diff --git a/.github/workflows/hosted-runner-docker-architecture.yml b/.github/workflows/hosted-runner-docker-architecture.yml new file mode 100644 index 0000000..d48bf1c --- /dev/null +++ b/.github/workflows/hosted-runner-docker-architecture.yml @@ -0,0 +1,162 @@ +name: Hosted runner Docker architecture proof + +on: + pull_request: + branches: + - develop + - main + paths: + - .github/workflows/hosted-runner-docker-architecture.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: hosted-docker-architecture-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + capability: + name: Capability (${{ matrix.runner }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + runner: + - ubuntu-latest + - ubuntu-24.04-arm + - windows-latest + - windows-11-arm + - macos-latest + - macos-15-intel + + steps: + - name: Record host and Docker capability + shell: pwsh + run: | + $ErrorActionPreference = 'Continue' + $summary = [System.Collections.Generic.List[string]]::new() + $summary.Add("## $env:RUNNER_OS/$env:RUNNER_ARCH on ``${{ matrix.runner }}``") + $summary.Add('') + $summary.Add("- Runner OS: ``$env:RUNNER_OS``") + $summary.Add("- Runner architecture: ``$env:RUNNER_ARCH``") + + $dockerCommand = Get-Command docker -ErrorAction SilentlyContinue + if (-not $dockerCommand) { + $summary.Add('- Docker CLI: not installed') + $summary.Add('- Linux container test: unavailable') + } else { + $summary.Add("- Docker CLI: ``$($dockerCommand.Source)``") + $versionOutput = (& docker version 2>&1 | Out-String).Trim() + $versionStatus = $LASTEXITCODE + $summary.Add("- Docker daemon reachable: ``$($versionStatus -eq 0)``") + + if ($versionStatus -eq 0) { + $serverOS = (& docker info --format '{{.OSType}}' 2>&1 | Out-String).Trim() + $serverArchitecture = (& docker info --format '{{.Architecture}}' 2>&1 | Out-String).Trim() + $summary.Add("- Docker server: ``$serverOS/$serverArchitecture``") + if ($serverOS -eq 'linux') { + $summary.Add('- Linux container test: available') + } else { + $summary.Add('- Linux container test: unavailable because the reachable daemon is not a Linux daemon') + } + } else { + $summary.Add('- Linux container test: unavailable because no Docker daemon is reachable') + $summary.Add('') + $summary.Add('
docker version diagnostic') + $summary.Add('') + $summary.Add('```text') + $summary.Add($versionOutput) + $summary.Add('```') + $summary.Add('
') + } + } + + $summary | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + $summary | ForEach-Object { Write-Host $_ } + + linux-cross-architecture: + name: Linux ${{ matrix.native }} runs ${{ matrix.foreign }} with explicit QEMU + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + native: amd64 + native_platform: linux/amd64 + native_output: x86_64 + foreign: arm64 + foreign_platform: linux/arm64 + foreign_output: aarch64 + - runner: ubuntu-24.04-arm + native: arm64 + native_platform: linux/arm64 + native_output: aarch64 + foreign: amd64 + foreign_platform: linux/amd64 + foreign_output: x86_64 + + steps: + - name: Verify native container execution + shell: bash + run: | + set -euo pipefail + docker info --format 'Docker server: {{.OSType}}/{{.Architecture}}' + native_architecture="$(docker run --rm --platform '${{ matrix.native_platform }}' alpine:3.22 uname -m)" + echo "Native container reported: ${native_architecture}" + [[ "${native_architecture}" == '${{ matrix.native_output }}' ]] + + - name: Observe foreign-architecture behavior before explicit QEMU setup + id: baseline + shell: bash + run: | + set -euo pipefail + set +e + baseline_output="$(docker run --rm --platform '${{ matrix.foreign_platform }}' alpine:3.22 uname -m 2>&1)" + baseline_status=$? + set -e + + printf '%s\n' "${baseline_output}" + { + echo '## ${{ matrix.foreign }} container before explicit QEMU setup' + echo + echo "- Exit status: \`${baseline_status}\`" + echo + echo '```text' + printf '%s\n' "${baseline_output}" + echo '```' + } >>"${GITHUB_STEP_SUMMARY}" + + if [[ ${baseline_status} -eq 0 ]]; then + [[ "${baseline_output}" == *'${{ matrix.foreign_output }}'* ]] + echo 'This hosted image already had a compatible foreign-architecture execution path before the workflow configured QEMU.' >>"${GITHUB_STEP_SUMMARY}" + elif [[ "${baseline_output}" == *"exec format error"* ]]; then + echo 'The baseline failed with the expected missing-emulation error.' >>"${GITHUB_STEP_SUMMARY}" + else + echo 'The baseline failed for a reason other than a recognized architecture mismatch.' >&2 + exit 1 + fi + + - name: Set up foreign-architecture container emulation + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 + with: + image: docker.io/tonistiigi/binfmt@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 + platforms: ${{ matrix.foreign }} + + - name: Verify foreign-architecture container execution after QEMU setup + shell: bash + run: | + set -euo pipefail + foreign_architecture="$(docker run --rm --platform '${{ matrix.foreign_platform }}' alpine:3.22 uname -m)" + echo "Emulated container reported: ${foreign_architecture}" + [[ "${foreign_architecture}" == '${{ matrix.foreign_output }}' ]] + { + echo '## ${{ matrix.foreign }} container after explicit QEMU setup' + echo + echo "- Reported architecture: \`${foreign_architecture}\`" + echo '- Result: success' + } >>"${GITHUB_STEP_SUMMARY}" diff --git a/README.md b/README.md index a8e6e51..f3ab97c 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Docker-DinD is the default first choice. Other providers are available when they | --- | --- | | Docker-DinD | You have a Docker-compatible daemon on Windows, macOS, or Linux, and want a private Docker daemon per runner. | | WSL2 | You are on Windows and want runners as disposable WSL distros. | -| Tart | You are on Apple Silicon macOS and want Linux VM runners; consider Docker-DinD first for Docker-heavy jobs because virtualization limits can affect compatibility. | +| Tart (experimental) | You are on Apple Silicon macOS and want native ARM64 Linux VM runners. Rosetta-based amd64 container execution has compatibility limits; prefer Docker-DinD for Docker-heavy or amd64-dependent jobs. | WSL2 also defaults to Catthehacker's full Ubuntu runner image, but it converts that Docker image into a WSL rootfs during `image build`. @@ -154,10 +154,11 @@ GitHub also warns against using self-hosted runners with public repositories tha - [GitHub App Setup](docs/github-app.md): required GitHub App permissions and fields. - [Docker-DinD Provider](docs/providers/docker-dind.md): default Docker runner mode. - [WSL Provider](docs/providers/wsl.md): Windows WSL2 runners. -- [Tart Provider](docs/providers/tart.md): Apple Silicon Linux VM runners. +- [Tart Provider (experimental)](docs/providers/tart.md): Apple Silicon ARM64 Linux VM runners and Rosetta compatibility limits. - [Image Build](docs/image-build.md): image internals and customization. - [Operations](docs/operations.md): logs, cleanup, and troubleshooting. - [Troubleshooting](docs/troubleshooting.md): symptom-first diagnostics by host and provider. +- [Support](SUPPORT.md): where to start, what diagnostic information to collect, and where to ask for help. - [Windows Startup](docs/advanced/windows-startup.md): start EPAR after Windows login. - [macOS Startup](docs/advanced/macos-startup.md): start EPAR after macOS login. - [Running EPAR Without Installing Go](docs/advanced/no-go-install.md): run from source with no local Go install. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..72f7890 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,18 @@ +# Support + +Start with the [troubleshooting guide](docs/troubleshooting.md). It provides symptom-first diagnostics for Docker-DinD, WSL, Tart, host trust, image builds, storage, and cross-architecture containers. + +Before asking for help, search the repository's existing [issues](https://github.com/solutionforest/ephemeral-action-runner/issues) and collect: + +- the EPAR version or commit; +- the host operating system and architecture; +- the selected provider and runner image; +- the relevant configuration with credentials, private keys, tokens, certificate contents, organization names, and other sensitive values removed; +- the smallest reproducible workflow or command; +- the relevant controller, image-build, and guest logs with secrets removed. + +For a reproducible bug or documentation gap, open a [GitHub issue](https://github.com/solutionforest/ephemeral-action-runner/issues/new/choose). For usage questions, include what you expected, what happened, and the diagnostics above so another contributor can reproduce the environment. + +Do not report security vulnerabilities in a public issue. Follow the private reporting instructions in the [security policy](docs/security.md). + +Community support is provided on a best-effort basis; there is no guaranteed response time or service-level agreement. diff --git a/docs/configuration.md b/docs/configuration.md index 07aa156..322b933 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -182,7 +182,7 @@ For `provider.type: docker-dind`, EPAR defaults to Catthehacker's full Ubuntu ru For `provider.type: wsl`, EPAR defaults to Catthehacker's full Ubuntu runner image, converts it into a WSL rootfs, and stores the output under `work/images/`. -For `provider.type: tart`, start from `configs/tart.example.yml` and adjust labels or image scripts as needed. +For the experimental `provider.type: tart`, start from `configs/tart.example.yml` and adjust labels or image scripts as needed. Tart runners are ARM64 VMs on Apple Silicon; Rosetta-based amd64 container execution has compatibility limits and must be validated against the exact workflow. See the provider docs for details: diff --git a/docs/providers/tart.md b/docs/providers/tart.md index d60e223..2105ca9 100644 --- a/docs/providers/tart.md +++ b/docs/providers/tart.md @@ -1,6 +1,9 @@ -# Tart Provider +# Tart Provider (Experimental) -The Tart provider targets Apple Silicon macOS hosts and can run Ubuntu ARM64 or macOS ARM64 VMs. +The Tart provider is experimental. It targets Apple Silicon macOS hosts and can run Ubuntu ARM64 or macOS ARM64 VMs. Prefer Docker-DinD for Docker-heavy workflows and workflows that depend on amd64 container images. + +> [!WARNING] +> Tart uses Apple's Virtualization framework, so an Apple Silicon host runs an ARM64 VM. Rosetta translates supported x86_64 Linux user-space programs inside that ARM64 guest; it does not create an x64 VM, and not every amd64 image or workload is compatible. Validate the exact CI workload before assigning it to a Tart runner. EPAR currently validates the Ubuntu path: @@ -19,7 +22,7 @@ The default network mode is Tart NAT. `softnet` is accepted by the provider, but If Docker is installed in the guest, optional `docker.registryMirrors` settings are applied to the guest Docker daemon when each disposable VM starts. Use a mirror URL that is reachable from inside the Tart VM; `host.docker.internal` is Docker-container-specific and may not resolve in Tart guests. See [Docker Registry Mirrors](../advanced/docker-registry-mirrors.md). -## Rosetta For Linux Amd64 Containers +## Experimental Rosetta Support For Linux Amd64 Containers Tart on Apple Silicon runs ARM64 VMs, but Tart can expose Apple's Linux Rosetta runtime to the guest with `tart run --rosetta `. EPAR supports this as an opt-in Tart-only setting: @@ -43,4 +46,4 @@ Host prerequisites: - Tart version with `--rosetta` support - Apple's Rosetta package installed on the macOS host -This is for Linux amd64 user-space containers. It is not nested virtualization and it does not make the Ubuntu VM an x64 VM. For native amd64 performance and compatibility, use a Windows WSL x64 provider or another x64 Linux host. For Tart Rosetta-capable runners, expose a distinct label such as `epar-tart-rosetta-amd64` so workflows can opt into the behavior explicitly. +This is experimental support for Linux amd64 user-space containers. It is not nested virtualization and it does not make the Ubuntu VM an x64 VM. For native amd64 performance and compatibility, use a Windows WSL x64 provider or another x64 Linux host. For Tart Rosetta-capable runners, expose a distinct label such as `epar-tart-rosetta-amd64` so workflows can opt into the behavior explicitly. diff --git a/docs/providers/wsl.md b/docs/providers/wsl.md index 5dc070c..1a62e9c 100644 --- a/docs/providers/wsl.md +++ b/docs/providers/wsl.md @@ -86,6 +86,8 @@ Runner startup sources `/opt/epar/source-image.env` before launching `/opt/actio WSL x64 is the preferred EPAR target for workflows that pull amd64-only Docker runtime images. +An x64 WSL runner can store an ARM64 image with `docker pull` or `docker load`, but it cannot run that image natively. Running ARM64 containers requires an explicitly configured emulation layer such as QEMU registered through `binfmt_misc`, or a native ARM64 runner. EPAR does not install cross-architecture emulation in WSL by default, so validate both the architecture and execution path before routing ARM64-dependent jobs to an x64 WSL label. + If `docker.registryMirrors` is configured, EPAR applies it to Docker Engine inside each disposable WSL distro before validation. Use a mirror URL reachable from inside WSL, such as an organization DNS name or a host/LAN address. See [Docker Registry Mirrors](../advanced/docker-registry-mirrors.md). ## Caveats diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f5b6f5e..993f380 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -84,6 +84,93 @@ docker run --rm ghcr.io/catthehacker/ubuntu:full-latest df -h / If the container-visible disk is full, adjust or clean the Docker/OrbStack storage from that product's settings. Finder free space by itself may not reflect the Linux VM storage available to containers. +## Docker Container Fails Because Its Architecture Does Not Match The Runner + +### Symptoms + +A Docker or Docker Compose service may fail immediately with one of these messages: + +```text +exec /bin/sh: exec format error +exec user process caused: exec format error +cannot execute binary file: Exec format error +``` + +Docker may also warn that the requested image platform does not match the detected host platform. That warning alone is not a failure: the container can still run when a compatible emulation handler is already registered. + +These related messages point to different problems: + +- `no matching manifest for linux/arm64/v8` or `no matching manifest for linux/amd64` means the image does not publish the requested platform. QEMU cannot supply a missing image manifest; choose an available platform or publish a multi-platform image. +- `qemu-x86_64: Could not open '/lib64/ld-linux-x86-64.so.2'` means translation started but the expected foreign-architecture loader or userspace is unavailable or incompatible. Registration alone may not make that image work. +- Exit code `139` indicates a segmentation fault. Emulation can expose workload-specific incompatibilities, but this code by itself does not prove an architecture mismatch. + +### Confirm The Host And Image Platforms + +Check the runner architecture and the Docker daemon that will execute the container: + +```bash +uname -m +docker info --format '{{.OSType}}/{{.Architecture}}' +``` + +Inspect the locally selected image platform: + +```bash +docker image inspect --format '{{.Os}}/{{.Architecture}}' IMAGE +``` + +Inspect all platforms published by a registry image: + +```bash +docker buildx imagetools inspect IMAGE +``` + +For Docker Compose, also inspect the resolved configuration and look for a service-level `platform:` value: + +```bash +docker compose config +``` + +An x64 Linux Docker daemon normally runs `linux/amd64` images natively, and an ARM64 daemon normally runs `linux/arm64` images natively. Pulling or loading a foreign image does not prove that the daemon can execute it. + +### Match GitHub-Hosted Linux Behavior With Explicit QEMU Setup + +GitHub's Ubuntu runner image installs Docker, but its published installation script and software inventory do not promise pre-registered foreign-architecture emulators. When a trusted Linux job must run foreign-architecture containers, configure the requirement explicitly before the first such container starts: + +```yaml +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Set up ARM64 container emulation + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 + with: + image: docker.io/tonistiigi/binfmt@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 + platforms: arm64 + + - name: Verify the foreign container + run: docker run --rm --platform linux/arm64 alpine:3.22 uname -m +``` + +The expected output is `aarch64`. Add only the platforms the workflow needs; emulated compilation and compute-heavy workloads can be substantially slower than native execution. Keep architecture-sensitive jobs on native runners when performance or full compatibility matters. + +`docker/setup-qemu-action` registers user-mode QEMU interpreters through Linux `binfmt_misc`. It helps Linux containers launch foreign-architecture user-space executables; it does not change the runner's CPU architecture, create a foreign-architecture VM, or make arbitrary host executables and libraries compatible. The action uses a privileged helper container, so use it only in trusted workflows and pin reviewed action and helper-image revisions according to your dependency policy. + +Provider notes: + +- Docker-DinD: run the setup action inside the EPAR job before Docker Compose or other foreign-image commands. It configures the disposable runner's Docker execution environment; no EPAR configuration switch is required. +- WSL: run the setup action inside the WSL runner when its Linux Docker daemon must execute a foreign image. An x64 WSL runner does not gain ARM64 container support merely by pulling or loading an ARM64 image. +- Tart: Tart runs an ARM64 VM on Apple Silicon. Its optional Rosetta path is experimental and is not equivalent to QEMU/binfmt compatibility. Prefer Docker-DinD or a native matching architecture when a workload is not compatible. +- GitHub-hosted Windows and macOS: GitHub documents Docker container actions and service containers as Linux-runner features. A Windows or macOS hardware label alone is therefore not a substitute for a Linux Docker daemon with emulation configured. + +Official references: + +- [Docker Setup QEMU action](https://github.com/docker/setup-qemu-action) +- [Docker multi-platform build strategies](https://docs.docker.com/build/building/multi-platform/) +- [GitHub-hosted runner labels and limitations](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) +- [GitHub self-hosted runner container requirements](https://docs.github.com/en/actions/reference/runners/self-hosted-runners#requirements-for-self-hosted-runner-machines) +- [GitHub Ubuntu runner Docker installation](https://github.com/actions/runner-images/blob/main/images/ubuntu/scripts/build/install-docker.sh) + ## Docker Image Build Runs Out Of Space ### Symptom From cf5200ceac2cb1f81df74e5bc15331277094d777 Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 21 Jul 2026 15:31:38 +0800 Subject: [PATCH 2/3] Add Tart option to macOS setup wizard --- README.md | 6 +- cmd/ephemeral-action-runner/init.go | 144 +++++++++++++++++++--- cmd/ephemeral-action-runner/init_test.go | 86 +++++++++++++ cmd/ephemeral-action-runner/start_test.go | 48 ++++++++ configs/tart.example.yml | 2 + docs/configuration.md | 2 +- docs/image-build.md | 3 + docs/providers/tart.md | 10 +- docs/usage.md | 10 +- start | 2 +- start.ps1 | 4 +- 11 files changed, 285 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index f3ab97c..7dd66e2 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ That's it. #### What Happens -EPAR initializes `.local/config.yml` for you if it does not exist. Docker-DinD is the default. The wizard asks whether new Docker-DinD runners should inherit the host's trusted TLS roots and defaults to yes. On native Windows, it also offers WSL2 when `wsl.exe --status` confirms default version 2; press Enter to keep Docker-DinD. Existing configs do not enable host trust inheritance automatically. You can customize the config afterward; see [Configuration](docs/configuration.md). +EPAR initializes `.local/config.yml` for you if it does not exist. Docker-DinD is the default. The wizard asks whether new Docker-DinD runners should inherit the host's trusted TLS roots and defaults to yes. On native Windows, it also offers WSL2 when `wsl.exe --status` confirms default version 2. On macOS, it offers experimental Tart mode when `tart --version` succeeds. Press Enter to keep Docker-DinD. Existing configs do not enable host trust inheritance automatically. You can customize the config afterward; see [Configuration](docs/configuration.md). Then EPAR checks the configured runner image, builds or replaces it when needed, and starts the configured number of runners. The default config uses `pool.instances: 1`. @@ -117,10 +117,12 @@ Docker-DinD is the default first choice. Other providers are available when they | --- | --- | | Docker-DinD | You have a Docker-compatible daemon on Windows, macOS, or Linux, and want a private Docker daemon per runner. | | WSL2 | You are on Windows and want runners as disposable WSL distros. | -| Tart (experimental) | You are on Apple Silicon macOS and want native ARM64 Linux VM runners. Rosetta-based amd64 container execution has compatibility limits; prefer Docker-DinD for Docker-heavy or amd64-dependent jobs. | +| Tart (experimental) | You are on Apple Silicon macOS and want to experiment with native ARM64 Linux VMs. The default Tart image is a basic Ubuntu OS image and does not include the normal GitHub-hosted runner dependency set. | WSL2 also defaults to Catthehacker's full Ubuntu runner image, but it converts that Docker image into a WSL rootfs during `image build`. +Tart is not a ready-made substitute for GitHub's hosted Ubuntu runners. If you need that environment, build and maintain your own bootable Tart runner image by adapting the scripts from [actions/runner-images](https://github.com/actions/runner-images), then configure EPAR to use it. EPAR does not automate that conversion. + See [Usage](docs/usage.md) for WSL, Tart, source builds, custom configs, and advanced options. ## FAQ diff --git a/cmd/ephemeral-action-runner/init.go b/cmd/ephemeral-action-runner/init.go index 3847769..f47271f 100644 --- a/cmd/ephemeral-action-runner/init.go +++ b/cmd/ephemeral-action-runner/init.go @@ -37,6 +37,8 @@ var initHostTrustOS = detectedInitHostTrustOS() var initWSLStatus = wslStatus +var initTartVersion = tartVersion + var initResolveHostTrust = hosttrust.Resolve func detectedInitHostTrustOS() string { @@ -105,14 +107,6 @@ func runInitWithOptions(opts initOptions) error { return err } } - if !opts.SkipDockerCheck { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := dockerAvailable(ctx); err != nil { - return fmt.Errorf("Docker is required for the default Docker-DinD setup. Install Docker Desktop, Docker Engine, or a compatible Docker host, then rerun %s init. If you want WSL or another custom provider, create .local/config.yml from the examples instead", binaryName) - } - } - fmt.Fprintln(opts.Out, "EPAR first-run setup") fmt.Fprintln(opts.Out, "") fmt.Fprintln(opts.Out, "This creates .local/config.yml for an EPAR runner.") @@ -135,10 +129,23 @@ func runInitWithOptions(opts initOptions) error { } providerType := "docker-dind" if wsl2Available() { - providerType, err = promptProviderType(opts.Out, reader) + providerType, err = promptProviderType(opts.Out, reader, "wsl") if err != nil { return err } + } else if tartAvailable() { + providerType, err = promptProviderType(opts.Out, reader, "tart") + if err != nil { + return err + } + } + if !opts.SkipDockerCheck && providerType != "tart" { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + dockerErr := dockerAvailable(ctx) + cancel() + if dockerErr != nil { + return fmt.Errorf("Docker is required for the selected %s setup. Install Docker Desktop, Docker Engine, or a compatible Docker host, then rerun %s init", providerDisplayName(providerType), binaryName) + } } defaultPrefix, err := generatedPoolNamePrefix() if err != nil { @@ -179,8 +186,11 @@ func runInitWithOptions(opts initOptions) error { } content := defaultDockerDindConfig(appID, organization, privateKeyPath, poolNamePrefix, hostTrustMode, hostTrustScopes) - if providerType == "wsl" { + switch providerType { + case "wsl": content = defaultWSLConfig(appID, organization, privateKeyPath, poolNamePrefix) + case "tart": + content = defaultTartConfig(appID, organization, privateKeyPath, poolNamePrefix) } if err := os.MkdirAll(filepath.Dir(opts.ConfigPath), 0755); err != nil { return err @@ -255,11 +265,12 @@ func promptPoolNamePrefix(out io.Writer, reader *bufio.Reader, defaultValue stri } } -func promptProviderType(out io.Writer, reader *bufio.Reader) (string, error) { +func promptProviderType(out io.Writer, reader *bufio.Reader, alternative string) (string, error) { + alternativeLabel := providerDisplayName(alternative) fmt.Fprintln(out, "") fmt.Fprintln(out, "Runner provider:") fmt.Fprintln(out, " 1. Docker-DinD (default)") - fmt.Fprintln(out, " 2. WSL2") + fmt.Fprintf(out, " 2. %s\n", alternativeLabel) for { value, hitEOF, err := promptDefault(out, reader, "Runner provider", "1") if err != nil { @@ -268,17 +279,33 @@ func promptProviderType(out io.Writer, reader *bufio.Reader) (string, error) { switch strings.ToLower(value) { case "1", "docker", "docker-dind": return "docker-dind", nil - case "2", "wsl", "wsl2": - return "wsl", nil - default: - fmt.Fprintln(out, "Runner provider must be 1 (Docker-DinD) or 2 (WSL2).") - if hitEOF { - return "", fmt.Errorf("invalid runner provider %q", value) + case "2", alternative: + return alternative, nil + case "wsl2": + if alternative == "wsl" { + return alternative, nil } + default: + // Continue below so aliases that belong to an unavailable provider are rejected. + } + fmt.Fprintf(out, "Runner provider must be 1 (Docker-DinD) or 2 (%s).\n", alternativeLabel) + if hitEOF { + return "", fmt.Errorf("invalid runner provider %q", value) } } } +func providerDisplayName(providerType string) string { + switch providerType { + case "wsl": + return "WSL2" + case "tart": + return "Tart (experimental)" + default: + return "Docker-DinD" + } +} + func promptDefault(out io.Writer, reader *bufio.Reader, label string, defaultValue string) (string, bool, error) { fmt.Fprintf(out, "%s (press Enter to use %s): ", label, defaultValue) value, err := reader.ReadString('\n') @@ -439,6 +466,19 @@ func wslStatus(ctx context.Context) ([]byte, error) { return output.Bytes(), nil } +func tartAvailable() bool { + if initGOOS != "darwin" { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + return initTartVersion(ctx) == nil +} + +func tartVersion(ctx context.Context) error { + return exec.CommandContext(ctx, "tart", "--version").Run() +} + type boundedBuffer struct { bytes.Buffer limit int @@ -595,6 +635,74 @@ timeouts: `, appID, organization, privateKeyPath, poolNamePrefix) } +func defaultTartConfig(appID int64, organization, privateKeyPath string, poolNamePrefix string) string { + return fmt.Sprintf(`# Experimental: this default is a basic Ubuntu ARM64 Tart VM, not a GitHub-hosted runner image. +# It does not include the broad dependency set from https://github.com/actions/runner-images. +github: + appId: %d + organization: %s + privateKeyPath: %s + apiBaseUrl: https://api.github.com + webBaseUrl: https://github.com + +image: + sourceImage: ghcr.io/cirruslabs/ubuntu:latest + outputImage: epar-ubuntu-24-arm64 + upstreamDir: third_party/runner-images + upstreamLock: third_party/runner-images.lock + runnerVersion: latest + customInstallScripts: + # - examples/custom-install/install-extra-apt-tools.sh + +pool: + instances: 1 + # Must be unique for this machine/config within the GitHub organization. + namePrefix: %s + replacementRetryInitialSeconds: 15 + replacementRetryMaxSeconds: 1800 + replacementRetryMultiplier: 2 + replacementRetryJitterPercent: 20 +logging: + directory: work/logs + managerSinks: [console] + managerConsoleFormat: text + managerConsoleTextFormat: "{time} [{level}] {message}" + managerFileFormat: json + transcriptSinks: [file] + transcriptConsoleFormat: text + maxFileSizeMiB: 100 + maxBackups: 3 + compressBackups: true + retentionEnabled: true + retentionMaxTotalMiB: 1024 + managerMaxAgeDays: 14 + instanceMaxAgeDays: 14 + buildMaxAgeDays: 14 + errorMaxAgeDays: 30 + benchmarkMaxAgeDays: 90 + retentionIntervalMinutes: 60 + +runner: + labels: [self-hosted, linux, ARM64, epar-tart-ubuntu-24.04-base] + includeHostLabel: true + ephemeral: true + +provider: + type: tart + sourceImage: epar-ubuntu-24-arm64 + network: default + +docker: + registryMirrors: + # - https://mirror.example.test + +timeouts: + bootSeconds: 180 + githubOnlineSeconds: 180 + commandSeconds: 900 +`, appID, organization, privateKeyPath, poolNamePrefix) +} + var stdinIsInteractive = func() bool { info, err := os.Stdin.Stat() if err != nil { diff --git a/cmd/ephemeral-action-runner/init_test.go b/cmd/ephemeral-action-runner/init_test.go index 0621b2a..c93792d 100644 --- a/cmd/ephemeral-action-runner/init_test.go +++ b/cmd/ephemeral-action-runner/init_test.go @@ -395,6 +395,80 @@ func TestInitWSL2ChoiceDefaultsToDockerDindAndRepromptsInvalidValues(t *testing. } } +func TestInitOffersTartConfigWhenAvailable(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubTartAvailable(t) + oldDockerAvailable := dockerAvailable + dockerAvailable = func(context.Context) error { + t.Fatal("Docker availability should not be checked for Tart") + return nil + } + t.Cleanup(func() { dockerAvailable = oldDockerAvailable }) + + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + var out bytes.Buffer + if err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipHostTrustCheck: true, + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n2\n\n"), + Out: &out, + }); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want, err := os.ReadFile(filepath.Join("..", "..", "configs", "tart.example.yml")) + if err != nil { + t.Fatal(err) + } + wantText := strings.NewReplacer( + "appId: 123456", "appId: 123456", + "organization: your-org", "organization: solutionforest", + "privateKeyPath: ~/.config/ephemeral-action-runner/github-app.pem", "privateKeyPath: .local/github-app.pem", + "namePrefix: CHANGE-ME-unique-machine-prefix", "namePrefix: build-box-01-a4f9c2", + ).Replace(string(want)) + wantText = strings.ReplaceAll(wantText, "\r\n", "\n") + if string(got) != wantText { + t.Fatalf("Tart config did not match configs/tart.example.yml:\nwant:\n%s\ngot:\n%s", wantText, got) + } + if !strings.Contains(out.String(), "2. Tart (experimental)") { + t.Fatalf("init output did not offer Tart:\n%s", out.String()) + } +} + +func TestTartAvailabilityRequiresNativeMacOSAndSuccessfulVersion(t *testing.T) { + oldGOOS := initGOOS + oldTartVersion := initTartVersion + t.Cleanup(func() { + initGOOS = oldGOOS + initTartVersion = oldTartVersion + }) + + initGOOS = "darwin" + initTartVersion = func(context.Context) error { return nil } + if !tartAvailable() { + t.Fatal("tartAvailable() = false when tart --version succeeds on macOS") + } + initTartVersion = func(context.Context) error { return errors.New("tart unavailable") } + if tartAvailable() { + t.Fatal("tartAvailable() = true when tart --version fails") + } + + initGOOS = "linux" + initTartVersion = func(context.Context) error { + t.Fatal("tart --version should not run outside native macOS") + return nil + } + if tartAvailable() { + t.Fatal("tartAvailable() = true outside native macOS") + } +} + func TestWSL2AvailabilityRequiresNativeWindowsSuccessfulVersion2Status(t *testing.T) { stubWSL2Available(t) for _, test := range []struct { @@ -510,3 +584,15 @@ func stubWSL2Available(t *testing.T) { initWSLStatus = oldWSLStatus }) } + +func stubTartAvailable(t *testing.T) { + t.Helper() + oldGOOS := initGOOS + oldTartVersion := initTartVersion + initGOOS = "darwin" + initTartVersion = func(context.Context) error { return nil } + t.Cleanup(func() { + initGOOS = oldGOOS + initTartVersion = oldTartVersion + }) +} diff --git a/cmd/ephemeral-action-runner/start_test.go b/cmd/ephemeral-action-runner/start_test.go index 1ca418c..3bc1d56 100644 --- a/cmd/ephemeral-action-runner/start_test.go +++ b/cmd/ephemeral-action-runner/start_test.go @@ -167,6 +167,54 @@ func TestStartInteractiveMissingConfigCanSelectWSL2(t *testing.T) { } } +func TestStartInteractiveMissingConfigCanSelectTartWithoutDocker(t *testing.T) { + dir := t.TempDir() + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubTartAvailable(t) + oldInteractive := stdinIsInteractive + oldDocker := dockerAvailable + t.Cleanup(func() { + stdinIsInteractive = oldInteractive + dockerAvailable = oldDocker + }) + stdinIsInteractive = func() bool { return true } + dockerAvailable = func(context.Context) error { + t.Fatal("Docker availability should not be checked for Tart") + return nil + } + + fake := &fakeStarterManager{} + var out bytes.Buffer + err := runStartWithOptions(startOptions{ + Context: context.Background(), + ProjectRoot: dir, + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n2\n\n"), + Out: &out, + ManagerFactory: func(path, _ string, _ bool, _ bool) (starterManager, error) { + if path != filepath.Join(dir, ".local", "config.yml") { + t.Fatalf("config path = %q", path) + } + return fake, nil + }, + }) + if err != nil { + t.Fatal(err) + } + cfg, err := config.Load(filepath.Join(dir, ".local", "config.yml")) + if err != nil { + t.Fatal(err) + } + if got, want := cfg.Provider.Type, "tart"; got != want { + t.Fatalf("provider.type = %q, want %q", got, want) + } + if !strings.Contains(out.String(), "Continuing with") { + t.Fatalf("output missing continuation message:\n%s", out.String()) + } + if fake.ensureCalls != 1 || fake.runCalls != 1 { + t.Fatalf("ensure/run calls = %d/%d, want 1/1", fake.ensureCalls, fake.runCalls) + } +} + func TestStartNonInteractiveMissingConfigFails(t *testing.T) { dir := t.TempDir() oldInteractive := stdinIsInteractive diff --git a/configs/tart.example.yml b/configs/tart.example.yml index 885278d..efe103e 100644 --- a/configs/tart.example.yml +++ b/configs/tart.example.yml @@ -1,3 +1,5 @@ +# Experimental: this default is a basic Ubuntu ARM64 Tart VM, not a GitHub-hosted runner image. +# It does not include the broad dependency set from https://github.com/actions/runner-images. github: appId: 123456 organization: your-org diff --git a/docs/configuration.md b/docs/configuration.md index 322b933..120b57f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -182,7 +182,7 @@ For `provider.type: docker-dind`, EPAR defaults to Catthehacker's full Ubuntu ru For `provider.type: wsl`, EPAR defaults to Catthehacker's full Ubuntu runner image, converts it into a WSL rootfs, and stores the output under `work/images/`. -For the experimental `provider.type: tart`, start from `configs/tart.example.yml` and adjust labels or image scripts as needed. Tart runners are ARM64 VMs on Apple Silicon; Rosetta-based amd64 container execution has compatibility limits and must be validated against the exact workflow. +For the experimental `provider.type: tart`, EPAR defaults to `ghcr.io/cirruslabs/ubuntu:latest`, a basic Ubuntu ARM64 VM image. EPAR installs its runner lifecycle but does not add the broad tool and dependency set found in GitHub's hosted runner images. If you require a GitHub-runner-like environment, build and maintain a bootable Tart image yourself by adapting the scripts in [actions/runner-images](https://github.com/actions/runner-images), then set `image.sourceImage` to that Tart image. Rosetta-based amd64 execution also has compatibility limits and must be validated against the exact workflow. See the provider docs for details: diff --git a/docs/image-build.md b/docs/image-build.md index ca05edc..7c0bedb 100644 --- a/docs/image-build.md +++ b/docs/image-build.md @@ -9,6 +9,9 @@ For Tart, the image build has two image names: These are Tart VM image names. They are stored in Tart's local VM registry and are visible with `tart list`; they are not emitted as repository-local files. +> [!WARNING] +> Tart support is experimental, and its default source is a basic Ubuntu ARM64 OS image rather than a GitHub-hosted runner image. It does not include the usual dependency inventory from [`actions/runner-images`](https://github.com/actions/runner-images). For a GitHub-runner-like Tart environment, adapt those upstream build scripts to produce and maintain your own bootable Tart VM image, then configure EPAR to use it; EPAR does not automate that image conversion. + For WSL, the image build produces a rootfs tar. It can start from either a Docker image or an existing rootfs tar: - `image.sourceType`: `docker-image` or `rootfs-tar`, default `docker-image` for WSL. diff --git a/docs/providers/tart.md b/docs/providers/tart.md index 2105ca9..30901f1 100644 --- a/docs/providers/tart.md +++ b/docs/providers/tart.md @@ -1,9 +1,9 @@ # Tart Provider (Experimental) -The Tart provider is experimental. It targets Apple Silicon macOS hosts and can run Ubuntu ARM64 or macOS ARM64 VMs. Prefer Docker-DinD for Docker-heavy workflows and workflows that depend on amd64 container images. +The Tart provider is experimental. It targets Apple Silicon macOS hosts and currently supports Ubuntu ARM64 guests. Tart itself can run macOS ARM64 VMs, but EPAR's image build, runner service, validation, and cleanup scripts currently depend on Ubuntu, systemd, and Linux process interfaces, so macOS guests are not yet an EPAR provider mode. > [!WARNING] -> Tart uses Apple's Virtualization framework, so an Apple Silicon host runs an ARM64 VM. Rosetta translates supported x86_64 Linux user-space programs inside that ARM64 guest; it does not create an x64 VM, and not every amd64 image or workload is compatible. Validate the exact CI workload before assigning it to a Tart runner. +> The default Tart source, `ghcr.io/cirruslabs/ubuntu:latest`, is a basic Ubuntu ARM64 OS image. It does not contain the broad dependency set normally present in GitHub's hosted images from [`actions/runner-images`](https://github.com/actions/runner-images), including many language SDKs, CLIs, browsers, and build tools. Tart uses Apple's Virtualization framework, so an Apple Silicon host runs an ARM64 VM. Rosetta translates supported x86_64 Linux user-space programs inside that ARM64 guest; it does not create an x64 VM, and not every amd64 image or workload is compatible. EPAR currently validates the Ubuntu path: @@ -14,13 +14,15 @@ EPAR currently validates the Ubuntu path: - register an ephemeral GitHub runner from the host - delete the VM after the runner exits -Use `configs/tart.example.yml` for the runner-only image or `configs/tart.web-e2e.example.yml` for the opt-in web/E2E install script. +Use `configs/tart.example.yml` for the basic runner-only Ubuntu image or `configs/tart.web-e2e.example.yml` for the existing opt-in web/E2E and Rosetta experiment. EPAR installs the GitHub Actions runner and its lifecycle scripts, but the default does not install Docker, .NET, PowerShell, Go, browsers, or the rest of GitHub's hosted-runner tool inventory. + +If a workflow needs a GitHub-runner-like environment, build and maintain your own bootable Tart source image. Adapt the Ubuntu build scripts and tool definitions from [`actions/runner-images`](https://github.com/actions/runner-images) to that image, validate the resulting ARM64 tools, push it as a Tart VM image, and set `image.sourceImage` to it. Alternatively, add narrowly scoped `image.customInstallScripts` for only the dependencies your workflows require. EPAR does not convert the Catthehacker Docker image or automatically reproduce the complete GitHub-hosted image for Tart. When Docker/browser support is selected on ARM64, EPAR exposes a Chromium-compatible browser through `epar-browser`, `chromium`, and `chromium-browser`; it is not guaranteed to be Google Chrome. The default network mode is Tart NAT. `softnet` is accepted by the provider, but it can require host-side privileges. -If Docker is installed in the guest, optional `docker.registryMirrors` settings are applied to the guest Docker daemon when each disposable VM starts. Use a mirror URL that is reachable from inside the Tart VM; `host.docker.internal` is Docker-container-specific and may not resolve in Tart guests. See [Docker Registry Mirrors](../advanced/docker-registry-mirrors.md). +If Docker is installed in a custom Tart guest, optional `docker.registryMirrors` settings are applied to the guest Docker daemon when each disposable VM starts. Use a mirror URL that is reachable from inside the Tart VM; `host.docker.internal` is Docker-container-specific and may not resolve in Tart guests. See [Docker Registry Mirrors](../advanced/docker-registry-mirrors.md). ## Experimental Rosetta Support For Linux Amd64 Containers diff --git a/docs/usage.md b/docs/usage.md index d408748..15df358 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -52,7 +52,7 @@ Equivalent without the wrapper: go run ./cmd/ephemeral-action-runner ``` -If no config exists, EPAR starts the initializer, asks for the GitHub App ID, organization, and private key path, then writes `.local/config.yml`. Docker-DinD is the default. For a new Docker-DinD config, the wizard asks whether to inherit the controller host's trusted TLS roots and defaults to yes; existing configs remain disabled unless they explicitly set `image.hostTrustMode: overlay`. On native Windows, when `wsl.exe --status` successfully confirms default version 2, the wizard also offers a WSL2 config; press Enter to retain Docker-DinD. The Docker preflight still applies because the default WSL image uses Docker for its one-time rootfs export. EPAR then checks the configured image, builds or replaces it when the image is missing or no longer matches the config, and starts the configured number of runners. The default config uses `pool.instances: 1`. +If no config exists, EPAR starts the initializer, asks for the GitHub App ID, organization, and private key path, then writes `.local/config.yml`. Docker-DinD is the default. For a new Docker-DinD config, the wizard asks whether to inherit the controller host's trusted TLS roots and defaults to yes; existing configs remain disabled unless they explicitly set `image.hostTrustMode: overlay`. On native Windows, when `wsl.exe --status` successfully confirms default version 2, the wizard also offers a WSL2 config. On macOS, when `tart --version` succeeds, it offers an experimental Tart config. Press Enter to retain Docker-DinD. The Docker preflight applies to Docker-DinD and the default WSL image, which uses Docker for its one-time rootfs export, but not to Tart. EPAR then checks the configured image, builds or replaces it when the image is missing or no longer matches the config, and starts the configured number of runners. The default config uses `pool.instances: 1`. Pass flags through `./start` to choose a config or runner count: @@ -84,7 +84,7 @@ If `--instances` is omitted, `start`, `pool up`, and `pool verify` use `pool.ins ## Configure Only -Use `init` when you only want to create a config without building an image or starting runners. It creates Docker-DinD by default, with the same conditional native-Windows WSL2 choice described above: +Use `init` when you only want to create a config without building an image or starting runners. It creates Docker-DinD by default, with the same conditional native-Windows WSL2 and macOS Tart choices described above: ```bash go run ./cmd/ephemeral-action-runner init @@ -96,11 +96,11 @@ On Windows PowerShell: go run ./cmd/ephemeral-action-runner init ``` -For WSL, Tart, or custom labels, copy one example config into `.local/config.yml`, then edit the GitHub App fields and any labels you want to expose to workflows. +For other WSL or Tart variants, or for custom labels, copy one example config into `.local/config.yml`, then edit the GitHub App fields and any labels you want to expose to workflows. | Host and image | Example config | | --- | --- | -| macOS Tart, runner-only | `configs/tart.example.yml` | +| macOS Tart, experimental basic Ubuntu ARM64 image | `configs/tart.example.yml` | | macOS Tart, web/E2E with Rosetta amd64 Docker support | `configs/tart.web-e2e.example.yml` | | Windows WSL2, default full Catthehacker runner image | `configs/wsl.example.yml` | | Windows WSL2, lean runner-only tar | `configs/wsl.lean.example.yml` | @@ -109,6 +109,8 @@ For WSL, Tart, or custom labels, copy one example config into `.local/config.yml | Docker-DinD, Docker-focused Catthehacker Act image | `configs/docker-dind.act.example.yml` | | Docker-DinD, smaller web/E2E custom image | `configs/docker-dind.web-e2e.example.yml` | +Tart is experimental. Its default image is a basic Ubuntu ARM64 OS image with the EPAR runner lifecycle, not the dependency-rich environment described by [`actions/runner-images`](https://github.com/actions/runner-images). If your workflows depend on that environment, adapt the upstream build scripts to create and maintain your own bootable Tart image and point `image.sourceImage` at it; EPAR does not automatically create one. + macOS: ```bash diff --git a/start b/start index 2c775da..2e8a9eb 100755 --- a/start +++ b/start @@ -5,7 +5,7 @@ set -euo pipefail # # Uses local Go if present. Otherwise runs EPAR from source inside a # containerized Go toolchain via `go run` (no local Go install needed, and -# no binary is built or left on disk). Docker is required either way. +# no binary is built or left on disk). The containerized fallback requires Docker. # See docs/advanced/no-go-install.md. script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" && pwd -P)" diff --git a/start.ps1 b/start.ps1 index ae49026..5dbd9f0 100644 --- a/start.ps1 +++ b/start.ps1 @@ -7,8 +7,8 @@ param( # # Uses local Go if present and actually runnable. Otherwise runs EPAR from # source inside a containerized Go toolchain via `go run` (no local Go -# install needed, and no binary is built or left on disk). Docker is -# required either way. See docs/advanced/no-go-install.md. +# install needed, and no binary is built or left on disk). The containerized +# fallback requires Docker. See docs/advanced/no-go-install.md. $ErrorActionPreference = "Stop" $Root = Split-Path -Parent $MyInvocation.MyCommand.Path From 484a0fffb1811487e65d045cb7a10b927ba92430 Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 21 Jul 2026 16:23:39 +0800 Subject: [PATCH 3/3] Test Tart init with distinct config values --- cmd/ephemeral-action-runner/init_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/ephemeral-action-runner/init_test.go b/cmd/ephemeral-action-runner/init_test.go index c93792d..641f65d 100644 --- a/cmd/ephemeral-action-runner/init_test.go +++ b/cmd/ephemeral-action-runner/init_test.go @@ -412,7 +412,7 @@ func TestInitOffersTartConfigWhenAvailable(t *testing.T) { ProjectRoot: dir, ConfigPath: path, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n2\n\n"), + In: strings.NewReader("654321\nexample\n.local/github-app.pem\n2\n\n"), Out: &out, }); err != nil { t.Fatal(err) @@ -427,8 +427,8 @@ func TestInitOffersTartConfigWhenAvailable(t *testing.T) { t.Fatal(err) } wantText := strings.NewReplacer( - "appId: 123456", "appId: 123456", - "organization: your-org", "organization: solutionforest", + "appId: 123456", "appId: 654321", + "organization: your-org", "organization: example", "privateKeyPath: ~/.config/ephemeral-action-runner/github-app.pem", "privateKeyPath: .local/github-app.pem", "namePrefix: CHANGE-ME-unique-machine-prefix", "namePrefix: build-box-01-a4f9c2", ).Replace(string(want))