enhance: add runner.tool_cache_mode and default it to none (#1171)

The current shared tools cache is not concurrency-safe, e.g. multiple jobs can write and corrupt it, for example `setup-go` with explicit go version under concurrency reliably corrupts the tool cache and fails all jobs.

This adds a new `runner.tool_cache_mode` (and `--tool-cache-mode` exec option) option which defaults to unshared tools cache:

- `none` mounts nothing, so a job uses what its image ships there and discards what it installs
- `shared` keeps the single volume every job reuses, and warns when `runner.capacity` is above 1

Under `none` effective tool cache can only come from the image or host, which is the same as it is on GitHub Actions which ships many preinstalled tools in its fat VM images.

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1171
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
silverwind
2026-08-18 20:16:11 +00:00
committed by silverwind
parent be90c01468
commit 3f70822458
10 changed files with 147 additions and 13 deletions
+7 -1
View File
@@ -93,6 +93,12 @@ runner:
# terminal; tools like `docker build` emit redrawing progress frames into the captured log
# when a TTY is present.
#allocate_pty: false
# What to mount at RUNNER_TOOL_CACHE (/opt/hostedtoolcache), where setup actions install tools:
# none: nothing. A docker job sees what its image ships there, a host job an empty dir, and
# either way what it installs is gone when the job ends.
# shared: one tool cache every job reuses. Two jobs writing the same tool version at once
# corrupt it, so use it only with capacity 1.
#tool_cache_mode: none
# Optional executable on the host, run once after each task's built-in cleanup
# (post-steps, container teardown, bind-workdir removal). Additive only.
#
@@ -195,7 +201,7 @@ container:
#privileged: false
# Any other options to be used when the container is started, for example:
# options: --add-host=my.gitea.url:host-gateway
# A volume declared here replaces the one the runner mounts on the same container path, so the
# A volume declared here replaces the one the runner would mount on the same container path, so the
# tool cache can be kept on the host. Its source must also be allowed by valid_volumes below:
# options: --volume /host/toolcache:/opt/hostedtoolcache
#options:
+20
View File
@@ -10,6 +10,7 @@ import (
"maps"
"os"
"path/filepath"
"slices"
"strings"
"time"
@@ -60,6 +61,7 @@ type Runner struct {
ActionShallowClone *bool `yaml:"action_shallow_clone"` // ActionShallowClone fetches only the requested ref of an action repository at depth 1 instead of cloning every branch's full history. It is a pointer to distinguish between false and not set; if not set, it defaults to true.
SetActEnv *bool `yaml:"set_act_env"` // SetActEnv controls whether the ACT=true environment variable is injected into jobs. It is a pointer to distinguish between false and not set; if not set, it defaults to true. Set it to false so workflows gated on `if: ${{ !env.ACT }}` behave like on GitHub.
AllocatePTY bool `yaml:"allocate_pty"` // AllocatePTY allocates a pseudo-TTY for each step's process. Default is false, matching GitHub's actions/runner. Enable only for jobs that need an interactive terminal; tools like docker build emit redrawing progress frames into the captured log when a TTY is present. Applies to both host and docker backends.
ToolCacheMode string `yaml:"tool_cache_mode"` // ToolCacheMode is what the runner mounts at RUNNER_TOOL_CACHE on both backends: ToolCacheModeNone or ToolCacheModeShared.
PostTaskScript string `yaml:"post_task_script"` // PostTaskScript is the path to an executable script run on the host after each task's cleanup completes. Empty disables the hook. On Windows use .exe/.bat/.cmd; PowerShell (.ps1) is not supported yet as the configured path.
PostTaskScriptTimeout time.Duration `yaml:"post_task_script_timeout"` // PostTaskScriptTimeout caps how long the post-task script may run. Default is 5m when post_task_script is set.
Hooks RunnerHooks `yaml:"hooks"` // Hooks are scripts run inside the job environment around the job's steps.
@@ -143,6 +145,14 @@ type Container struct {
ServiceReadyTimeout time.Duration `yaml:"service_ready_timeout"` // ServiceReadyTimeout bounds how long a job waits for a service container that declares a healthcheck to report healthy. Negative disables waiting.
}
// Values of Runner.ToolCacheMode: the runner mounts no tool cache, or one that every job reuses.
const (
ToolCacheModeNone = "none"
ToolCacheModeShared = "shared"
)
var ToolCacheModes = []string{ToolCacheModeNone, ToolCacheModeShared}
type ContainerNetworkCreateOptions struct {
EnableIPv4 *bool `yaml:"enable_ipv4"` // Enable or disable IPv4 for the network (true for docker by default)
EnableIPv6 *bool `yaml:"enable_ipv6"` // Enable or disable IPv6 for the network (false for docker by default)
@@ -257,6 +267,12 @@ func LoadDefault(file string) (*Config, error) {
if cfg.Container.WorkdirParent == "" {
cfg.Container.WorkdirParent = "workspace"
}
if cfg.Runner.ToolCacheMode == "" {
cfg.Runner.ToolCacheMode = ToolCacheModeNone
}
if !slices.Contains(ToolCacheModes, cfg.Runner.ToolCacheMode) {
return nil, fmt.Errorf("invalid runner.tool_cache_mode %q: must be one of %q", cfg.Runner.ToolCacheMode, ToolCacheModes)
}
if cfg.Host.WorkdirParent == "" {
home, err := os.UserHomeDir()
if err != nil {
@@ -314,6 +330,10 @@ func LoadDefault(file string) (*Config, error) {
}
// Validate and fix invalid config combinations to prevent confusing behavior.
if cfg.Runner.ToolCacheMode == ToolCacheModeShared && cfg.Runner.Capacity > 1 {
log.Warnf("runner.tool_cache_mode %q with capacity %d: two jobs writing the same tool version at once corrupt it",
ToolCacheModeShared, cfg.Runner.Capacity)
}
if cfg.Runner.FetchIntervalMax < cfg.Runner.FetchInterval {
log.Warnf("fetch_interval_max (%v) is less than fetch_interval (%v), setting fetch_interval_max to fetch_interval",
cfg.Runner.FetchIntervalMax, cfg.Runner.FetchInterval)
+17
View File
@@ -42,6 +42,23 @@ cache:
require.NoError(t, err)
}
func TestLoadDefault_ToolCacheMode(t *testing.T) {
cfg, err := LoadDefault("")
require.NoError(t, err)
assert.Equal(t, ToolCacheModeNone, cfg.Runner.ToolCacheMode)
path := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, os.WriteFile(path, []byte("runner:\n tool_cache_mode: shared\n"), 0o600))
cfg, err = LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, ToolCacheModeShared, cfg.Runner.ToolCacheMode)
require.NoError(t, os.WriteFile(path, []byte("runner:\n tool_cache_mode: everyone\n"), 0o600))
_, err = LoadDefault(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "tool_cache_mode")
}
func TestLoadDefault_DefaultsWorkdirCleanupAge(t *testing.T) {
cfg, err := LoadDefault("")
require.NoError(t, err)