enhance: improve config, comment out values in example file (#1145)

`config.example.yaml` now has every value commented out, as gitea's `app.example.ini` does, so it documents each option with its default instead of imposing it. Copying it no longer pins values the runner would otherwise pick, and a changed default reaches configs that never named the option.

`config init` writes the file to configure: a header comment and no option, so every option keeps its default. It refuses to overwrite an existing config without `--force`, and writes `config.yaml` in the working directory when `-c` is absent.

`config set`, `add` and `remove` keep such a file readable. Comments go back to the indentation they were written at, which the encoder drops for a comment block that has no key below it, and a file of only comments keeps its text instead of being emptied by the first edit.

Also rewrote the config docs sections for clarity.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1145
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
silverwind
2026-08-06 05:15:32 +00:00
committed by bircni
parent 70387cca44
commit 1d6c6ffef9
10 changed files with 248 additions and 131 deletions

View File

@@ -1,7 +1,5 @@
# Example configuration file, it's safe to copy this as the default config file without any modification.
# You don't have to copy this file to your instance,
# just run `./gitea-runner config generate > config.yaml` to generate a config file.
# Every option with its default value, all commented out. Read this file, do not copy it.
# `./gitea-runner config init` writes a config file to copy the lines you change into.
# Logging for the runner process itself (messages printed to stderr).
# This does not control how workflow step output is streamed to the Gitea UI;
@@ -9,92 +7,92 @@
log:
# logrus severity: trace, debug, info, warn, error, fatal, panic.
# trace and debug turn on caller/file:line in log lines. Default if omitted: info.
level: info
#level: info
runner:
# Where to store the registration result.
file: .runner
#file: .runner
# Execute how many tasks concurrently at the same time.
# With `container.network` empty, each concurrent docker job takes a subnet from the
# daemon's address pool, so a high capacity can exhaust it. See `default-address-pools`
# in the docker daemon config.
capacity: 1
#capacity: 1
# Extra environment variables to run jobs.
envs:
A_TEST_ENV_NAME_1: a_test_env_value_1
A_TEST_ENV_NAME_2: a_test_env_value_2
#envs:
# A_TEST_ENV_NAME_1: a_test_env_value_1
# A_TEST_ENV_NAME_2: a_test_env_value_2
# Extra environment variables to run jobs from a file.
# It will be ignored if it's empty or the file doesn't exist.
env_file: .env
#env_file: .env
# The timeout for a job to be finished.
# Please note that the Gitea instance also has a timeout (3h by default) for the job.
# So the job could be stopped by the Gitea instance if its timeout is shorter than this.
timeout: 3h
#timeout: 3h
# The timeout for the runner to wait for running jobs to finish when shutting down.
# Any running jobs that haven't finished after this timeout will be cancelled.
shutdown_timeout: 0s
#shutdown_timeout: 0s
# Whether skip verifying the TLS certificate of the Gitea instance.
insecure: false
#insecure: false
# The timeout for fetching the job from the Gitea instance.
fetch_timeout: 5s
#fetch_timeout: 5s
# The interval for fetching the job from the Gitea instance.
fetch_interval: 2s
#fetch_interval: 2s
# The maximum interval for fetching the job from the Gitea instance.
# The runner uses exponential backoff when idle, increasing the interval up to this maximum.
# Set to 0 or same as fetch_interval to disable backoff.
fetch_interval_max: 5s
#fetch_interval_max: 5s
# While idle, remove stale bind-workdir task directories and orphaned host-mode
# scratch directories (left behind when a host cleanup delete stalls) older than
# this duration. Setting either workdir_cleanup_age or idle_cleanup_interval to 0
# (or any non-positive value) disables stale-directory cleanup entirely, along with
# the docker network cleanup below.
workdir_cleanup_age: 24h
#workdir_cleanup_age: 24h
# Cadence for the idle cleanup pass. Besides the directories above, on runners that use
# docker it removes the per-job networks of jobs this runner did not live to tear down,
# which would otherwise hold a subnet of the daemon address pool until the host is rebuilt.
idle_cleanup_interval: 10m
#idle_cleanup_interval: 10m
# The base interval for periodic log flush to the Gitea instance.
# Logs may be sent earlier if the buffer reaches log_report_batch_size
# or if log_report_max_latency expires after the first buffered row.
log_report_interval: 5s
#log_report_interval: 5s
# The maximum time a log row can wait before being sent.
# This ensures even a single log line appears on the frontend within this duration.
# Must be less than log_report_interval to have any effect.
log_report_max_latency: 3s
#log_report_max_latency: 3s
# Flush logs immediately when the buffer reaches this many rows.
# This ensures bursty output (e.g., npm install) is delivered promptly.
log_report_batch_size: 100
#log_report_batch_size: 100
# The interval for reporting task state (step status, timing) to the Gitea instance.
# State is also reported immediately on step transitions (start/stop).
state_report_interval: 5s
#state_report_interval: 5s
# Per-attempt deadline for flushing the final logs and task state when a job
# finishes, on a detached context so a server cancel can't block the acknowledgement.
report_close_timeout: 10s
#report_close_timeout: 10s
# The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository.
# It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github,
# and github_mirror is not empty. In this case,
# it replaces https://github.com with the value here, which is useful for some special network environments.
github_mirror: ''
# When true (the default), fetch only the requested ref of an action repository (e.g. actions/checkout@v4) at depth 1 instead of cloning every branch's full history.
#github_mirror: ''
# When true (the default), fetch only the requested ref of an action repository (e.g. actions/checkout@v4) at depth 1 instead of cloning every branch's full history.
# Set to false to clone the full history.
action_shallow_clone: true
#action_shallow_clone: true
# When true (the default), inject the ACT=true environment variable into jobs.
# Set to false so workflows gated on `if: ${{ !env.ACT }}` behave like they do on GitHub.
set_act_env: true
#set_act_env: true
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
# If it's empty when registering, it will ask for inputting labels.
# If it's empty when execute `daemon`, will use labels in `.runner` file.
labels:
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
- "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
#labels:
# - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
# - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
# - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
# Allocate a pseudo-TTY for each step's process. Applies to both host and docker backends.
# Default false matches GitHub 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.
allocate_pty: false
#allocate_pty: false
# Optional executable on the host, run once after each task's built-in cleanup
# (post-steps, container teardown, bind-workdir removal). Additive only.
#
@@ -107,24 +105,24 @@ runner:
# Windows: use .exe, .bat, or .cmd. PowerShell (.ps1) is not supported yet as
# the configured path; wrap PowerShell commands in a .cmd file instead.
# Full guide: docs/post-task-script.md
post_task_script: ''
#post_task_script: ''
# Hard limit on post_task_script runtime. Default if omitted: 5m.
post_task_script_timeout: 5m
#post_task_script_timeout: 5m
# Scripts run inside the job environment before the job's first step and after its last
# one, the equivalent of GitHub's ACTIONS_RUNNER_HOOK_JOB_STARTED and
# ACTIONS_RUNNER_HOOK_JOB_COMPLETED, which are read when these are unset. The paths are
# resolved inside the job environment. Either one failing fails the job.
# Full guide: docs/job-hooks.md
hooks:
job_started: ''
job_completed: ''
#hooks:
# job_started: ''
# job_completed: ''
cache:
# Enable the built-in cache server (used by actions/cache and similar actions).
enabled: true
#enabled: true
# Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache
# Ignored when external_server is set.
dir: ""
#dir: ""
# Outbound IP or hostname that job containers use to reach this runner's cache server.
# Leave empty to detect automatically. 0.0.0.0 is not valid here.
# If the runner itself runs in Docker, automatic detection can choose an
@@ -133,33 +131,33 @@ cache:
# to a hostname/IP reachable from job containers, and set port to a fixed
# published port or put the job containers on a shared Docker network.
# Ignored when external_server is set.
host: ""
#host: ""
# Port for the built-in cache server. 0 picks a random free port.
# Ignored when external_server is set.
port: 0
#port: 0
# URL of a shared `gitea-runner cache-server` to use instead of starting a local one.
# Set on every runner that should share a cache pool. A trailing slash is optional.
# Example: "http://cache-host:8088/"
# Requires external_secret (below) to match the value on the cache-server.
external_server: ""
#external_server: ""
# Shared secret between this runner and the external cache-server.
# Required when external_server is set. Must be identical on every runner and the cache-server.
# Generate with: openssl rand -hex 32
external_secret: ""
#external_secret: ""
# Path to a file containing the shared secret, as an alternative to external_secret.
# Use this to keep the secret out of this file.
# Surrounding whitespace is trimmed, so a trailing newline in the file is fine.
# Setting both external_secret and external_secret_file is an error.
external_secret_file: ""
#external_secret_file: ""
# When true, reuse a cached action instead of fetching from the remote on every job.
# A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit
# until its cache entry expires or is manually removed.
offline_mode: false
#offline_mode: false
# Serve the actions cache service v2 API, used by actions/cache@v4.2 and later. Those actions
# refuse any host they do not take for GitHub, so reaching it means editing that check out of
# the action's own bundle, keeping the untouched copy beside it. The same edit lets the stock
# upload-artifact and download-artifact work here. A bundle that does not match is left alone.
v2: true
#v2: true
container:
# Specifies the network to which the container will connect.
@@ -168,31 +166,31 @@ container:
# For dockerized runners using the built-in cache server, a custom shared
# network can be required so job containers can reach cache.host/cache.port.
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
network: ""
#network: ""
# network_create_options only apply when `network` is left empty and the runner
# auto-creates a per-job network that does not already exist. They have no effect
# when a custom `network` name is set, because that network is used as-is and never
# created by the runner. Omit the entire block to use Docker's defaults. An auto-created
# network is labelled com.gitea.runner.uuid=<this runner's uuid>, which is how the idle
# cleanup tells its own leftovers apart from those of other runners on the same daemon.
network_create_options:
enable_ipv4: true # Omit to use Docker's default (IPv4 enabled). Set false to disable IPv4.
enable_ipv6: false # Omit to use Docker's default (IPv6 disabled). Enabling it requires dockerd started with --ipv6.
#network_create_options:
# enable_ipv4: true # Omit to use Docker's default (IPv4 enabled). Set false to disable IPv4.
# enable_ipv6: false # Omit to use Docker's default (IPv6 disabled). Enabling it requires dockerd started with --ipv6.
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
privileged: false
#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
# 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:
#options:
# The parent directory of a job's working directory.
# NOTE: There is no need to add the first '/' of the path as runner will add it automatically.
# If the path starts with '/', the '/' will be trimmed.
# For example, if the parent directory is /path/to/my/dir, workdir_parent should be path/to/my/dir
# If it's empty, /workspace will be used.
# Purely numeric subdirectories under this path are reserved for task workspaces and may be removed by idle cleanup.
workdir_parent:
#workdir_parent:
# Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob
# You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.
# For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, you should change the config to:
@@ -202,62 +200,62 @@ container:
# If you want to allow any volume, please use the following configuration:
# valid_volumes:
# - '**'
valid_volumes: []
#valid_volumes: []
# Overrides the docker client host with the specified one.
# If it's empty, runner will find an available docker host automatically.
# If it's "-", runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers.
# If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work.
docker_host: ""
#docker_host: ""
# Pull docker image(s) even if already present.
# Defaults to false when the key is omitted.
# Two exceptions: an image pinned by digest (image@sha256:...) cannot change, so it is never
# re-pulled, and a pull that fails while a copy is already on the host does not fail the job,
# which runs on that copy with a warning in its log.
force_pull: false
#force_pull: false
# Rebuild docker image(s) even if already present
force_rebuild: false
#force_rebuild: false
# Always require a reachable docker daemon, even if not required by runner
require_docker: false
#require_docker: false
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
docker_timeout: 0s
#docker_timeout: 0s
# Bind the workspace to the host filesystem instead of using Docker volumes.
# This is required for Docker-in-Docker (DinD) setups when jobs use docker compose
# with bind mounts (e.g., ".:/app"), as volume-based workspaces are not accessible
# from the DinD daemon's filesystem. When enabled, ensure the workspace parent
# directory is also mounted into the runner container and listed in valid_volumes.
bind_workdir: false
#bind_workdir: false
# How long a job waits for a service container that declares a healthcheck to become
# healthy. A negative value (e.g. -1s) starts the steps without waiting.
service_ready_timeout: 5m
#service_ready_timeout: 5m
host:
# The parent directory of a job's working directory.
# If it's empty, $HOME/.cache/act/ will be used.
workdir_parent:
#workdir_parent:
# Optional local task-admission checks. Disabled by default. When enabled, low
# disk space or a failing script pauses new task fetching; existing jobs continue.
# No health checks run while any job is active; the last result is reused until idle.
health_check:
enabled: false
#enabled: false
# Minimum free space required on the filesystem holding runner workspaces.
# Defaults to 1024 MiB when omitted or set to zero.
min_free_disk_space_mb: 1024
#min_free_disk_space_mb: 1024
# Optional additional executable. A non-zero exit, timeout, or startup failure
# marks the runner unavailable.
script: ''
#script: ''
# How long a script result is cached and its maximum execution time.
interval: 30s
timeout: 10s
#interval: 30s
#timeout: 10s
metrics:
# Enable the Prometheus metrics endpoint.
# When enabled, metrics are served at /metrics, liveness at /healthz, and
# task-admission readiness at /readyz.
enabled: false
#enabled: false
# The address for the metrics HTTP server to listen on.
# Defaults to localhost only. Set to ":9101" to allow external access,
# but ensure the port is firewall-protected as there is no authentication.
addr: "127.0.0.1:9101"
#addr: "127.0.0.1:9101"
# Consecutive polling failures may last this long before /readyz returns 503.
readiness_grace: 30s
#readiness_grace: 30s

View File

@@ -24,6 +24,12 @@ import (
// (so a programmatically built config still gets a sane bound).
const DefaultPostTaskScriptTimeout = 5 * time.Minute
// Minimal is the smallest config file that runs the runner: options it does not
// name keep their default, and it names none.
const Minimal = `# Minimal config file. Every option it does not set keeps its default.
# "gitea-runner config generate" prints all options, "config set <key> <value>" sets one here.
`
// Log represents the configuration for logging.
type Log struct {
Level string `yaml:"level"` // Level indicates the logging level.

View File

@@ -348,12 +348,23 @@ cache:
assert.Contains(t, err.Error(), "contains no secret")
}
// The shipped example must parse, and every key in it must be one the config knows.
func TestLoadDefault_ExampleConfigParses(t *testing.T) {
// The shipped configs must parse, hold no key the config does not know, and leave
// every option at its default, as all of their values are commented out.
func TestLoadDefault_ShippedConfigsChangeNothing(t *testing.T) {
hook := test.NewGlobal()
defer hook.Reset()
_, err := LoadDefault("config.example.yaml")
defaults, err := LoadDefault("")
require.NoError(t, err)
dir := t.TempDir()
for name, content := range map[string][]byte{"config.example.yaml": Example, "minimal.yaml": []byte(Minimal)} {
file := filepath.Join(dir, name)
require.NoError(t, os.WriteFile(file, content, 0o600))
cfg, err := LoadDefault(file)
require.NoError(t, err, name)
assert.Equal(t, defaults, cfg, name)
}
assert.Empty(t, hook.AllEntries())
}

View File

@@ -160,6 +160,7 @@ type editSession struct {
root *yaml.Node
field *fieldInfo
segments []string
preamble []byte // text of a file that holds no YAML node, which the encoder cannot give back
}
// loadForEdit validates the path and parses the file, so every caller fails before anything is written.
@@ -176,7 +177,7 @@ func loadForEdit(file, path string) (*editSession, error) {
content, err := os.ReadFile(file)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("config file %q does not exist, create one with `config generate`", file)
return nil, fmt.Errorf("config file %q does not exist, create one with `config init`", file)
}
return nil, err
}
@@ -185,7 +186,9 @@ func loadForEdit(file, path string) (*editSession, error) {
if err := yaml.Unmarshal(content, &root); err != nil {
return nil, fmt.Errorf("parse config file %q: %w", file, err)
}
var preamble []byte
if root.Kind == 0 || len(root.Content) == 0 {
preamble = bytes.TrimSpace(content) // all the file has is comments
root = yaml.Node{
Kind: yaml.DocumentNode,
Content: []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}},
@@ -195,7 +198,7 @@ func loadForEdit(file, path string) (*editSession, error) {
return nil, fmt.Errorf("config file %q is not a YAML mapping", file)
}
return &editSession{file: file, path: path, original: content, root: &root, field: field, segments: segments}, nil
return &editSession{file: file, path: path, original: content, root: &root, field: field, segments: segments, preamble: preamble}, nil
}
func loadSequenceEdit(file, path string, values []string) (*editSession, error) {
@@ -319,16 +322,28 @@ func encodeYAML(node *yaml.Node) ([]byte, error) {
return buf.Bytes(), nil
}
// restoreBlankLines re-inserts the blank lines between top-level sections that the encoder drops.
func restoreBlankLines(original, generated []byte) []byte {
// restoreLayout re-applies the spacing the encoder drops: the blank lines between
// top-level sections, and the indentation of comments, which the encoder emits at the
// indentation of the node it attached them to rather than the one they were written at.
func restoreLayout(original, generated []byte) []byte {
type comment struct {
line string // as written, indentation included
trimmed string
blankBefore bool
}
var comments []comment
spaced := map[string]bool{}
blank := false
for line := range strings.Lines(string(original)) {
line = strings.TrimRight(line, "\r\n")
trimmed := strings.TrimSpace(line)
switch {
case strings.TrimSpace(line) == "":
case trimmed == "":
blank = true
case strings.HasPrefix(line, "#"): // the block belongs to the key below it
case strings.HasPrefix(trimmed, "#"):
comments = append(comments, comment{line: line, trimmed: trimmed, blankBefore: blank})
blank = false
default:
if key, ok := topLevelKey(line); ok && blank {
spaced[key] = true
@@ -338,16 +353,26 @@ func restoreBlankLines(original, generated []byte) []byte {
}
var out []string
appendBlank := func() {
if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" {
out = append(out, "")
}
}
for line := range strings.Lines(string(generated)) {
line = strings.TrimRight(line, "\r\n")
if key, ok := topLevelKey(line); ok && spaced[key] {
start := len(out)
for start > 0 && strings.HasPrefix(out[start-1], "#") {
start--
}
if start > 0 && strings.TrimSpace(out[start-1]) != "" {
out = slices.Insert(out, start, "")
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") {
if i := slices.IndexFunc(comments, func(c comment) bool { return c.trimmed == trimmed }); i >= 0 {
if comments[i].blankBefore {
appendBlank()
} else if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" {
out = out[:len(out)-1] // the encoder separates a comment block it moved
}
line = comments[i].line
comments = comments[i+1:] // the encoder keeps their order, so earlier ones cannot match again
}
} else if key, ok := topLevelKey(line); ok && spaced[key] {
appendBlank()
}
out = append(out, line)
}
@@ -377,12 +402,21 @@ func (s *editSession) write() error {
return fmt.Errorf("the edit would produce a config the runner cannot load: %w", err)
}
content := restoreBlankLines(s.original, generated)
if len(s.preamble) > 0 { // before restoreLayout, so that it spaces the preamble too
generated = slices.Concat(s.preamble, []byte("\n"), generated)
}
content := restoreLayout(s.original, generated)
if bytes.Contains(s.original, []byte("\r\n")) { // the encoder only ever emits LF
content = bytes.ReplaceAll(content, []byte("\n"), []byte("\r\n"))
}
file := s.file
return WriteFile(s.file, content)
}
// WriteFile replaces the config file in one step, keeping the mode and owner of the
// file it replaces, so a half-written config never reaches a runner reading it.
func WriteFile(file string, content []byte) error {
if resolved, err := filepath.EvalSymlinks(file); err == nil {
file = resolved // keeps a config linked in from elsewhere intact
}

View File

@@ -252,16 +252,39 @@ func TestEditValuesFileHandling(t *testing.T) {
})
}
// The example config is the file users edit, so it has to stay written the way
// the encoder emits it, down to the single space before a trailing comment.
func TestEditValuesKeepsExampleConfigIntact(t *testing.T) {
file := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, os.WriteFile(file, Example, 0o600))
// An edit has to give the file back unchanged around it, down to the indentation of
// a commented-out option, as that documentation is what the user reads and edits.
func TestEditValuesPreservesFileText(t *testing.T) {
tests := []struct {
name string
content []byte
edit func(file string) error
added string // the only text the edit may add
}{
{
name: "example config",
content: Example,
edit: func(file string) error { return AddValue(file, "runner.labels", "ubuntu:docker://node:22") },
added: " labels:\n - ubuntu:docker://node:22\n",
},
{
name: "minimal config",
content: []byte(Minimal),
edit: func(file string) error { return SetValue(file, "runner.capacity", "4") },
added: "runner:\n capacity: 4\n",
},
}
require.NoError(t, AddValue(file, "runner.labels", "ubuntu:docker://node:22"))
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
file := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, os.WriteFile(file, tc.content, 0o600))
content, err := os.ReadFile(file)
require.NoError(t, err)
withoutAdded := strings.Replace(string(content), " - ubuntu:docker://node:22\n", "", 1)
assert.Equal(t, string(Example), withoutAdded, "only the appended label may differ")
require.NoError(t, tc.edit(file))
content, err := os.ReadFile(file)
require.NoError(t, err)
assert.Equal(t, string(tc.content), strings.Replace(string(content), tc.added, "", 1))
})
}
}