fix: Minor fixes (#1075)

A batch of small, self-contained fixes and docs/example additions.

Fixes #625 - align the example config's `force_pull` with the actual default (`false`)
Fixes #804 - return an error instead of discarding `os.UserHomeDir()` when defaulting `cache.dir`/`host.workdir_parent`
Fixes #571 - send a `gitea-runner/<version>` User-Agent on API requests
Fixes #650 - detect an `Unauthenticated` fetch response and exit the daemon with an error instead of retrying forever
Fixes #766 - add `exec --eventpath` to supply a JSON event payload file
Fixes #256 - add a `bug-report` subcommand that prints version/Go/OS-arch/CPU info
Fixes #617 - add `runner.set_act_env` (default `true`) to optionally omit the `ACT=true` env var
Fixes #635 - record a failure result (and guard a nil reusable-workflow caller) when the job `if`-expression fails to evaluate
Fixes #1005 - remove README docs for config env-var overrides that were already removed from the code
Fixes #448 - clarify in `exec --job` help that `--workflows` may be needed to disambiguate
Fixes #209 - note that `host`-labelled runners still need Docker for `docker://` actions and service containers
Fixes #757 - add a systemd service example with automatic restart
Fixes #474 - add a Kubernetes StatefulSet example that persists the `.runner` registration across reschedules
Fixes #776 - build the basic (non-dind) docker image for `linux/riscv64`
Fixes #628 - build the basic (non-dind) docker image for `linux/s390x`Reviewed-on: https://gitea.com/gitea/runner/pulls/1075
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
Nicolas
2026-07-13 18:41:19 +00:00
parent be9b4502d6
commit 65756d60b3
19 changed files with 301 additions and 25 deletions

View File

@@ -43,12 +43,18 @@ jobs:
strategy:
matrix:
variant:
# The basic image is built from source and can target any arch the
# toolchain supports. The dind variants are limited to the arches the
# docker:dind base image publishes.
- target: basic
tag_suffix: ""
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/s390x
- target: dind
tag_suffix: "-dind"
platforms: linux/amd64,linux/arm64
- target: dind-rootless
tag_suffix: "-dind-rootless"
platforms: linux/amd64,linux/arm64
steps:
- name: Checkout
@@ -82,9 +88,7 @@ jobs:
context: .
file: ./Dockerfile
target: ${{ matrix.variant.target }}
platforms: |
linux/amd64
linux/arm64
platforms: ${{ matrix.variant.platforms }}
push: true
tags: |
${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}

View File

@@ -42,12 +42,18 @@ jobs:
strategy:
matrix:
variant:
# The basic image is built from source and can target any arch the
# toolchain supports. The dind variants are limited to the arches the
# docker:dind base image publishes.
- target: basic
tag_suffix: ""
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/s390x
- target: dind
tag_suffix: "-dind"
platforms: linux/amd64,linux/arm64
- target: dind-rootless
tag_suffix: "-dind-rootless"
platforms: linux/amd64,linux/arm64
container:
image: catthehacker/ubuntu:act-latest
env:
@@ -91,9 +97,7 @@ jobs:
context: .
file: ./Dockerfile
target: ${{ matrix.variant.target }}
platforms: |
linux/amd64
linux/arm64
platforms: ${{ matrix.variant.platforms }}
push: true
tags: ${{ steps.docker_meta.outputs.tags }}
build-args: |

View File

@@ -143,23 +143,16 @@ Every option is described in [config.example.yaml](internal/pkg/config/config.ex
#### Without a config file
If you omit `-c`, built-in defaults apply (same as an empty YAML document). A small set of **deprecated** environment variables can still override parts of that default config, but **only when no `-c` path was given**; they are ignored if you use a config file:
If you omit `-c`, built-in defaults apply (same as an empty YAML document).
| Variable | Effect |
| --- | --- |
| `GITEA_DEBUG` | If true, sets log level to `debug` |
| `GITEA_TRACE` | If true, sets log level to `trace` |
| `GITEA_RUNNER_CAPACITY` | Concurrent jobs (integer) |
| `GITEA_RUNNER_FILE` | Registration state file path (default `.runner`) |
| `GITEA_RUNNER_ENVIRON` | Extra job env vars as comma-separated `KEY:VALUE` pairs |
| `GITEA_RUNNER_ENV_FILE` | Path to an env file merged into job env (same idea as `runner.env_file` in YAML) |
Prefer a YAML file for all settings.
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.
#### Registration vs config labels
If `runner.labels` is set in the YAML file, those labels are used during `register` and the `--labels` CLI flag is ignored.
> **Note:** A runner that only exposes `host` labels still needs access to a Docker daemon (e.g. a mounted `/var/run/docker.sock`) whenever a job uses a `docker://` action or a service container. `host` labels only change where the job's own steps run; container-based steps and actions are still executed with Docker.
#### Caching (`actions/cache`)
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.

View File

@@ -138,7 +138,9 @@ func (rc *RunContext) GetEnv() map[string]string {
}
}
}
rc.Env["ACT"] = "true"
if !rc.Config.DisableActEnv {
rc.Env["ACT"] = "true"
}
if !rc.Config.NoSkipCheckout {
rc.Env["ACT_SKIP_CHECKOUT"] = "true"
@@ -790,7 +792,13 @@ func (rc *RunContext) Executor() (common.Executor, error) {
return func(ctx context.Context) error {
res, err := rc.isEnabled(ctx)
if err != nil {
rc.caller.setReusedWorkflowJobResult(rc.JobName, "failure") // For Gitea
// Record the failure so a job whose if-expression fails to evaluate
// gets a result (and therefore a stop time) instead of being left
// unfinished. rc.caller is only set for reusable workflows.
rc.result("failure")
if rc.caller != nil { // For Gitea
rc.caller.setReusedWorkflowJobResult(rc.JobName, "failure")
}
return err
}
if res {

View File

@@ -65,6 +65,7 @@ type Config struct {
ArtifactServerAddr string // the address the artifact server binds to
ArtifactServerPort string // the port the artifact server binds to
NoSkipCheckout bool // do not skip actions/checkout
DisableActEnv bool // do not inject the ACT=true environment variable into jobs
RemoteName string // remote name in local git repo config
ReplaceGheActionWithGithubCom []string // Use actions from GitHub Enterprise instance to GitHub
ReplaceGheActionTokenWithGithubCom string // Token of private action repo on GitHub.

View File

@@ -13,3 +13,6 @@ Files in this directory:
- [`rootless-docker.yaml`](rootless-docker.yaml)
How to create a rootless Deployment and Persistent Volume for Kubernetes to act as a runner. The Docker credentials are re-generated each time the pod connects and does not need to be persisted.
- [`statefulset-dind.yaml`](statefulset-dind.yaml)
StatefulSet variant of the dind example. Each replica gets a stable identity and its own persistent volume via `volumeClaimTemplates`, so the runner keeps its `.runner` registration across restarts and reschedules instead of trying to register again.

View File

@@ -0,0 +1,79 @@
# StatefulSet variant of the dind example.
#
# Unlike the Deployment, a StatefulSet gives each replica a stable identity and,
# via volumeClaimTemplates, its own persistent volume. That means every runner
# pod keeps its own `.runner` registration file across restarts and reschedules,
# so it re-attaches to the server instead of trying to register again.
apiVersion: v1
data:
# The registration token can be obtained from the web UI, API or command-line.
# You can also set a pre-defined global runner registration token for the Gitea instance via
# `GITEA_RUNNER_REGISTRATION_TOKEN`/`GITEA_RUNNER_REGISTRATION_TOKEN_FILE` environment variable.
token: << base64 encoded registration token >>
kind: Secret
metadata:
name: runner-secret
type: Opaque
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
labels:
app: runner
name: runner
spec:
serviceName: runner
replicas: 1
selector:
matchLabels:
app: runner
template:
metadata:
labels:
app: runner
spec:
restartPolicy: Always
volumes:
- name: docker-socket
emptyDir: {}
initContainers:
- name: docker
image: docker:28.2.2-dind
securityContext:
privileged: true
volumeMounts:
- name: docker-socket
mountPath: /var/run
startupProbe:
exec:
command: ["/usr/bin/test", "-S", "/var/run/docker.sock"]
livenessProbe:
exec:
command: ["/usr/bin/test", "-S", "/var/run/docker.sock"]
restartPolicy: Always
containers:
- name: runner
image: gitea/runner:nightly
env:
- name: GITEA_INSTANCE_URL
value: http://gitea-http.gitea.svc.cluster.local:3000
- name: GITEA_RUNNER_REGISTRATION_TOKEN
valueFrom:
secretKeyRef:
name: runner-secret
key: token
volumeMounts:
- name: runner-data
mountPath: /data
- name: docker-socket
mountPath: /var/run
volumeClaimTemplates:
- metadata:
name: runner-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
storageClassName: standard

View File

@@ -0,0 +1,34 @@
# Running the runner as a systemd service
[`gitea-runner.service`](./gitea-runner.service) is an example unit for running
the runner as a background service on a systemd host.
## Setup
1. Install the `gitea-runner` binary (e.g. to `/usr/local/bin/gitea-runner`).
2. Create a dedicated user and working directory:
```bash
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
`.runner` file ends up in the working directory:
```bash
sudo -u gitea-runner gitea-runner generate-config > /etc/gitea-runner/config.yaml
cd /var/lib/gitea-runner
sudo -u gitea-runner gitea-runner register --config /etc/gitea-runner/config.yaml
```
4. Install and enable the unit:
```bash
sudo cp gitea-runner.service /etc/systemd/system/gitea-runner.service
sudo systemctl daemon-reload
sudo systemctl enable --now gitea-runner
```
Adjust the binary path, config path, working directory and user to match your
installation. If jobs use the host's Docker daemon, uncomment the
`docker.service` dependencies in the unit.

View File

@@ -0,0 +1,30 @@
[Unit]
Description=Gitea Actions runner
Documentation=https://gitea.com/gitea/runner
After=network-online.target
Wants=network-online.target
# Uncomment when jobs use the local Docker daemon:
# After=docker.service
# Requires=docker.service
[Service]
Type=simple
# Adjust the binary path, config path and working directory to your setup.
# The working directory is where the .runner registration file is read from
# unless runner.file is set to an absolute path in the config.
ExecStart=/usr/local/bin/gitea-runner daemon --config /etc/gitea-runner/config.yaml
WorkingDirectory=/var/lib/gitea-runner
User=gitea-runner
Group=gitea-runner
# Restart automatically so the runner survives transient failures, e.g. the
# Gitea instance being temporarily unreachable at startup.
Restart=on-failure
RestartSec=5s
# Allow running jobs to finish before the runner is stopped. Keep this in sync
# with runner.shutdown_timeout in the config.
TimeoutStopSec=3h
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,31 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"fmt"
"runtime"
"gitea.com/gitea/runner/internal/pkg/ver"
"github.com/spf13/cobra"
)
// loadBugReportCmd prints environment details that are useful when opening a
// bug report, so users can paste them straight into an issue.
func loadBugReportCmd() *cobra.Command {
return &cobra.Command{
Use: "bug-report",
Short: "Print information useful when filing a bug report",
Args: cobra.MaximumNArgs(0),
RunE: func(cmd *cobra.Command, _ []string) error {
w := cmd.OutOrStdout()
fmt.Fprintf(w, "Runner version: %s\n", ver.Version())
fmt.Fprintf(w, "Go version: %s\n", runtime.Version())
fmt.Fprintf(w, "OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
fmt.Fprintf(w, "NumCPU: %d\n", runtime.NumCPU())
return nil
},
}
}

View File

@@ -56,6 +56,9 @@ func Execute(ctx context.Context) {
// ./gitea-runner exec
rootCmd.AddCommand(loadExecCmd(ctx))
// ./gitea-runner bug-report
rootCmd.AddCommand(loadBugReportCmd())
// ./gitea-runner config
rootCmd.AddCommand(&cobra.Command{
Use: "generate-config",

View File

@@ -176,7 +176,12 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
} else {
go poller.Poll()
<-ctx.Done()
// Stop either on an external cancellation or when the poller shuts
// itself down (e.g. after the runner has been unregistered).
select {
case <-ctx.Done():
case <-poller.Done():
}
}
log.Infof("runner: %s shutdown initiated, waiting %s for running jobs to complete before shutting down", resp.Msg.Runner.Name, cfg.Runner.ShutdownTimeout)
@@ -189,6 +194,10 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
log.Warnf("runner: %s cancelled in progress jobs during shutdown", resp.Msg.Runner.Name)
}
if poller.Unregistered() {
return errors.New("runner is no longer registered with the server; please register it again")
}
return nil
}
}

View File

@@ -34,6 +34,7 @@ type executeArgs struct {
runList bool
job string
event string
eventpath string
workdir string
workflowsPath string
noWorkflowRecurse bool
@@ -441,6 +442,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
ArtifactServerPort: execArgs.artifactServerPort,
ArtifactServerAddr: execArgs.artifactServerAddr,
NoSkipCheckout: execArgs.noSkipCheckout,
EventPath: execArgs.resolve(execArgs.eventpath),
// PresetGitHubContext: preset,
// EventJSON: string(eventJSON),
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
@@ -496,8 +498,9 @@ func loadExecCmd(ctx context.Context) *cobra.Command {
}
execCmd.Flags().BoolVarP(&execArg.runList, "list", "l", false, "list workflows")
execCmd.Flags().StringVarP(&execArg.job, "job", "j", "", "run a specific job ID")
execCmd.Flags().StringVarP(&execArg.job, "job", "j", "", "run a specific job ID; when several workflow files define that job, also pass --workflows/-W to select the file")
execCmd.Flags().StringVarP(&execArg.event, "event", "E", "", "run a event name")
execCmd.Flags().StringVarP(&execArg.eventpath, "eventpath", "e", "", "path to a JSON event payload file exposed as the event that triggered the workflow")
execCmd.PersistentFlags().StringVarP(&execArg.workflowsPath, "workflows", "W", "./.gitea/workflows/", "path to workflow file(s)")
execCmd.PersistentFlags().StringVarP(&execArg.workdir, "directory", "C", ".", "working directory")
execCmd.PersistentFlags().BoolVarP(&execArg.noWorkflowRecurse, "no-recurse", "", false, "Flag to disable running workflows from subdirectories of specified path in '--workflows'/'-W' flag")

View File

@@ -45,6 +45,10 @@ type Poller struct {
shutdownJobs context.CancelFunc
done chan struct{}
// unregistered is set when the server rejects the runner with an
// Unauthenticated response, meaning the runner is no longer registered.
unregistered atomic.Bool
}
// workerState holds the single poller's backoff state. Consecutive empty or
@@ -137,6 +141,19 @@ func (p *Poller) PollOnce() {
}
}
// Done returns a channel that is closed once polling has fully stopped,
// allowing callers to react when the poller shuts itself down (e.g. after the
// runner has been unregistered) rather than only on an external cancellation.
func (p *Poller) Done() <-chan struct{} {
return p.done
}
// Unregistered reports whether polling stopped because the server rejected the
// runner as unregistered (an Unauthenticated response).
func (p *Poller) Unregistered() bool {
return p.unregistered.Load()
}
func (p *Poller) runIdleMaintenance() {
if idleRunner, ok := p.runner.(IdleRunner); ok {
idleRunner.OnIdle(p.jobsCtx)
@@ -264,6 +281,15 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
metrics.PollFetchDuration.Observe(time.Since(start).Seconds())
if err != nil {
// An Unauthenticated response means the server no longer knows this
// runner (e.g. it was deleted). Retrying forever is pointless, so stop
// polling and let the daemon exit with an error instead of spinning.
if connect.CodeOf(err) == connect.CodeUnauthenticated {
log.WithError(err).Error("server rejected the runner as unregistered, stopping poller")
p.unregistered.Store(true)
p.shutdownPolling()
return nil, false
}
log.WithError(err).Error("failed to fetch task")
s.consecutiveErrors++
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultError).Inc()

View File

@@ -78,6 +78,35 @@ func TestPoller_FetchErrorIncrementsErrorsOnly(t *testing.T) {
assert.Equal(t, int64(0), s.consecutiveEmpty)
}
// TestPoller_FetchUnauthenticatedStopsPolling verifies that an Unauthenticated
// response marks the runner as unregistered and cancels the polling context so
// the daemon can exit instead of retrying forever.
func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) {
client := mocks.NewClient(t)
client.On("FetchTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *connect_go.Request[runnerv1.FetchTaskRequest]) (*connect_go.Response[runnerv1.FetchTaskResponse], error) {
return nil, connect_go.NewError(connect_go.CodeUnauthenticated, errors.New("unregistered runner"))
},
)
cfg, err := config.LoadDefault("")
require.NoError(t, err)
p := New(cfg, client, nil)
s := &workerState{}
_, ok := p.fetchTask(context.Background(), s)
require.False(t, ok)
assert.True(t, p.Unregistered(), "runner should be marked unregistered")
assert.Equal(t, int64(0), s.consecutiveErrors, "unauthenticated must not drive error backoff")
select {
case <-p.pollingCtx.Done():
default:
t.Fatal("expected polling context to be cancelled after an Unauthenticated response")
}
}
// TestPoller_CalculateInterval verifies the exponential backoff math is
// correctly driven by the workerState counters.
func TestPoller_CalculateInterval(t *testing.T) {

View File

@@ -445,6 +445,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
GitHubInstance: strings.TrimSuffix(r.client.Address(), "/"),
AutoRemove: true,
NoSkipCheckout: true,
DisableActEnv: r.cfg.Runner.SetActEnv != nil && !*r.cfg.Runner.SetActEnv,
PresetGitHubContext: preset,
EventJSON: string(eventJSON),
ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id),

View File

@@ -10,6 +10,8 @@ import (
"strings"
"time"
"gitea.com/gitea/runner/internal/pkg/ver"
"connectrpc.com/connect"
"gitea.dev/actions-proto-go/ping/v1/pingv1connect"
"gitea.dev/actions-proto-go/runner/v1/runnerv1connect"
@@ -36,6 +38,7 @@ func New(endpoint string, insecure bool, uuid, token string, opts ...connect.Cli
opts = append(opts, connect.WithInterceptors(connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
req.Header().Set("User-Agent", "gitea-runner/"+ver.Version())
if uuid != "" {
req.Header().Set(UUIDHeader, uuid)
}

View File

@@ -72,6 +72,9 @@ runner:
# 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
# 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
# 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 .
@@ -175,8 +178,9 @@ container:
# 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: ""
# Pull docker image(s) even if already present
force_pull: true
# Pull docker image(s) even if already present.
# Defaults to false when the key is omitted.
force_pull: false
# Rebuild docker image(s) even if already present
force_rebuild: false
# Always require a reachable docker daemon, even if not required by runner

View File

@@ -49,6 +49,7 @@ type Runner struct {
Labels []string `yaml:"labels"` // Labels specify the labels of the runner. Labels are declared on each startup
GithubMirror string `yaml:"github_mirror"` // GithubMirror defines what mirrors should be used when using github
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.
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.
@@ -156,13 +157,20 @@ func LoadDefault(file string) (*Config, error) {
b := true
cfg.Runner.ActionShallowClone = &b
}
if cfg.Runner.SetActEnv == nil {
b := true
cfg.Runner.SetActEnv = &b
}
if cfg.Cache.Enabled == nil {
b := true
cfg.Cache.Enabled = &b
}
if *cfg.Cache.Enabled {
if cfg.Cache.Dir == "" {
home, _ := os.UserHomeDir()
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("cache.dir is unset and the user home directory could not be determined: %w", err)
}
cfg.Cache.Dir = filepath.Join(home, ".cache", "actcache")
}
if cfg.Cache.ExternalServer != "" && cfg.Cache.ExternalSecret == "" {
@@ -173,7 +181,10 @@ func LoadDefault(file string) (*Config, error) {
cfg.Container.WorkdirParent = "workspace"
}
if cfg.Host.WorkdirParent == "" {
home, _ := os.UserHomeDir()
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("host.workdir_parent is unset and the user home directory could not be determined: %w", err)
}
cfg.Host.WorkdirParent = filepath.Join(home, ".cache", "act")
}
if cfg.Runner.FetchTimeout <= 0 {