From 2db24c7401a29a6856f1d4c52cde3812760faa1b Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sat, 5 Sep 2026 13:20:03 -0700 Subject: [PATCH] enhance: add macOS VM executor backend Replace the Tart-based prototype with a runner-oriented macOS VM executor backed by runner-executor-macos and Apple Virtualization. Assisted-by: Codet:MODEL_VERSION --- README.md | 18 +- act/container/macos_vm_cleanup.go | 73 +++ act/container/macos_vm_cleanup_test.go | 37 ++ act/container/macos_vm_environment.go | 450 ++++++++++++++++++ .../macos_vm_environment_integration_test.go | 89 ++++ act/container/macos_vm_environment_test.go | 91 ++++ act/runner/run_context.go | 120 ++++- act/runner/run_context_test.go | 23 + act/runner/runner.go | 11 + docs/macos-vm-executor.md | 93 ++++ internal/app/cmd/register_test.go | 1 + internal/app/run/runner.go | 26 +- internal/app/run/runner_idle_cleanup_test.go | 35 ++ internal/pkg/config/config.example.yaml | 16 +- internal/pkg/config/config.go | 19 + internal/pkg/config/config_test.go | 25 + internal/pkg/labels/labels.go | 23 +- internal/pkg/labels/labels_test.go | 32 ++ 18 files changed, 1167 insertions(+), 15 deletions(-) create mode 100644 act/container/macos_vm_cleanup.go create mode 100644 act/container/macos_vm_cleanup_test.go create mode 100644 act/container/macos_vm_environment.go create mode 100644 act/container/macos_vm_environment_integration_test.go create mode 100644 act/container/macos_vm_environment_test.go create mode 100644 docs/macos-vm-executor.md diff --git a/README.md b/README.md index f01e4a75..99258725 100644 --- a/README.md +++ b/README.md @@ -201,10 +201,10 @@ A label is written as: | Part | Meaning | | --- | --- | | `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. | -| `schema` | Either `docker` or `host`. Defaults to `host` when omitted. | -| `args` | Only used by the `docker` schema: the image to run the job in. | +| `schema` | Either `docker`, `host`, or `macos-vm`. Defaults to `host` when omitted. | +| `args` | Used by `docker` for the image, and by `macos-vm` for the source VM. | -Two schemas are supported: +Three schemas are supported: - **`docker://`** — the job runs inside a container created from ``: @@ -218,15 +218,21 @@ Two schemas are supported: macos:host ``` +- **`macos-vm://`** — the job runs inside an ephemeral macOS VM created by `runner-executor-macos`. The VM image must include the runner guest agent. Service containers are not supported with this schema. See `docs/macos-vm-executor.md` for configuration, integration testing, and cleanup details: + + ```text + macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest + ``` + So with the labels ```text -ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host +ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host,macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest ``` -a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, and one with `runs-on: macos` is executed directly on the host. +a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, one with `runs-on: macos` is executed directly on the host, and one with `runs-on: macos-latest` is executed in the macOS VM. -Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas. +Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host`, `docker`, and `macos-vm` are treated as schemas. If a job's `runs-on` matches none of the runner's labels, or sets no `runs-on` at all, it still runs: in `runner.default_image` where docker is available, on the host where it is not. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images). diff --git a/act/container/macos_vm_cleanup.go b/act/container/macos_vm_cleanup.go new file mode 100644 index 00000000..9d4efcc7 --- /dev/null +++ b/act/container/macos_vm_cleanup.go @@ -0,0 +1,73 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package container + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" + "time" +) + +type macOSVM struct { + Name string `json:"name"` + State string `json:"state"` + Running bool `json:"running"` + AccessedAt string `json:"accessed_at"` +} + +// CleanupOrphanMacOSVMs deletes local macOS VMs left behind by this runner that +// have not been accessed since cutoff. +func CleanupOrphanMacOSVMs(ctx context.Context, executorPath, uuid string, cutoff time.Time) error { + if uuid == "" { + return nil + } + + output, err := exec.CommandContext(ctx, executorPath, "list", "--format", "json").Output() + if err != nil { + return fmt.Errorf("list macOS VMs: %w", err) + } + + var vms []macOSVM + if err := json.Unmarshal(output, &vms); err != nil { + return fmt.Errorf("parse macOS VM list: %w", err) + } + + var errs []error + for _, name := range macOSVMsToDelete(vms, uuid, cutoff) { + if err := exec.CommandContext(ctx, executorPath, "destroy", name).Run(); err != nil { + errs = append(errs, fmt.Errorf("destroy macOS VM %s: %w", name, err)) + } + } + return errors.Join(errs...) +} + +func macOSVMsToDelete(vms []macOSVM, uuid string, cutoff time.Time) []string { + prefix := "GITEA-MACOS-VM-" + uuid + "-" + var stale []string + for _, vm := range vms { + if vm.Running || !strings.HasPrefix(vm.Name, prefix) { + continue + } + accessed, err := parseMacOSVMTime(vm.AccessedAt) + if err != nil || accessed.After(cutoff) { + continue + } + stale = append(stale, vm.Name) + } + return stale +} + +func parseMacOSVMTime(raw string) (time.Time, error) { + if raw == "" { + return time.Time{}, errors.New("empty access time") + } + if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil { + return parsed, nil + } + return time.Parse(time.RFC3339, raw) +} diff --git a/act/container/macos_vm_cleanup_test.go b/act/container/macos_vm_cleanup_test.go new file mode 100644 index 00000000..6be072af --- /dev/null +++ b/act/container/macos_vm_cleanup_test.go @@ -0,0 +1,37 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package container + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMacOSVMsToDelete(t *testing.T) { + cutoff := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC) + vms := []macOSVM{ + {Name: "GITEA-MACOS-VM-runner-1-stale", AccessedAt: "2026-09-04T10:00:00Z"}, + {Name: "GITEA-MACOS-VM-runner-1-running", AccessedAt: "2026-09-04T10:00:00Z", Running: true}, + {Name: "GITEA-MACOS-VM-runner-1-fresh", AccessedAt: "2026-09-05T11:00:00Z"}, + {Name: "GITEA-MACOS-VM-runner-2-stale", AccessedAt: "2026-09-04T10:00:00Z"}, + } + + assert.Equal(t, []string{"GITEA-MACOS-VM-runner-1-stale"}, macOSVMsToDelete(vms, "runner-1", cutoff)) +} + +func TestParseMacOSVMTime(t *testing.T) { + parsed, err := parseMacOSVMTime("2026-09-05T10:00:00Z") + require.NoError(t, err) + assert.Equal(t, time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC), parsed) + + parsed, err = parseMacOSVMTime("2026-09-05T10:00:00.123456789Z") + require.NoError(t, err) + assert.Equal(t, time.Date(2026, 9, 5, 10, 0, 0, 123456789, time.UTC), parsed) + + _, err = parseMacOSVMTime("") + require.Error(t, err) +} diff --git a/act/container/macos_vm_environment.go b/act/container/macos_vm_environment.go new file mode 100644 index 00000000..4e30c74c --- /dev/null +++ b/act/container/macos_vm_environment.go @@ -0,0 +1,450 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package container + +import ( + "archive/tar" + "context" + "errors" + "fmt" + "io" + "maps" + "os/exec" + "path" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "time" + + "gitea.com/gitea/runner/act/common" + "gitea.com/gitea/runner/act/filecollector" + + "github.com/go-git/go-billy/v5/helper/polyfill" + "github.com/go-git/go-billy/v5/osfs" + "github.com/go-git/go-git/v5/plumbing/format/gitignore" +) + +const ( + defaultMacOSVMBootTimeout = 5 * time.Minute + macOSVMReadyPollInterval = 500 * time.Millisecond + macOSVMStopWaitTimeout = 10 * time.Second +) + +// MacOSVMEnvironment runs workflow steps in an ephemeral macOS VM. +type MacOSVMEnvironment struct { + Path string // guest scratch directory + TmpDir string // guest temp directory + ToolCache string // guest tool cache directory + Workdir string // host-side workspace path + GuestWorkdir string // guest-side workspace path + ActPath string // guest act directory + + Image string + VMName string + ExecutorPath string + CPU int + Memory int + BootTimeout time.Duration + + Stdout io.Writer + Stderr io.Writer + + executorPath string + running bool + runCmd *exec.Cmd + runDone chan struct{} + runErr error +} + +// MacOSVMEnvironmentInput is the input for NewMacOSVMEnvironment. +type MacOSVMEnvironmentInput struct { + Path string + TmpDir string + ToolCache string + Workdir string + GuestWorkdir string + ActPath string + Image string + VMName string + ExecutorPath string + CPU int + Memory int + BootTimeout time.Duration + Stdout io.Writer + Stderr io.Writer +} + +func NewMacOSVMEnvironment(input MacOSVMEnvironmentInput) (*MacOSVMEnvironment, error) { + if input.Image == "" { + return nil, errors.New("macOS VM image is required") + } + if input.VMName == "" { + return nil, errors.New("macOS VM name is required") + } + if input.BootTimeout <= 0 { + input.BootTimeout = defaultMacOSVMBootTimeout + } + if input.ExecutorPath == "" { + input.ExecutorPath = "runner-executor-macos" + } + return &MacOSVMEnvironment{ + Path: input.Path, + TmpDir: input.TmpDir, + ToolCache: input.ToolCache, + Workdir: input.Workdir, + GuestWorkdir: input.GuestWorkdir, + ActPath: input.ActPath, + Image: input.Image, + VMName: input.VMName, + ExecutorPath: input.ExecutorPath, + CPU: input.CPU, + Memory: input.Memory, + BootTimeout: input.BootTimeout, + Stdout: input.Stdout, + Stderr: input.Stderr, + executorPath: input.ExecutorPath, + }, nil +} + +var _ ExecutionsEnvironment = &MacOSVMEnvironment{} + +func (e *MacOSVMEnvironment) Create(_, _ []string) common.Executor { + return func(ctx context.Context) error { + if common.Dryrun(ctx) { + return nil + } + // A previous run can leave the deterministic job VM behind. Destroy it first. + _ = e.runExecutor(ctx, nil, io.Discard, io.Discard, "destroy", e.VMName) + + args := []string{"provision", e.Image, e.VMName} + if e.CPU > 0 { + args = append(args, "--cpu", strconv.Itoa(e.CPU)) + } + if e.Memory > 0 { + args = append(args, "--memory", strconv.Itoa(e.Memory)) + } + return e.runExecutor(ctx, nil, e.Stdout, e.Stderr, args...) + } +} + +func (e *MacOSVMEnvironment) Copy(destPath string, files ...*FileEntry) common.Executor { + return func(ctx context.Context) error { + if common.Dryrun(ctx) { + return nil + } + for _, file := range files { + dest := path.Join(destPath, file.Name) + script := `mkdir -p "$1" && cat > "$2" && chmod "$3" "$2"` + mode := file.Mode + if mode == 0 { + mode = 0o644 + } + args := []string{"exec", "--stdin", e.VMName, "/bin/sh", "-c", script, "sh", path.Dir(dest), dest, strconv.FormatInt(mode, 8)} + if err := e.runExecutor(ctx, strings.NewReader(file.Body), e.Stdout, e.Stderr, args...); err != nil { + return err + } + } + return nil + } +} + +func (e *MacOSVMEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor { + return func(ctx context.Context) error { + if common.Dryrun(ctx) { + return nil + } + pipeReader, pipeWriter := io.Pipe() + writeDone := make(chan error, 1) + go func() { + err := e.writeTar(ctx, pipeWriter, srcPath, useGitIgnore) + _ = pipeWriter.CloseWithError(err) + writeDone <- err + }() + + script := `mkdir -p "$1" && /usr/bin/tar -xf - -C "$1"` + cmdErr := e.runExecutor(ctx, pipeReader, e.Stdout, e.Stderr, "exec", "--stdin", e.VMName, "/bin/sh", "-c", script, "sh", destPath) + _ = pipeReader.Close() + writeErr := <-writeDone + return errors.Join(cmdErr, writeErr) + } +} + +func (e *MacOSVMEnvironment) writeTar(ctx context.Context, writer io.Writer, srcPath string, useGitIgnore bool) error { + tarWriter := tar.NewWriter(writer) + defer tarWriter.Close() + + srcPrefix := filepath.Dir(srcPath) + if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) { + srcPrefix += string(filepath.Separator) + } + + var ignorer gitignore.Matcher + if useGitIgnore { + patterns, err := gitignore.ReadPatterns(polyfill.New(osfs.New(srcPath)), nil) + if err != nil { + common.Logger(ctx).Debugf("Error loading .gitignore: %v", err) + } + ignorer = gitignore.NewMatcher(patterns) + } + + collector := &filecollector.FileCollector{ + Ignorer: ignorer, + SrcPath: srcPath, + SrcPrefix: srcPrefix, + Handler: &filecollector.TarCollector{TarWriter: tarWriter}, + } + return filepath.Walk(srcPath, collector.CollectFiles(ctx, []string{})) +} + +func (e *MacOSVMEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) { + if common.Dryrun(ctx) { + return nil, errors.New("DRYRUN is not supported in GetContainerArchive") + } + cmd := exec.CommandContext(ctx, e.executorPath, "exec", e.VMName, "/usr/bin/tar", "-cf", "-", srcPath) + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + cmd.Stderr = e.Stderr + if err := cmd.Start(); err != nil { + return nil, err + } + return &macOSVMArchiveReader{ReadCloser: stdout, cmd: cmd}, nil +} + +type macOSVMArchiveReader struct { + io.ReadCloser + cmd *exec.Cmd +} + +func (r *macOSVMArchiveReader) Close() error { + closeErr := r.ReadCloser.Close() + return errors.Join(closeErr, r.cmd.Wait()) +} + +func (e *MacOSVMEnvironment) Inspect(ctx context.Context) (*Info, error) { + if common.Dryrun(ctx) { + return &Info{Health: HealthNone, Ports: map[string]string{}}, nil + } + state := "exited" + if e.running { + state = StateRunning + } + return &Info{ID: e.VMName, State: state, Health: HealthNone, Ports: map[string]string{}}, nil +} + +func (e *MacOSVMEnvironment) DumpLogs(context.Context) error { + return nil +} + +func (e *MacOSVMEnvironment) Pull(forcePull bool) common.Executor { + return func(ctx context.Context) error { + if common.Dryrun(ctx) { + return nil + } + return nil + } +} + +func (e *MacOSVMEnvironment) Start(bool) common.Executor { + return func(ctx context.Context) error { + if common.Dryrun(ctx) { + return nil + } + cmd := exec.Command(e.executorPath, "start", "--no-graphics", e.VMName) + cmd.Stdout = e.Stdout + cmd.Stderr = e.Stderr + if err := cmd.Start(); err != nil { + return err + } + e.runCmd = cmd + e.runDone = make(chan struct{}) + go func() { + e.runErr = cmd.Wait() + close(e.runDone) + }() + e.running = true + return e.waitReady(ctx) + } +} + +func (e *MacOSVMEnvironment) waitReady(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, e.BootTimeout) + defer cancel() + + var lastErr error + for { + if err := e.runExecutor(ctx, nil, io.Discard, io.Discard, "exec", e.VMName, "/bin/true"); err == nil { + return nil + } else { + lastErr = err + } + + select { + case <-ctx.Done(): + return fmt.Errorf("macOS VM %s did not become ready: %w", e.VMName, lastErr) + case <-e.runDone: + if e.runErr != nil { + return fmt.Errorf("macOS VM start exited before the VM became ready: %w", e.runErr) + } + return errors.New("macOS VM start exited before the VM became ready") + case <-time.After(macOSVMReadyPollInterval): + } + } +} + +func (e *MacOSVMEnvironment) Exec(command []string, env map[string]string, _, workdir string) common.Executor { + return func(ctx context.Context) error { + if common.Dryrun(ctx) { + return nil + } + err := e.runExecutor(ctx, nil, e.Stdout, e.Stderr, macOSVMExecArgs(e.VMName, command, env, e.resolveWorkdir(workdir))...) + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + return ExitCodeError(exitErr.ExitCode()) + } + return err + } +} + +func macOSVMExecArgs(vmName string, command []string, env map[string]string, workdir string) []string { + args := []string{"exec", vmName, "/usr/bin/env"} + for _, key := range slices.Sorted(maps.Keys(env)) { + args = append(args, key+"="+env[key]) + } + args = append(args, "/bin/sh", "-c", `cd "$1" && shift && exec "$@"`, "sh", workdir) + return append(args, command...) +} + +func (e *MacOSVMEnvironment) resolveWorkdir(workdir string) string { + switch { + case workdir == "": + return e.GuestWorkdir + case strings.HasPrefix(workdir, "/"): + return workdir + default: + return path.Join(e.GuestWorkdir, workdir) + } +} + +func (e *MacOSVMEnvironment) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor { + return parseEnvFile(e, srcPath, env) +} + +func (e *MacOSVMEnvironment) UpdateFromImageEnv(*map[string]string) common.Executor { + return func(context.Context) error { + return nil + } +} + +func (e *MacOSVMEnvironment) Remove() common.Executor { + return func(ctx context.Context) error { + if common.Dryrun(ctx) { + return nil + } + cleanCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute) + defer cancel() + + if e.runCmd != nil && e.runDone != nil { + select { + case <-e.runDone: + default: + if err := e.runExecutor(cleanCtx, nil, e.Stdout, e.Stderr, "stop", e.VMName, "--timeout", "20"); err != nil { + common.Logger(ctx).Debugf("macOS VM stop failed for VM %s: %v", e.VMName, err) + } + select { + case <-e.runDone: + case <-time.After(macOSVMStopWaitTimeout): + if e.runCmd.Process != nil { + _ = e.runCmd.Process.Kill() + } + common.Logger(ctx).Warnf("timed out waiting for macOS VM start to exit for VM %s", e.VMName) + } + } + } + e.running = false + if err := e.runExecutor(cleanCtx, nil, e.Stdout, e.Stderr, "destroy", e.VMName); err != nil { + common.Logger(ctx).Warnf("failed to destroy macOS VM %s: %v", e.VMName, err) + } + return nil + } +} + +func (e *MacOSVMEnvironment) Close() common.Executor { + return func(ctx context.Context) error { + if common.Dryrun(ctx) { + return nil + } + if e.runCmd != nil && e.runCmd.Process != nil { + _ = e.runCmd.Process.Kill() + } + e.runCmd = nil + return nil + } +} + +func (e *MacOSVMEnvironment) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writer, io.Writer) { + oldOut := e.Stdout + oldErr := e.Stderr + e.Stdout = stdout + e.Stderr = stderr + return oldOut, oldErr +} + +func (e *MacOSVMEnvironment) ToContainerPath(hostPath string) string { + if filepath.Clean(hostPath) == filepath.Clean(e.Workdir) { + return e.GuestWorkdir + } + if rel, err := filepath.Rel(e.Workdir, hostPath); err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return path.Join(e.GuestWorkdir, filepath.ToSlash(rel)) + } + return filepath.ToSlash(hostPath) +} + +func (e *MacOSVMEnvironment) GetActPath() string { + return e.ActPath +} + +func (*MacOSVMEnvironment) GetPathVariableName() string { + return "PATH" +} + +func (*MacOSVMEnvironment) DefaultPathVariable() string { + return "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" +} + +func (*MacOSVMEnvironment) JoinPathVariable(paths ...string) string { + return strings.Join(paths, ":") +} + +func (e *MacOSVMEnvironment) GetRunnerContext(context.Context) map[string]any { + return map[string]any{ + "os": "macOS", + "arch": goArchToActionArch(runtime.GOARCH), + "temp": e.TmpDir, + "tool_cache": e.ToolCache, + } +} + +func (*MacOSVMEnvironment) IsEnvironmentCaseInsensitive() bool { + return false +} + +func (e *MacOSVMEnvironment) runExecutor(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args ...string) error { + if stdout == nil { + stdout = e.Stdout + } + if stderr == nil { + stderr = e.Stderr + } + cmd := exec.CommandContext(ctx, e.executorPath, args...) + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("macOS VM executor %s: %w", args[0], err) + } + return nil +} diff --git a/act/container/macos_vm_environment_integration_test.go b/act/container/macos_vm_environment_integration_test.go new file mode 100644 index 00000000..b3d3f041 --- /dev/null +++ b/act/container/macos_vm_environment_integration_test.go @@ -0,0 +1,89 @@ +//go:build macos_vm_integration + +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package container + +import ( + "archive/tar" + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestMacOSVMEnvironmentIntegration(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("macOS VMs run on macOS") + } + if _, err := exec.LookPath("runner-executor-macos"); err != nil { + t.Skip("runner-executor-macos executable not found") + } + image := os.Getenv("MACOS_VM_TEST_IMAGE") + if image == "" { + t.Skip("set MACOS_VM_TEST_IMAGE to a macOS VM image to run this test") + } + + ctx := context.Background() + vmName := fmt.Sprintf("gitea-runner-test-%d", time.Now().UnixNano()) + env, err := NewMacOSVMEnvironment(MacOSVMEnvironmentInput{ + Path: "/Users/admin/runner/scratch/" + vmName, + TmpDir: "/Users/admin/runner/scratch/" + vmName + "/tmp", + ToolCache: "/Users/admin/runner/scratch/" + vmName + "/tool_cache", + Workdir: t.TempDir(), + GuestWorkdir: "/Users/admin/runner/workspace/" + vmName, + ActPath: "/Users/admin/runner/scratch/" + vmName + "/act", + Image: image, + VMName: vmName, + ExecutorPath: "runner-executor-macos", + BootTimeout: 10 * time.Minute, + Stdout: io.Discard, + Stderr: io.Discard, + }) + require.NoError(t, err) + t.Cleanup(func() { + _ = env.Remove()(context.Background()) + }) + + require.NoError(t, env.Create(nil, nil)(ctx)) + require.NoError(t, env.Start(false)(ctx)) + + require.NoError(t, env.Copy("/tmp", &FileEntry{ + Name: "gitea-runner-macos.txt", + Mode: 0o644, + Body: "hello from macos-vm", + })(ctx)) + + archive, err := env.GetContainerArchive(ctx, "/tmp/gitea-runner-macos.txt") + require.NoError(t, err) + body := readFirstTarFile(t, archive) + require.NoError(t, archive.Close()) + require.Equal(t, "hello from macos-vm", body) + + var output bytes.Buffer + oldOut, oldErr := env.ReplaceLogWriter(&output, &output) + defer env.ReplaceLogWriter(oldOut, oldErr) + require.NoError(t, env.Exec([]string{"/usr/bin/uname", "-s"}, nil, "", "")(ctx)) + require.Contains(t, output.String(), "Darwin") + + require.NoError(t, env.Remove()(ctx)) +} + +func readFirstTarFile(t *testing.T, archive io.Reader) string { + t.Helper() + reader := tar.NewReader(archive) + header, err := reader.Next() + require.NoError(t, err) + require.Contains(t, header.Name, "gitea-runner-macos.txt") + body, err := io.ReadAll(reader) + require.NoError(t, err) + return string(body) +} diff --git a/act/container/macos_vm_environment_test.go b/act/container/macos_vm_environment_test.go new file mode 100644 index 00000000..a160cae2 --- /dev/null +++ b/act/container/macos_vm_environment_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package container + +import ( + "context" + "io" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewMacOSVMEnvironment(t *testing.T) { + env, err := NewMacOSVMEnvironment(MacOSVMEnvironmentInput{ + Image: "ghcr.io/cirruslabs/macos-sonoma-base:latest", + VMName: "job-vm", + }) + require.NoError(t, err) + assert.Equal(t, "ghcr.io/cirruslabs/macos-sonoma-base:latest", env.Image) + assert.Equal(t, "job-vm", env.VMName) + assert.Equal(t, defaultMacOSVMBootTimeout, env.BootTimeout) + assert.Equal(t, "runner-executor-macos", env.executorPath) + + _, err = NewMacOSVMEnvironment(MacOSVMEnvironmentInput{VMName: "job-vm"}) + require.Error(t, err) + _, err = NewMacOSVMEnvironment(MacOSVMEnvironmentInput{Image: "macos"}) + require.Error(t, err) +} + +func TestMacOSVMExecArgs(t *testing.T) { + args := macOSVMExecArgs("vm", []string{"bash", "-c", "echo hi"}, map[string]string{"B": "two", "A": "one"}, "/Users/admin/work") + assert.Equal(t, []string{ + "exec", "vm", "/usr/bin/env", + "A=one", "B=two", + "/bin/sh", "-c", `cd "$1" && shift && exec "$@"`, "sh", "/Users/admin/work", + "bash", "-c", "echo hi", + }, args) + + args = macOSVMExecArgs("vm", []string{"true"}, nil, "") + assert.Equal(t, []string{ + "exec", "vm", "/usr/bin/env", + "/bin/sh", "-c", `cd "$1" && shift && exec "$@"`, "sh", "", + "true", + }, args) +} + +func TestMacOSVMEnvironmentResolveWorkdir(t *testing.T) { + env := &MacOSVMEnvironment{GuestWorkdir: "/Users/admin/work"} + assert.Equal(t, "/Users/admin/work", env.resolveWorkdir("")) + assert.Equal(t, "/Users/admin/work/sub", env.resolveWorkdir("sub")) + assert.Equal(t, "/tmp", env.resolveWorkdir("/tmp")) +} + +func TestMacOSVMEnvironmentToContainerPath(t *testing.T) { + env := &MacOSVMEnvironment{Workdir: "/workspace/owner/repo", GuestWorkdir: "/Users/admin/work/owner/repo"} + assert.Equal(t, "/Users/admin/work/owner/repo", env.ToContainerPath("/workspace/owner/repo")) + assert.Equal(t, "/Users/admin/work/owner/repo/sub", env.ToContainerPath("/workspace/owner/repo/sub")) + assert.Equal(t, "/elsewhere", env.ToContainerPath("/elsewhere")) +} + +func TestMacOSVMEnvironmentGetRunnerContext(t *testing.T) { + env := &MacOSVMEnvironment{ + TmpDir: "/Users/admin/scratch/tmp", + ToolCache: "/Users/admin/scratch/tool_cache", + } + ctx := env.GetRunnerContext(context.Background()) + assert.Equal(t, "macOS", ctx["os"]) + assert.Equal(t, goArchToActionArch(runtime.GOARCH), ctx["arch"]) + assert.Equal(t, "/Users/admin/scratch/tmp", ctx["temp"]) + assert.Equal(t, "/Users/admin/scratch/tool_cache", ctx["tool_cache"]) +} + +func TestMacOSVMEnvironmentReplaceLogWriter(t *testing.T) { + oldOut := io.Discard + oldErr := io.Discard + env := &MacOSVMEnvironment{Stdout: oldOut, Stderr: oldErr} + newOut, newErr := env.ReplaceLogWriter(io.Discard, io.Discard) + assert.Equal(t, oldOut, newOut) + assert.Equal(t, oldErr, newErr) +} + +func TestMacOSVMEnvironmentDefaultPathVariable(t *testing.T) { + env := &MacOSVMEnvironment{} + assert.Equal(t, "PATH", env.GetPathVariableName()) + assert.Equal(t, "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin", env.DefaultPathVariable()) + assert.Equal(t, "/a:/b", env.JoinPathVariable("/a", "/b")) + assert.False(t, env.IsEnvironmentCaseInsensitive()) +} diff --git a/act/runner/run_context.go b/act/runner/run_context.go index 8c3efa89..07d6d4fc 100644 --- a/act/runner/run_context.go +++ b/act/runner/run_context.go @@ -17,6 +17,7 @@ import ( "io" maps0 "maps" "os" + "path" "path/filepath" "regexp" "runtime" @@ -28,6 +29,7 @@ import ( "gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/act/ghcontext" + "gitea.com/gitea/runner/internal/pkg/labels" "gitea.com/gitea/runner/internal/pkg/lock" "gitea.dev/actionslib/pkg/exprparser" @@ -424,6 +426,112 @@ func (rc *RunContext) startHostEnvironment() common.Executor { } } +// printStartMacOSVMGroup mirrors the "Starting job container" section for macOS VMs. +func printStartMacOSVMGroup(ctx context.Context, image, name string) func() { + rawLogger := common.Logger(ctx).WithField(rawOutputField, true) + rawLogger.Infof("::group::Starting job VM") + rawLogger.Infof("image: %s", image) + rawLogger.Infof("name: %s", name) + return func() { + rawLogger.Infof("::endgroup::") + } +} + +func (rc *RunContext) startMacOSVMEnvironment() common.Executor { + return func(ctx context.Context) error { + if len(rc.Run.Job().Services) > 0 { + return errors.New("service containers are not supported with the macOS VM executor; see docs/macos-vm-executor.md") + } + + image := rc.macOSVMImage() + logWriter := rc.commandLogWriter(ctx) + name := rc.macOSVMName() + rc.Env["JOB_CONTAINER_NAME"] = name + + guestWorkdir := rc.macOSVMGuestWorkdir() + scratch := path.Join("/", rc.macOSVMWorkdirParent(), "scratch", name) + actPath := path.Join(scratch, "act") + tmpDir := path.Join(scratch, "tmp") + toolCache := rc.toolCache(path.Join(scratch, "tool_cache")) + + jobContainer, err := container.NewMacOSVMEnvironment(container.MacOSVMEnvironmentInput{ + Path: scratch, + TmpDir: tmpDir, + ToolCache: toolCache, + Workdir: rc.Config.Workdir, + GuestWorkdir: guestWorkdir, + ActPath: actPath, + Image: image, + VMName: name, + ExecutorPath: rc.Config.MacOSVM.ExecutorPath, + CPU: rc.Config.MacOSVM.CPU, + Memory: rc.Config.MacOSVM.Memory, + BootTimeout: rc.Config.MacOSVM.BootTimeout, + Stdout: logWriter, + Stderr: logWriter, + }) + if err != nil { + return err + } + rc.JobContainer = jobContainer + rc.cleanUpJobContainer = jobContainer.Remove() + + for k, v := range rc.getRunnerContext(ctx) { + if v, ok := v.(string); ok { + rc.Env["RUNNER_"+strings.ToUpper(k)] = v + } + } + for _, env := range os.Environ() { + if k, v, ok := strings.Cut(env, "="); ok { + if _, ok := rc.Env[k]; !ok { + rc.Env[k] = v + } + } + } + + defer printStartMacOSVMGroup(ctx, image, name)() + return common.NewPipelineExecutor( + jobContainer.Pull(rc.Config.ForcePull), + jobContainer.Create(nil, nil), + jobContainer.Start(false), + rc.captureJobContainerInfo(), + jobContainer.Copy(jobContainer.GetActPath()+"/", &container.FileEntry{ + Name: "workflow/event.json", + Mode: 0o644, + Body: rc.EventJSON, + }, &container.FileEntry{ + Name: "workflow/envs.txt", + Mode: 0o666, + Body: "", + }), + )(ctx) + } +} + +func (rc *RunContext) macOSVMImage() string { + return strings.TrimPrefix(rc.platformImage, labels.MacOSVMSchemePrefix) +} + +func (rc *RunContext) macOSVMName() string { + nameParts := []string{"GITEA-MACOS-VM", rc.Config.RunnerUUID, rc.Config.ContainerNamePrefix, "WORKFLOW-" + rc.Run.Workflow.Name, "JOB-" + rc.Run.JobID, rc.Name} + if rc.caller != nil { + nameParts = append(nameParts, "CALLED-BY-"+rc.caller.runContext.JobName) + } + return createContainerName(nameParts...) +} + +func (rc *RunContext) macOSVMGuestWorkdir() string { + rel := strings.TrimLeft(filepath.ToSlash(rc.Config.Workdir), "/") + return path.Join("/", rc.macOSVMWorkdirParent(), rel) +} + +func (rc *RunContext) macOSVMWorkdirParent() string { + if rc.Config.MacOSVM.WorkdirParent != "" { + return rc.Config.MacOSVM.WorkdirParent + } + return "/Users/admin/runner" +} + // printStartJobContainerGroup mirrors actions/runner's "Starting job container" // section: emit the group header and summary, return a closer for ::endgroup::. func printStartJobContainerGroup(ctx context.Context, image, name, network string) func() { @@ -980,9 +1088,12 @@ func (rc *RunContext) interpolateOutputs() common.Executor { func (rc *RunContext) startContainer() common.Executor { return func(ctx context.Context) error { var err error - if rc.IsHostEnv() { + switch { + case rc.IsHostEnv(): err = rc.startHostEnvironment()(ctx) - } else { + case rc.IsMacOSVMEnv(): + err = rc.startMacOSVMEnvironment()(ctx) + default: err = rc.startJobContainer()(ctx) } if err != nil { @@ -1014,6 +1125,11 @@ func (rc *RunContext) IsHostEnv() bool { return strings.EqualFold(rc.platformImage, "-self-hosted") } +// IsMacOSVMEnv reports whether the resolved platform image requests a macOS VM. +func (rc *RunContext) IsMacOSVMEnv() bool { + return strings.HasPrefix(rc.platformImage, labels.MacOSVMSchemePrefix) +} + func (rc *RunContext) stopContainer() common.Executor { return rc.stopJobContainer() } diff --git a/act/runner/run_context_test.go b/act/runner/run_context_test.go index 08dc936b..b7657c48 100644 --- a/act/runner/run_context_test.go +++ b/act/runner/run_context_test.go @@ -18,6 +18,7 @@ import ( "gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/container" + "gitea.com/gitea/runner/internal/pkg/labels" "gitea.dev/actionslib/pkg/exprparser" "gitea.dev/actionslib/pkg/model" @@ -1458,3 +1459,25 @@ func TestRunContextWithGithubEnvRunnerValues(t *testing.T) { assert.Equal(t, "/workspace/owner", env["RUNNER_WORKSPACE"]) assert.Equal(t, "1", env["RUNNER_DEBUG"]) } + +func TestRunContextMacOSVMPlatform(t *testing.T) { + rc := createRunsOnRunContext(t, "macos-latest") + rc.Config.PlatformPicker = func([]string) string { + return labels.MacOSVMSchemePrefix + "ghcr.io/cirruslabs/macos-sonoma-base:latest" + } + require.NoError(t, rc.resolvePlatformImage(t.Context())) + + assert.True(t, rc.IsMacOSVMEnv()) + assert.False(t, rc.IsHostEnv()) + assert.Equal(t, "ghcr.io/cirruslabs/macos-sonoma-base:latest", rc.macOSVMImage()) +} + +func TestRunContextMacOSVMGuestWorkdir(t *testing.T) { + rc := createRunsOnRunContext(t, "macos-latest") + rc.Config.Workdir = "/workspace/owner/repo" + rc.Config.MacOSVM.WorkdirParent = "/Users/admin/runner" + rc.Config.RunnerUUID = "runner-1" + + assert.Equal(t, "/Users/admin/runner/workspace/owner/repo", rc.macOSVMGuestWorkdir()) + assert.Contains(t, rc.macOSVMName(), "GITEA-MACOS-VM-runner-1-") +} diff --git a/act/runner/runner.go b/act/runner/runner.go index 91102930..568142b8 100644 --- a/act/runner/runner.go +++ b/act/runner/runner.go @@ -79,9 +79,20 @@ type Config struct { MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count) AllocatePTY bool // allocate a pseudo-TTY for each step's process ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default) + RunnerUUID string // UUID this runner registered with, used to identify its macOS VMs RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty + MacOSVM MacOSVMConfig // configures the macOS VM executor +} + +// MacOSVMConfig contains the macOS VM executor settings that come from the runner config. +type MacOSVMConfig struct { + ExecutorPath string // path to the runner-executor-macos binary + WorkdirParent string // guest directory under which the job workspace is created + CPU int // number of virtual CPUs, 0 keeps the image default + Memory int // VM memory in megabytes, 0 keeps the image default + BootTimeout time.Duration // how long to wait for the VM to become ready } // RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and diff --git a/docs/macos-vm-executor.md b/docs/macos-vm-executor.md new file mode 100644 index 00000000..5a758fbf --- /dev/null +++ b/docs/macos-vm-executor.md @@ -0,0 +1,93 @@ +# macOS VM executor + +The runner can execute a job in an ephemeral macOS VM by registering a +`macos-vm` label: + +```yaml +runner: + labels: + - macos-latest:macos-vm://registry.example.com/macos-15.2-xcode-26.1 +``` + +The VM is created and managed by `runner-executor-macos`, a dedicated Swift +binary built on Apple Virtualization.framework. The source VM must include the +runner guest agent. + +## Configuration + +```yaml +macos_vm: + executor_path: runner-executor-macos + workdir_parent: /Users/admin/runner + cpu: 0 + memory: 0 + boot_timeout: 5m +``` + +`workdir_parent` is the guest directory under which job workspaces are created. +`executor_path` is the binary to invoke; an empty value uses PATH lookup. + +## Executor CLI + +The runner uses a narrow, runner-oriented command set: + +```text +runner-executor-macos provision [--cpu N] [--memory MB] +runner-executor-macos start [--no-graphics] +runner-executor-macos exec [--stdin] +runner-executor-macos stop [--timeout N] +runner-executor-macos destroy +runner-executor-macos list --format json +runner-executor-macos version +``` + +`list --format json` returns: + +```json +[ + { + "name": "GITEA-MACOS-VM--...", + "state": "stopped", + "running": false, + "accessed_at": "2026-09-05T12:00:00Z" + } +] +``` + +## Integration test + +The end-to-end test is opt-in because it provisions and boots a real VM: + +```bash +MACOS_VM_TEST_IMAGE=/path/or/reference \ + go test -tags macos_vm_integration ./act/container -run '^TestMacOSVMEnvironmentIntegration$' -v +``` + +The test skips when `MACOS_VM_TEST_IMAGE` is unset. + +## Orphaned VM cleanup + +Job VM names start with `GITEA-MACOS-VM--`. While the runner is +idle, it lists local VMs and destroys the non-running ones whose name has this +runner's UUID and whose access time is older than +`runner.workdir_cleanup_age`. + +## Service containers + +Service containers are intentionally rejected for macOS VM jobs today. Docker +service containers live on a Docker network and are reached by workflow service +id, which a macOS VM cannot resolve. + +Possible future designs: + +1. **Host-published Docker services + guest host mapping** — publish each + service port on the runner host and inject ` -> ` into + the VM through the guest agent. +2. **macOS VM services** — run each service as another VM; this requires + service discovery and is much heavier than Docker containers. +3. **Environment-based discovery** — require workflows to read service + addresses from env vars instead of hostnames, breaking GitHub Actions + compatibility. + +Option 1 is the most compatible direction once the guest agent and host network +configuration are stable. diff --git a/internal/app/cmd/register_test.go b/internal/app/cmd/register_test.go index 72b22495..9cc36349 100644 --- a/internal/app/cmd/register_test.go +++ b/internal/app/cmd/register_test.go @@ -62,6 +62,7 @@ func TestRegisterInputsValidate(t *testing.T) { func TestValidateLabels(t *testing.T) { require.NoError(t, validateLabels([]string{"ubuntu:host", "ubuntu:docker://node:18"})) + require.NoError(t, validateLabels([]string{"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest"})) // a colon that is not a supported schema is part of the label name require.NoError(t, validateLabels([]string{"pool:e57e18d4-10d4-406f-93bf-60f127221bdd"})) require.Error(t, validateLabels([]string{"ubuntu:host", ""})) diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index f25bb839..7820bf50 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -144,6 +144,9 @@ func (r *Runner) Close() error { // removeOrphanNetworks is a variable so tests can substitute one that needs no Docker daemon. var removeOrphanNetworks = container.RemoveOrphanNetworks +// removeOrphanMacOSVMs is a variable so tests can substitute one that needs no macOS VM executor. +var removeOrphanMacOSVMs = container.CleanupOrphanMacOSVMs + // OnIdle performs lightweight maintenance during polling idle windows. // It runs synchronously on the poller goroutine; shouldRunIdleCleanup // throttles invocations to runner.idle_cleanup_interval so the impact on @@ -166,6 +169,7 @@ func (r *Runner) OnIdle(ctx context.Context) { r.cleanupStaleDirs(ctx, hostRoot, isHostScratchDir) } r.cleanupOrphanNetworks(ctx) + r.cleanupOrphanMacOSVMs(ctx) } // cleanupOrphanNetworks reclaims the per-job networks of jobs this runner did not live to @@ -182,6 +186,16 @@ func (r *Runner) cleanupOrphanNetworks(ctx context.Context) { } } +func (r *Runner) cleanupOrphanMacOSVMs(ctx context.Context) { + if r.uuid == "" || !r.labels.RequireMacOSVM() || r.cfg.Runner.WorkdirCleanupAge <= 0 { + return + } + cutoff := r.now().Add(-r.cfg.Runner.WorkdirCleanupAge) + if err := removeOrphanMacOSVMs(ctx, r.cfg.MacOSVM.ExecutorPath, r.uuid, cutoff); err != nil { + log.Warnf("failed to clean up macOS VMs left behind by earlier jobs: %v", err) + } +} + func (r *Runner) shouldRunIdleCleanup() bool { if r.cfg.Runner.WorkdirCleanupAge <= 0 || r.cfg.Runner.IdleCleanupInterval <= 0 { return false @@ -548,8 +562,16 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. Vars: task.Vars, ValidVolumes: r.cfg.Container.ValidVolumes, SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared, - InsecureSkipTLS: r.cfg.Runner.Insecure, - RunnerName: r.name, + MacOSVM: runner.MacOSVMConfig{ + ExecutorPath: r.cfg.MacOSVM.ExecutorPath, + WorkdirParent: r.cfg.MacOSVM.WorkdirParent, + CPU: r.cfg.MacOSVM.CPU, + Memory: r.cfg.MacOSVM.Memory, + BootTimeout: r.cfg.MacOSVM.BootTimeout, + }, + InsecureSkipTLS: r.cfg.Runner.Insecure, + RunnerUUID: r.uuid, + RunnerName: r.name, } rr, err := runner.New(runnerConfig) diff --git a/internal/app/run/runner_idle_cleanup_test.go b/internal/app/run/runner_idle_cleanup_test.go index c19fb796..2bb96894 100644 --- a/internal/app/run/runner_idle_cleanup_test.go +++ b/internal/app/run/runner_idle_cleanup_test.go @@ -12,6 +12,7 @@ import ( "time" "gitea.com/gitea/runner/internal/pkg/config" + "gitea.com/gitea/runner/internal/pkg/labels" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -331,3 +332,37 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) { hostOnly.OnIdle(context.Background()) assert.Equal(t, []string{"runner-1"}, swept) } + +func TestRunnerOnIdleRemovesOrphanMacOSVMs(t *testing.T) { + now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC) + cfg := &config.Config{ + Runner: config.Runner{ + WorkdirCleanupAge: 24 * time.Hour, + IdleCleanupInterval: time.Minute, + }, + } + macOSVMLabel, err := labels.Parse("macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest") + require.NoError(t, err) + + var swept []string + var sweptCutoff time.Time + origRemoveOrphanMacOSVMs := removeOrphanMacOSVMs + removeOrphanMacOSVMs = func(_ context.Context, _, runnerUUID string, accessedBefore time.Time) error { + swept = append(swept, runnerUUID) + sweptCutoff = accessedBefore + return nil + } + t.Cleanup(func() { removeOrphanMacOSVMs = origRemoveOrphanMacOSVMs }) + + r := &Runner{uuid: "runner-1", cfg: cfg, labels: labels.Labels{macOSVMLabel}, now: func() time.Time { return now }} + r.OnIdle(context.Background()) + assert.Equal(t, []string{"runner-1"}, swept) + assert.Equal(t, now.Add(-24*time.Hour), sweptCutoff) + + // a docker-only runner has no macOS VMs to sweep + dockerLabel, err := labels.Parse("ubuntu:docker://node:18") + require.NoError(t, err) + dockerOnly := &Runner{uuid: "runner-2", cfg: &config.Config{Runner: cfg.Runner}, labels: labels.Labels{dockerLabel}, now: func() time.Time { return now }} + dockerOnly.OnIdle(context.Background()) + assert.Equal(t, []string{"runner-1"}, swept) +} diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml index 7ade6668..37839efe 100644 --- a/internal/pkg/config/config.example.yaml +++ b/internal/pkg/config/config.example.yaml @@ -92,7 +92,8 @@ runner: # at the price of the stock artifact actions refusing and the cache client keeping to v1. #patch_actions: true # The labels of a runner are used to determine which jobs the runner can run, and how to run them. - # Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest" + # Like: "macos-arm64:host", "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest", + # or "macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest" # Find more images provided by Gitea at https://gitea.com/gitea/runner-images . # If it's empty when registering, it will ask for inputting labels. # If it's empty when execute `daemon`, will use labels in `.runner` file. @@ -271,6 +272,19 @@ host: # If it's empty, $HOME/.cache/act/ will be used. #workdir_parent: +macos_vm: + # Path to the runner-executor-macos binary. Empty uses PATH lookup. + #executor_path: runner-executor-macos + # The guest directory under which the job workspace is created. The VM must run + # as a user that can write there. + #workdir_parent: /Users/admin/runner + # Number of virtual CPUs. Omit or set to 0 to keep the image default. + #cpu: 0 + # VM memory in megabytes. Omit or set to 0 to keep the image default. + #memory: 0 + # How long to wait for the VM to become ready after it starts. + #boot_timeout: 5m + # Optional local task-admission checks. Disabled by default. When enabled, low # disk space or a failing script pauses new task fetching; existing jobs continue. # No health checks run while any job is active; the last result is reused until idle. diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index c1ada7d9..a1ef34a2 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -179,6 +179,15 @@ type Host struct { WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the host's working directory. } +// MacOSVM represents the configuration for the macOS VM executor. +type MacOSVM struct { + ExecutorPath string `yaml:"executor_path"` // ExecutorPath is the runner-executor-macos binary to invoke. + WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent is the guest directory under which the job workspace is created. + CPU int `yaml:"cpu"` // CPU is the number of virtual CPUs. Zero keeps the image default. + Memory int `yaml:"memory"` // Memory is the VM memory in megabytes. Zero keeps the image default. + BootTimeout time.Duration `yaml:"boot_timeout"` // BootTimeout bounds how long to wait for the VM to become ready. +} + // Metrics represents the configuration for the Prometheus metrics endpoint. type Metrics struct { Enabled bool `yaml:"enabled"` // Enabled indicates whether the metrics endpoint is exposed. @@ -203,6 +212,7 @@ type Config struct { Cache Cache `yaml:"cache"` // Cache represents the configuration for caching. Container Container `yaml:"container"` // Container represents the configuration for the container. Host Host `yaml:"host"` // Host represents the configuration for the host. + MacOSVM MacOSVM `yaml:"macos_vm"` // MacOSVM represents the configuration for the macOS VM executor. Metrics Metrics `yaml:"metrics"` // Metrics represents the configuration for the Prometheus metrics endpoint. HealthCheck HealthCheck `yaml:"health_check"` // HealthCheck controls opt-in local task-admission checks. } @@ -300,6 +310,15 @@ func LoadDefault(file string) (*Config, error) { } cfg.Host.WorkdirParent = filepath.Join(home, ".cache", "act") } + if cfg.MacOSVM.ExecutorPath == "" { + cfg.MacOSVM.ExecutorPath = "runner-executor-macos" + } + if cfg.MacOSVM.WorkdirParent == "" { + cfg.MacOSVM.WorkdirParent = "/Users/admin/runner" + } + if cfg.MacOSVM.BootTimeout <= 0 { + cfg.MacOSVM.BootTimeout = 5 * time.Minute + } if cfg.Runner.FetchTimeout <= 0 { cfg.Runner.FetchTimeout = 5 * time.Second } diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index a0409170..0dbede84 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -67,6 +67,31 @@ func TestLoadDefault_DefaultsWorkdirCleanupAge(t *testing.T) { assert.Equal(t, DefaultImage, cfg.Runner.DefaultImage) } +func TestLoadDefault_MacOSVMDefaults(t *testing.T) { + cfg, err := LoadDefault("") + require.NoError(t, err) + assert.Equal(t, "runner-executor-macos", cfg.MacOSVM.ExecutorPath) + assert.Equal(t, "/Users/admin/runner", cfg.MacOSVM.WorkdirParent) + assert.Equal(t, 5*time.Minute, cfg.MacOSVM.BootTimeout) + + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +macos_vm: + executor_path: /usr/local/bin/runner-executor-macos + workdir_parent: /Users/runner + cpu: 4 + memory: 8192 + boot_timeout: 3m +`), 0o600)) + cfg, err = LoadDefault(path) + require.NoError(t, err) + assert.Equal(t, "/usr/local/bin/runner-executor-macos", cfg.MacOSVM.ExecutorPath) + assert.Equal(t, "/Users/runner", cfg.MacOSVM.WorkdirParent) + assert.Equal(t, 4, cfg.MacOSVM.CPU) + assert.Equal(t, 8192, cfg.MacOSVM.Memory) + assert.Equal(t, 3*time.Minute, cfg.MacOSVM.BootTimeout) +} + func TestLoadDefault_HealthChecksAreOptIn(t *testing.T) { cfg, err := LoadDefault("") require.NoError(t, err) diff --git a/internal/pkg/labels/labels.go b/internal/pkg/labels/labels.go index 66e3f8e2..696a24bb 100644 --- a/internal/pkg/labels/labels.go +++ b/internal/pkg/labels/labels.go @@ -9,11 +9,15 @@ import ( ) const ( - SchemeHost = "host" - SchemeDocker = "docker" + SchemeHost = "host" + SchemeDocker = "docker" + SchemeMacOSVM = "macos-vm" // SelfHostedPlatform is the platform marker act treats as "run on the host". SelfHostedPlatform = "-self-hosted" + + // MacOSVMSchemePrefix marks a platform image that act should run in a macOS VM. + MacOSVMSchemePrefix = "macos-vm://" ) type Label struct { @@ -42,7 +46,7 @@ func Parse(str string) (*Label, error) { if len(splits) >= 3 { label.Arg = splits[2] } - if label.Schema != SchemeHost && label.Schema != SchemeDocker { + if label.Schema != SchemeHost && label.Schema != SchemeDocker && label.Schema != SchemeMacOSVM { // Not a schema we know: the colon belongs to the label name itself. return &Label{ Name: str, @@ -64,6 +68,15 @@ func (l Labels) RequireDocker() bool { return false } +func (l Labels) RequireMacOSVM() bool { + for _, label := range l { + if label.Schema == SchemeMacOSVM { + return true + } + } + return false +} + // PickPlatform returns the platform of the first runs-on entry this runner has a label for, or "". func (l Labels) PickPlatform(runsOn []string) string { platforms := make(map[string]string, len(l)) @@ -72,10 +85,12 @@ func (l Labels) PickPlatform(runsOn []string) string { case SchemeDocker: // "//" will be ignored platforms[label.Name] = strings.TrimPrefix(label.Arg, "//") + case SchemeMacOSVM: + platforms[label.Name] = MacOSVMSchemePrefix + strings.TrimPrefix(label.Arg, "//") case SchemeHost: platforms[label.Name] = SelfHostedPlatform default: - // unreachable: Parse only produces host or docker schemas + // unreachable: Parse only produces host, docker, or macOS VM schemas continue } } diff --git a/internal/pkg/labels/labels_test.go b/internal/pkg/labels/labels_test.go index d70e1c32..98052f16 100644 --- a/internal/pkg/labels/labels_test.go +++ b/internal/pkg/labels/labels_test.go @@ -25,6 +25,15 @@ func TestParse(t *testing.T) { }, wantErr: false, }, + { + args: "macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest", + want: &Label{ + Name: "macos-latest", + Schema: "macos-vm", + Arg: "//ghcr.io/cirruslabs/macos-sonoma-base:latest", + }, + wantErr: false, + }, { args: "ubuntu:host", want: &Label{ @@ -103,6 +112,7 @@ func TestRequireDocker(t *testing.T) { {"empty", nil, false}, {"only host", []string{"ubuntu:host", "self-hosted"}, false}, {"has docker", []string{"ubuntu:host", "ubuntu:docker://node:18"}, true}, + {"macos-vm does not require docker", []string{"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest"}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -111,10 +121,29 @@ func TestRequireDocker(t *testing.T) { } } +func TestRequireMacOSVM(t *testing.T) { + tests := []struct { + name string + strs []string + want bool + }{ + {"empty", nil, false}, + {"only host", []string{"ubuntu:host", "self-hosted"}, false}, + {"only docker", []string{"ubuntu:docker://node:18"}, false}, + {"has macos-vm", []string{"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, mustParse(t, tt.strs...).RequireMacOSVM()) + }) + } +} + func TestPickPlatform(t *testing.T) { ls := mustParse(t, "ubuntu:docker://node:18", "self-hosted:host", + "macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest", ) tests := []struct { @@ -124,6 +153,7 @@ func TestPickPlatform(t *testing.T) { }{ {"docker strips leading slashes", []string{"ubuntu"}, "node:18"}, {"host maps to self-hosted marker", []string{"self-hosted"}, SelfHostedPlatform}, + {"macos-vm prefixes platform marker", []string{"macos-latest"}, MacOSVMSchemePrefix + "ghcr.io/cirruslabs/macos-sonoma-base:latest"}, {"first match wins", []string{"self-hosted", "ubuntu"}, SelfHostedPlatform}, {"unknown label picks nothing", []string{"windows"}, ""}, {"no runsOn picks nothing", nil, ""}, @@ -145,12 +175,14 @@ func TestToStrings(t *testing.T) { ls := mustParse(t, "ubuntu:docker://node:18", "self-hosted:host", + "macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest", "bare", "pool:e57e18d4", ) require.Equal(t, []string{ "ubuntu:docker://node:18", "self-hosted:host", + "macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest", "bare:host", "pool:e57e18d4", }, ls.ToStrings())