fix: mount the job workspace above the repository (#1224)

Mount the job workspace volume, or the `bind_workdir` directory, above `<owner>/<repo>` 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 <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1224
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
This commit is contained in:
silverwind
2026-09-14 18:58:19 +00:00
committed by bircni
co-authored by bircni
parent 3d116eb0c2
commit 2cc3000369
2 changed files with 50 additions and 7 deletions
+22 -5
View File
@@ -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 <parent>/<owner>/<repo>
}
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
}
+28 -2
View File
@@ -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{}