From 2cc30003697fffb723aba6b5ab4c80e2454c3875 Mon Sep 17 00:00:00 2001 From: silverwind <2021+silverwind@noreply.gitea.com> Date: Mon, 14 Sep 2026 18:58:19 +0000 Subject: [PATCH] fix: mount the job workspace above the repository (#1224) Mount the job workspace volume, or the `bind_workdir` directory, above `/` instead of at the repository, like GitHub's work directory. With the repository as a mount point, pnpm 12 puts its store in `node_modules/.pnpm-store` (https://github.com/pnpm/pnpm/pull/13536), so a restored `setup-node` pnpm cache creates `node_modules` before install. Renames into `$RUNNER_WORKSPACE` fail with `EXDEV` and removing the workspace fails with `EBUSY`. Verified against GitHub hosted and container jobs with cold and warm caches, on volume and `bind_workdir` runners, covering checkout, cache, artifacts, github-script, the node, pnpm, bun, python, uv, go, rust, java, gradle, dotnet, ruby and terraform setup actions, docker and Dockerfile actions, buildx, compose via `GITEA_DOCKER_WORKSPACE`, services, host mode, `container.volumes` on the workspace and `exec`. --------- Co-authored-by: bircni Reviewed-on: https://gitea.com/gitea/runner/pulls/1224 Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com> --- act/runner/run_context.go | 27 ++++++++++++++++++++++----- act/runner/run_context_test.go | 30 ++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/act/runner/run_context.go b/act/runner/run_context.go index d4db782a..792dd32a 100644 --- a/act/runner/run_context.go +++ b/act/runner/run_context.go @@ -18,6 +18,7 @@ import ( maps0 "maps" "net" "os" + "path" "path/filepath" "regexp" "runtime" @@ -255,7 +256,7 @@ func (rc *RunContext) validVolumes() []string { volumes = append(volumes, sharedToolCacheVolume) } if rc.Config.BindWorkdir { - volumes = append(volumes, rc.Config.Workdir) + volumes = append(volumes, rc.Config.Workdir, rc.workdirMountRoot()) } if rc.dockerProxy != nil { volumes = append(volumes, rc.dockerProxy.Socket) @@ -265,6 +266,13 @@ func (rc *RunContext) validVolumes() []string { getDockerDaemonSocketMountPath(rc.containerDaemonSocket())) } +func (rc *RunContext) workdirMountRoot() string { + if rc.Config.PresetGitHubContext != nil { + return filepath.Dir(filepath.Dir(rc.Config.Workdir)) // only the daemon presets, its workdir is // + } + return rc.Config.Workdir +} + func (rc *RunContext) jobDockerSocket() string { if rc.dockerProxy != nil { return rc.dockerProxy.Socket @@ -359,6 +367,11 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) { mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] { + source := rc.workdirMountRoot() + target := ext.ToContainerPath(source) + if claimed[target] { + source, target = rc.Config.Workdir, workdir + } if rc.Config.BindWorkdir { bindModifiers := "" if runtime.GOOS == "darwin" { @@ -367,9 +380,9 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) { if selinux.GetEnabled() { bindModifiers = ":z" } - binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, workdir, bindModifiers)) + binds = append(binds, fmt.Sprintf("%s:%s%s", source, target, bindModifiers)) } else { - mounts[name] = workdir + mounts[name] = target } } @@ -979,8 +992,12 @@ func (rc *RunContext) captureJobContainerInfo() common.Executor { return nil } rc.jobContainerID = info.ID - if source := info.Mounts[rc.githubWorkspace()]; source != "" { - rc.Env["GITEA_DOCKER_WORKSPACE"] = source + workspace := rc.githubWorkspace() + for dir := workspace; dir != "/" && dir != "."; dir = path.Dir(dir) { + if source := info.Mounts[dir]; source != "" { + rc.Env["GITEA_DOCKER_WORKSPACE"] = path.Join(source, strings.TrimPrefix(workspace, dir)) + break + } } return nil } diff --git a/act/runner/run_context_test.go b/act/runner/run_context_test.go index 509824af..d0bcfe38 100644 --- a/act/runner/run_context_test.go +++ b/act/runner/run_context_test.go @@ -641,6 +641,30 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) { require.NoError(t, err) assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume]) }) + + t.Run("DaemonMountsAboveOwnerRepo", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("container paths are linux paths") + } + rc := &RunContext{ + Run: &model.Run{JobID: "job1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job1": {}}}}, + Config: &Config{BindWorkdir: true, Workdir: "/workspace/1/owner/repo", PresetGitHubContext: &model.GithubContext{}}, + } + + gotbind, _, err := rc.GetBindsAndMounts() + require.NoError(t, err) + assert.True(t, slices.ContainsFunc(gotbind, func(bind string) bool { return strings.HasPrefix(bind, "/workspace/1:/workspace/1") }), gotbind) + + rc.Config.BindWorkdir = false + _, gotmount, err := rc.GetBindsAndMounts() + require.NoError(t, err) + assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()]) + + require.NoError(t, rc.Run.Job().RawContainer.Encode(map[string][]string{"volumes": {"claimed:/workspace/1"}})) + _, gotmount, err = rc.GetBindsAndMounts() + require.NoError(t, err) + assert.Equal(t, "/workspace/1/owner/repo", gotmount[rc.jobContainerName()]) + }) } func TestRunContextValidVolumes(t *testing.T) { @@ -669,6 +693,8 @@ func TestRunContextValidVolumes(t *testing.T) { assert.NotContains(t, rc.validVolumes(), rc.Config.Workdir) rc.Config.BindWorkdir = true assert.Contains(t, rc.validVolumes(), rc.Config.Workdir) + rc.Config.PresetGitHubContext = &model.GithubContext{} + assert.Contains(t, rc.validVolumes(), filepath.FromSlash("/workspace/1")) } func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) { @@ -1523,7 +1549,7 @@ func TestCaptureJobContainerInfoExportsDockerWorkspace(t *testing.T) { job := &containerMock{} job.On("Inspect", mock.Anything).Return(&container.Info{ ID: "job-container-id", - Mounts: map[string]string{"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data"}, + Mounts: map[string]string{"/workspace": "/var/lib/docker/volumes/job/_data"}, }, nil) rc := &RunContext{ Config: &Config{Workdir: "/workspace/owner/repo/"}, @@ -1534,7 +1560,7 @@ func TestCaptureJobContainerInfoExportsDockerWorkspace(t *testing.T) { require.NoError(t, rc.captureJobContainerInfo()(context.Background())) assert.Equal(t, "job-container-id", rc.jobContainerID) - assert.Equal(t, "/var/lib/docker/volumes/job/_data", rc.Env["GITEA_DOCKER_WORKSPACE"]) + assert.Equal(t, "/var/lib/docker/volumes/job/_data/owner/repo", rc.Env["GITEA_DOCKER_WORKSPACE"]) rc.Config.Workdir = "/elsewhere" rc.Env = map[string]string{}