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

@@ -129,48 +129,38 @@ Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon a
### Configuration ### Configuration
The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree): The runner reads a YAML file. Without one, every option keeps its default.
```bash ```bash
./gitea-runner config generate > config.yaml ./gitea-runner config init # write config.yaml, with no option set
./gitea-runner config generate | less # read what the options do
./gitea-runner -c config.yaml daemon # -c also works on register and cache-server
``` ```
> The top-level `generate-config` command still does the same thing, but is deprecated in favour of `config generate`. `config generate` prints [config.example.yaml](internal/pkg/config/config.example.yaml). Every value in it is commented out, so copy the lines you want to change into your own file and uncomment them.
Pass it with `-c` / `--config` on any command that loads configuration (`register`, `daemon`, `cache-server`):
```bash
./gitea-runner -c config.yaml register
./gitea-runner -c config.yaml daemon
./gitea-runner -c config.yaml cache-server
```
Every option is described in [config.example.yaml](internal/pkg/config/config.example.yaml) (the same content `config generate` prints).
#### Editing a config file #### Editing a config file
`config` changes an existing file in place, keeping its comments and key order, which is handy in provisioning scripts: `config` edits a file in place, which is handy in provisioning scripts:
```bash ```bash
./gitea-runner -c config.yaml config set runner.capacity 4 ./gitea-runner config set runner.capacity 4
./gitea-runner -c config.yaml config set runner.timeout 90m # written as 1h30m0s ./gitea-runner config set runner.timeout 90m # written as 1h30m0s
./gitea-runner -c config.yaml config set runner.envs.MY_VAR value ./gitea-runner config set runner.envs.MY_VAR value
./gitea-runner -c config.yaml config add runner.labels 'ubuntu:docker://node:22' ./gitea-runner config add runner.labels 'ubuntu:docker://node:22'
./gitea-runner -c config.yaml config remove runner.labels 'ubuntu:docker://node:22' ./gitea-runner config remove runner.labels 'ubuntu:docker://node:22'
./gitea-runner -c config.yaml config get runner.labels ./gitea-runner config get runner.labels
``` ```
`-c` is optional for these subcommands: without it they use `config.yaml` (or `config.yml`) from the working directory, falling back to the directory of the `gitea-runner` binary, and print which file they picked to stderr. A key is its dotted YAML path. An unknown key, a value of the wrong type, or `add`/`remove` on anything but a list is refused before the file is touched. `set` replaces a whole list when you give it several values.
Keys are the dotted YAML path and are validated against the known options, so a typo is rejected instead of being written. `add` and `remove` only work on list options such as `runner.labels` and `container.valid_volumes`, and fail if the value is already present or missing. `set` replaces the whole list when given several values. An edit keeps the comments and the key order of the file. Indentation becomes two spaces, and a blank line between two values is dropped.
The file is re-encoded on every edit, so indentation is normalised to two spaces and blank lines inside a section are dropped. `config get`, `set`, `add` and `remove` use `config.yaml` (or `config.yml`) from the working directory, then from the directory of the binary, and print their choice to stderr. `config init` writes `config.yaml` in the working directory, and refuses to overwrite an existing config without `--force`. Pass `-c` for another path.
#### Without a config file #### Environment variables
If you omit `-c`, built-in defaults apply (same as an empty YAML document). Earlier releases let a few environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the config. They are gone, use the YAML file for all settings. The Docker images still read their own variables, such as `RUNNER_STATE_FILE`, see [scripts/run.sh](scripts/run.sh) and the container documentation below.
Earlier releases let a small set of environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the default config. Those overrides have been removed — use a YAML config file for all settings instead. For the Docker images, the entrypoint still understands a separate set of variables (such as `RUNNER_STATE_FILE`); see [scripts/run.sh](scripts/run.sh) and the container documentation below.
### Labels ### Labels

View File

@@ -12,11 +12,13 @@ the runner as a background service on a systemd host.
sudo useradd --system --home-dir /var/lib/gitea-runner --create-home gitea-runner sudo useradd --system --home-dir /var/lib/gitea-runner --create-home gitea-runner
``` ```
3. Generate a config and register the runner (as the service user), so the 3. Write a config, hand it to the service user, and register as that user so the
`.runner` file ends up in the working directory: `.runner` file ends up in the working directory:
```bash ```bash
sudo -u gitea-runner gitea-runner config generate > /etc/gitea-runner/config.yaml sudo mkdir -p /etc/gitea-runner
sudo gitea-runner config init --config /etc/gitea-runner/config.yaml
sudo chown gitea-runner /etc/gitea-runner/config.yaml
cd /var/lib/gitea-runner cd /var/lib/gitea-runner
sudo -u gitea-runner gitea-runner register --config /etc/gitea-runner/config.yaml sudo -u gitea-runner gitea-runner register --config /etc/gitea-runner/config.yaml
``` ```

View File

@@ -49,10 +49,10 @@ export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
gitea-runner register gitea-runner register
``` ```
- Generate a `gitea-runner` configuration file in the data directory. Edit the file to adjust for the system. - Write a `gitea-runner` configuration file in the data directory. Edit the file to adjust for the system, `gitea-runner config generate` documents every option.
```bash ```bash
gitea-runner config generate >/home/rootless/gitea-runner/config gitea-runner config init --config /home/rootless/gitea-runner/config
``` ```
- Create a new user-level`systemd` unit file as `/home/rootless/.config/systemd/user/gitea-runner.service` with the following contents: - Create a new user-level`systemd` unit file as `/home/rootless/.config/systemd/user/gitea-runner.service` with the following contents:

View File

@@ -26,6 +26,7 @@ func loadConfigCmd(configFile *string) *cobra.Command {
} }
configCmd.AddCommand(loadGenerateConfigCmd("generate")) configCmd.AddCommand(loadGenerateConfigCmd("generate"))
configCmd.AddCommand(loadInitConfigCmd(configFile))
configCmd.AddCommand(&cobra.Command{ configCmd.AddCommand(&cobra.Command{
Use: "get <key>", Use: "get <key>",
@@ -73,10 +74,38 @@ func loadConfigCmd(configFile *string) *cobra.Command {
return configCmd return configCmd
} }
func loadInitConfigCmd(configFile *string) *cobra.Command {
var force bool
initCmd := &cobra.Command{
Use: "init",
Short: "Write a minimal config file",
Long: "Write a minimal config file, leaving every option at its default.\nWithout --config it writes config.yaml in the working directory.",
Args: cobra.MaximumNArgs(0),
RunE: func(cmd *cobra.Command, _ []string) error {
file, taken := *configFile, []string{*configFile}
if file == "" {
file, taken = defaultConfigFileNames[0], defaultConfigFileNames // any of them would shadow the new file
}
for _, name := range taken {
if _, err := os.Stat(name); err == nil && !force {
return fmt.Errorf("config file %q already exists, pass --force to overwrite it", name)
}
}
if err := config.WriteFile(file, []byte(config.Minimal)); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "wrote config file %q\n", file)
return nil
},
}
initCmd.Flags().BoolVarP(&force, "force", "f", false, "overwrite an existing config file")
return initCmd
}
func loadGenerateConfigCmd(use string) *cobra.Command { func loadGenerateConfigCmd(use string) *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: use, Use: use,
Short: "Generate an example config file", Short: "Print the example config, which documents every option",
Args: cobra.MaximumNArgs(0), Args: cobra.MaximumNArgs(0),
Run: func(cmd *cobra.Command, _ []string) { Run: func(cmd *cobra.Command, _ []string) {
fmt.Fprintf(cmd.OutOrStdout(), "%s", config.Example) fmt.Fprintf(cmd.OutOrStdout(), "%s", config.Example)

View File

@@ -32,6 +32,30 @@ func TestConfigCmdGeneratePrintsTheExample(t *testing.T) {
assert.Equal(t, string(config.Example), out) assert.Equal(t, string(config.Example), out)
} }
func TestConfigCmdInitWritesTheMinimalConfig(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "config.yaml")
out, _, err := runConfigCmd(t, file, "init")
require.NoError(t, err)
assert.Contains(t, out, file)
content, err := os.ReadFile(file)
require.NoError(t, err)
assert.Equal(t, config.Minimal, string(content))
_, _, err = runConfigCmd(t, file, "init")
require.Error(t, err)
assert.Contains(t, err.Error(), "--force")
_, _, err = runConfigCmd(t, file, "init", "--force")
require.NoError(t, err)
t.Chdir(t.TempDir())
_, _, err = runConfigCmd(t, "", "init")
require.NoError(t, err)
assert.FileExists(t, defaultConfigFileNames[0])
}
// The subcommands only wire arguments through, so one pass over all of them is enough. // The subcommands only wire arguments through, so one pass over all of them is enough.
func TestConfigCmdEditsTheFile(t *testing.T) { func TestConfigCmdEditsTheFile(t *testing.T) {
file := filepath.Join(t.TempDir(), "config.yaml") file := filepath.Join(t.TempDir(), "config.yaml")

View File

@@ -1,7 +1,5 @@
# Example configuration file, it's safe to copy this as the default config file without any modification. # 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.
# You don't have to copy this file to your instance,
# just run `./gitea-runner config generate > config.yaml` to generate a config file.
# Logging for the runner process itself (messages printed to stderr). # Logging for the runner process itself (messages printed to stderr).
# This does not control how workflow step output is streamed to the Gitea UI; # This does not control how workflow step output is streamed to the Gitea UI;
@@ -9,92 +7,92 @@
log: log:
# logrus severity: trace, debug, info, warn, error, fatal, panic. # logrus severity: trace, debug, info, warn, error, fatal, panic.
# trace and debug turn on caller/file:line in log lines. Default if omitted: info. # trace and debug turn on caller/file:line in log lines. Default if omitted: info.
level: info #level: info
runner: runner:
# Where to store the registration result. # Where to store the registration result.
file: .runner #file: .runner
# Execute how many tasks concurrently at the same time. # Execute how many tasks concurrently at the same time.
# With `container.network` empty, each concurrent docker job takes a subnet from the # 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` # daemon's address pool, so a high capacity can exhaust it. See `default-address-pools`
# in the docker daemon config. # in the docker daemon config.
capacity: 1 #capacity: 1
# Extra environment variables to run jobs. # Extra environment variables to run jobs.
envs: #envs:
A_TEST_ENV_NAME_1: a_test_env_value_1 # A_TEST_ENV_NAME_1: a_test_env_value_1
A_TEST_ENV_NAME_2: a_test_env_value_2 # A_TEST_ENV_NAME_2: a_test_env_value_2
# Extra environment variables to run jobs from a file. # Extra environment variables to run jobs from a file.
# It will be ignored if it's empty or the file doesn't exist. # 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. # The timeout for a job to be finished.
# Please note that the Gitea instance also has a timeout (3h by default) for the job. # 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. # 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. # 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. # 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. # Whether skip verifying the TLS certificate of the Gitea instance.
insecure: false #insecure: false
# The timeout for fetching the job from the Gitea instance. # 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. # 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 maximum interval for fetching the job from the Gitea instance.
# The runner uses exponential backoff when idle, increasing the interval up to this maximum. # 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. # 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 # While idle, remove stale bind-workdir task directories and orphaned host-mode
# scratch directories (left behind when a host cleanup delete stalls) older than # 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 # 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 # (or any non-positive value) disables stale-directory cleanup entirely, along with
# the docker network cleanup below. # 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 # 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, # 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. # 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. # The base interval for periodic log flush to the Gitea instance.
# Logs may be sent earlier if the buffer reaches log_report_batch_size # 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. # 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. # 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. # 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. # 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. # Flush logs immediately when the buffer reaches this many rows.
# This ensures bursty output (e.g., npm install) is delivered promptly. # 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. # The interval for reporting task state (step status, timing) to the Gitea instance.
# State is also reported immediately on step transitions (start/stop). # 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 # 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. # 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. # 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, # 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, # 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. # it replaces https://github.com with the value here, which is useful for some special network environments.
github_mirror: '' #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. # 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. # 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. # 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 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. # 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" # 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 . # 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 registering, it will ask for inputting labels.
# If it's empty when execute `daemon`, will use labels in `.runner` file. # If it's empty when execute `daemon`, will use labels in `.runner` file.
labels: #labels:
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest" # - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
- "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04" # - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.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. # 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 # 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 # terminal; tools like `docker build` emit redrawing progress frames into the captured log
# when a TTY is present. # 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 # Optional executable on the host, run once after each task's built-in cleanup
# (post-steps, container teardown, bind-workdir removal). Additive only. # (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 # Windows: use .exe, .bat, or .cmd. PowerShell (.ps1) is not supported yet as
# the configured path; wrap PowerShell commands in a .cmd file instead. # the configured path; wrap PowerShell commands in a .cmd file instead.
# Full guide: docs/post-task-script.md # Full guide: docs/post-task-script.md
post_task_script: '' #post_task_script: ''
# Hard limit on post_task_script runtime. Default if omitted: 5m. # 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 # 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 # 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 # 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. # resolved inside the job environment. Either one failing fails the job.
# Full guide: docs/job-hooks.md # Full guide: docs/job-hooks.md
hooks: #hooks:
job_started: '' # job_started: ''
job_completed: '' # job_completed: ''
cache: cache:
# Enable the built-in cache server (used by actions/cache and similar actions). # 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 # Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache
# Ignored when external_server is set. # Ignored when external_server is set.
dir: "" #dir: ""
# Outbound IP or hostname that job containers use to reach this runner's cache server. # 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. # 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 # 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 # 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. # published port or put the job containers on a shared Docker network.
# Ignored when external_server is set. # Ignored when external_server is set.
host: "" #host: ""
# Port for the built-in cache server. 0 picks a random free port. # Port for the built-in cache server. 0 picks a random free port.
# Ignored when external_server is set. # 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. # 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. # Set on every runner that should share a cache pool. A trailing slash is optional.
# Example: "http://cache-host:8088/" # Example: "http://cache-host:8088/"
# Requires external_secret (below) to match the value on the cache-server. # 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. # 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. # Required when external_server is set. Must be identical on every runner and the cache-server.
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
external_secret: "" #external_secret: ""
# Path to a file containing the shared secret, as an alternative to 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. # Use this to keep the secret out of this file.
# Surrounding whitespace is trimmed, so a trailing newline in the file is fine. # Surrounding whitespace is trimmed, so a trailing newline in the file is fine.
# Setting both external_secret and external_secret_file is an error. # 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. # 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 # 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. # 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 # 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 # 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 # 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. # upload-artifact and download-artifact work here. A bundle that does not match is left alone.
v2: true #v2: true
container: container:
# Specifies the network to which the container will connect. # 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 # 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. # 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. # 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 # 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 # 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 # 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 # 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 # 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. # cleanup tells its own leftovers apart from those of other runners on the same daemon.
network_create_options: #network_create_options:
enable_ipv4: true # Omit to use Docker's default (IPv4 enabled). Set false to disable IPv4. # 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. # 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). # 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: # Any other options to be used when the container is started, for example:
# options: --add-host=my.gitea.url:host-gateway # 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 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: # 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: --volume /host/toolcache:/opt/hostedtoolcache
options: #options:
# The parent directory of a job's working directory. # 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. # 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. # 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 # 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. # 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. # 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 # 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. # 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: # 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: # If you want to allow any volume, please use the following configuration:
# valid_volumes: # valid_volumes:
# - '**' # - '**'
valid_volumes: [] #valid_volumes: []
# Overrides the docker client host with the specified one. # Overrides the docker client host with the specified one.
# If it's empty, runner will find an available docker host automatically. # 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 "-", 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. # 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. # Pull docker image(s) even if already present.
# Defaults to false when the key is omitted. # Defaults to false when the key is omitted.
# Two exceptions: an image pinned by digest (image@sha256:...) cannot change, so it is never # 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, # 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. # 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 # 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 # 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 # 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. # 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 # 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 # 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 # 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. # 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 # 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. # healthy. A negative value (e.g. -1s) starts the steps without waiting.
service_ready_timeout: 5m #service_ready_timeout: 5m
host: host:
# The parent directory of a job's working directory. # The parent directory of a job's working directory.
# If it's empty, $HOME/.cache/act/ will be used. # 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 # Optional local task-admission checks. Disabled by default. When enabled, low
# disk space or a failing script pauses new task fetching; existing jobs continue. # 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. # No health checks run while any job is active; the last result is reused until idle.
health_check: health_check:
enabled: false #enabled: false
# Minimum free space required on the filesystem holding runner workspaces. # Minimum free space required on the filesystem holding runner workspaces.
# Defaults to 1024 MiB when omitted or set to zero. # 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 # Optional additional executable. A non-zero exit, timeout, or startup failure
# marks the runner unavailable. # marks the runner unavailable.
script: '' #script: ''
# How long a script result is cached and its maximum execution time. # How long a script result is cached and its maximum execution time.
interval: 30s #interval: 30s
timeout: 10s #timeout: 10s
metrics: metrics:
# Enable the Prometheus metrics endpoint. # Enable the Prometheus metrics endpoint.
# When enabled, metrics are served at /metrics, liveness at /healthz, and # When enabled, metrics are served at /metrics, liveness at /healthz, and
# task-admission readiness at /readyz. # task-admission readiness at /readyz.
enabled: false #enabled: false
# The address for the metrics HTTP server to listen on. # The address for the metrics HTTP server to listen on.
# Defaults to localhost only. Set to ":9101" to allow external access, # Defaults to localhost only. Set to ":9101" to allow external access,
# but ensure the port is firewall-protected as there is no authentication. # 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. # 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). // (so a programmatically built config still gets a sane bound).
const DefaultPostTaskScriptTimeout = 5 * time.Minute 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. // Log represents the configuration for logging.
type Log struct { type Log struct {
Level string `yaml:"level"` // Level indicates the logging level. Level string `yaml:"level"` // Level indicates the logging level.

View File

@@ -348,12 +348,23 @@ cache:
assert.Contains(t, err.Error(), "contains no secret") assert.Contains(t, err.Error(), "contains no secret")
} }
// The shipped example must parse, and every key in it must be one the config knows. // The shipped configs must parse, hold no key the config does not know, and leave
func TestLoadDefault_ExampleConfigParses(t *testing.T) { // every option at its default, as all of their values are commented out.
func TestLoadDefault_ShippedConfigsChangeNothing(t *testing.T) {
hook := test.NewGlobal() hook := test.NewGlobal()
defer hook.Reset() defer hook.Reset()
_, err := LoadDefault("config.example.yaml") defaults, err := LoadDefault("")
require.NoError(t, err) 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()) assert.Empty(t, hook.AllEntries())
} }

View File

@@ -160,6 +160,7 @@ type editSession struct {
root *yaml.Node root *yaml.Node
field *fieldInfo field *fieldInfo
segments []string 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. // 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) content, err := os.ReadFile(file)
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) { 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 return nil, err
} }
@@ -185,7 +186,9 @@ func loadForEdit(file, path string) (*editSession, error) {
if err := yaml.Unmarshal(content, &root); err != nil { if err := yaml.Unmarshal(content, &root); err != nil {
return nil, fmt.Errorf("parse config file %q: %w", file, err) return nil, fmt.Errorf("parse config file %q: %w", file, err)
} }
var preamble []byte
if root.Kind == 0 || len(root.Content) == 0 { if root.Kind == 0 || len(root.Content) == 0 {
preamble = bytes.TrimSpace(content) // all the file has is comments
root = yaml.Node{ root = yaml.Node{
Kind: yaml.DocumentNode, Kind: yaml.DocumentNode,
Content: []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}, 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 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) { func loadSequenceEdit(file, path string, values []string) (*editSession, error) {
@@ -319,16 +322,28 @@ func encodeYAML(node *yaml.Node) ([]byte, error) {
return buf.Bytes(), nil return buf.Bytes(), nil
} }
// restoreBlankLines re-inserts the blank lines between top-level sections that the encoder drops. // restoreLayout re-applies the spacing the encoder drops: the blank lines between
func restoreBlankLines(original, generated []byte) []byte { // 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{} spaced := map[string]bool{}
blank := false blank := false
for line := range strings.Lines(string(original)) { for line := range strings.Lines(string(original)) {
line = strings.TrimRight(line, "\r\n") line = strings.TrimRight(line, "\r\n")
trimmed := strings.TrimSpace(line)
switch { switch {
case strings.TrimSpace(line) == "": case trimmed == "":
blank = true 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: default:
if key, ok := topLevelKey(line); ok && blank { if key, ok := topLevelKey(line); ok && blank {
spaced[key] = true spaced[key] = true
@@ -338,16 +353,26 @@ func restoreBlankLines(original, generated []byte) []byte {
} }
var out []string 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)) { for line := range strings.Lines(string(generated)) {
line = strings.TrimRight(line, "\r\n") line = strings.TrimRight(line, "\r\n")
if key, ok := topLevelKey(line); ok && spaced[key] { trimmed := strings.TrimSpace(line)
start := len(out) if strings.HasPrefix(trimmed, "#") {
for start > 0 && strings.HasPrefix(out[start-1], "#") { if i := slices.IndexFunc(comments, func(c comment) bool { return c.trimmed == trimmed }); i >= 0 {
start-- if comments[i].blankBefore {
} appendBlank()
if start > 0 && strings.TrimSpace(out[start-1]) != "" { } else if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" {
out = slices.Insert(out, start, "") 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) 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) 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 if bytes.Contains(s.original, []byte("\r\n")) { // the encoder only ever emits LF
content = bytes.ReplaceAll(content, []byte("\n"), []byte("\r\n")) 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 { if resolved, err := filepath.EvalSymlinks(file); err == nil {
file = resolved // keeps a config linked in from elsewhere intact 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 // An edit has to give the file back unchanged around it, down to the indentation of
// the encoder emits it, down to the single space before a trailing comment. // a commented-out option, as that documentation is what the user reads and edits.
func TestEditValuesKeepsExampleConfigIntact(t *testing.T) { func TestEditValuesPreservesFileText(t *testing.T) {
file := filepath.Join(t.TempDir(), "config.yaml") tests := []struct {
require.NoError(t, os.WriteFile(file, Example, 0o600)) 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, tc.edit(file))
require.NoError(t, err)
withoutAdded := strings.Replace(string(content), " - ubuntu:docker://node:22\n", "", 1) content, err := os.ReadFile(file)
assert.Equal(t, string(Example), withoutAdded, "only the appended label may differ") require.NoError(t, err)
assert.Equal(t, string(tc.content), strings.Replace(string(content), tc.added, "", 1))
})
}
} }