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
This commit is contained in:
Lunny Xiao
2026-09-05 13:20:03 -07:00
parent 1745c7c841
commit 2db24c7401
18 changed files with 1167 additions and 15 deletions
+118 -2
View File
@@ -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()
}
+23
View File
@@ -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-")
}
+11
View File
@@ -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