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
}