From f2e0cf9131e2b956229a923c48d24f41835254b1 Mon Sep 17 00:00:00 2001 From: bircni Date: Sat, 4 Jul 2026 00:06:26 +0000 Subject: [PATCH 01/26] docs: clarify cache reachability for dockerized runners (#1069) Refs https://gitea.com/gitea/runner/issues/155 The remaining failure mode described in the issue comments is the job container timing out when it tries to reach ACTIONS_CACHE_URL. The generated config now explains that cache.host/cache.port must be reachable from job containers, and calls out dockerized runners with auto-created per-job networks as a case that may need a fixed published cache endpoint or a shared Docker network Co-authored-by: bircni --- internal/pkg/config/config.example.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml index 2176dff7..72d6b9c7 100644 --- a/internal/pkg/config/config.example.yaml +++ b/internal/pkg/config/config.example.yaml @@ -110,6 +110,11 @@ cache: 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 + # address on the runner container's network that job containers cannot reach + # when the runner creates a separate per-job network. In that case, set this + # 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: "" # Port for the built-in cache server. 0 picks a random free port. @@ -133,6 +138,8 @@ container: # Specifies the network to which the container will connect. # Could be host, bridge or the name of a custom network. # If it's empty, runner will create a network automatically. + # 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_create_options only apply when `network` is left empty and the runner From b12d02c25f9e8eeb95b8ffaa74ed9cb50f9b7865 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 9 Jul 2026 09:14:35 +0000 Subject: [PATCH 02/26] fix(deps): update module golang.org/x/sys to v0.47.0 (#1073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) | [`v0.46.0` β†’ `v0.47.0`](https://cs.opensource.google/go/x/sys/+/refs/tags/v0.46.0...refs/tags/v0.47.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fsys/v0.47.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fsys/v0.46.0/v0.47.0?slim=true) | --- ### Configuration πŸ“… **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. πŸ”• **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate). Reviewed-on: https://gitea.com/gitea/runner/pulls/1073 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> Co-authored-by: Renovate Bot --- go.mod | 4 ++-- go.sum | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 3e6f8456..4eac8621 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/opencontainers/selinux v1.15.1 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 github.com/rhysd/actionlint v1.7.12 github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 @@ -37,7 +38,7 @@ require ( github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928 go.etcd.io/bbolt v1.5.0 go.yaml.in/yaml/v4 v4.0.0-rc.3 - golang.org/x/sys v0.46.0 + golang.org/x/sys v0.47.0 golang.org/x/term v0.44.0 google.golang.org/protobuf v1.36.11 gotest.tools/v3 v3.5.2 @@ -84,7 +85,6 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.17.0 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 9b07d478..954e1936 100644 --- a/go.sum +++ b/go.sum @@ -262,6 +262,8 @@ golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= From 1d74ae636a07ec3df70f3a25d8bc05b890824a20 Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 9 Jul 2026 15:08:59 +0000 Subject: [PATCH 03/26] fix: skip service containers with an empty image (#1074) GitHub Actions skips services whose image evaluates to an empty string, enabling conditional services via expressions like `image: ${{ matrix.image || '' }}`. Here such a service failed the job at `docker create`. Skip them before container creation, using GitHub's log message verbatim, and add a regression test. Reviewed-on: https://gitea.com/gitea/runner/pulls/1074 Reviewed-by: Nicolas Co-authored-by: silverwind --- act/runner/run_context.go | 9 ++++++++- act/runner/runner_test.go | 1 + act/runner/testdata/services-empty-image/push.yml | 10 ++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 act/runner/testdata/services-empty-image/push.yml diff --git a/act/runner/run_context.go b/act/runner/run_context.go index 68998801..5fdfeb41 100644 --- a/act/runner/run_context.go +++ b/act/runner/run_context.go @@ -379,6 +379,13 @@ func (rc *RunContext) startJobContainer() common.Executor { // add service containers for serviceID, spec := range rc.Run.Job().Services { + // GitHub compatibility: skip services whose image evaluates to an + // empty string, enabling conditional services via expressions + serviceImage := rc.ExprEval.Interpolate(ctx, spec.Image) + if serviceImage == "" { + logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID) + continue + } // interpolate env interpolatedEnvs := make(map[string]string, len(spec.Env)) for k, v := range spec.Env { @@ -417,7 +424,7 @@ func (rc *RunContext) startJobContainer() common.Executor { c := container.NewContainer(&container.NewContainerInput{ Name: serviceContainerName, WorkingDir: ext.ToContainerPath(rc.Config.Workdir), - Image: rc.ExprEval.Interpolate(ctx, spec.Image), + Image: serviceImage, Username: username, Password: password, Cmd: interpolatedCmd, diff --git a/act/runner/runner_test.go b/act/runner/runner_test.go index 92425818..61244f4b 100644 --- a/act/runner/runner_test.go +++ b/act/runner/runner_test.go @@ -303,6 +303,7 @@ func TestRunEvent(t *testing.T) { // services {workdir, "services", "push", "", platforms, secrets}, {workdir, "services-with-container", "push", "", platforms, secrets}, + {workdir, "services-empty-image", "push", "", platforms, secrets}, // local remote action overrides {workdir, "local-remote-action-overrides", "push", "", platforms, secrets}, diff --git a/act/runner/testdata/services-empty-image/push.yml b/act/runner/testdata/services-empty-image/push.yml new file mode 100644 index 00000000..b7c7082e --- /dev/null +++ b/act/runner/testdata/services-empty-image/push.yml @@ -0,0 +1,10 @@ +name: services-empty-image +on: push +jobs: + test: + runs-on: ubuntu-latest + services: + db: + image: ${{ false && 'postgres:16' || '' }} + steps: + - run: echo "empty-image service was skipped" From be9b4502d61c55e7ac8ba60292d96333b4d61a54 Mon Sep 17 00:00:00 2001 From: h7x4 Date: Sat, 11 Jul 2026 17:02:11 +0000 Subject: [PATCH 04/26] enhance: add `--token-file` flag to `register` command (#1076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuation of #362. In addition to fixing merge conflicts and the `fmt.Errorf` issue from the previous version, I've also added a set of tests to cover some basic usage of `initInputs` --------- Co-authored-by: FΓ©lix Baylac JacquΓ© Reviewed-on: https://gitea.com/gitea/runner/pulls/1076 Reviewed-by: Lunny Xiao Co-authored-by: h7x4 --- internal/app/cmd/cmd.go | 3 +- internal/app/cmd/register.go | 36 ++++++++-- internal/app/cmd/register_test.go | 109 ++++++++++++++++++++++++++---- 3 files changed, 130 insertions(+), 18 deletions(-) diff --git a/internal/app/cmd/cmd.go b/internal/app/cmd/cmd.go index a964f614..95074001 100644 --- a/internal/app/cmd/cmd.go +++ b/internal/app/cmd/cmd.go @@ -35,7 +35,8 @@ func Execute(ctx context.Context) { } registerCmd.Flags().BoolVar(®Args.NoInteractive, "no-interactive", false, "Disable interactive mode") registerCmd.Flags().StringVar(®Args.InstanceAddr, "instance", "", "Gitea instance address") - registerCmd.Flags().StringVar(®Args.Token, "token", "", "Runner token") + registerCmd.Flags().StringVar(®Args.Token, "token", "", "Runner token (or set the GITEA_RUNNER_REGISTRATION_TOKEN envvar)") + registerCmd.Flags().StringVar(®Args.TokenFile, "token-file", "", "Path to a file containing the runner token") registerCmd.Flags().StringVar(®Args.RunnerName, "name", "", "Runner name") registerCmd.Flags().StringVar(®Args.Labels, "labels", "", "Runner tags, comma separated") registerCmd.Flags().BoolVar(®Args.Ephemeral, "ephemeral", false, "Configure the runner to be ephemeral and only ever be able to pick a single job (stricter than --once)") diff --git a/internal/app/cmd/register.go b/internal/app/cmd/register.go index 2063c366..bc2f3f63 100644 --- a/internal/app/cmd/register.go +++ b/internal/app/cmd/register.go @@ -75,6 +75,7 @@ type registerArgs struct { NoInteractive bool InstanceAddr string Token string + TokenFile string RunnerName string Labels string Ephemeral bool @@ -93,6 +94,8 @@ const ( StageExit ) +const registerTokenEnvVar = "GITEA_RUNNER_REGISTRATION_TOKEN" + var defaultLabels = []string{ "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest", "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04", @@ -207,10 +210,27 @@ func (r *registerInputs) assignToNext(stage registerStage, value string, cfg *co return StageUnknown } -func initInputs(regArgs *registerArgs) *registerInputs { +func initInputs(regArgs *registerArgs) (*registerInputs, error) { + var token string + switch { + case regArgs.TokenFile != "": + tokenBytes, err := os.ReadFile(regArgs.TokenFile) + if err != nil { + return nil, fmt.Errorf("cannot read the token file: %s, %v", regArgs.TokenFile, err) + } + token = string(tokenBytes) + case regArgs.Token != "": + token = regArgs.Token + default: + envToken, ok := os.LookupEnv(registerTokenEnvVar) + if !ok || envToken == "" { + return nil, fmt.Errorf("missing token, token-file argument, or %s environment variable", registerTokenEnvVar) + } + token = envToken + } inputs := ®isterInputs{ InstanceAddr: regArgs.InstanceAddr, - Token: regArgs.Token, + Token: token, RunnerName: regArgs.RunnerName, Ephemeral: regArgs.Ephemeral, } @@ -219,7 +239,7 @@ func initInputs(regArgs *registerArgs) *registerInputs { if regArgs.Labels != "" { inputs.Labels = strings.Split(regArgs.Labels, ",") } - return inputs + return inputs, nil } func registerInteractive(ctx context.Context, configFile string, regArgs *registerArgs) error { @@ -235,7 +255,10 @@ func registerInteractive(ctx context.Context, configFile string, regArgs *regist if f, err := os.Stat(cfg.Runner.File); err == nil && !f.IsDir() { stage = StageOverwriteLocalConfig } - inputs := initInputs(regArgs) + inputs, err := initInputs(regArgs) + if err != nil { + return err + } for { cmdString := inputs.stageValue(stage) @@ -292,7 +315,10 @@ func registerNoInteractive(ctx context.Context, configFile string, regArgs *regi if err != nil { return err } - inputs := initInputs(regArgs) + inputs, err := initInputs(regArgs) + if err != nil { + return err + } // specify labels in config file. if len(cfg.Runner.Labels) > 0 { if regArgs.Labels != "" { diff --git a/internal/app/cmd/register_test.go b/internal/app/cmd/register_test.go index 031672c5..0c3234b5 100644 --- a/internal/app/cmd/register_test.go +++ b/internal/app/cmd/register_test.go @@ -139,18 +139,103 @@ func TestRegisterInputsAssignToNext(t *testing.T) { } func TestInitInputs(t *testing.T) { - inputs := initInputs(®isterArgs{ - InstanceAddr: "http://localhost:3000", - Token: "token", - RunnerName: "runner", - Ephemeral: true, - Labels: " ubuntu:host , ubuntu:docker://node:18 ", + t.Run("missing token", func(t *testing.T) { + _, err := initInputs(®isterArgs{ + InstanceAddr: "http://localhost:3000", + RunnerName: "runner", + Ephemeral: true, + Labels: " ubuntu:host , ubuntu:docker://node:18 ", + }) + require.EqualError(t, err, "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable") }) - require.Equal(t, "http://localhost:3000", inputs.InstanceAddr) - require.Equal(t, "token", inputs.Token) - require.Equal(t, "runner", inputs.RunnerName) - require.True(t, inputs.Ephemeral) - require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels) - require.Nil(t, initInputs(®isterArgs{Labels: " "}).Labels) + t.Run("empty token", func(t *testing.T) { + t.Setenv(registerTokenEnvVar, "") + _, err := initInputs(®isterArgs{ + InstanceAddr: "http://localhost:3000", + Token: "", + TokenFile: "", + RunnerName: "runner", + Ephemeral: true, + Labels: " ubuntu:host , ubuntu:docker://node:18 ", + }) + require.EqualError(t, err, "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable") + }) + + t.Run("invalid token file", func(t *testing.T) { + t.Setenv(registerTokenEnvVar, "from-env") + _, err := initInputs(®isterArgs{ + InstanceAddr: "http://localhost:3000", + TokenFile: "/tmp/nonexistent", + RunnerName: "runner", + Ephemeral: true, + Labels: " ubuntu:host , ubuntu:docker://node:18 ", + }) + require.EqualError(t, err, "cannot read the token file: /tmp/nonexistent, open /tmp/nonexistent: no such file or directory") + }) + + t.Run("valid token", func(t *testing.T) { + t.Setenv(registerTokenEnvVar, "from-env") + inputs, err := initInputs(®isterArgs{ + InstanceAddr: "http://localhost:3000", + Token: "from-plain-arg", + RunnerName: "runner", + Ephemeral: true, + Labels: " ubuntu:host , ubuntu:docker://node:18 ", + }) + require.NoError(t, err) + require.Equal(t, "http://localhost:3000", inputs.InstanceAddr) + require.Equal(t, "from-plain-arg", inputs.Token) + require.Equal(t, "runner", inputs.RunnerName) + require.True(t, inputs.Ephemeral) + require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels) + }) + + t.Run("valid token file", func(t *testing.T) { + t.Setenv(registerTokenEnvVar, "from-env") + tokenFile, createErr := os.CreateTemp(t.TempDir(), "from-file") + require.NoError(t, createErr) + defer tokenFile.Close() + _, writeErr := tokenFile.WriteString("from-file") + require.NoError(t, writeErr) + _ = tokenFile.Sync() + + inputs, err := initInputs(®isterArgs{ + InstanceAddr: "http://localhost:3000", + TokenFile: tokenFile.Name(), + RunnerName: "runner", + Ephemeral: true, + Labels: " ubuntu:host , ubuntu:docker://node:18 ", + }) + require.NoError(t, err) + require.Equal(t, "http://localhost:3000", inputs.InstanceAddr) + require.Equal(t, "from-file", inputs.Token) + require.Equal(t, "runner", inputs.RunnerName) + require.True(t, inputs.Ephemeral) + require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels) + }) + + t.Run("token from environment variable", func(t *testing.T) { + t.Setenv(registerTokenEnvVar, "from-env") + inputs, err := initInputs(®isterArgs{ + InstanceAddr: "http://localhost:3000", + RunnerName: "runner", + Ephemeral: true, + Labels: " ubuntu:host , ubuntu:docker://node:18 ", + }) + require.NoError(t, err) + require.Equal(t, "http://localhost:3000", inputs.InstanceAddr) + require.Equal(t, "from-env", inputs.Token) + require.Equal(t, "runner", inputs.RunnerName) + require.True(t, inputs.Ephemeral) + require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels) + }) + + t.Run("empty labels", func(t *testing.T) { + inputs, _ := initInputs(®isterArgs{ + Token: "from-plain-arg", + Labels: " ", + }) + require.Nil(t, inputs.Labels) + }) } From 65756d60b364797c449d8335bf367686358a6811 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 13 Jul 2026 18:41:19 +0000 Subject: [PATCH 05/26] 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/` 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 --- .gitea/workflows/release-nightly.yml | 10 ++- .gitea/workflows/release-tag.yml | 10 ++- README.md | 15 ++--- act/runner/run_context.go | 12 +++- act/runner/runner.go | 1 + examples/kubernetes/README.md | 3 + examples/kubernetes/statefulset-dind.yaml | 79 +++++++++++++++++++++++ examples/systemd/README.md | 34 ++++++++++ examples/systemd/gitea-runner.service | 30 +++++++++ internal/app/cmd/bug_report.go | 31 +++++++++ internal/app/cmd/cmd.go | 3 + internal/app/cmd/daemon.go | 11 +++- internal/app/cmd/exec.go | 5 +- internal/app/poll/poller.go | 26 ++++++++ internal/app/poll/poller_test.go | 29 +++++++++ internal/app/run/runner.go | 1 + internal/pkg/client/http.go | 3 + internal/pkg/config/config.example.yaml | 8 ++- internal/pkg/config/config.go | 15 ++++- 19 files changed, 301 insertions(+), 25 deletions(-) create mode 100644 examples/kubernetes/statefulset-dind.yaml create mode 100644 examples/systemd/README.md create mode 100644 examples/systemd/gitea-runner.service create mode 100644 internal/app/cmd/bug_report.go diff --git a/.gitea/workflows/release-nightly.yml b/.gitea/workflows/release-nightly.yml index 280cc359..708e1621 100644 --- a/.gitea/workflows/release-nightly.yml +++ b/.gitea/workflows/release-nightly.yml @@ -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 }} diff --git a/.gitea/workflows/release-tag.yml b/.gitea/workflows/release-tag.yml index 0691dde1..e9d76e5c 100644 --- a/.gitea/workflows/release-tag.yml +++ b/.gitea/workflows/release-tag.yml @@ -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: | diff --git a/README.md b/README.md index ad03a664..ffc53149 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/act/runner/run_context.go b/act/runner/run_context.go index 5fdfeb41..1f36da0c 100644 --- a/act/runner/run_context.go +++ b/act/runner/run_context.go @@ -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 { diff --git a/act/runner/runner.go b/act/runner/runner.go index b8a12cde..51191a98 100644 --- a/act/runner/runner.go +++ b/act/runner/runner.go @@ -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. diff --git a/examples/kubernetes/README.md b/examples/kubernetes/README.md index c755c885..403bea52 100644 --- a/examples/kubernetes/README.md +++ b/examples/kubernetes/README.md @@ -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. diff --git a/examples/kubernetes/statefulset-dind.yaml b/examples/kubernetes/statefulset-dind.yaml new file mode 100644 index 00000000..783f6b97 --- /dev/null +++ b/examples/kubernetes/statefulset-dind.yaml @@ -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 diff --git a/examples/systemd/README.md b/examples/systemd/README.md new file mode 100644 index 00000000..e7244f6e --- /dev/null +++ b/examples/systemd/README.md @@ -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. diff --git a/examples/systemd/gitea-runner.service b/examples/systemd/gitea-runner.service new file mode 100644 index 00000000..17c37694 --- /dev/null +++ b/examples/systemd/gitea-runner.service @@ -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 diff --git a/internal/app/cmd/bug_report.go b/internal/app/cmd/bug_report.go new file mode 100644 index 00000000..666e7a9f --- /dev/null +++ b/internal/app/cmd/bug_report.go @@ -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 + }, + } +} diff --git a/internal/app/cmd/cmd.go b/internal/app/cmd/cmd.go index 95074001..d20d518c 100644 --- a/internal/app/cmd/cmd.go +++ b/internal/app/cmd/cmd.go @@ -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", diff --git a/internal/app/cmd/daemon.go b/internal/app/cmd/daemon.go index f533b017..486fc18f 100644 --- a/internal/app/cmd/daemon.go +++ b/internal/app/cmd/daemon.go @@ -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 } } diff --git a/internal/app/cmd/exec.go b/internal/app/cmd/exec.go index 053930bd..ccbfae71 100644 --- a/internal/app/cmd/exec.go +++ b/internal/app/cmd/exec.go @@ -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") diff --git a/internal/app/poll/poller.go b/internal/app/poll/poller.go index d5399831..37057afd 100644 --- a/internal/app/poll/poller.go +++ b/internal/app/poll/poller.go @@ -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() diff --git a/internal/app/poll/poller_test.go b/internal/app/poll/poller_test.go index 78fe2cc5..92ca8fad 100644 --- a/internal/app/poll/poller_test.go +++ b/internal/app/poll/poller_test.go @@ -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) { diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index a751c2de..d041aff0 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -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), diff --git a/internal/pkg/client/http.go b/internal/pkg/client/http.go index b2fa2bb0..6a837568 100644 --- a/internal/pkg/client/http.go +++ b/internal/pkg/client/http.go @@ -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) } diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml index 72d6b9c7..95412e3b 100644 --- a/internal/pkg/config/config.example.yaml +++ b/internal/pkg/config/config.example.yaml @@ -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 diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 240def9d..423ee9ed 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -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 { From 554b3b767144902dab9f78a8d7742086f83bedd9 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 14 Jul 2026 11:19:52 +0000 Subject: [PATCH 06/26] fix(deps): update module golang.org/x/term to v0.45.0 (#1081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) | [`v0.44.0` β†’ `v0.45.0`](https://cs.opensource.google/go/x/term/+/refs/tags/v0.44.0...refs/tags/v0.45.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fterm/v0.45.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fterm/v0.44.0/v0.45.0?slim=true) | --- ### Configuration πŸ“… **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. πŸ”• **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate). Reviewed-on: https://gitea.com/gitea/runner/pulls/1081 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> Co-authored-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 4eac8621..0362708f 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,7 @@ require ( go.etcd.io/bbolt v1.5.0 go.yaml.in/yaml/v4 v4.0.0-rc.3 golang.org/x/sys v0.47.0 - golang.org/x/term v0.44.0 + golang.org/x/term v0.45.0 google.golang.org/protobuf v1.36.11 gotest.tools/v3 v3.5.2 tags.cncf.io/container-device-interface v1.1.0 diff --git a/go.sum b/go.sum index 954e1936..c7108a3e 100644 --- a/go.sum +++ b/go.sum @@ -267,6 +267,8 @@ golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= From d53538ac38ab07e0e17e2a85aff0fc04e4f5ce43 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 14 Jul 2026 14:28:57 +0000 Subject: [PATCH 07/26] fix: drop action outputs whose value exceeds the size limit (#1070) `SetOutputs` logged "ignore output because the value is too long" for values larger than 1 MiB but then fell through and stored the value anyway, sending it upstream via `UpdateTask`. The key-too-long branch directly above correctly skips oversized keys with `continue`; this adds the same `continue` to the value branch so the size guard is actually enforced and the log message matches the behavior. Adds regression coverage in `TestReporter_SetOutputs` for an oversized value (dropped) and a value at exactly the 1 MiB limit (retained). --------- Co-authored-by: Zettat123 <39446+zettat123@noreply.gitea.com> Co-authored-by: Zettat123 Reviewed-on: https://gitea.com/gitea/runner/pulls/1070 Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com> --- internal/pkg/report/reporter.go | 18 +++++++++++++----- internal/pkg/report/reporter_test.go | 17 +++++++++++++++-- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/internal/pkg/report/reporter.go b/internal/pkg/report/reporter.go index f25790bd..72cc2b4f 100644 --- a/internal/pkg/report/reporter.go +++ b/internal/pkg/report/reporter.go @@ -26,6 +26,12 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) +// Size limits for the outputs reported to the server. +const ( + maxOutputKeyLen = 255 + maxOutputValueLen = 1024 * 1024 // 1 MiB +) + type Reporter struct { ctx context.Context cancel context.CancelFunc @@ -390,13 +396,15 @@ func (r *Reporter) SetOutputs(outputs map[string]string) { defer r.stateMu.Unlock() for k, v := range outputs { - if len(k) > 255 { - r.logf("ignore output because the key is too long: %q", k) + if l := len(k); l > maxOutputKeyLen { + log.Warnf("ignore output %q because the key is too long: %d > %d", k, l, maxOutputKeyLen) + r.logf("ignore output %q because the key is too long: %d > %d", k, l, maxOutputKeyLen) continue } - if l := len(v); l > 1024*1024 { - log.Println("ignore output because the value is too long:", k, l) - r.logf("ignore output because the value %q is too long: %d", k, l) + if l := len(v); l > maxOutputValueLen { + log.Warnf("ignore output %q because the value is too long: %d > %d", k, l, maxOutputValueLen) + r.logf("ignore output %q because the value is too long: %d > %d", k, l, maxOutputValueLen) + continue } if _, ok := r.outputs.Load(k); ok { continue diff --git a/internal/pkg/report/reporter_test.go b/internal/pkg/report/reporter_test.go index 65d097e6..06be9307 100644 --- a/internal/pkg/report/reporter_test.go +++ b/internal/pkg/report/reporter_test.go @@ -1020,11 +1020,24 @@ func TestReporter_SetOutputs(t *testing.T) { got, _ = r.outputs.Load("foo") assert.Equal(t, "bar", got) - // keys longer than 255 chars are dropped - longKey := strings.Repeat("k", 256) + // keys longer than maxOutputKeyLen are dropped + longKey := strings.Repeat("k", maxOutputKeyLen+1) r.SetOutputs(map[string]string{longKey: "v"}) _, ok = r.outputs.Load(longKey) assert.False(t, ok) + + // values longer than maxOutputValueLen are dropped + longValue := strings.Repeat("v", maxOutputValueLen+1) + r.SetOutputs(map[string]string{"big": longValue}) + _, ok = r.outputs.Load("big") + assert.False(t, ok) + + // a value at exactly the limit is still stored + maxValue := strings.Repeat("v", maxOutputValueLen) + r.SetOutputs(map[string]string{"atlimit": maxValue}) + got, ok = r.outputs.Load("atlimit") + require.True(t, ok) + assert.Len(t, got, maxOutputValueLen) } func TestReporter_EffectiveCloseTimeout(t *testing.T) { From 16357a34b2c6c143f58f797159870ddf2c61ebd0 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 14 Jul 2026 20:16:19 +0000 Subject: [PATCH 08/26] fix: accept natively typed boolean workflow inputs (#1087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea 1.27 resolves `workflow_call` inputs server-side and sends natively typed JSON values in `github.event.inputs`, so a `type: boolean` input now arrives as a real JSON boolean. The runner coerced booleans by comparing the `any` value against the string `"true"`, which a native bool never matches, so every boolean input evaluated to `false` β€” including when the callee relied on its default, since the server pre-fills defaults into the event payload and the string fallback is never reached. This accepts a native bool and keeps the string comparison as a fallback, since `workflow_dispatch` inputs are still strings and YAML defaults decode to strings; servers before 1.27 never put a native bool in the payload, so they take the exact same code path as before. The same coercion is applied to `setupWorkflowInputs` (locally-called reusable workflows, `uses: ./.gitea/workflows/x.yml`), where a `type: boolean` input was previously a native bool when passed as `with: { flag: true }` but a string when interpolated or taken from `default:`. It is now always a bool, matching GitHub, whose `inputs` context "preserves Boolean values as Booleans instead of converting them to strings". **This is potentially breaking**: `inputs.flag == 'true'` now evaluates to `false` and must become `inputs.flag == true`. That pattern is already false on GitHub (a bool compared to a string coerces to `1 == NaN`), but it works on Gitea today, so I am happy to split this hunk into its own PR if you would rather keep this one backport-safe. Fixes #1082 Reviewed-on: https://gitea.com/gitea/runner/pulls/1087 Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com> Co-authored-by: Nicolas --- act/runner/expression.go | 27 ++++--- act/runner/expression_test.go | 81 +++++++++++++++++++ .../testdata/workflow_call_inputs/event.json | 2 +- 3 files changed, 98 insertions(+), 12 deletions(-) diff --git a/act/runner/expression.go b/act/runner/expression.go index c64f921b..060ac5fd 100644 --- a/act/runner/expression.go +++ b/act/runner/expression.go @@ -497,11 +497,7 @@ func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *mod if value == nil { value = v.Default } - if v.Type == "boolean" { - inputs[k] = value == "true" - } else { - inputs[k] = value - } + inputs[k] = coerceInputValue(value, v.Type) } } } @@ -514,17 +510,26 @@ func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *mod if value == nil { value = v.Default } - if v.Type == "boolean" { - inputs[k] = value == "true" - } else { - inputs[k] = value - } + inputs[k] = coerceInputValue(value, v.Type) } } } return inputs } +// coerceInputValue converts an input value to the type declared by the workflow. +// The event payload carries natively typed JSON values on newer Gitea versions, +// while defaults and older servers provide strings. +func coerceInputValue(value any, inputType string) any { + if inputType != "boolean" { + return value + } + if b, ok := value.(bool); ok { + return b + } + return value == "true" +} + func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunContext) { if rc.caller != nil { config := rc.Run.Workflow.WorkflowCallConfig() @@ -548,7 +553,7 @@ func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunCon } } - (*inputs)[name] = value + (*inputs)[name] = coerceInputValue(value, input.Type) } } } diff --git a/act/runner/expression_test.go b/act/runner/expression_test.go index 59fc704b..e988e086 100644 --- a/act/runner/expression_test.go +++ b/act/runner/expression_test.go @@ -6,12 +6,14 @@ package runner import ( "context" + "strings" "testing" "gitea.com/gitea/runner/act/exprparser" "gitea.com/gitea/runner/act/model" assert "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" yaml "go.yaml.in/yaml/v4" ) @@ -321,3 +323,82 @@ func TestRewriteSubExpressionForceFormat(t *testing.T) { }) } } + +func TestGetEvaluatorInputsBoolean(t *testing.T) { + workflows := map[string]string{ + "workflow_call": ` +on: + workflow_call: + inputs: + flag: + type: boolean + default: true + name: + type: string + default: gitea +`, + "workflow_dispatch": ` +on: + workflow_dispatch: + inputs: + flag: + type: boolean + default: true + name: + type: string + default: gitea +`, + } + + tables := []struct { + name string + event map[string]any + flag any + }{ + { + // Gitea >= 1.27 resolves the inputs server-side and sends native JSON types + name: "native bool true", + event: map[string]any{"inputs": map[string]any{"flag": true}}, + flag: true, + }, + { + name: "native bool false", + event: map[string]any{"inputs": map[string]any{"flag": false}}, + flag: false, + }, + { + name: "string true", + event: map[string]any{"inputs": map[string]any{"flag": "true"}}, + flag: true, + }, + { + name: "string false", + event: map[string]any{"inputs": map[string]any{"flag": "false"}}, + flag: false, + }, + { + name: "default is used when the event carries no inputs", + event: map[string]any{}, + flag: true, + }, + } + + for eventName, workflow := range workflows { + for _, table := range tables { + t.Run(eventName+"/"+table.name, func(t *testing.T) { + wf, err := model.ReadWorkflow(strings.NewReader(workflow)) + require.NoError(t, err) + + rc := &RunContext{ + Config: &Config{Workdir: "."}, + Run: &model.Run{JobID: "job1", Workflow: wf}, + } + ghc := &model.GithubContext{EventName: eventName, Event: table.event} + + inputs := getEvaluatorInputs(context.Background(), rc, nil, ghc) + assert.Equal(t, table.flag, inputs["flag"]) + assert.Equal(t, "gitea", inputs["name"]) + }) + } + } +} diff --git a/act/runner/testdata/workflow_call_inputs/event.json b/act/runner/testdata/workflow_call_inputs/event.json index d3ecab10..48471c49 100644 --- a/act/runner/testdata/workflow_call_inputs/event.json +++ b/act/runner/testdata/workflow_call_inputs/event.json @@ -1,6 +1,6 @@ { "inputs": { "required": "required input", - "boolean": "true" + "boolean": true } } From 7e7e3ef1a6f725e210bba203166757f9a07e5b81 Mon Sep 17 00:00:00 2001 From: bircni Date: Wed, 15 Jul 2026 06:15:50 +0000 Subject: [PATCH 09/26] fix: stop service containers from clobbering the job container's credentials (#1083) Fixes #835 Fixes #643 --------- Co-authored-by: Lunny Xiao Reviewed-on: https://gitea.com/gitea/runner/pulls/1083 Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com> Reviewed-by: Lunny Xiao --- act/container/docker_pull.go | 4 +- act/runner/expression.go | 3 +- act/runner/run_context.go | 15 ++++-- act/runner/run_context_test.go | 88 ++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 7 deletions(-) diff --git a/act/container/docker_pull.go b/act/container/docker_pull.go index dc0f28f4..6bb3f6c4 100644 --- a/act/container/docker_pull.go +++ b/act/container/docker_pull.go @@ -72,7 +72,9 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor { _ = logDockerResponse(logger, reader, err != nil) } - return err + if err != nil { + return fmt.Errorf("failed to pull image '%s' (%s): %w", imageRef, input.Platform, err) + } } return nil } diff --git a/act/runner/expression.go b/act/runner/expression.go index 060ac5fd..3dc9a108 100644 --- a/act/runner/expression.go +++ b/act/runner/expression.go @@ -229,7 +229,8 @@ func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultSt logger.Debugf("evaluating expression '%s'", in) evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck) - printable := regexp.MustCompile(`::add-mask::.*`).ReplaceAllString(fmt.Sprintf("%t", evaluated), "::add-mask::***)") + // evaluated is an any: %t renders everything but a bool as "%!t(string=...)" + printable := regexp.MustCompile(`::add-mask::.*`).ReplaceAllString(fmt.Sprintf("%v", evaluated), "::add-mask::***)") logger.Debugf("expression '%s' evaluated to '%s'", in, printable) return evaluated, err diff --git a/act/runner/run_context.go b/act/runner/run_context.go index 1f36da0c..1f85029f 100644 --- a/act/runner/run_context.go +++ b/act/runner/run_context.go @@ -339,6 +339,9 @@ func printStartJobContainerGroup(ctx context.Context, image, name, network strin } } +// newContainer is a variable so tests can substitute a container that needs no Docker daemon. +var newContainer = container.NewContainer + func (rc *RunContext) startJobContainer() common.Executor { return func(ctx context.Context) error { logger := common.Logger(ctx) @@ -402,7 +405,9 @@ func (rc *RunContext) startJobContainer() common.Executor { for _, v := range spec.Cmd { interpolatedCmd = append(interpolatedCmd, rc.ExprEval.Interpolate(ctx, v)) } - username, password, err = rc.handleServiceCredentials(ctx, spec.Credentials) + // keep these local: reusing username/password would overwrite the + // credentials the job container is pulled with further down + serviceUsername, servicePassword, err := rc.handleServiceCredentials(ctx, spec.Credentials) if err != nil { return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err) } @@ -423,12 +428,12 @@ func (rc *RunContext) startJobContainer() common.Executor { } serviceContainerName := createContainerName(rc.jobContainerName(), serviceID) - c := container.NewContainer(&container.NewContainerInput{ + c := newContainer(&container.NewContainerInput{ Name: serviceContainerName, WorkingDir: ext.ToContainerPath(rc.Config.Workdir), Image: serviceImage, - Username: username, - Password: password, + Username: serviceUsername, + Password: servicePassword, Cmd: interpolatedCmd, Env: envs, Mounts: serviceMounts, @@ -484,7 +489,7 @@ func (rc *RunContext) startJobContainer() common.Executor { // For Gitea, `jobContainerNetwork` should be the same as `networkName` jobContainerNetwork := networkName - rc.JobContainer = container.NewContainer(&container.NewContainerInput{ + rc.JobContainer = newContainer(&container.NewContainerInput{ Cmd: nil, Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())}, WorkingDir: ext.ToContainerPath(rc.Config.Workdir), diff --git a/act/runner/run_context_test.go b/act/runner/run_context_test.go index 8b2563af..46b67a8f 100644 --- a/act/runner/run_context_test.go +++ b/act/runner/run_context_test.go @@ -14,6 +14,7 @@ import ( "testing" "gitea.com/gitea/runner/act/common" + "gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/act/exprparser" "gitea.com/gitea/runner/act/model" @@ -202,6 +203,93 @@ jobs: assert.Empty(t, password) } +// fakeContainer turns every container operation into a no-op, so startJobContainer +// runs without a Docker daemon. The embedded interface is nil, so any method the +// test does not exercise panics rather than silently doing the wrong thing. +type fakeContainer struct { + container.ExecutionsEnvironment +} + +func (fakeContainer) Pull(bool) common.Executor { return func(context.Context) error { return nil } } +func (fakeContainer) Start(bool) common.Executor { return func(context.Context) error { return nil } } +func (fakeContainer) Remove() common.Executor { return func(context.Context) error { return nil } } +func (fakeContainer) Close() common.Executor { return func(context.Context) error { return nil } } +func (fakeContainer) GetActPath() string { return "/var/run/act" } +func (fakeContainer) Create([]string, []string) common.Executor { + return func(context.Context) error { return nil } +} + +func (fakeContainer) Copy(string, ...*container.FileEntry) common.Executor { + return func(context.Context) error { return nil } +} + +// Regression test: a service without a `credentials:` block resolves to empty +// credentials, which used to overwrite the job container's own credentials. +func TestStartJobContainerKeepsJobCredentialsWithServices(t *testing.T) { + workflow, err := model.ReadWorkflow(strings.NewReader(` +name: test +on: push +jobs: + job: + runs-on: ubuntu-latest + container: + image: registry.example/private:latest + credentials: + username: job-user + password: job-password + services: + redis: + image: redis:latest + db: + image: postgres:latest + credentials: + username: db-user + password: db-password + steps: [] +`)) + require.NoError(t, err) + + var inputs []*container.NewContainerInput + origNewContainer := newContainer + newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment { + inputs = append(inputs, input) + return fakeContainer{} + } + t.Cleanup(func() { newContainer = origNewContainer }) + + rc := &RunContext{ + Name: "test", + Config: &Config{ + Workdir: "/tmp", + // no daemon: an explicit network mode creates no network, and + // reusing containers short-circuits the volume cleanup executors + ContainerNetworkMode: "host", + ReuseContainers: true, + Env: map[string]string{}, + Secrets: map[string]string{}, + }, + Env: map[string]string{}, + Run: &model.Run{ + JobID: "job", + Workflow: workflow, + }, + } + rc.ExprEval = rc.NewExpressionEvaluator(t.Context()) + + require.NoError(t, rc.startJobContainer()(t.Context())) + + credentials := map[string][2]string{} + for _, in := range inputs { + credentials[in.Image] = [2]string{in.Username, in.Password} + } + + // the job container keeps its own credentials, whichever services exist + require.Equal(t, [2]string{"job-user", "job-password"}, credentials["registry.example/private:latest"]) + // each service keeps its own, and a service without credentials gets none + require.Equal(t, [2]string{"db-user", "db-password"}, credentials["postgres:latest"]) + require.Equal(t, [2]string{"", ""}, credentials["redis:latest"]) +} + func TestRunContext_GetBindsAndMounts(t *testing.T) { rctemplate := &RunContext{ Name: "TestRCName", From d6882b3df5d87bd682be4abf7b4df67b62551f50 Mon Sep 17 00:00:00 2001 From: bircni Date: Wed, 15 Jul 2026 06:34:32 +0000 Subject: [PATCH 10/26] docs: explain labels, the docker image cache volume, and the dind-rootless UID (#1086) Fixes #106 Fixes #570 Fixes #627 Reviewed-on: https://gitea.com/gitea/runner/pulls/1086 Reviewed-by: Lunny Xiao --- README.md | 48 +++++++++++++++++++++++ examples/kubernetes/README.md | 5 +++ examples/kubernetes/dind-docker.yaml | 22 +++++++++++ examples/kubernetes/rootless-docker.yaml | 25 ++++++++++++ examples/kubernetes/statefulset-dind.yaml | 17 ++++++++ 5 files changed, 117 insertions(+) diff --git a/README.md b/README.md index ffc53149..ee2c1566 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRA Mount a volume on `/data` if you want the registration file and optional config to survive container recreation (see [scripts/run.sh](scripts/run.sh)). +> **`/data` does not hold the image cache.** It is the runner's working directory and contains only the `.runner` registration file and, optionally, your config file. Images pulled for jobs live in the *Docker daemon's* data root, which for the `dind` flavours is inside the container (`/var/lib/docker`, or `/home/rootless/.local/share/docker` for `dind-rootless`). To keep the image cache across restarts, give that path its own volume as well β€” otherwise every new container re-pulls the job images. With the `basic` flavour the images live on whichever daemon you point the runner at, so there is nothing extra to persist. + ### Image flavours The image is published in three flavours, all built from the single multi-stage [Dockerfile](Dockerfile) in this repository. They differ only in how a Docker daemon is made available to the jobs the runner executes; the `gitea-runner` binary inside them is identical. @@ -121,6 +123,8 @@ Two processes have to run side by side here (the Docker daemon and the runner), Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon and the runner run as an unprivileged user (`rootless`, UID 1000) rather than `root`. `DOCKER_HOST` is preset to `unix:///run/user/1000/docker.sock` so the runner talks to the rootless daemon. This reduces the blast radius compared to the privileged `dind` flavour, but rootless Docker carries the usual rootless limitations (networking, cgroups, storage drivers, and some operations that need additional host configuration such as `/etc/subuid` / `/etc/subgid` mappings and unprivileged user-namespace support). +> **The UID is fixed at 1000.** It comes from the `rootless` user baked into the upstream `docker:dind-rootless` base image, and the bundled daemon always listens on `/run/user/1000/docker.sock` inside the container, so running this flavour as a different user (`--user 1001`) does not work. If you need the runner to talk to a *host* rootless daemon that runs under some other UID, use the `basic` flavour instead and bind-mount that daemon's socket (see [examples/vm/rootless-docker.md](examples/vm/rootless-docker.md)); pointing `DOCKER_HOST` at a host socket from inside `dind-rootless` will not work. Changing the UID otherwise means rebuilding the image from a base with a different `rootless` user. + > **Note on Podman:** these images target the Docker daemon. The bundled `dind`/`dind-rootless` daemons are `dockerd`, not Podman, and the `basic` flavour expects a Docker-compatible socket. Running them under rootless Podman is not a supported configuration, though pointing the `basic` flavour at a Podman socket that emulates the Docker API may work for some workloads. ### Configuration @@ -147,6 +151,50 @@ If you omit `-c`, built-in defaults apply (same as an empty YAML document). 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 decide **which jobs a runner accepts** and **how it runs them**. A job's `runs-on` is matched against the runner's label names; the first match wins and selects the execution environment for that job. + +A label is written as: + +```text +[:[:]] +``` + +| Part | Meaning | +| --- | --- | +| `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. | +| `schema` | Either `docker` or `host`. Defaults to `host` when omitted. | +| `args` | Only used by the `docker` schema: the image to run the job in. | + +Two schemas are supported: + +- **`docker://`** β€” the job runs inside a container created from ``: + + ```text + ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest + ``` + +- **`host`** β€” the job's steps run directly on the machine the runner is on, using the tools installed there: + + ```text + macos:host + ``` + +So with the labels + +```text +ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host +``` + +a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, and one with `runs-on: macos` is executed directly on the host. + +Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas. + +If a job's `runs-on` matches none of the runner's labels, the job still runs, in the default `docker.gitea.com/runner-images:ubuntu-latest` image. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images). + +Labels are chosen at registration time (`--labels`, or the interactive prompt) and can be changed afterwards by editing `runner.labels` in the config file, or in the Gitea UI under the runner's settings. + #### 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. diff --git a/examples/kubernetes/README.md b/examples/kubernetes/README.md index 403bea52..b7ddada5 100644 --- a/examples/kubernetes/README.md +++ b/examples/kubernetes/README.md @@ -6,6 +6,11 @@ NOTE: `dind-docker.yaml` uses the native sidecar pattern (init container with `r NOTE: A helm chart for `gitea-runner` also exists for easier deployments https://gitea.com/gitea/helm-actions +Each example persists **two** things, and it is worth knowing which is which: + +- `/data` is the runner's working directory. It holds the `.runner` registration file and, optionally, the config file β€” so the runner re-attaches to the server instead of registering again. +- The Docker daemon's data root holds the images pulled for jobs (`/var/lib/docker` for the dind sidecar, `/home/rootless/.local/share/docker` for `dind-rootless`). It is *not* under `/data`. If you drop this volume, the examples still work, but the image cache is discarded whenever the pod is recreated and every job re-pulls its images. + Files in this directory: - [`dind-docker.yaml`](dind-docker.yaml) diff --git a/examples/kubernetes/dind-docker.yaml b/examples/kubernetes/dind-docker.yaml index ce3b4a22..cfe6eb5d 100644 --- a/examples/kubernetes/dind-docker.yaml +++ b/examples/kubernetes/dind-docker.yaml @@ -1,3 +1,5 @@ +# Holds the runner's working directory (/data): the .runner registration file +# and, optionally, the config file. kind: PersistentVolumeClaim apiVersion: v1 metadata: @@ -10,6 +12,21 @@ spec: storage: 1Gi storageClassName: standard --- +# Holds the Docker daemon's data root (/var/lib/docker), i.e. the images pulled +# for jobs. Without it, the image cache is lost whenever the pod is recreated +# and every job re-pulls its images. Size it for the images you expect to cache. +kind: PersistentVolumeClaim +apiVersion: v1 +metadata: + name: docker-vol +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 20Gi + storageClassName: standard +--- apiVersion: v1 data: # The registration token can be obtained from the web UI, API or command-line. @@ -45,6 +62,9 @@ spec: - name: runner-data persistentVolumeClaim: claimName: runner-vol + - name: docker-data + persistentVolumeClaim: + claimName: docker-vol initContainers: - name: docker image: docker:28.2.2-dind @@ -53,6 +73,8 @@ spec: volumeMounts: - name: docker-socket mountPath: /var/run + - name: docker-data + mountPath: /var/lib/docker startupProbe: exec: command: ["/usr/bin/test", "-S", "/var/run/docker.sock"] diff --git a/examples/kubernetes/rootless-docker.yaml b/examples/kubernetes/rootless-docker.yaml index 7f357e44..62f5539c 100644 --- a/examples/kubernetes/rootless-docker.yaml +++ b/examples/kubernetes/rootless-docker.yaml @@ -1,3 +1,5 @@ +# Holds the runner's working directory (/data): the .runner registration file +# and, optionally, the config file. kind: PersistentVolumeClaim apiVersion: v1 metadata: @@ -10,6 +12,21 @@ spec: storage: 1Gi storageClassName: standard --- +# Holds the rootless Docker daemon's data root, i.e. the images pulled for jobs. +# Without it, the image cache is lost whenever the pod is recreated and every job +# re-pulls its images. Size it for the images you expect to cache. +kind: PersistentVolumeClaim +apiVersion: v1 +metadata: + name: docker-vol +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 20Gi + storageClassName: standard +--- apiVersion: v1 data: # The registration token can be obtained from the web UI, API or command-line. @@ -43,7 +60,12 @@ spec: - name: runner-data persistentVolumeClaim: claimName: runner-vol + - name: docker-data + persistentVolumeClaim: + claimName: docker-vol securityContext: + # The dind-rootless image runs as the `rootless` user (UID/GID 1000); + # fsGroup makes both volumes writable for it. fsGroup: 1000 containers: - name: runner @@ -68,4 +90,7 @@ spec: volumeMounts: - name: runner-data mountPath: /data + # The rootless daemon keeps its images here, not under /data. + - name: docker-data + mountPath: /home/rootless/.local/share/docker diff --git a/examples/kubernetes/statefulset-dind.yaml b/examples/kubernetes/statefulset-dind.yaml index 783f6b97..ad8116a7 100644 --- a/examples/kubernetes/statefulset-dind.yaml +++ b/examples/kubernetes/statefulset-dind.yaml @@ -44,6 +44,10 @@ spec: volumeMounts: - name: docker-socket mountPath: /var/run + # Keeps the images pulled for jobs across restarts. Without this, the + # daemon's data root is ephemeral and every job re-pulls its images. + - name: docker-data + mountPath: /var/lib/docker startupProbe: exec: command: ["/usr/bin/test", "-S", "/var/run/docker.sock"] @@ -68,6 +72,8 @@ spec: - name: docker-socket mountPath: /var/run volumeClaimTemplates: + # The runner's working directory: the .runner registration file and, optionally, + # the config file. - metadata: name: runner-data spec: @@ -77,3 +83,14 @@ spec: requests: storage: 1Gi storageClassName: standard + # The Docker daemon's data root: the images pulled for jobs. Size it for the + # images you expect to cache. + - metadata: + name: docker-data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 20Gi + storageClassName: standard From 58c5eb8d21d28d8bb2de7be028f2e3bb1f1d1c7c Mon Sep 17 00:00:00 2001 From: bircni Date: Wed, 15 Jul 2026 16:58:07 +0000 Subject: [PATCH 11/26] feat: honour GITEA_RUNNER_LABELS on daemon start and accept labels containing a colon (#1085) Fixes #648 Fixes #656 Fixes #664 --------- Co-authored-by: silverwind Reviewed-on: https://gitea.com/gitea/runner/pulls/1085 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> --- README.md | 8 ++++++ internal/app/cmd/cmd.go | 1 + internal/app/cmd/daemon.go | 30 +++++++++++++++++---- internal/app/cmd/daemon_test.go | 26 ++++++++++++++++++ internal/app/cmd/register.go | 5 +++- internal/app/cmd/register_test.go | 25 +++++++++++------- internal/app/run/runner_test.go | 5 ++-- internal/pkg/labels/labels.go | 22 ++++++++++++---- internal/pkg/labels/labels_test.go | 42 +++++++++++++++++++++++++++--- scripts/run.sh | 10 ++++--- 10 files changed, 146 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index ee2c1566..e848a679 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,14 @@ Labels are chosen at registration time (`--labels`, or the interactive prompt) a If `runner.labels` is set in the YAML file, those labels are used during `register` and the `--labels` CLI flag is ignored. +The `daemon` command also accepts `--labels` (which defaults to the `GITEA_RUNNER_LABELS` environment variable), so the labels of an already registered runner can be changed without deleting its registration file. The most explicit source wins: + +``` +--labels / GITEA_RUNNER_LABELS > runner.labels in the config file > labels in the .runner file +``` + +Whenever the resulting labels differ from the ones in the registration file, they are written back to it and re-declared to the Gitea instance on startup. + > **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`) diff --git a/internal/app/cmd/cmd.go b/internal/app/cmd/cmd.go index d20d518c..18aaa373 100644 --- a/internal/app/cmd/cmd.go +++ b/internal/app/cmd/cmd.go @@ -51,6 +51,7 @@ func Execute(ctx context.Context) { RunE: runDaemon(ctx, &daemArgs, &configFile), } daemonCmd.Flags().BoolVar(&daemArgs.Once, "once", false, "Run one job then exit") + daemonCmd.Flags().StringVar(&daemArgs.Labels, "labels", os.Getenv("GITEA_RUNNER_LABELS"), "Runner labels, comma separated. Overrides the labels of an already registered runner") rootCmd.AddCommand(daemonCmd) // ./gitea-runner exec diff --git a/internal/app/cmd/daemon.go b/internal/app/cmd/daemon.go index 486fc18f..a4b5a0a6 100644 --- a/internal/app/cmd/daemon.go +++ b/internal/app/cmd/daemon.go @@ -49,10 +49,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu return fmt.Errorf("failed to load registration file: %w", err) } - lbls := reg.Labels - if len(cfg.Runner.Labels) > 0 { - lbls = cfg.Runner.Labels - } + lbls := resolveLabels(daemArgs.Labels, cfg.Runner.Labels, reg.Labels) ls := labels.Labels{} for _, l := range lbls { @@ -203,7 +200,30 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu } type daemonArgs struct { - Once bool + Once bool + Labels string +} + +// resolveLabels picks the labels to run with: --labels/GITEA_RUNNER_LABELS > config > .runner. +// The flag lets a registered runner change its labels without deleting the .runner file. +func resolveLabels(argLabels string, cfgLabels, regLabels []string) []string { + if lbls := splitLabels(argLabels); len(lbls) > 0 { + return lbls + } + if len(cfgLabels) > 0 { + return cfgLabels + } + return regLabels +} + +func splitLabels(s string) []string { + var lbls []string + for l := range strings.SplitSeq(s, ",") { + if l = strings.TrimSpace(l); l != "" { + lbls = append(lbls, l) + } + } + return lbls } // initLogging setup the global logrus logger. diff --git a/internal/app/cmd/daemon_test.go b/internal/app/cmd/daemon_test.go index e90a510a..215b6127 100644 --- a/internal/app/cmd/daemon_test.go +++ b/internal/app/cmd/daemon_test.go @@ -12,6 +12,32 @@ import ( "github.com/stretchr/testify/require" ) +func TestResolveLabels(t *testing.T) { + var ( + cfgLabels = []string{"cfg:host"} + regLabels = []string{"reg:host"} + ) + + tests := []struct { + name string + arg string + cfg []string + reg []string + want []string + }{ + {"flag wins", "flag:host,other", cfgLabels, regLabels, []string{"flag:host", "other"}}, + {"config wins over registration", "", cfgLabels, regLabels, cfgLabels}, + {"registration is the fallback", "", nil, regLabels, regLabels}, + {"blank flag is ignored", " , ", cfgLabels, regLabels, cfgLabels}, + {"nothing configured", "", nil, nil, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, resolveLabels(tt.arg, tt.cfg, tt.reg)) + }) + } +} + func TestGetDockerSocketPathUsesConfigAndEnvironment(t *testing.T) { got, err := getDockerSocketPath("tcp://docker.example:2376") require.NoError(t, err) diff --git a/internal/app/cmd/register.go b/internal/app/cmd/register.go index bc2f3f63..6859522d 100644 --- a/internal/app/cmd/register.go +++ b/internal/app/cmd/register.go @@ -387,7 +387,10 @@ func doRegister(ctx context.Context, cfg *config.Config, inputs *registerInputs) ls := make([]string, len(reg.Labels)) for i, v := range reg.Labels { - l, _ := labels.Parse(v) + l, err := labels.Parse(v) + if err != nil { + return fmt.Errorf("failed to parse label %q: %w", v, err) + } ls[i] = l.Name } // register new runner. diff --git a/internal/app/cmd/register_test.go b/internal/app/cmd/register_test.go index 0c3234b5..5442d16d 100644 --- a/internal/app/cmd/register_test.go +++ b/internal/app/cmd/register_test.go @@ -15,11 +15,11 @@ import ( func TestRegisterNonInteractiveReturnsLabelValidationError(t *testing.T) { err := registerNoInteractive(t.Context(), "", ®isterArgs{ - Labels: "label:invalid", + Labels: "ubuntu:host,,broken", Token: "token", InstanceAddr: "http://localhost:3000", }) - assert.Error(t, err, "unsupported schema: invalid") + assert.ErrorContains(t, err, "empty label") } func TestRegisterInputsValidate(t *testing.T) { @@ -40,8 +40,8 @@ func TestRegisterInputsValidate(t *testing.T) { }, { name: "invalid label", - inputs: registerInputs{InstanceAddr: "http://localhost:3000", Token: "token", Labels: []string{"ubuntu:vm:bad"}}, - wantErr: "unsupported schema: vm", + inputs: registerInputs{InstanceAddr: "http://localhost:3000", Token: "token", Labels: []string{""}}, + wantErr: "empty label", }, { name: "valid", @@ -62,7 +62,9 @@ func TestRegisterInputsValidate(t *testing.T) { func TestValidateLabels(t *testing.T) { require.NoError(t, validateLabels([]string{"ubuntu:host", "ubuntu:docker://node:18"})) - require.Error(t, validateLabels([]string{"ubuntu:host", "ubuntu:vm:bad"})) + // a colon that is not a supported schema is part of the label name + require.NoError(t, validateLabels([]string{"pool:e57e18d4-10d4-406f-93bf-60f127221bdd"})) + require.Error(t, validateLabels([]string{"ubuntu:host", ""})) } func TestRegisterInputsStageValue(t *testing.T) { @@ -106,11 +108,10 @@ func TestRegisterInputsAssignToNext(t *testing.T) { t.Run("labels from config skip the labels stage", func(t *testing.T) { cfg := &config.Config{} - cfg.Runner.Labels = []string{"ubuntu:host", "ubuntu:vm:bad"} + cfg.Runner.Labels = []string{"ubuntu:host", "", "pool:e57e18d4"} inputs := ®isterInputs{} require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputRunnerName, "runner", cfg)) - // only the valid label survives - require.Equal(t, []string{"ubuntu:host"}, inputs.Labels) + require.Equal(t, []string{"ubuntu:host", "pool:e57e18d4"}, inputs.Labels) }) t.Run("blank labels input uses defaults", func(t *testing.T) { @@ -121,10 +122,16 @@ func TestRegisterInputsAssignToNext(t *testing.T) { t.Run("invalid labels input loops back", func(t *testing.T) { inputs := ®isterInputs{} - require.Equal(t, StageInputLabels, inputs.assignToNext(StageInputLabels, "ubuntu:vm:bad", emptyCfg)) + require.Equal(t, StageInputLabels, inputs.assignToNext(StageInputLabels, "ubuntu:host,,bad", emptyCfg)) require.Nil(t, inputs.Labels) }) + t.Run("labels containing a colon are accepted", func(t *testing.T) { + inputs := ®isterInputs{} + require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputLabels, "pool:e57e18d4,ubuntu:host", emptyCfg)) + require.Equal(t, []string{"pool:e57e18d4", "ubuntu:host"}, inputs.Labels) + }) + t.Run("overwrite local config", func(t *testing.T) { inputs := ®isterInputs{} require.Equal(t, StageInputInstance, inputs.assignToNext(StageOverwriteLocalConfig, "Y", emptyCfg)) diff --git a/internal/app/run/runner_test.go b/internal/app/run/runner_test.go index 43635549..6e0ff6a7 100644 --- a/internal/app/run/runner_test.go +++ b/internal/app/run/runner_test.go @@ -75,7 +75,7 @@ func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) { cfg.Runner.Envs = map[string]string{"EXISTING": "value"} reg := &config.Registration{ Name: "runner", - Labels: []string{"ubuntu:host", "bad:vm:label"}, + Labels: []string{"ubuntu:host", "", "pool:e57e18d4"}, } cli := clientmocks.NewClient(t) cli.On("Address").Return("https://gitea.example/").Maybe() @@ -83,7 +83,8 @@ func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) { r := NewRunner(cfg, reg, cli) require.Equal(t, "runner", r.name) - require.Len(t, r.labels, 1) + require.Len(t, r.labels, 2) + require.Equal(t, []string{"ubuntu", "pool:e57e18d4"}, r.labels.Names()) require.Equal(t, "value", r.envs["EXISTING"]) require.Equal(t, "https://gitea.example/api/actions_pipeline/", r.envs["ACTIONS_RUNTIME_URL"]) require.Equal(t, "https://gitea.example", r.envs["ACTIONS_RESULTS_URL"]) diff --git a/internal/pkg/labels/labels.go b/internal/pkg/labels/labels.go index 542b52dc..342d5b4d 100644 --- a/internal/pkg/labels/labels.go +++ b/internal/pkg/labels/labels.go @@ -4,7 +4,7 @@ package labels import ( - "fmt" + "errors" "strings" ) @@ -17,13 +17,20 @@ type Label struct { Name string Schema string Arg string + // Opaque marks a label whose name contains a colon but no supported schema, + // like "pool:e57e18d4-...". It is kept verbatim and behaves like a host label. + Opaque bool } func Parse(str string) (*Label, error) { + if str == "" { + return nil, errors.New("empty label") + } + splits := strings.SplitN(str, ":", 3) label := &Label{ Name: splits[0], - Schema: "host", + Schema: SchemeHost, Arg: "", } if len(splits) >= 2 { @@ -33,7 +40,12 @@ func Parse(str string) (*Label, error) { label.Arg = splits[2] } if label.Schema != SchemeHost && label.Schema != SchemeDocker { - return nil, fmt.Errorf("unsupported schema: %s", label.Schema) + // Not a schema we know: the colon belongs to the label name itself. + return &Label{ + Name: str, + Schema: SchemeHost, + Opaque: true, + }, nil } return label, nil } @@ -59,7 +71,7 @@ func (l Labels) PickPlatform(runsOn []string) string { case SchemeHost: platforms[label.Name] = "-self-hosted" default: - // It should not happen, because Parse has checked it. + // unreachable: Parse only produces host or docker schemas continue } } @@ -94,7 +106,7 @@ func (l Labels) ToStrings() []string { ls := make([]string, 0, len(l)) for _, label := range l { lbl := label.Name - if label.Schema != "" { + if !label.Opaque && label.Schema != "" { lbl += ":" + label.Schema if label.Arg != "" { lbl += ":" + label.Arg diff --git a/internal/pkg/labels/labels_test.go b/internal/pkg/labels/labels_test.go index 8b276d70..8d8eb598 100644 --- a/internal/pkg/labels/labels_test.go +++ b/internal/pkg/labels/labels_test.go @@ -44,7 +44,27 @@ func TestParse(t *testing.T) { wantErr: false, }, { - args: "ubuntu:vm:ubuntu-18.04", + args: "pool:e57e18d4-10d4-406f-93bf-60f127221bdd", + want: &Label{ + Name: "pool:e57e18d4-10d4-406f-93bf-60f127221bdd", + Schema: "host", + Arg: "", + Opaque: true, + }, + wantErr: false, + }, + { + args: "ubuntu:vm:ubuntu-18.04", + want: &Label{ + Name: "ubuntu:vm:ubuntu-18.04", + Schema: "host", + Arg: "", + Opaque: true, + }, + wantErr: false, + }, + { + args: "", want: nil, wantErr: true, }, @@ -116,8 +136,8 @@ func TestPickPlatform(t *testing.T) { } func TestNames(t *testing.T) { - ls := mustParse(t, "ubuntu:docker://node:18", "self-hosted:host") - require.Equal(t, []string{"ubuntu", "self-hosted"}, ls.Names()) + ls := mustParse(t, "ubuntu:docker://node:18", "self-hosted:host", "pool:e57e18d4") + require.Equal(t, []string{"ubuntu", "self-hosted", "pool:e57e18d4"}, ls.Names()) require.Empty(t, Labels{}.Names()) } @@ -126,10 +146,26 @@ func TestToStrings(t *testing.T) { "ubuntu:docker://node:18", "self-hosted:host", "bare", + "pool:e57e18d4", ) require.Equal(t, []string{ "ubuntu:docker://node:18", "self-hosted:host", "bare:host", + "pool:e57e18d4", }, ls.ToStrings()) } + +// a colon-containing name must survive a write to and read back from the .runner file +func TestOpaqueLabelRoundTrip(t *testing.T) { + const raw = "pool:e57e18d4-10d4-406f-93bf-60f127221bdd" + + ls := mustParse(t, raw) + require.Equal(t, []string{raw}, ls.ToStrings()) + + again := mustParse(t, ls.ToStrings()...) + require.Equal(t, ls, again) + require.Equal(t, []string{raw}, again.Names()) + require.False(t, again.RequireDocker()) + require.Equal(t, "-self-hosted", again.PickPlatform([]string{raw})) +} diff --git a/scripts/run.sh b/scripts/run.sh index 75f43b55..1914af65 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -12,14 +12,18 @@ CONFIG_ARG="" if [[ ! -z "${CONFIG_FILE}" ]]; then CONFIG_ARG="--config ${CONFIG_FILE}" fi -EXTRA_ARGS="" +LABEL_ARGS="" if [[ ! -z "${GITEA_RUNNER_LABELS}" ]]; then - EXTRA_ARGS="${EXTRA_ARGS} --labels ${GITEA_RUNNER_LABELS}" + LABEL_ARGS="--labels ${GITEA_RUNNER_LABELS}" fi +EXTRA_ARGS="${LABEL_ARGS}" if [[ ! -z "${GITEA_RUNNER_EPHEMERAL}" ]]; then EXTRA_ARGS="${EXTRA_ARGS} --ephemeral" fi -RUN_ARGS="" +# Also pass the labels to the daemon, so that an already registered runner +# picks up changes to GITEA_RUNNER_LABELS instead of keeping the labels it +# was first registered with. +RUN_ARGS="${LABEL_ARGS}" if [[ ! -z "${GITEA_RUNNER_ONCE}" ]]; then RUN_ARGS="${RUN_ARGS} --once" fi From 60177008a5920899c871dc960eb1baaed7d87a24 Mon Sep 17 00:00:00 2001 From: bircni Date: Wed, 15 Jul 2026 19:45:50 +0000 Subject: [PATCH 12/26] fix: ignore blank lines and decode UTF-16 in the runner env files (#1084) Fixes #496 Fixes #552 Reviewed-on: https://gitea.com/gitea/runner/pulls/1084 Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com> --- act/container/parse_env_file.go | 13 +++++- act/container/parse_env_file_test.go | 59 ++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 14 ------- 4 files changed, 72 insertions(+), 15 deletions(-) diff --git a/act/container/parse_env_file.go b/act/container/parse_env_file.go index bfa261ca..ec8f1545 100644 --- a/act/container/parse_env_file.go +++ b/act/container/parse_env_file.go @@ -13,6 +13,9 @@ import ( "strings" "gitea.com/gitea/runner/act/common" + + "golang.org/x/text/encoding/unicode" + "golang.org/x/text/transform" ) func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Executor { @@ -28,11 +31,19 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex if err != nil && err != io.EOF { return err } - s := bufio.NewScanner(reader) + // Decode by BOM: Windows PowerShell 5.1 redirection writes UTF-16, and some + // tools emit a UTF-8 BOM. Without a BOM the file is read as UTF-8, as before. + decoded := transform.NewReader(reader, unicode.BOMOverride(unicode.UTF8.NewDecoder())) + + s := bufio.NewScanner(decoded) // Default 64 KiB max token size is too small for realistic env-file lines; allow up to 16 MiB. s.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) for s.Scan() { line := s.Text() + // GitHub's runner ignores blank lines + if strings.TrimSpace(line) == "" { + continue + } singleLineEnv := strings.Index(line, "=") multiLineEnv := strings.Index(line, "<<") if singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv) { diff --git a/act/container/parse_env_file_test.go b/act/container/parse_env_file_test.go index 6a8525a6..891b0f37 100644 --- a/act/container/parse_env_file_test.go +++ b/act/container/parse_env_file_test.go @@ -13,6 +13,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/text/encoding" + "golang.org/x/text/encoding/unicode" ) func newTestHostEnv(t *testing.T) (*HostEnvironment, string) { @@ -64,6 +66,63 @@ func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) { assert.Contains(t, err.Error(), "reading env file") } +// Regression test: a blank line used to fail the job at "Complete Job", after +// every step had already been recorded as successful. +func TestParseEnvFileBlankLines(t *testing.T) { + e, envPath := newTestHostEnv(t) + require.NoError(t, os.WriteFile(envPath, []byte("\nFOO=bar\n\n \nBAZ=qux\n\n"), 0o600)) + + env := map[string]string{} + require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background())) + assert.Equal(t, "bar", env["FOO"]) + assert.Equal(t, "qux", env["BAZ"]) +} + +// blank lines inside a heredoc value are content, not separators +func TestParseEnvFileMultiLineKeepsBlankLines(t *testing.T) { + e, envPath := newTestHostEnv(t) + require.NoError(t, os.WriteFile(envPath, []byte("FOO< Date: Thu, 16 Jul 2026 09:22:33 +0000 Subject: [PATCH 13/26] fix(deps): update module golang.org/x/text to v0.40.0 (#1091) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) | [`v0.37.0` β†’ `v0.40.0`](https://cs.opensource.google/go/x/text/+/refs/tags/v0.37.0...refs/tags/v0.40.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2ftext/v0.40.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2ftext/v0.37.0/v0.40.0?slim=true) | --- ### Configuration πŸ“… **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. πŸ”• **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate). Reviewed-on: https://gitea.com/gitea/runner/pulls/1091 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> Co-authored-by: Renovate Bot --- go.mod | 4 ++-- go.sum | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 2b468547..72735eac 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ require ( go.yaml.in/yaml/v4 v4.0.0-rc.3 golang.org/x/sys v0.47.0 golang.org/x/term v0.45.0 - golang.org/x/text v0.37.0 + golang.org/x/text v0.40.0 google.golang.org/protobuf v1.36.11 gotest.tools/v3 v3.5.2 tags.cncf.io/container-device-interface v1.1.0 @@ -107,7 +107,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.52.0 // indirect golang.org/x/net v0.54.0 // indirect - golang.org/x/sync v0.20.0 // indirect + golang.org/x/sync v0.22.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ca17c464..5b4b5e62 100644 --- a/go.sum +++ b/go.sum @@ -242,6 +242,8 @@ golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -258,6 +260,8 @@ golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= From aa7a29a157f99c39bceaa6dc4ffe067f5cfa33c0 Mon Sep 17 00:00:00 2001 From: bircni Date: Thu, 16 Jul 2026 21:33:26 +0000 Subject: [PATCH 14/26] fix: guard status-check functions against a nil job context (#1092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `cancelled()`, `success()` and `failure()` expression functions dereferenced `Job.Status` unconditionally, so a nil `Job` context panicked the interpreter β€” which is why Gitea currently hands the runner a non-nil (empty) `JobContext` as a workaround. This routes all three through a `jobStatus()` helper that treats a nil `Job` as an empty status, keeping existing behaviour identical while removing the panic. Includes a regression test that panics on the old code and passes with the fix. Related: https://github.com/go-gitea/gitea/pull/38495 Reviewed-on: https://gitea.com/gitea/runner/pulls/1092 Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com> --- act/exprparser/functions.go | 15 ++++++++++++--- act/exprparser/functions_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/act/exprparser/functions.go b/act/exprparser/functions.go index 294227c4..a4853bfb 100644 --- a/act/exprparser/functions.go +++ b/act/exprparser/functions.go @@ -274,8 +274,17 @@ func (impl *interperterImpl) jobSuccess() (bool, error) { //nolint:unparam // pr return true, nil } +// jobStatus returns the current job status, treating a nil Job context as an +// empty status so status-check functions never panic on a nil dereference. +func (impl *interperterImpl) jobStatus() string { + if impl.env.Job == nil { + return "" + } + return impl.env.Job.Status +} + func (impl *interperterImpl) stepSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act - return impl.env.Job.Status == "success", nil + return impl.jobStatus() == "success", nil } func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act @@ -292,9 +301,9 @@ func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pr } func (impl *interperterImpl) stepFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act - return impl.env.Job.Status == "failure", nil + return impl.jobStatus() == "failure", nil } func (impl *interperterImpl) cancelled() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act - return impl.env.Job.Status == "cancelled", nil + return impl.jobStatus() == "cancelled", nil } diff --git a/act/exprparser/functions_test.go b/act/exprparser/functions_test.go index a05dd24b..707bf6ab 100644 --- a/act/exprparser/functions_test.go +++ b/act/exprparser/functions_test.go @@ -254,3 +254,27 @@ func TestFunctionFormat(t *testing.T) { }) } } + +func TestStatusFunctionsNilJob(t *testing.T) { + // A nil Job context must not panic: the status-check functions should treat + // it as an empty status and return false rather than dereferencing nil. + env := &EvaluationEnvironment{} + + table := []struct { + input string + context string + name string + }{ + {"cancelled()", "job", "cancelled-nil-job"}, + {"success()", "step", "step-success-nil-job"}, + {"failure()", "step", "step-failure-nil-job"}, + } + + for _, tt := range table { + t.Run(tt.name, func(t *testing.T) { + output, err := NewInterpeter(env, Config{Context: tt.context}).Evaluate(tt.input, DefaultStatusCheckNone) + assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act + assert.Equal(t, false, output) + }) + } +} From 0c08b0f2da5f3fcaba36d687931f4b9f3db9068b Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 21 Jul 2026 07:15:33 +0000 Subject: [PATCH 15/26] chore(deps): update actions/setup-node action to v7 (#1094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/setup-node](https://github.com/actions/setup-node) | action | major | `v6` β†’ `v7` | --- ### Release Notes
actions/setup-node (actions/setup-node) ### [`v7.0.0`](https://github.com/actions/setup-node/releases/tag/v7.0.0) [Compare Source](https://github.com/actions/setup-node/compare/v7.0.0...v7.0.0) #### What's Changed ##### Enhancements: - Add cache-primary-key and cache-matched-key as outputs by [@​gowridurgad](https://github.com/gowridurgad) in [#​1577](https://github.com/actions/setup-node/pull/1577) - Migrate to ESM and upgrade dependencies by [@​gowridurgad](https://github.com/gowridurgad) in [#​1574](https://github.com/actions/setup-node/pull/1574) ##### Bug fixes: - Remove dummy NODE\_AUTH\_TOKEN export by [@​gowridurgad](https://github.com/gowridurgad) in [#​1558](https://github.com/actions/setup-node/pull/1558) - Only use `mirrorToken` in `getManifest` if it's provided by [@​deiga](https://github.com/deiga) in [#​1548](https://github.com/actions/setup-node/pull/1548) ##### Documentation updates: - Add documentation for publishing to npm with Trusted Publisher (OIDC) by [@​chiranjib-swain](https://github.com/chiranjib-swain) in [#​1536](https://github.com/actions/setup-node/pull/1536) - docs: Update restore-only cache documentation by [@​priya-kinthali](https://github.com/priya-kinthali) in [#​1550](https://github.com/actions/setup-node/pull/1550) - docs: Update caching recommendations to mitigate cache poisoning risks by [@​chiranjib-swain](https://github.com/chiranjib-swain) in [#​1567](https://github.com/actions/setup-node/pull/1567) ##### Dependency update: - Upgrade [@​actions/cache](https://github.com/actions/cache) to 5.1.0, log cache write denied by [@​jasongin](https://github.com/jasongin) in [#​1569](https://github.com/actions/setup-node/pull/1569) #### New Contributors - [@​chiranjib-swain](https://github.com/chiranjib-swain) made their first contribution in [#​1536](https://github.com/actions/setup-node/pull/1536) - [@​deiga](https://github.com/deiga) made their first contribution in [#​1548](https://github.com/actions/setup-node/pull/1548) - [@​jasongin](https://github.com/jasongin) made their first contribution in [#​1569](https://github.com/actions/setup-node/pull/1569) **Full Changelog**: ### [`v7`](https://github.com/actions/setup-node/compare/v6.5.0...v7.0.0) [Compare Source](https://github.com/actions/setup-node/compare/v6.5.0...v7.0.0) ### [`v6.5.0`](https://github.com/actions/setup-node/releases/tag/v6.5.0) [Compare Source](https://github.com/actions/setup-node/compare/v6.4.0...v6.5.0) #### What's Changed - Update [@​actions/cache](https://github.com/actions/cache) to 5.1.0 and add security overrides for undici and fast-xml-parser by [@​HarithaVattikuti](https://github.com/HarithaVattikuti) in [#​1579](https://github.com/actions/setup-node/pull/1579) **Full Changelog**: ### [`v6.4.0`](https://github.com/actions/setup-node/releases/tag/v6.4.0) [Compare Source](https://github.com/actions/setup-node/compare/v6.3.0...v6.4.0) #### What's Changed ##### Dependency updates: - Upgrade [@​actions](https://github.com/actions) dependencies by [@​Copilot](https://github.com/Copilot) in [#​1525](https://github.com/actions/setup-node/pull/1525) - Update Node.js versions in versions.yml and bump package to v6.4.0 by [@​priya-kinthali](https://github.com/priya-kinthali) in [#​1533](https://github.com/actions/setup-node/pull/1533) #### New Contributors - [@​Copilot](https://github.com/Copilot) made their first contribution in [#​1525](https://github.com/actions/setup-node/pull/1525) **Full Changelog**: ### [`v6.3.0`](https://github.com/actions/setup-node/releases/tag/v6.3.0) [Compare Source](https://github.com/actions/setup-node/compare/v6.2.0...v6.3.0) #### What's Changed ##### Enhancements: - Support parsing `devEngines` field by [@​susnux](https://github.com/susnux) in [#​1283](https://github.com/actions/setup-node/pull/1283) > When usingΒ node-version-file: package.json, setup-node now prefersΒ devEngines.runtimeΒ overΒ engines.node. ##### Dependency updates: - Fix npm audit issues by [@​gowridurgad](https://github.com/gowridurgad) in [#​1491](https://github.com/actions/setup-node/pull/1491) - Replace uuid with crypto.randomUUID() by [@​trivikr](https://github.com/trivikr) in [#​1378](https://github.com/actions/setup-node/pull/1378) - Upgrade minimatch from 3.1.2 to 3.1.5 by [@​dependabot](https://github.com/dependabot) in [#​1498](https://github.com/actions/setup-node/pull/1498) ##### Bug fixes: - Remove hardcoded bearer for mirror-url [@​marco-ippolito](https://github.com/marco-ippolito) in [#​1467](https://github.com/actions/setup-node/pull/1467) - Scope test lockfiles by package manager and update cache tests by [@​gowridurgad](https://github.com/gowridurgad) in [#​1495](https://github.com/actions/setup-node/pull/1495) #### New Contributors - [@​susnux](https://github.com/susnux) made their first contribution in [#​1283](https://github.com/actions/setup-node/pull/1283) **Full Changelog**: ### [`v6.2.0`](https://github.com/actions/setup-node/releases/tag/v6.2.0) [Compare Source](https://github.com/actions/setup-node/compare/v6.1.0...v6.2.0) #### What's Changed ##### Documentation - Documentation update related to absence of Lockfile by [@​mahabaleshwars](https://github.com/mahabaleshwars) in [#​1454](https://github.com/actions/setup-node/pull/1454) - Correct mirror option typos by [@​MikeMcC399](https://github.com/MikeMcC399) in [#​1442](https://github.com/actions/setup-node/pull/1442) - Readme update on checkout version v6 by [@​deining](https://github.com/deining) in [#​1446](https://github.com/actions/setup-node/pull/1446) - Readme typo fixes [@​munyari](https://github.com/munyari) in [#​1226](https://github.com/actions/setup-node/pull/1226) - Advanced document update on checkout version v6 by [@​aparnajyothi-y](https://github.com/aparnajyothi-y) in [#​1468](https://github.com/actions/setup-node/pull/1468) ##### Dependency updates: - Upgrade [@​actions/cache](https://github.com/actions/cache) to v5.0.1 by [@​salmanmkc](https://github.com/salmanmkc) in [#​1449](https://github.com/actions/setup-node/pull/1449) #### New Contributors - [@​mahabaleshwars](https://github.com/mahabaleshwars) made their first contribution in [#​1454](https://github.com/actions/setup-node/pull/1454) - [@​MikeMcC399](https://github.com/MikeMcC399) made their first contribution in [#​1442](https://github.com/actions/setup-node/pull/1442) - [@​deining](https://github.com/deining) made their first contribution in [#​1446](https://github.com/actions/setup-node/pull/1446) - [@​munyari](https://github.com/munyari) made their first contribution in [#​1226](https://github.com/actions/setup-node/pull/1226) **Full Changelog**: ### [`v6.1.0`](https://github.com/actions/setup-node/releases/tag/v6.1.0) [Compare Source](https://github.com/actions/setup-node/compare/v6...v6.1.0) #### What's Changed ##### Enhancement: - Remove always-auth configuration handling by [@​priyagupta108](https://github.com/priyagupta108) in [#​1436](https://github.com/actions/setup-node/pull/1436) ##### Dependency updates: - Upgrade [@​actions/cache](https://github.com/actions/cache) from 4.0.3 to 4.1.0 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​1384](https://github.com/actions/setup-node/pull/1384) - Upgrade actions/checkout from 5 to 6 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​1439](https://github.com/actions/setup-node/pull/1439) - Upgrade js-yaml from 3.14.1 to 3.14.2 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​1435](https://github.com/actions/setup-node/pull/1435) ##### Documentation update: - Add example for restore-only cache in documentation by [@​aparnajyothi-y](https://github.com/aparnajyothi-y) in [#​1419](https://github.com/actions/setup-node/pull/1419) **Full Changelog**:
--- ### Configuration πŸ“… **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. πŸ”• **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate). Reviewed-on: https://gitea.com/gitea/runner/pulls/1094 Reviewed-by: Lunny Xiao Co-authored-by: Renovate Bot --- .gitea/workflows/pull-pr-title.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/pull-pr-title.yml b/.gitea/workflows/pull-pr-title.yml index 110c34f0..b2c5b96d 100644 --- a/.gitea/workflows/pull-pr-title.yml +++ b/.gitea/workflows/pull-pr-title.yml @@ -19,7 +19,7 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: 24 - run: make lint-pr-title From 89467c9dd07d017f05797e6a23d9fcc937a7e823 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 21 Jul 2026 11:03:15 +0000 Subject: [PATCH 16/26] fix: stop racing the daemon when removing containers (#1093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Containers set `HostConfig.AutoRemove` but act also removes them explicitly, so the two removers race and the loser logs a 409 `removal of container X is already in progress` β€” seen at the end of nearly every `uses: docker://` step. The explicit remove is redundant for `docker://` steps and docker actions (`Start(true)` already awaited exit), so it's skipped. Job and service containers keep both removers β€” their `sleep` entrypoint needs `AutoRemove` as a fallback reaper β€” so there the race is inherent and `remove()` now treats `NotFound` and `Conflict` as success. --------- Co-authored-by: bircni Reviewed-on: https://gitea.com/gitea/runner/pulls/1093 Reviewed-by: bircni Co-authored-by: silverwind --- act/container/docker_run.go | 7 +++++- act/container/docker_run_test.go | 39 ++++++++++++++++++++++++++++++++ act/runner/action.go | 2 +- act/runner/action_test.go | 39 ++++++++++++++++++++++++++++++++ act/runner/step_docker.go | 2 +- act/runner/step_docker_test.go | 38 +++++++++++++++++++++++++++++++ 6 files changed, 124 insertions(+), 3 deletions(-) diff --git a/act/container/docker_run.go b/act/container/docker_run.go index 87323cfd..8365562b 100644 --- a/act/container/docker_run.go +++ b/act/container/docker_run.go @@ -376,6 +376,11 @@ func (cr *containerReference) find() common.Executor { } } +// isContainerGone reports whether a failed remove still left the container gone (NotFound or Conflict). +func isContainerGone(err error) bool { + return cerrdefs.IsNotFound(err) || cerrdefs.IsConflict(err) +} + func (cr *containerReference) remove() common.Executor { return func(ctx context.Context) error { if cr.id == "" { @@ -387,7 +392,7 @@ func (cr *containerReference) remove() common.Executor { RemoveVolumes: true, Force: true, }) - if err != nil { + if err != nil && !isContainerGone(err) { logger.Error(fmt.Errorf("failed to remove container: %w", err)) } diff --git a/act/container/docker_run_test.go b/act/container/docker_run_test.go index dc51278e..57c180fa 100644 --- a/act/container/docker_run_test.go +++ b/act/container/docker_run_test.go @@ -116,6 +116,11 @@ func (m *mockDockerClient) ContainerList(ctx context.Context, opts mobyclient.Co return args.Get(0).(mobyclient.ContainerListResult), args.Error(1) } +func (m *mockDockerClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) { + args := m.Called(ctx, id, opts) + return args.Get(0).(mobyclient.ContainerRemoveResult), args.Error(1) +} + type endlessReader struct { io.Reader } @@ -381,6 +386,40 @@ func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) { client.AssertExpectations(t) } +// A remove that raced the daemon's AutoRemove teardown is not a failure and must not +// be logged as one. +func TestRemoveIgnoresAutoRemoveRace(t *testing.T) { + removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true} + for _, tc := range []struct { + name string + err error + wantLogs bool + }{ + {name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress")}, + {name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")}, + {name: "removed cleanly", err: nil}, + {name: "real failure", err: errors.New("driver failed to remove root filesystem"), wantLogs: true}, + } { + t.Run(tc.name, func(t *testing.T) { + logger, hook := test.NewNullLogger() + ctx := common.WithLogger(context.Background(), logger) + client := &mockDockerClient{} + client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err) + cr := &containerReference{id: "abc", cli: client} + + require.NoError(t, cr.remove()(ctx)) + assert.Empty(t, cr.id) + + if tc.wantLogs { + assert.Len(t, hook.AllEntries(), 1) + } else { + assert.Empty(t, hook.AllEntries()) + } + client.AssertExpectations(t) + }) + } +} + // find() must drop a stale cached id so later Copy/Exec don't hit the // daemon with a torn-down container. func TestFindRevalidatesStaleID(t *testing.T) { diff --git a/act/runner/action.go b/act/runner/action.go index ea93c571..4e736a09 100644 --- a/act/runner/action.go +++ b/act/runner/action.go @@ -405,7 +405,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop), stepContainer.Start(true), ).Finally( - stepContainer.Remove().IfBool(!rc.Config.ReuseContainers), + stepContainer.Remove().IfBool(!rc.Config.ReuseContainers && !rc.Config.AutoRemove), ).Finally(stepContainer.Close())(ctx) } diff --git a/act/runner/action_test.go b/act/runner/action_test.go index 9122e025..a3b686fe 100644 --- a/act/runner/action_test.go +++ b/act/runner/action_test.go @@ -20,6 +20,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) type closerMock struct { @@ -150,6 +151,44 @@ runs: } } +// With AutoRemove the daemon reaps the container on exit, so act must not remove it afterwards. +func TestExecAsDockerAutoRemove(t *testing.T) { + orig := ContainerNewContainer + defer func() { ContainerNewContainer = orig }() + + for _, tc := range []struct { + autoRemove bool + removes int + }{ + {false, 2}, // stale + post-run + {true, 1}, // post-run skipped + } { + cm := &containerMock{} + ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return cm } + + step := &stepActionRemote{ + Step: &model.Step{ID: "1", Uses: "org/action@v1"}, + RunContext: &RunContext{ + Config: &Config{AutoRemove: tc.autoRemove}, + Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}}, + JobContainer: cm, + }, + action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "docker://node:14"}}, + } + + removes := 0 + cm.On("Pull", false).Return(func(context.Context) error { return nil }) + cm.On("Remove").Return(func(context.Context) error { removes++; return nil }) + cm.On("Create", []string(nil), []string(nil)).Return(func(context.Context) error { return nil }) + cm.On("Start", true).Return(func(context.Context) error { return nil }) + cm.On("Close").Return(func(context.Context) error { return nil }) + + require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false)) + cm.AssertExpectations(t) + assert.Equal(t, tc.removes, removes) + } +} + func TestActionRunner(t *testing.T) { table := []struct { name string diff --git a/act/runner/step_docker.go b/act/runner/step_docker.go index 9d2a85a1..ecdf26f9 100644 --- a/act/runner/step_docker.go +++ b/act/runner/step_docker.go @@ -85,7 +85,7 @@ func (sd *stepDocker) runUsesContainer() common.Executor { stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop), stepContainer.Start(true), ).Finally( - stepContainer.Remove().IfBool(!rc.Config.ReuseContainers), + stepContainer.Remove().IfBool(!rc.Config.ReuseContainers && !rc.Config.AutoRemove), ).Finally(stepContainer.Close())(ctx) } } diff --git a/act/runner/step_docker_test.go b/act/runner/step_docker_test.go index da508d1f..2aac21f3 100644 --- a/act/runner/step_docker_test.go +++ b/act/runner/step_docker_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) func TestStepDockerMain(t *testing.T) { @@ -118,6 +119,43 @@ func TestStepDockerMain(t *testing.T) { cm.AssertExpectations(t) } +// With AutoRemove the daemon reaps the container on exit, so act must not remove it afterwards. +func TestStepDockerAutoRemove(t *testing.T) { + orig := ContainerNewContainer + defer func() { ContainerNewContainer = orig }() + + for _, tc := range []struct { + autoRemove bool + removes int + }{ + {false, 2}, // stale + post-run + {true, 1}, // post-run skipped + } { + cm := &containerMock{} + ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return cm } + + sd := &stepDocker{ + RunContext: &RunContext{ + Config: &Config{AutoRemove: tc.autoRemove}, + Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}}, + JobContainer: cm, + }, + Step: &model.Step{ID: "1", Uses: "docker://node:14"}, + } + + removes := 0 + cm.On("Pull", false).Return(func(context.Context) error { return nil }) + cm.On("Remove").Return(func(context.Context) error { removes++; return nil }) + cm.On("Create", []string(nil), []string(nil)).Return(func(context.Context) error { return nil }) + cm.On("Start", true).Return(func(context.Context) error { return nil }) + cm.On("Close").Return(func(context.Context) error { return nil }) + + require.NoError(t, sd.runUsesContainer()(context.Background())) + cm.AssertExpectations(t) + assert.Equal(t, tc.removes, removes) + } +} + func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) { for _, tc := range []struct { name string From 0e8896c52a97fd28debe34a32ddb5bde49894fa4 Mon Sep 17 00:00:00 2001 From: silverwind <2021+silverwind@noreply.gitea.com> Date: Tue, 21 Jul 2026 11:16:56 +0000 Subject: [PATCH 17/26] fix: classify a cancelled step as an interruption, not a failure (#1095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `reportStepError` reported every step error as FAILURE, including a `context.Canceled` from a docker file-command read cancelled at job finalization β€” non-deterministic red CI. Classify `context.Canceled` as an interruption instead (deferring to the job context), so a genuine cancel reports cancelled and a stray teardown cancellation is ignored, never a failure. --------- Co-authored-by: bircni Reviewed-on: https://gitea.com/gitea/runner/pulls/1095 Reviewed-by: bircni --- act/runner/cancellation_test.go | 27 +++++++++++++++++++++++++++ act/runner/job_executor.go | 11 +++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/act/runner/cancellation_test.go b/act/runner/cancellation_test.go index ec2d7afe..add340ce 100644 --- a/act/runner/cancellation_test.go +++ b/act/runner/cancellation_test.go @@ -283,3 +283,30 @@ func TestPostStepsContextDeadlinePreservesJobError(t *testing.T) { require.NoError(t, postCtx.Err(), "post context must not carry the expired deadline") assert.ErrorIs(t, common.JobError(postCtx), assert.AnError, "the timeout job error must be preserved") } + +// reportStepError must treat a context.Canceled (e.g. a teardown-cancelled read) as an +// interruption, never a job failure. +func TestReportStepErrorTreatsCancelAsInterruption(t *testing.T) { + rc := &RunContext{} + + // stray read cancellation while the job context is live: ignored, not a failure + live := common.WithJobErrorContainer(context.Background()) + reportStepError(live, rc, context.Canceled) + require.NoError(t, common.JobError(live)) + assert.False(t, rc.jobFailed) + assert.False(t, rc.jobCancelled) + + // genuine job cancellation: recorded as cancelled, still not a failure + cancelled, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background())) + cancel() + reportStepError(cancelled, rc, context.Canceled) + require.NoError(t, common.JobError(cancelled)) + assert.False(t, rc.jobFailed) + assert.True(t, rc.jobCancelled) + + // a real error still fails the job + failed := common.WithJobErrorContainer(context.Background()) + reportStepError(failed, rc, assert.AnError) + require.ErrorIs(t, common.JobError(failed), assert.AnError) + assert.True(t, rc.jobFailed) +} diff --git a/act/runner/job_executor.go b/act/runner/job_executor.go index abd92346..ed27f1c5 100644 --- a/act/runner/job_executor.go +++ b/act/runner/job_executor.go @@ -10,6 +10,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -56,9 +57,15 @@ type jobInfo interface { result(result string) } -// reportStepError emits the GitHub Actions ##[error] annotation and records -// the error against the job so the job is reported as failed. +// reportStepError records a step error so the job is reported failed β€” except a +// cancellation, which is an interruption, not a failure. func reportStepError(ctx context.Context, rc *RunContext, err error) { + if errors.Is(err, context.Canceled) { + // Defer to the job context: a genuine cancel reports cancelled, a stray teardown + // cancellation on a live ctx is ignored β€” never a step FAILURE. + rc.markInterrupted(ctx.Err()) + return + } common.Logger(ctx).Errorf("##[error]%v", err) common.SetJobError(ctx, err) rc.markFailed() From 068afc399654a109c340d116f58e71eb45d38a07 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 22 Jul 2026 14:05:48 +0000 Subject: [PATCH 18/26] fix(deps): update module github.com/docker/cli to v29.6.2+incompatible (#1096) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [github.com/docker/cli](https://github.com/docker/cli) | `v29.6.1+incompatible` β†’ `v29.6.2+incompatible` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fdocker%2fcli/v29.6.2+incompatible?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fdocker%2fcli/v29.6.1+incompatible/v29.6.2+incompatible?slim=true) | --- ### Release Notes
docker/cli (github.com/docker/cli) ### [`v29.6.2+incompatible`](https://github.com/docker/cli/compare/v29.6.1...v29.6.2) [Compare Source](https://github.com/docker/cli/compare/v29.6.1...v29.6.2)
--- ### Configuration πŸ“… **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. πŸ”• **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate). Reviewed-on: https://gitea.com/gitea/runner/pulls/1096 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> Co-authored-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 72735eac..84370614 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/containerd/errdefs v1.0.0 github.com/creack/pty v1.1.24 github.com/distribution/reference v0.6.0 - github.com/docker/cli v29.6.1+incompatible + github.com/docker/cli v29.6.2+incompatible github.com/docker/go-connections v0.7.0 github.com/go-git/go-billy/v5 v5.9.0 github.com/go-git/go-git/v5 v5.19.1 diff --git a/go.sum b/go.sum index 5b4b5e62..b76013aa 100644 --- a/go.sum +++ b/go.sum @@ -49,6 +49,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/cli v29.6.1+incompatible h1:oO7F4nn3Ovr/5TlfTUWFbMwBSS/B7Xs6Epv26gBrUP8= github.com/docker/cli v29.6.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw= +github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.9.6 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0= github.com/docker/docker-credential-helpers v0.9.6/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= From 46f22c78d23534d1481bccda43b030253a03cfd4 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 22 Jul 2026 14:51:10 +0000 Subject: [PATCH 19/26] fix(deps): update module github.com/mattn/go-isatty to v0.0.23 (#1097) Co-authored-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 84370614..eb74ed0e 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/julienschmidt/httprouter v1.3.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/mattn/go-isatty v0.0.22 + github.com/mattn/go-isatty v0.0.23 github.com/moby/go-archive v0.2.0 github.com/moby/moby/api v1.55.0 github.com/moby/moby/client v0.5.0 diff --git a/go.sum b/go.sum index b76013aa..6af2e1ab 100644 --- a/go.sum +++ b/go.sum @@ -121,6 +121,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= +github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= From 8af385d14729ae4f7ffb8a8573454f0fa6001cd6 Mon Sep 17 00:00:00 2001 From: bircni Date: Wed, 22 Jul 2026 14:58:23 +0000 Subject: [PATCH 20/26] enhance: report a GitHub-style "Set up job" section (#1089) Reshapes the job log's "Set up job" section to mirror `actions/runner`: - runner name/version, then `Runner Information` (labels, task, job, repository, event) and `Operating System` groups - every required action downloaded up front under `Prepare all required actions`, each as `Download action repository '@' (SHA:)` - `Complete job name` closes the section Downloading up front is the one behavioral change: the same set was already fetched during the pre stage regardless of a step's `if`, now just before the first pre step, so a download failure is reported against the job rather than a step. --------- Co-authored-by: silverwind Reviewed-on: https://gitea.com/gitea/runner/pulls/1089 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> --- act/common/git/git.go | 10 +- act/common/git/git_test.go | 42 ++++++ act/runner/job_executor.go | 69 +++++++++- act/runner/job_executor_test.go | 177 ++++++++++++++++++++++++++ act/runner/step.go | 8 +- act/runner/step_action_remote.go | 28 ++++ act/runner/step_action_remote_test.go | 94 ++++++++++++++ internal/app/run/runner.go | 2 +- internal/app/run/setup.go | 83 ++++++++++++ internal/app/run/setup_test.go | 103 +++++++++++++++ 10 files changed, 612 insertions(+), 4 deletions(-) create mode 100644 internal/app/run/setup.go create mode 100644 internal/app/run/setup_test.go diff --git a/act/common/git/git.go b/act/common/git/git.go index 243aa5db..0ef1eab5 100644 --- a/act/common/git/git.go +++ b/act/common/git/git.go @@ -261,6 +261,10 @@ type NewGitCloneExecutorInput struct { // 0 for full clone. Depth int + // Quiet drops the informational clone line to debug level, for callers that log their own + // download summary (the setup section's action report). + Quiet bool + // For Gitea InsecureSkipTLS bool } @@ -347,7 +351,11 @@ func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.Pu func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor { return func(ctx context.Context) error { logger := common.Logger(ctx) - logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref) + if input.Quiet { + logger.Debugf("git clone '%s' # ref=%s", input.URL, input.Ref) + } else { + logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref) + } logger.Debugf(" cloning %s to %s", input.URL, input.Dir) defer AcquireCloneLock(input.Dir)() diff --git a/act/common/git/git_test.go b/act/common/git/git_test.go index 271080b7..02811acd 100644 --- a/act/common/git/git_test.go +++ b/act/common/git/git_test.go @@ -17,6 +17,10 @@ import ( "testing" "time" + "gitea.com/gitea/runner/act/common" + + log "github.com/sirupsen/logrus" + logrustest "github.com/sirupsen/logrus/hooks/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -404,6 +408,44 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) { }) } +func TestGitCloneExecutorQuietDemotesCloneLine(t *testing.T) { + remoteDir := t.TempDir() + require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir)) + workDir := t.TempDir() + require.NoError(t, gitCmd("clone", remoteDir, workDir)) + require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main")) + require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial")) + require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main")) + + // Quiet callers report the download themselves, so the clone line must not reach the job log. + for name, quiet := range map[string]bool{"quiet": true, "not quiet": false} { + t.Run(name, func(t *testing.T) { + logger, hook := logrustest.NewNullLogger() + logger.SetLevel(log.InfoLevel) + ctx := common.WithLogger(context.Background(), logger.WithField("job", "j1")) + + require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ + URL: remoteDir, + Ref: "main", + Dir: t.TempDir(), + Quiet: quiet, + })(ctx)) + + var cloneLines int + for _, entry := range hook.AllEntries() { + if strings.HasPrefix(entry.Message, "git clone ") { + cloneLines++ + } + } + if quiet { + assert.Zero(t, cloneLines) + } else { + assert.Equal(t, 1, cloneLines) + } + }) + } +} + func TestGitCloneExecutorShallow(t *testing.T) { // Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one. remoteDir := t.TempDir() diff --git a/act/runner/job_executor.go b/act/runner/job_executor.go index ed27f1c5..099362fb 100644 --- a/act/runner/job_executor.go +++ b/act/runner/job_executor.go @@ -71,9 +71,67 @@ func reportStepError(ctx context.Context, rc *RunContext, err error) { rc.markFailed() } +// actionPreparer is implemented by steps that download an action before they run, so the job +// executor can fetch all of them up front. +type actionPreparer interface { + prepareActionExecutor() common.Executor + actionDownloadInfo() (reference, sha string, ok bool) +} + +// printPrepareActions downloads every action the job uses before its first step runs and reports +// them as actions/runner's "Prepare all required actions" section does. The steps still call +// prepareActionExecutor themselves; it is a no-op once the action is resolved here. +func printPrepareActions(rc *RunContext, preparers []actionPreparer) common.Executor { + return func(ctx context.Context) error { + if len(preparers) == 0 { + return nil + } + + rawLogger := common.Logger(ctx).WithField(rawOutputField, true) + rawLogger.Infof("Prepare all required actions") + + for _, preparer := range preparers { + if err := preparer.prepareActionExecutor()(ctx); err != nil { + // No step has run yet, so the failure belongs to the job. + reportStepError(ctx, rc, err) + return err + } + reference, sha, ok := preparer.actionDownloadInfo() + if !ok { + continue + } + if sha == "" { + rawLogger.Infof("Download action repository '%s'", reference) + } else { + rawLogger.Infof("Download action repository '%s' (SHA:%s)", reference, sha) + } + } + return nil + } +} + +// printCompleteJobName closes the setup section the way actions/runner ends its "Set up job" step. +func printCompleteJobName(rc *RunContext) common.Executor { + return func(ctx context.Context) error { + // Name holds a matrix combination; JobName is the shared name GitHub reports. + name := rc.JobName + if name == "" { + name = rc.Name + } + if name == "" && rc.Run != nil { + name = rc.Run.JobID + } + common.Logger(ctx).WithField(rawOutputField, true).Infof("Complete job name: %s", name) + return nil + } +} + func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor { steps := make([]common.Executor, 0) preSteps := make([]common.Executor, 0) + // Collected separately: every action is downloaded before the first pre step runs. + stepPreSteps := make([]common.Executor, 0) + preparers := make([]actionPreparer, 0) var postExecutor common.Executor steps = append(steps, func(ctx context.Context) error { @@ -120,9 +178,13 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo return common.NewErrorExecutor(err) } + if preparer, ok := step.(actionPreparer); ok { + preparers = append(preparers, preparer) + } + stepIdx := stepModel.Number preExec := step.pre() - preSteps = append(preSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error { + stepPreSteps = append(stepPreSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error { rc.CurrentStepIndex = stepIdx preErr := preExec(ctx) if preErr != nil { @@ -164,6 +226,11 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo } } + // The setup section of the job log: download the actions, run the pre steps, then name the job. + preSteps = append(preSteps, printPrepareActions(rc, preparers)) + preSteps = append(preSteps, stepPreSteps...) + preSteps = append(preSteps, printCompleteJobName(rc)) + postExecutor = postExecutor.Finally(func(ctx context.Context) error { jobError := common.JobError(ctx) var err error diff --git a/act/runner/job_executor_test.go b/act/runner/job_executor_test.go index 4c1cf7f0..aa723721 100644 --- a/act/runner/job_executor_test.go +++ b/act/runner/job_executor_test.go @@ -24,6 +24,7 @@ import ( "gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/act/model" + log "github.com/sirupsen/logrus" logrustest "github.com/sirupsen/logrus/hooks/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -111,6 +112,182 @@ func (sfm *stepFactoryMock) newStep(model *model.Step, rc *RunContext) (step, er return args.Get(0).(step), args.Error(1) } +// actionPreparerMock stands in for a step whose action is downloaded before the job's first step. +type actionPreparerMock struct { + reference string + sha string + ok bool + err error + prepared int +} + +func (apm *actionPreparerMock) prepareActionExecutor() common.Executor { + return func(context.Context) error { + apm.prepared++ + return apm.err + } +} + +func (apm *actionPreparerMock) actionDownloadInfo() (string, string, bool) { + return apm.reference, apm.sha, apm.ok +} + +func TestPrintPrepareActionsGolden(t *testing.T) { + buf := &bytes.Buffer{} + logger := log.New() + logger.SetOutput(buf) + logger.SetLevel(log.InfoLevel) + logger.SetFormatter(&jobLogFormatter{color: cyan}) + ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"})) + + preparers := []actionPreparer{ + &actionPreparerMock{reference: "actions/checkout@v7", sha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", ok: true}, + // A resolved commit is best effort; the ref alone is reported when it is unknown. + &actionPreparerMock{reference: "actions/setup-go@v6", ok: true}, + // A step that downloads nothing, such as the checkout of the workflow's own repository. + &actionPreparerMock{ok: false}, + } + require.NoError(t, printPrepareActions(&RunContext{}, preparers)(ctx)) + + want := strings.Join([]string{ + "[j1] | Prepare all required actions", + "[j1] | Download action repository 'actions/checkout@v7' (SHA:9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)", + "[j1] | Download action repository 'actions/setup-go@v6'", + "", + }, "\n") + assert.Equal(t, want, buf.String()) +} + +func TestPrintPrepareActionsSkipsWithoutActions(t *testing.T) { + buf := &bytes.Buffer{} + logger := log.New() + logger.SetOutput(buf) + logger.SetFormatter(&jobLogFormatter{color: cyan}) + ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"})) + + require.NoError(t, printPrepareActions(&RunContext{}, nil)(ctx)) + + assert.Empty(t, buf.String()) +} + +func TestPrintPrepareActionsFailsJobOnDownloadError(t *testing.T) { + logger, _ := logrustest.NewNullLogger() + ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logger.WithField("job", "j1"))) + + downloadErr := errors.New("failed to fetch \"actions/checkout\"") + rc := &RunContext{} + remaining := &actionPreparerMock{reference: "actions/setup-go@v6", ok: true} + + err := printPrepareActions(rc, []actionPreparer{ + &actionPreparerMock{err: downloadErr}, + remaining, + })(ctx) + + require.ErrorIs(t, err, downloadErr) + // No step has run yet, so the failure has to be recorded against the job itself. + assert.Equal(t, downloadErr, common.JobError(ctx)) + assert.True(t, rc.jobFailed) + assert.Zero(t, remaining.prepared) +} + +func TestPrintCompleteJobName(t *testing.T) { + for name, tt := range map[string]struct { + rc *RunContext + want string + }{ + "job name": {rc: &RunContext{JobName: "lint", Name: "lint-1"}, want: "lint"}, + "falls back to name": {rc: &RunContext{Name: "lint-1"}, want: "lint-1"}, + "falls back to jobID": {rc: &RunContext{Run: &model.Run{JobID: "lint"}}, want: "lint"}, + } { + t.Run(name, func(t *testing.T) { + buf := &bytes.Buffer{} + logger := log.New() + logger.SetOutput(buf) + logger.SetFormatter(&jobLogFormatter{color: cyan}) + ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"})) + + require.NoError(t, printCompleteJobName(tt.rc)(ctx)) + + assert.Equal(t, "[j1] | Complete job name: "+tt.want+"\n", buf.String()) + }) + } +} + +// actionStepMock is a step whose action has to be downloaded before it can run. +type actionStepMock struct { + *stepMock + *actionPreparerMock +} + +// TestNewJobExecutorDownloadsAllActionsBeforeTheFirstStep pins the shape of the setup section: +// every action is downloaded before any step runs, and the job name closes the section. A pre +// step that downloaded its own action would leave the log interleaved with the downloads. +func TestNewJobExecutorDownloadsAllActionsBeforeTheFirstStep(t *testing.T) { + ctx := common.WithJobErrorContainer(context.Background()) + jim := &jobInfoMock{} + sfm := &stepFactoryMock{} + rc := &RunContext{ + JobContainer: &jobContainerMock{}, + Run: &model.Run{ + JobID: "test", + Workflow: &model.Workflow{ + Jobs: map[string]*model.Job{"test": {}}, + }, + }, + Config: &Config{}, + } + rc.ExprEval = rc.NewExpressionEvaluator(ctx) + + steps := []*model.Step{{ID: "1"}, {ID: "2"}} + executorOrder := make([]string, 0) + + jim.On("steps").Return(steps) + jim.On("matrix").Return(map[string]any{}) + jim.On("startContainer").Return(func(context.Context) error { return nil }) + jim.On("stopContainer").Return(func(context.Context) error { return nil }) + jim.On("closeContainer").Return(func(context.Context) error { return nil }) + jim.On("interpolateOutputs").Return(func(context.Context) error { return nil }) + jim.On("result", "success") + + for _, stepModel := range steps { + sm := &stepMock{} + apm := &actionPreparerMock{reference: "actions/checkout@v" + stepModel.ID, ok: true} + sfm.On("newStep", stepModel, rc).Return(&actionStepMock{stepMock: sm, actionPreparerMock: apm}, nil) + + sm.On("pre").Return(func(context.Context) error { + executorOrder = append(executorOrder, "pre"+stepModel.ID) + return nil + }) + sm.On("main").Return(func(context.Context) error { + executorOrder = append(executorOrder, "step"+stepModel.ID) + return nil + }) + sm.On("post").Return(func(context.Context) error { return nil }) + + defer sm.AssertExpectations(t) + } + + logger, hook := logrustest.NewNullLogger() + err := newJobExecutor(jim, sfm, rc)(common.WithLogger(ctx, logger.WithField("job", "test"))) + require.NoError(t, err) + + assert.Equal(t, []string{"pre1", "pre2", "step1", "step2"}, executorOrder) + + setup := make([]string, 0) + for _, entry := range hook.AllEntries() { + if strings.HasPrefix(entry.Message, "Prepare all required actions") || strings.HasPrefix(entry.Message, "Download action") || + strings.HasPrefix(entry.Message, "Complete job name") { + setup = append(setup, entry.Message) + } + } + assert.Equal(t, []string{ + "Prepare all required actions", + "Download action repository 'actions/checkout@v1'", + "Download action repository 'actions/checkout@v2'", + "Complete job name: test", + }, setup) +} + func TestNewJobExecutor(t *testing.T) { table := []struct { name string diff --git a/act/runner/step.go b/act/runner/step.go index c67f5ba7..01a44108 100644 --- a/act/runner/step.go +++ b/act/runner/step.go @@ -107,7 +107,13 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo if strings.Contains(stepString, "::add-mask::") { stepString = "add-mask command" } - logger.Infof("Run %s %s", stage, stepString) + if stage == stepStageMain { + // Main steps print their own raw "Run " header, so this line is redundant and + // only leaks into the "Set up job" section for the first step; keep it as a debug trace. + logger.Debugf("Run %s %s", stage, stepString) + } else { + logger.Infof("Run %s %s", stage, stepString) + } // Prepare and clean Runner File Commands actPath := rc.JobContainer.GetActPath() diff --git a/act/runner/step_action_remote.go b/act/runner/step_action_remote.go index b61276a0..0cccb729 100644 --- a/act/runner/step_action_remote.go +++ b/act/runner/step_action_remote.go @@ -131,6 +131,8 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor { Token: token, OfflineMode: sar.RunContext.Config.ActionOfflineMode, Depth: sar.RunContext.Config.ActionCloneDepth, + // printPrepareActions reports the download with its resolved commit. + Quiet: true, InsecureSkipTLS: sar.cloneSkipTLS(), // For Gitea }) @@ -146,6 +148,13 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor { } } + // Best effort: the download report falls back to the ref alone when the commit is unknown. + if _, sha, err := git.FindGitRevision(ctx, actionDir); err != nil { + common.Logger(ctx).Debugf("unable to resolve the commit of %s: %v", sar.remoteAction.Reference(), err) + } else { + sar.resolvedSha = sha + } + remoteReader := func(ctx context.Context) actionYamlReader { //nolint:unparam // pre-existing issue from nektos/act return func(filename string) (io.Reader, io.Closer, error) { f, err := os.Open(filepath.Join(actionDir, sar.remoteAction.Path, filename)) @@ -165,6 +174,15 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor { } } +// actionDownloadInfo reports the action this step downloaded and the commit it resolved to. ok is +// false when nothing was fetched, as for the local checkout of the workflow's own repository. +func (sar *stepActionRemote) actionDownloadInfo() (reference, sha string, ok bool) { + if sar.remoteAction == nil || sar.action == nil { + return "", "", false + } + return sar.remoteAction.Reference(), sar.resolvedSha, true +} + func (sar *stepActionRemote) pre() common.Executor { sar.env = map[string]string{} @@ -313,6 +331,16 @@ func (ra *remoteAction) CloneURL(u string) string { return fmt.Sprintf("%s/%s/%s", u, ra.Org, ra.Repo) } +// Reference renders the action as {org}/{repo}[/path]@{ref}, omitting the download source, which +// can be interpolated from a secret. +func (ra *remoteAction) Reference() string { + repo := fmt.Sprintf("%s/%s", ra.Org, ra.Repo) + if ra.Path != "" { + repo = fmt.Sprintf("%s/%s", repo, ra.Path) + } + return fmt.Sprintf("%s@%s", repo, ra.Ref) +} + func (ra *remoteAction) IsCheckout() bool { if ra.Org == "actions" && ra.Repo == "checkout" { return true diff --git a/act/runner/step_action_remote_test.go b/act/runner/step_action_remote_test.go index 6759b78a..473cbb62 100644 --- a/act/runner/step_action_remote_test.go +++ b/act/runner/step_action_remote_test.go @@ -10,6 +10,9 @@ import ( "errors" "fmt" "io" + "os" + "os/exec" + "path/filepath" "strings" "testing" "time" @@ -818,6 +821,97 @@ func Test_newRemoteAction(t *testing.T) { } } +func Test_remoteActionReference(t *testing.T) { + tests := []struct { + uses string + want string + }{ + {uses: "actions/checkout@v7", want: "actions/checkout@v7"}, + {uses: "actions/aws/ec2@main", want: "actions/aws/ec2@main"}, + // The download source can be interpolated from a secret and must stay out of the log. + {uses: "https://gitea.example.com/actions/checkout@v7", want: "actions/checkout@v7"}, + } + for _, tt := range tests { + t.Run(tt.uses, func(t *testing.T) { + assert.Equal(t, tt.want, newRemoteAction(tt.uses).Reference()) + }) + } +} + +// TestStepActionRemotePreResolvesDownloadedCommit runs the real download path against a local +// git repository standing in for the actions instance, so the reported commit is the one the +// clone actually checked out. +func TestStepActionRemotePreResolvesDownloadedCommit(t *testing.T) { + instance := t.TempDir() + actionDir := filepath.Join(instance, "actions", "setup-go") + require.NoError(t, os.MkdirAll(actionDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(actionDir, "action.yml"), + []byte("name: setup-go\nruns:\n using: node20\n main: index.js\n"), 0o600)) + + // Supply an identity on the commit so the test does not depend on a + // git identity being configured in the environment; a CI runner without + // user.name/user.email would otherwise fail "commit" with exit code 128. + for _, args := range [][]string{ + {"init", "--initial-branch=main", actionDir}, + {"-C", actionDir, "add", "action.yml"}, + {"-C", actionDir, "-c", "user.name=runner", "-c", "user.email=runner@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "action"}, + } { + cmd := exec.Command("git", args...) + require.NoError(t, cmd.Run(), "git %v", args) + } + out, err := exec.Command("git", "-C", actionDir, "rev-parse", "HEAD").Output() + require.NoError(t, err) + wantSha := strings.TrimSpace(string(out)) + + sar := &stepActionRemote{ + Step: &model.Step{Uses: "actions/setup-go@main"}, + RunContext: &RunContext{ + Config: &Config{ + GitHubInstance: "https://gitea.example.com", + DefaultActionInstance: instance, + ActionCacheDir: t.TempDir(), + }, + Run: &model.Run{ + JobID: "1", + Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}, + }, + }, + readAction: readActionImpl, + } + + require.NoError(t, sar.prepareActionExecutor()(context.Background())) + + reference, sha, ok := sar.actionDownloadInfo() + assert.True(t, ok) + assert.Equal(t, "actions/setup-go@main", reference) + assert.Equal(t, wantSha, sha) +} + +func TestStepActionRemoteActionDownloadInfo(t *testing.T) { + t.Run("reports the action and its resolved commit", func(t *testing.T) { + sar := &stepActionRemote{ + remoteAction: newRemoteAction("actions/checkout@v7"), + action: &model.Action{}, + resolvedSha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", + } + + reference, sha, ok := sar.actionDownloadInfo() + + assert.True(t, ok) + assert.Equal(t, "actions/checkout@v7", reference) + assert.Equal(t, "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", sha) + }) + + t.Run("reports nothing when no action was downloaded", func(t *testing.T) { + // The local checkout of the workflow's own repository resolves no action. + sar := &stepActionRemote{remoteAction: newRemoteAction("actions/checkout@v7")} + + _, _, ok := sar.actionDownloadInfo() + + assert.False(t, ok) + }) +} + func Test_safeFilename(t *testing.T) { tests := []struct { s string diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index d041aff0..f2ed4f7a 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -315,7 +315,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. } }() - reporter.Logf("%s(version:%s) received task %v of job %v, be triggered by event: %s", r.name, ver.Version(), task.Id, task.Context.Fields["job"].GetStringValue(), task.Context.Fields["event_name"].GetStringValue()) + r.reportSetup(reporter, task) workflow, jobID, err := generateWorkflow(task) if err != nil { diff --git a/internal/app/run/setup.go b/internal/app/run/setup.go new file mode 100644 index 00000000..d4806d5c --- /dev/null +++ b/internal/app/run/setup.go @@ -0,0 +1,83 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package run + +import ( + "fmt" + "os" + "runtime" + "strconv" + "strings" + + "gitea.com/gitea/runner/internal/pkg/report" + "gitea.com/gitea/runner/internal/pkg/ver" + + runnerv1 "gitea.dev/actions-proto-go/runner/v1" +) + +// osReleasePath describes the host distribution on Linux; absent elsewhere, where the platform +// falls back to the Go runtime alone. A var so tests can point it at a fixture. +var osReleasePath = "/etc/os-release" + +// reportSetup opens the job log the way actions/runner opens its "Set up job" step. The action +// downloads and the closing job name are written later, as the job starts. +func (r *Runner) reportSetup(reporter *report.Reporter, task *runnerv1.Task) { + for _, line := range r.setupLines(task) { + reporter.Logf("%s", line) + } +} + +// setupLines names the runner, then reports what it was asked to run and the host it runs on, each +// in its own group. +func (r *Runner) setupLines(task *runnerv1.Task) []string { + fields := task.Context.Fields + lines := []string{ + fmt.Sprintf("%s(version:%s)", r.name, ver.Version()), + "::group::Runner Information", + } + if names := r.labels.Names(); len(names) > 0 { + lines = append(lines, "Runner labels: "+strings.Join(names, ", ")) + } + lines = append(lines, + // The task id correlates the job log with the runner's log and the server's task list. + fmt.Sprintf("Task: %d", task.Id), + "Job: "+fields["job"].GetStringValue(), + "Repository: "+fields["repository"].GetStringValue(), + "Triggered by event: "+fields["event_name"].GetStringValue(), + "::endgroup::", + "::group::Operating System", + ) + lines = append(lines, osInfo()...) + return append(lines, "::endgroup::") +} + +// osInfo describes the host the runner executes on. +func osInfo() []string { + lines := make([]string, 0, 2) + if name := prettyOSName(); name != "" { + lines = append(lines, name) + } + return append(lines, fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)) +} + +// prettyOSName reads PRETTY_NAME (e.g. "Ubuntu 24.04.4 LTS") from os-release, or "" when absent. +func prettyOSName() string { + data, err := os.ReadFile(osReleasePath) + if err != nil { + return "" + } + + for line := range strings.SplitSeq(string(data), "\n") { + key, value, ok := strings.Cut(strings.TrimSpace(line), "=") + if !ok || key != "PRETTY_NAME" { + continue + } + // Values are shell-quoted, but the quotes are optional. + if unquoted, err := strconv.Unquote(value); err == nil { + return unquoted + } + return value + } + return "" +} diff --git a/internal/app/run/setup_test.go b/internal/app/run/setup_test.go new file mode 100644 index 00000000..9ef00d83 --- /dev/null +++ b/internal/app/run/setup_test.go @@ -0,0 +1,103 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package run + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + + "gitea.com/gitea/runner/internal/pkg/labels" + "gitea.com/gitea/runner/internal/pkg/ver" + + runnerv1 "gitea.dev/actions-proto-go/runner/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestSetupLines(t *testing.T) { + original := osReleasePath + path := filepath.Join(t.TempDir(), "os-release") + require.NoError(t, os.WriteFile(path, []byte("PRETTY_NAME=\"Ubuntu 24.04.4 LTS\"\n"), 0o600)) + osReleasePath = path + defer func() { osReleasePath = original }() + + r := &Runner{ + name: "gitea-com-gitea-0003", + labels: labels.Labels{ + {Name: "ubuntu-latest", Schema: labels.SchemeDocker, Arg: "//node:20"}, + {Name: "ubuntu-22.04", Schema: labels.SchemeDocker, Arg: "//node:20"}, + }, + } + taskCtx, err := structpb.NewStruct(map[string]any{ + "job": "lint", + "repository": "gitea/runner", + "event_name": "pull_request", + }) + require.NoError(t, err) + + assert.Equal(t, []string{ + "gitea-com-gitea-0003(version:" + ver.Version() + ")", + "::group::Runner Information", + "Runner labels: ubuntu-latest, ubuntu-22.04", + "Task: 268506", + "Job: lint", + "Repository: gitea/runner", + "Triggered by event: pull_request", + "::endgroup::", + "::group::Operating System", + "Ubuntu 24.04.4 LTS", + fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), + "::endgroup::", + }, r.setupLines(&runnerv1.Task{Id: 268506, Context: taskCtx})) +} + +func TestPrettyOSName(t *testing.T) { + tests := map[string]struct { + osRelease string + want string + }{ + "quoted value": { + osRelease: "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\nPRETTY_NAME=\"Ubuntu 24.04.4 LTS\"\n", + want: "Ubuntu 24.04.4 LTS", + }, + "unquoted value": { + osRelease: "PRETTY_NAME=Alpine Linux v3.21\n", + want: "Alpine Linux v3.21", + }, + "no pretty name": { + osRelease: "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\n", + want: "", + }, + // A key that merely ends in PRETTY_NAME must not be mistaken for it. + "similar key": { + osRelease: "IMAGE_PRETTY_NAME=\"Ubuntu Core 24\"\n", + want: "", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "os-release") + require.NoError(t, os.WriteFile(path, []byte(tt.osRelease), 0o600)) + + original := osReleasePath + osReleasePath = path + defer func() { osReleasePath = original }() + + assert.Equal(t, tt.want, prettyOSName()) + }) + } + + t.Run("missing file", func(t *testing.T) { + original := osReleasePath + osReleasePath = filepath.Join(t.TempDir(), "absent") + defer func() { osReleasePath = original }() + + assert.Empty(t, prettyOSName()) + }) +} From 7bec31000208c1f60e4a41862db52d907db782ff Mon Sep 17 00:00:00 2001 From: bircni <bircni@icloud.com> Date: Wed, 22 Jul 2026 15:04:07 +0000 Subject: [PATCH 21/26] fix: stop host-mode jobs from leaking processes on Windows (#1080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem On host-mode Windows runners, a job can leave processes running after it finishes, and those leftovers hold file handles that block deletion of the workspace. Today a step's tree is only torn down when the step is *cancelled* (`process.Killer`). A step that completes leaves whatever it spawned alive, and the existing workspace scan in `terminateRunningProcesses` misses two shapes of leftover: orphans whose parent already exited (no tree to walk, and their executable often lives outside the workspace), and processes that merely *run in* the workspace but reference no path from it β€” `Win32_Process` exposes no working directory, so the scan cannot match them. ## Solution Two additions in `internal/pkg/process`, both no-ops outside Windows: - **`process.Group`** β€” a job-scoped Windows Job Object created with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. Step processes are assigned to it before they get their own `Killer`, so the step's job nests inside the job's: cancellation still kills exactly the step's tree, while `Remove` closing the group makes the kernel terminate everything still assigned, whatever its parentage. The kernel also drops the handle when the runner exits, so a crashed runner cannot strand processes. - **`process.KillProcessesWithCWDUnder`** β€” a best-effort net for processes that never joined the job (started via a service or scheduled task). It reads each process's working directory from its PEB and terminates those under a workspace dir. Processes it cannot open are skipped. --------- Co-authored-by: silverwind <me@silverwind.io> Reviewed-on: https://gitea.com/gitea/runner/pulls/1080 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> --- act/container/host_environment.go | 104 +++++----- internal/pkg/process/group_others.go | 28 +++ internal/pkg/process/group_windows.go | 86 +++++++++ internal/pkg/process/group_windows_test.go | 113 +++++++++++ internal/pkg/process/leftover.go | 29 +++ internal/pkg/process/leftover_others.go | 14 ++ internal/pkg/process/leftover_test.go | 41 ++++ internal/pkg/process/leftover_windows.go | 182 ++++++++++++++++++ internal/pkg/process/leftover_windows_test.go | 91 +++++++++ 9 files changed, 645 insertions(+), 43 deletions(-) create mode 100644 internal/pkg/process/group_others.go create mode 100644 internal/pkg/process/group_windows.go create mode 100644 internal/pkg/process/group_windows_test.go create mode 100644 internal/pkg/process/leftover.go create mode 100644 internal/pkg/process/leftover_others.go create mode 100644 internal/pkg/process/leftover_test.go create mode 100644 internal/pkg/process/leftover_windows.go create mode 100644 internal/pkg/process/leftover_windows_test.go diff --git a/act/container/host_environment.go b/act/container/host_environment.go index bf52e09e..6937e39f 100644 --- a/act/container/host_environment.go +++ b/act/container/host_environment.go @@ -17,6 +17,7 @@ import ( "path/filepath" "runtime" "strings" + "sync" "sync/atomic" "time" @@ -43,6 +44,25 @@ type HostEnvironment struct { CleanUp func() StdOut io.Writer AllocatePTY bool // allocate a pseudo-TTY for each step's process + + // procGroup owns every process the job's steps start. Atomic: Remove may read + // it while a step is still starting. + procGroupOnce sync.Once + procGroup atomic.Pointer[process.Group] +} + +// processGroup returns the job-scoped process group, creating it on first use. +// Returns nil if the job object could not be created; Group is nil-safe. +func (e *HostEnvironment) processGroup(ctx context.Context) *process.Group { + e.procGroupOnce.Do(func() { + group, err := process.NewGroup() + if err != nil { + common.Logger(ctx).Warnf("could not create the job's process group; processes a step leaves behind can only be reclaimed by the workspace scan: %v", err) + return + } + e.procGroup.Store(group) + }) + return e.procGroup.Load() } func (e *HostEnvironment) Create(_, _ []string) common.Executor { @@ -324,11 +344,8 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st cmd.Dir = wd cmd.SysProcAttr = process.SysProcAttr(cmdline, false) - // Kill the step's whole process tree on cancellation (a step often launches a - // shell that spawns further background or GUI children) and bound the post-exit - // I/O wait, so an orphan inheriting cmd's stdout/stderr pipe can never hang - // cmd.Wait() and the runner. See process.TreeKill. The PTY path below may - // override SysProcAttr, but never touches Cancel/WaitDelay. + // Kills the step's whole tree on cancellation and bounds the post-exit I/O + // wait, so an orphan holding cmd's stdout pipe cannot hang cmd.Wait(). treeKill := process.NewTreeKill(cmd) var ppty *os.File @@ -360,6 +377,11 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st if err := cmd.Start(); err != nil { return err } + // Assign before the step's Killer so the step's job nests inside the group's; + // cancellation still scopes to this step's tree. + if err := e.processGroup(ctx).Assign(cmd.Process); err != nil { + common.Logger(ctx).Warnf("could not assign the step's process to the job's process group; a process it leaves behind may outlive the job: %v", err) + } if k, kerr := treeKill.Capture(cmd.Process); kerr != nil { common.Logger(ctx).Warnf("process tree kill setup failed, falling back to single-process kill: %v", kerr) } else { @@ -407,13 +429,12 @@ func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string) return parseEnvFile(e, srcPath, env) } -// removeAll is the filesystem delete used by removeAllWithContext. A package -// var so tests can substitute a blocking stub without patching os.RemoveAll. +// removeAll is a var so tests can substitute a blocking stub. var removeAll = os.RemoveAll -// removeAllWithContext runs removeAll in a goroutine and returns once it -// finishes or ctx is cancelled. On cancellation the goroutine is left running β€” -// a delete blocked inside a syscall cannot be interrupted (see runWithTimeout). +// removeAllWithContext returns once the delete finishes or ctx is cancelled. On +// cancellation the goroutine leaks: a delete inside a syscall cannot be +// interrupted (see runWithTimeout). func removeAllWithContext(ctx context.Context, path string) error { done := make(chan error, 1) go func() { done <- removeAll(path) }() @@ -455,17 +476,12 @@ func removePathWithRetry(ctx context.Context, path string) error { return lastErr } -// buildWindowsWorkspaceKillScript builds a PowerShell command that `taskkill -// /T /F`s every process tree whose ExecutablePath or CommandLine references one -// of the given absolute workspace dirs, releasing file handles for cleanup. -// -// Win32_Process is used because it exposes both ExecutablePath and CommandLine -// (Get-Process doesn't, wmic is deprecated). Both match the dir+separator -// prefix, so a sibling dir sharing a name prefix (job1 vs job10) is spared. -// Ordinal String methods, not -like, so path metacharacters ([ ] ? *) stay -// literal. -// -// Pure function so the quote-escaping can be unit-tested without PowerShell. +// buildWindowsWorkspaceKillScript builds a PowerShell command that taskkills +// every process tree whose ExecutablePath or CommandLine references one of the +// given workspace dirs, releasing file handles for cleanup. Win32_Process +// exposes both fields (Get-Process doesn't, wmic is deprecated); matching is on +// the dir+separator prefix via ordinal String methods, so a name-prefix sibling +// (job1 vs job10) is spared and path metacharacters stay literal. func buildWindowsWorkspaceKillScript(dirs []string) string { quoted := make([]string, len(dirs)) for i, d := range dirs { @@ -501,9 +517,8 @@ func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) { logger := common.Logger(ctx) - // Workspace dirs we own. Any process running from or referencing one is a - // leftover job process. ToolCache is shared across jobs; Workdir only when - // we own it (else it's a caller-provided checkout, e.g. act local mode). + // Dirs we own; a process referencing one is a leftover. ToolCache is shared + // across jobs, and Workdir may be a caller-owned checkout. owned := []string{e.Path, e.TmpDir} if e.CleanWorkdir { owned = append(owned, e.Workdir) @@ -530,21 +545,24 @@ func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) { if err != nil { logger.Debugf("workspace process-tree kill via PowerShell failed: %v output=%s", err, strings.TrimSpace(string(out))) } + + // Win32_Process exposes no working directory, so the scan above misses a + // process that merely runs in a workspace dir while pinning a handle on it. + if killed, err := process.KillProcessesWithCWDUnder(killCtx, dirs); err != nil { + logger.Debugf("workspace process kill by working directory reported errors: %v", err) + } else if killed > 0 { + logger.Debugf("terminated %d leftover process(es) by workspace working directory", killed) + } } -// hostCleanupTimeout bounds each filesystem-teardown phase of the host -// environment so a single stalled delete cannot wedge the runner slot forever. -// A var (not const) so tests can shrink it. +// hostCleanupTimeout bounds each teardown phase so one stalled delete cannot +// wedge the runner slot. A var so tests can shrink it. var hostCleanupTimeout = 30 * time.Second -// runWithTimeout runs fn in a goroutine and returns once it finishes or timeout -// elapses, whichever comes first. On timeout the goroutine is left running β€” an -// os.RemoveAll blocked inside a delete syscall (AV/EDR filter drivers, an -// unresponsive network mount, a dying disk) cannot be interrupted β€” and -// context.DeadlineExceeded is returned. Leaking the goroutine and the scratch -// state it was deleting is strictly better than blocking the caller forever and -// permanently losing the runner's capacity slot; the leaked scratch dir is -// reclaimed later by the runner's idle stale-dir sweep. +// runWithTimeout returns context.DeadlineExceeded once timeout elapses, leaking +// the goroutine: a delete blocked in a syscall (AV filter driver, dead network +// mount) cannot be interrupted, and leaking scratch state beats losing the +// runner's capacity slot forever. The idle stale-dir sweep reclaims it later. func runWithTimeout(fn func(), timeout time.Duration) error { done := make(chan struct{}) go func() { @@ -565,14 +583,15 @@ func (e *HostEnvironment) Remove() common.Executor { return func(ctx context.Context) error { logger := common.Logger(ctx) - // Ensure any lingering child processes are ended before attempting - // to remove the workspace (Windows file locks otherwise prevent cleanup). + // End lingering processes before removing the workspace; on Windows their + // file locks block cleanup. Closing the group is deterministic, the scan a net. + if err := e.procGroup.Load().Close(); err != nil { + logger.Debugf("closing the job's process group failed: %v", err) + } e.terminateRunningProcesses(ctx) - // Only removes per-job misc state. Must not remove the cache/toolcache root. - // Bound it: CleanUp is a caller-supplied, typically unbounded os.RemoveAll, - // and a delete stalled by a filesystem filter driver would otherwise hang - // the job forever at "Cleaning up container" and hold the capacity slot. + // Removes per-job misc state only, never the toolcache root. Bounded because + // CleanUp is a caller-supplied, typically unbounded os.RemoveAll. if e.CleanUp != nil { logger.Debugf("running host environment cleanup callback") if err := runWithTimeout(e.CleanUp, hostCleanupTimeout); err != nil { @@ -603,8 +622,7 @@ func (e *HostEnvironment) Remove() common.Executor { return errors.Join(errs...) } } - // Bounded teardown timed out; warnings already logged above. Do not - // fail job completion β€” leaked scratch is reclaimed by the idle sweep. + // Teardown timed out; warned above. Do not fail job completion over it. return nil } } diff --git a/internal/pkg/process/group_others.go b/internal/pkg/process/group_others.go new file mode 100644 index 00000000..11d2a9b6 --- /dev/null +++ b/internal/pkg/process/group_others.go @@ -0,0 +1,28 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !windows + +package process + +import "os" + +// Group is a no-op outside Windows: process groups already reclaim a step's +// tree, and an orphan does not block workspace deletion the way an open Windows +// handle does. +type Group struct{} + +// NewGroup returns a Group that owns nothing. +func NewGroup() (*Group, error) { + return &Group{}, nil +} + +// Assign does nothing. +func (g *Group) Assign(_ *os.Process) error { + return nil +} + +// Close does nothing. +func (g *Group) Close() error { + return nil +} diff --git a/internal/pkg/process/group_windows.go b/internal/pkg/process/group_windows.go new file mode 100644 index 00000000..6c9e719a --- /dev/null +++ b/internal/pkg/process/group_windows.go @@ -0,0 +1,86 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package process + +import ( + "os" + "sync" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Group is a job-scoped Windows Job Object holding every process the job's steps +// start; closing it terminates whatever is still assigned, reaching orphans the +// per-step Killer misses (a completed step's leftover has no parent to walk from). +type Group struct { + mu sync.Mutex + job windows.Handle +} + +// NewGroup creates the job object. Closing it is what terminates the leftovers, +// so the caller must Close it when the job ends. +func NewGroup() (*Group, error) { + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return nil, err + } + + info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{ + BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{ + LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }, + } + if _, err := windows.SetInformationJobObject( + job, + windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ); err != nil { + _ = windows.CloseHandle(job) + return nil, err + } + + return &Group{job: job}, nil +} + +// Assign adds a started process to the job; anything it spawns afterwards joins +// too. Call before NewKiller so the step's job nests inside this one. Nil-safe. +func (g *Group) Assign(p *os.Process) error { + if g == nil || p == nil { + return nil + } + + g.mu.Lock() + defer g.mu.Unlock() + if g.job == 0 { + return nil + } + + h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(p.Pid)) + if err != nil { + return err + } + defer func() { _ = windows.CloseHandle(h) }() + + return windows.AssignProcessToJobObject(g.job, h) +} + +// Close drops the job handle, terminating every process still assigned to it. +// Nil-safe and idempotent. +func (g *Group) Close() error { + if g == nil { + return nil + } + + g.mu.Lock() + defer g.mu.Unlock() + if g.job == 0 { + return nil + } + + h := g.job + g.job = 0 + return windows.CloseHandle(h) +} diff --git a/internal/pkg/process/group_windows_test.go b/internal/pkg/process/group_windows_test.go new file mode 100644 index 00000000..7f4a1a38 --- /dev/null +++ b/internal/pkg/process/group_windows_test.go @@ -0,0 +1,113 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package process + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// startOrphanMaker starts a process that spawns a detached, long-lived child and +// exits, leaving the child parentless. Returns the cmd and the child's PID file. +func startOrphanMaker(t *testing.T) (*exec.Cmd, string) { + t.Helper() + + pidFile := filepath.Join(t.TempDir(), "child.pid") + script := fmt.Sprintf( + `$c = Start-Process powershell -PassThru -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 600'; `+ + `Set-Content -LiteralPath %q -Value $c.Id`, pidFile) + + cmd := exec.Command("powershell.exe", "-NoProfile", "-Command", script) + require.NoError(t, cmd.Start()) + t.Cleanup(func() { _ = cmd.Process.Kill() }) + + return cmd, pidFile +} + +// awaitChildPID waits until the spawned child has reported its PID and is running. +func awaitChildPID(t *testing.T, pidFile string) int { + t.Helper() + + var childPID int + require.Eventually(t, func() bool { + b, err := os.ReadFile(pidFile) + if err != nil { + return false + } + s := strings.TrimSpace(string(b)) + if s == "" { + return false + } + childPID, _ = strconv.Atoi(s) + return childPID > 0 && processAlive(childPID) + }, 20*time.Second, 200*time.Millisecond, "child process should start") + + return childPID +} + +// TestGroupClosePropagatesToOrphan covers what the per-step Killer cannot: a +// completed step's orphan, reachable only by closing the job object. +func TestGroupClosePropagatesToOrphan(t *testing.T) { + group, err := NewGroup() + require.NoError(t, err) + t.Cleanup(func() { _ = group.Close() }) + + cmd, pidFile := startOrphanMaker(t) + require.NoError(t, group.Assign(cmd.Process)) + + childPID := awaitChildPID(t, pidFile) + + require.NoError(t, cmd.Wait()) // the step process exits cleanly, like a passing step + require.Eventually(t, func() bool { + return !processAlive(cmd.Process.Pid) + }, 20*time.Second, 200*time.Millisecond, "the step process should have exited on its own") + + require.True(t, processAlive(childPID), "orphan should outlive its parent") + + require.NoError(t, group.Close()) + require.Eventually(t, func() bool { + return !processAlive(childPID) + }, 20*time.Second, 200*time.Millisecond, "closing the job must terminate the orphan") +} + +// TestGroupNestsWithKiller: assigned to the group first and the step's Killer +// second, cancelling the step still kills exactly that step's tree. +func TestGroupNestsWithKiller(t *testing.T) { + group, err := NewGroup() + require.NoError(t, err) + t.Cleanup(func() { _ = group.Close() }) + + cmd := exec.Command("powershell.exe", "-NoProfile", "-Command", "Start-Sleep -Seconds 600") + require.NoError(t, cmd.Start()) + t.Cleanup(func() { _ = cmd.Process.Kill() }) + + require.NoError(t, group.Assign(cmd.Process)) + + // Nesting must be accepted, else cancellation falls back to a single-process kill. + killer, err := NewKiller(cmd.Process) + require.NoError(t, err, "the step's job object must nest inside the group's") + defer killer.Close() + + require.NoError(t, killer.Kill()) + require.Eventually(t, func() bool { + return !processAlive(cmd.Process.Pid) + }, 20*time.Second, 200*time.Millisecond, "cancelling the step should still kill its tree") +} + +// TestGroupNilSafe covers the fallback path: when the job object cannot be +// created the caller holds a nil *Group and must still be able to use it. +func TestGroupNilSafe(t *testing.T) { + var group *Group + require.NoError(t, group.Assign(nil)) + require.NoError(t, group.Close()) + require.NoError(t, group.Close()) +} diff --git a/internal/pkg/process/leftover.go b/internal/pkg/process/leftover.go new file mode 100644 index 00000000..396ab10b --- /dev/null +++ b/internal/pkg/process/leftover.go @@ -0,0 +1,29 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package process + +import ( + "path/filepath" + "strings" +) + +// pathWithin is the OS-agnostic core of dirContainsPath: dir and target must +// already be cleaned, and fold folds case. +func pathWithin(dir, target string, fold bool) bool { + if dir == "" || target == "" || dir == "." || target == "." { + return false + } + if fold { + dir = strings.ToLower(dir) + target = strings.ToLower(target) + } + if dir == target { + return true + } + sep := string(filepath.Separator) + if !strings.HasSuffix(dir, sep) { + dir += sep + } + return strings.HasPrefix(target, dir) +} diff --git a/internal/pkg/process/leftover_others.go b/internal/pkg/process/leftover_others.go new file mode 100644 index 00000000..24778310 --- /dev/null +++ b/internal/pkg/process/leftover_others.go @@ -0,0 +1,14 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !windows + +package process + +import "context" + +// KillProcessesWithCWDUnder is a no-op outside Windows: an open handle does not +// block os.RemoveAll there. +func KillProcessesWithCWDUnder(_ context.Context, _ []string) (int, error) { + return 0, nil +} diff --git a/internal/pkg/process/leftover_test.go b/internal/pkg/process/leftover_test.go new file mode 100644 index 00000000..b547a4ad --- /dev/null +++ b/internal/pkg/process/leftover_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package process + +import ( + "path/filepath" + "testing" +) + +func TestPathWithin(t *testing.T) { + base := filepath.Join("base", "job1") + + cases := []struct { + name string + dir string + target string + fold bool + want bool + }{ + {"same dir", base, base, false, true}, + {"direct child", base, filepath.Join(base, "app.exe"), false, true}, + {"deep child", base, filepath.Join(base, "sub", "sub", "app.exe"), false, true}, + {"parent is not within child", filepath.Join(base, "sub"), base, false, false}, + {"name-prefix sibling spared", base, filepath.Join("base", "job10", "app.exe"), false, false}, + {"unrelated", base, filepath.Join("other", "app.exe"), false, false}, + {"empty dir", "", base, false, false}, + {"empty target", base, "", false, false}, + {"dot dir", ".", base, false, false}, + {"case-sensitive miss", base, filepath.Join("base", "JOB1", "app.exe"), false, false}, + {"case-insensitive hit", base, filepath.Join("base", "JOB1", "app.exe"), true, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := pathWithin(filepath.Clean(tc.dir), filepath.Clean(tc.target), tc.fold) + if got != tc.want { + t.Fatalf("pathWithin(%q, %q, fold=%v) = %v, want %v", tc.dir, tc.target, tc.fold, got, tc.want) + } + }) + } +} diff --git a/internal/pkg/process/leftover_windows.go b/internal/pkg/process/leftover_windows.go new file mode 100644 index 00000000..ae1b5f17 --- /dev/null +++ b/internal/pkg/process/leftover_windows.go @@ -0,0 +1,182 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package process + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "unsafe" + + "golang.org/x/sys/windows" +) + +// dirContainsPath reports whether target is dir or lives beneath it, +// case-insensitively, so a name-prefix sibling (job1 vs job10) is not a match. +func dirContainsPath(dir, target string) bool { + return pathWithin(filepath.Clean(dir), filepath.Clean(target), true) +} + +// KillProcessesWithCWDUnder terminates every process in the runner's session +// whose cwd is one of dirs or below, catching leftovers the path scan misses +// (Win32_Process exposes no cwd) that still pin a handle. Best-effort. +func KillProcessesWithCWDUnder(ctx context.Context, dirs []string) (int, error) { + if len(dirs) == 0 { + return 0, nil + } + + pids, err := listProcessIDs() + if err != nil { + return 0, err + } + + self := uint32(os.Getpid()) + var ownSession uint32 + if err := windows.ProcessIdToSessionId(self, &ownSession); err != nil { + return 0, fmt.Errorf("look up own session: %w", err) + } + + var killed int + var errs []error + for _, pid := range pids { + if ctx.Err() != nil { + break + } + // Never touch ourselves, System Idle (0) or System (4). + if pid == self || pid == 0 || pid == 4 { + continue + } + var session uint32 + if err := windows.ProcessIdToSessionId(pid, &session); err != nil || session != ownSession { + continue + } + ok, err := terminateIfCWDUnder(pid, dirs) + if err != nil { + errs = append(errs, err) + continue + } + if ok { + killed++ + } + } + + return killed, errors.Join(errs...) +} + +// terminateIfCWDUnder opens pid once and both reads its cwd and terminates it +// through that one handle, so a PID recycled between the two cannot be hit. +func terminateIfCWDUnder(pid uint32, dirs []string) (bool, error) { + h, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ|windows.PROCESS_TERMINATE, false, pid) + if err != nil { + return false, nil // best-effort: gone, higher integrity, or another user + } + defer func() { _ = windows.CloseHandle(h) }() + + cwd, err := processCWDHandle(h) + if err != nil || cwd == "" { + return false, nil + } + + for _, d := range dirs { + if dirContainsPath(d, cwd) { + if err := windows.TerminateProcess(h, 1); err != nil { + return false, fmt.Errorf("terminate pid %d: %w", pid, err) + } + return true, nil + } + } + return false, nil +} + +// listProcessIDs returns the PIDs of every process in a Toolhelp snapshot. +func listProcessIDs() ([]uint32, error) { + snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, err + } + defer func() { _ = windows.CloseHandle(snap) }() + + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + + pids := make([]uint32, 0, 256) + err = windows.Process32First(snap, &entry) + for err == nil { + pids = append(pids, entry.ProcessID) + err = windows.Process32Next(snap, &entry) + } + if !errors.Is(err, windows.ERROR_NO_MORE_FILES) { + return pids, err + } + return pids, nil +} + +// processCWDHandle reads a process's working directory from its PEB (walking +// PEB -> ProcessParameters -> CurrentDirectory.DosPath). h must carry +// PROCESS_QUERY_INFORMATION|PROCESS_VM_READ. +func processCWDHandle(h windows.Handle) (string, error) { + var pbi windows.PROCESS_BASIC_INFORMATION + var retLen uint32 + if err := windows.NtQueryInformationProcess(h, windows.ProcessBasicInformation, unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), &retLen); err != nil { + return "", err + } + if pbi.PebBaseAddress == nil { + return "", errors.New("nil PEB base address") + } + pebAddr := uintptr(unsafe.Pointer(pbi.PebBaseAddress)) + + paramsAddr, err := readRemotePtr(h, pebAddr+unsafe.Offsetof(windows.PEB{}.ProcessParameters)) + if err != nil || paramsAddr == 0 { + return "", err + } + + // DosPath is the leading NTUnicodeString of CURDIR, so it shares CurrentDirectory's address. + dosPathAddr := paramsAddr + unsafe.Offsetof(windows.RTL_USER_PROCESS_PARAMETERS{}.CurrentDirectory) + length, err := readRemoteU16(h, dosPathAddr+unsafe.Offsetof(windows.NTUnicodeString{}.Length)) + if err != nil || length == 0 { + return "", err + } + bufAddr, err := readRemotePtr(h, dosPathAddr+unsafe.Offsetof(windows.NTUnicodeString{}.Buffer)) + if err != nil || bufAddr == 0 { + return "", err + } + + u16 := make([]uint16, (length+1)/2) // round up so an odd Length cannot overflow the buffer + var n uintptr + if err := windows.ReadProcessMemory(h, bufAddr, (*byte)(unsafe.Pointer(&u16[0])), uintptr(length), &n); err != nil { + return "", err + } + if n != uintptr(length) { + return "", errors.New("short read of working directory") + } + return windows.UTF16ToString(u16), nil +} + +// readRemotePtr reads a single pointer-sized value from another process. +func readRemotePtr(h windows.Handle, addr uintptr) (uintptr, error) { + var v uintptr + var n uintptr + if err := windows.ReadProcessMemory(h, addr, (*byte)(unsafe.Pointer(&v)), unsafe.Sizeof(v), &n); err != nil { + return 0, err + } + if n != unsafe.Sizeof(v) { + return 0, errors.New("short pointer read") + } + return v, nil +} + +// readRemoteU16 reads a single uint16 from another process. +func readRemoteU16(h windows.Handle, addr uintptr) (uint16, error) { + var v uint16 + var n uintptr + if err := windows.ReadProcessMemory(h, addr, (*byte)(unsafe.Pointer(&v)), unsafe.Sizeof(v), &n); err != nil { + return 0, err + } + if n != unsafe.Sizeof(v) { + return 0, errors.New("short uint16 read") + } + return v, nil +} diff --git a/internal/pkg/process/leftover_windows_test.go b/internal/pkg/process/leftover_windows_test.go new file mode 100644 index 00000000..9748024a --- /dev/null +++ b/internal/pkg/process/leftover_windows_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package process + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// startSleeperIn launches a long-running child whose cwd is dir but whose +// executable lives outside it β€” what the path scan cannot see. +func startSleeperIn(t *testing.T, dir string) *exec.Cmd { + t.Helper() + ping := filepath.Join(os.Getenv("SystemRoot"), "System32", "ping.exe") + cmd := exec.Command(ping, "-n", "60", "127.0.0.1") + cmd.Dir = dir + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + require.NoError(t, cmd.Start()) + t.Cleanup(func() { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + }) + return cmd +} + +// processCWD opens pid and reads its working directory. Test-only; the reaper +// shares one handle across read and kill via processCWDHandle. +func processCWD(pid uint32) (string, error) { + h, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, pid) + if err != nil { + return "", err + } + defer func() { _ = windows.CloseHandle(h) }() + return processCWDHandle(h) +} + +// awaitCWD waits until pid's PEB reports a readable cwd (not populated the +// instant the process starts) and returns it. +func awaitCWD(t *testing.T, pid int) string { + t.Helper() + var cwd string + require.Eventually(t, func() bool { + var err error + cwd, err = processCWD(uint32(pid)) + return err == nil && cwd != "" + }, 5*time.Second, 50*time.Millisecond, "process cwd should become readable") + return cwd +} + +func TestProcessCWDReadsWorkingDirectory(t *testing.T) { + dir := t.TempDir() + cmd := startSleeperIn(t, dir) + cwd := awaitCWD(t, cmd.Process.Pid) + require.True(t, dirContainsPath(dir, cwd), "cwd %q should be within %q", cwd, dir) +} + +func TestKillProcessesWithCWDUnderTerminatesMatch(t *testing.T) { + dir := t.TempDir() + // Executable outside dir and arguments carry no workspace path, so only the + // working directory links this process to dir. + cmd := startSleeperIn(t, dir) + awaitCWD(t, cmd.Process.Pid) + + killed, err := KillProcessesWithCWDUnder(context.Background(), []string{dir}) + require.NoError(t, err) + require.GreaterOrEqual(t, killed, 1) + + require.Eventually(t, func() bool { + return !processAlive(cmd.Process.Pid) + }, 5*time.Second, 50*time.Millisecond, "matched process should exit") +} + +func TestKillProcessesWithCWDUnderSparesUnrelated(t *testing.T) { + workspace := t.TempDir() + elsewhere := t.TempDir() + cmd := startSleeperIn(t, elsewhere) + awaitCWD(t, cmd.Process.Pid) + + _, err := KillProcessesWithCWDUnder(context.Background(), []string{workspace}) + require.NoError(t, err) + require.True(t, processAlive(cmd.Process.Pid), "process outside owned dirs must be spared") +} From c43cbe87ca95efd12eee221b5640646f24e6a511 Mon Sep 17 00:00:00 2001 From: bircni <bircni@icloud.com> Date: Wed, 22 Jul 2026 15:10:45 +0000 Subject: [PATCH 22/26] feat: add runner health admission checks (#1090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in local task-admission checks under a `health_check` config section (disabled by default): - pause new task fetching when free disk space on the workspace volume is below the configured minimum - optional executable health-check script β€” a non-zero exit, timeout, or start failure marks the runner unavailable - checks run only while the runner is idle; the last result is reused while a job is active, and polling resumes automatically on recovery - `/readyz` reports task-admission readiness (reusing the poll loop's last check); `/healthz` stays a process-liveness endpoint --------- Co-authored-by: silverwind <me@silverwind.io> Reviewed-on: https://gitea.com/gitea/runner/pulls/1090 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> --- internal/app/cmd/daemon.go | 8 +- internal/app/poll/poller.go | 89 +++++++++++++- internal/app/poll/poller_test.go | 75 ++++++++++++ internal/app/run/disk_other.go | 12 ++ internal/app/run/disk_test.go | 53 +++++++++ internal/app/run/disk_unix.go | 16 +++ internal/app/run/disk_windows.go | 20 ++++ internal/app/run/health_check.go | 106 +++++++++++++++++ internal/app/run/health_check_test.go | 148 ++++++++++++++++++++++++ internal/app/run/runner.go | 83 +++++++++++-- internal/pkg/config/config.example.yaml | 20 +++- internal/pkg/config/config.go | 40 +++++-- internal/pkg/config/config_test.go | 35 ++++++ internal/pkg/metrics/metrics_test.go | 2 +- internal/pkg/metrics/server.go | 35 ++++-- internal/pkg/metrics/server_test.go | 43 +++++++ 16 files changed, 753 insertions(+), 32 deletions(-) create mode 100644 internal/app/run/disk_other.go create mode 100644 internal/app/run/disk_test.go create mode 100644 internal/app/run/disk_unix.go create mode 100644 internal/app/run/disk_windows.go create mode 100644 internal/app/run/health_check.go create mode 100644 internal/app/run/health_check_test.go create mode 100644 internal/pkg/metrics/server_test.go diff --git a/internal/app/cmd/daemon.go b/internal/app/cmd/daemon.go index a4b5a0a6..2ed6f70e 100644 --- a/internal/app/cmd/daemon.go +++ b/internal/app/cmd/daemon.go @@ -147,17 +147,19 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu } runner.SetCapabilitiesFromDeclare(resp) + poller := poll.New(cfg, cli, runner) + if cfg.Metrics.Enabled { metrics.Init() metrics.RunnerInfo.WithLabelValues(ver.Version(), resp.Msg.Runner.Name).Set(1) metrics.RunnerCapacity.Set(float64(cfg.Runner.Capacity)) metrics.RegisterUptimeFunc(time.Now()) metrics.RegisterRunningJobsFunc(runner.RunningCount, cfg.Runner.Capacity) - metrics.StartServer(ctx, cfg.Metrics.Addr) + metrics.StartServer(ctx, cfg.Metrics.Addr, func() (bool, string) { + return poller.Ready(cfg.Metrics.ReadinessGrace) + }) } - poller := poll.New(cfg, cli, runner) - if daemArgs.Once || reg.Ephemeral { done := make(chan struct{}) go func() { diff --git a/internal/app/poll/poller.go b/internal/app/poll/poller.go index 37057afd..b41d3ceb 100644 --- a/internal/app/poll/poller.go +++ b/internal/app/poll/poller.go @@ -32,6 +32,12 @@ type IdleRunner interface { OnIdle(ctx context.Context) } +// AvailabilityRunner can temporarily pause task fetching for local resource +// conditions such as low disk space without changing server-side scheduling. +type AvailabilityRunner interface { + CanAcceptTask(ctx context.Context) (bool, string) +} + type Poller struct { client client.Client runner TaskRunner @@ -48,7 +54,12 @@ type Poller struct { // unregistered is set when the server rejects the runner with an // Unauthenticated response, meaning the runner is no longer registered. - unregistered atomic.Bool + unregistered atomic.Bool + lastHealthyPoll atomic.Int64 + lastPollFailed atomic.Bool + availabilityMu sync.Mutex + availabilityReady bool + availabilityReason string } // workerState holds the single poller's backoff state. Consecutive empty or @@ -70,7 +81,7 @@ func New(cfg *config.Config, client client.Client, runner TaskRunner) *Poller { done := make(chan struct{}) - return &Poller{ + p := &Poller{ client: client, runner: runner, cfg: cfg, @@ -83,6 +94,10 @@ func New(cfg *config.Config, client client.Client, runner TaskRunner) *Poller { done: done, } + p.lastHealthyPoll.Store(time.Now().UnixNano()) + p.availabilityReady = true + p.availabilityReason = "ok" + return p } func (p *Poller) Poll() { @@ -102,6 +117,17 @@ func (p *Poller) Poll() { return } + ready, reason := p.localAvailability(p.pollingCtx) + p.reportAvailability(ready, reason) + if !ready { + p.runIdleMaintenance() + <-sem + if !p.waitBackoff(s) { + return + } + continue + } + task, ok := p.fetchTask(p.pollingCtx, s) if !ok { p.runIdleMaintenance() @@ -127,6 +153,15 @@ func (p *Poller) PollOnce() { defer close(p.done) s := &workerState{} for { + ready, reason := p.localAvailability(p.pollingCtx) + p.reportAvailability(ready, reason) + if !ready { + p.runIdleMaintenance() + if !p.waitBackoff(s) { + return + } + continue + } task, ok := p.fetchTask(p.pollingCtx, s) if !ok { p.runIdleMaintenance() @@ -154,6 +189,48 @@ func (p *Poller) Unregistered() bool { return p.unregistered.Load() } +// Ready reports whether the daemon can currently communicate with Gitea and +// accept work, reusing the availability the poll loop last observed rather than +// re-running the check. Transient transport failures are tolerated for grace. +func (p *Poller) Ready(grace time.Duration) (bool, string) { + if p.unregistered.Load() { + return false, "runner is no longer registered" + } + p.availabilityMu.Lock() + ready, reason := p.availabilityReady, p.availabilityReason + p.availabilityMu.Unlock() + if !ready { + return false, reason + } + if !p.lastPollFailed.Load() { + return true, "ok" + } + if time.Since(time.Unix(0, p.lastHealthyPoll.Load())) <= grace { + return true, "polling errors within grace period" + } + return false, "unable to poll Gitea" +} + +func (p *Poller) localAvailability(ctx context.Context) (bool, string) { + if available, ok := p.runner.(AvailabilityRunner); ok { + return available.CanAcceptTask(ctx) + } + return true, "ok" +} + +func (p *Poller) reportAvailability(ready bool, reason string) { + p.availabilityMu.Lock() + defer p.availabilityMu.Unlock() + switch { + case !ready && p.availabilityReady: + log.Warnf("runner temporarily unavailable: %s", reason) + case ready && !p.availabilityReady: + log.Info("runner local health recovered, resuming task polling") + } + p.availabilityReady = ready + p.availabilityReason = reason +} + func (p *Poller) runIdleMaintenance() { if idleRunner, ok := p.runner.(IdleRunner); ok { idleRunner.OnIdle(p.jobsCtx) @@ -273,6 +350,7 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task, // found no work within FetchTimeout. Treat it as an empty response and do // not record the duration β€” the timeout value would swamp the histogram. if errors.Is(err, context.DeadlineExceeded) { + p.markHealthyPoll() s.consecutiveEmpty++ s.consecutiveErrors = 0 // timeout is a healthy idle response metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc() @@ -291,11 +369,13 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task, return nil, false } log.WithError(err).Error("failed to fetch task") + p.lastPollFailed.Store(true) s.consecutiveErrors++ metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultError).Inc() metrics.ClientErrors.WithLabelValues(metrics.LabelMethodFetchTask).Inc() return nil, false } + p.markHealthyPoll() // Successful response β€” reset error counter. s.consecutiveErrors = 0 @@ -322,3 +402,8 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task, metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultTask).Inc() return resp.Msg.Task, true } + +func (p *Poller) markHealthyPoll() { + p.lastHealthyPoll.Store(time.Now().UnixNano()) + p.lastPollFailed.Store(false) +} diff --git a/internal/app/poll/poller_test.go b/internal/app/poll/poller_test.go index 92ca8fad..cb28ba67 100644 --- a/internal/app/poll/poller_test.go +++ b/internal/app/poll/poller_test.go @@ -159,6 +159,81 @@ type idleAwareRunner struct { idleCalls atomic.Int64 } +type availabilityRunner struct { + mockRunner + ready atomic.Bool + reason string +} + +func (r *availabilityRunner) CanAcceptTask(_ context.Context) (bool, string) { + if r.ready.Load() { + return true, "ok" + } + return false, r.reason +} + +func TestPollerReady(t *testing.T) { + cfg, err := config.LoadDefault("") + require.NoError(t, err) + poller := New(cfg, nil, &availabilityRunner{}) + + // /readyz reuses the availability the poll loop last recorded. + ready, reason := poller.Ready(time.Second) + assert.True(t, ready) + assert.Equal(t, "ok", reason) + + poller.reportAvailability(false, "low disk space") + ready, reason = poller.Ready(time.Second) + assert.False(t, ready) + assert.Equal(t, "low disk space", reason) + + poller.reportAvailability(true, "ok") + poller.lastPollFailed.Store(true) + poller.lastHealthyPoll.Store(time.Now().UnixNano()) + ready, _ = poller.Ready(time.Second) + assert.True(t, ready, "transient polling errors should remain ready during grace") + + poller.lastHealthyPoll.Store(time.Now().Add(-2 * time.Second).UnixNano()) + ready, reason = poller.Ready(time.Second) + assert.False(t, ready) + assert.Equal(t, "unable to poll Gitea", reason) + + poller.unregistered.Store(true) + ready, reason = poller.Ready(time.Second) + assert.False(t, ready) + assert.Equal(t, "runner is no longer registered", reason) +} + +func TestPollerPausesAndResumesForLocalAvailability(t *testing.T) { + var fetches atomic.Int64 + cli := mocks.NewClient(t) + cli.On("FetchTask", mock.Anything, mock.Anything).Maybe().Run(func(mock.Arguments) { + fetches.Add(1) + }).Return(connect_go.NewResponse(&runnerv1.FetchTaskResponse{}), nil) + + cfg, err := config.LoadDefault("") + require.NoError(t, err) + cfg.Runner.FetchInterval = 10 * time.Millisecond + cfg.Runner.FetchIntervalMax = 10 * time.Millisecond + runner := &availabilityRunner{reason: "low disk space"} + poller := New(cfg, cli, runner) + + var wg sync.WaitGroup + wg.Go(poller.Poll) + time.Sleep(40 * time.Millisecond) + assert.Zero(t, fetches.Load(), "an unavailable runner must not fetch a task") + + runner.ready.Store(true) + require.Eventually(t, func() bool { + return fetches.Load() > 0 + }, time.Second, 10*time.Millisecond, "polling should resume after local recovery") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + require.NoError(t, poller.Shutdown(shutdownCtx)) + wg.Wait() +} + func (m *mockRunner) Run(ctx context.Context, _ *runnerv1.Task) error { atomicMax(&m.maxConcurrent, m.running.Add(1)) select { diff --git a/internal/app/run/disk_other.go b/internal/app/run/disk_other.go new file mode 100644 index 00000000..2fa0d06b --- /dev/null +++ b/internal/app/run/disk_other.go @@ -0,0 +1,12 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !windows + +package run + +import "fmt" + +func freeDiskBytes(path string) (uint64, error) { + return 0, fmt.Errorf("free disk space checks are not supported for %s", path) +} diff --git a/internal/app/run/disk_test.go b/internal/app/run/disk_test.go new file mode 100644 index 00000000..07851a9c --- /dev/null +++ b/internal/app/run/disk_test.go @@ -0,0 +1,53 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package run + +import ( + "testing" + + "gitea.com/gitea/runner/internal/pkg/config" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCanAcceptTaskDiskGuard(t *testing.T) { + cfg, err := config.LoadDefault("") + require.NoError(t, err) + cfg.Host.WorkdirParent = t.TempDir() + cfg.HealthCheck.Enabled = true + r := &Runner{cfg: cfg} + + ready, _ := r.CanAcceptTask(t.Context()) + assert.True(t, ready) + + cfg.HealthCheck.MinFreeDiskSpaceMB = 1 << 40 + ready, reason := r.CanAcceptTask(t.Context()) + assert.False(t, ready) + assert.Contains(t, reason, "low disk space") +} + +func TestDiskCheckDeferredWhileJobRuns(t *testing.T) { + cfg, err := config.LoadDefault("") + require.NoError(t, err) + cfg.HealthCheck.Enabled = true + cfg.HealthCheck.MinFreeDiskSpaceMB = 1 + cfg.Host.WorkdirParent = t.TempDir() + r := &Runner{cfg: cfg} + + ready, reason := r.CanAcceptTask(t.Context()) + assert.True(t, ready) + assert.Equal(t, "ok", reason) + + cfg.HealthCheck.MinFreeDiskSpaceMB = 1 << 40 + r.runningCount.Store(1) + ready, reason = r.CanAcceptTask(t.Context()) + assert.True(t, ready, "the disk check must not run while a job is active") + assert.Equal(t, "ok", reason) + + r.runningCount.Store(0) + ready, reason = r.CanAcceptTask(t.Context()) + assert.False(t, ready) + assert.Contains(t, reason, "low disk space") +} diff --git a/internal/app/run/disk_unix.go b/internal/app/run/disk_unix.go new file mode 100644 index 00000000..7c7294c8 --- /dev/null +++ b/internal/app/run/disk_unix.go @@ -0,0 +1,16 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris + +package run + +import "golang.org/x/sys/unix" + +func freeDiskBytes(path string) (uint64, error) { + var stat unix.Statfs_t + if err := unix.Statfs(path, &stat); err != nil { + return 0, err + } + return stat.Bavail * uint64(stat.Bsize), nil +} diff --git a/internal/app/run/disk_windows.go b/internal/app/run/disk_windows.go new file mode 100644 index 00000000..e3a08a68 --- /dev/null +++ b/internal/app/run/disk_windows.go @@ -0,0 +1,20 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build windows + +package run + +import "golang.org/x/sys/windows" + +func freeDiskBytes(path string) (uint64, error) { + pathPtr, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + var available uint64 + if err := windows.GetDiskFreeSpaceEx(pathPtr, &available, nil, nil); err != nil { + return 0, err + } + return available, nil +} diff --git a/internal/app/run/health_check.go b/internal/app/run/health_check.go new file mode 100644 index 00000000..5b00ee50 --- /dev/null +++ b/internal/app/run/health_check.go @@ -0,0 +1,106 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package run + +import ( + "context" + "errors" + "fmt" + "maps" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "gitea.com/gitea/runner/act/common" + "gitea.com/gitea/runner/internal/pkg/process" +) + +func (r *Runner) checkConfiguredHealth(ctx context.Context) (bool, string) { + script := r.cfg.HealthCheck.Script + if script == "" { + return true, "ok" + } + + now := time.Now() + if r.now != nil { + now = r.now() + } + if !r.healthCheckLast.IsZero() && now.Sub(r.healthCheckLast) < r.cfg.HealthCheck.Interval { + return r.healthCheckReady, r.healthCheckReason + } + + env := processEnvironment() + maps.Copy(env, r.cloneEnvs()) + env["GITEA_RUNNER_HEALTH_CHECK"] = "true" + env["GITEA_RUNNER_NAME"] = r.name + if r.client != nil { + env["GITEA_INSTANCE_URL"] = r.client.Address() + } + env["GITEA_RUNNER_RUNNING_JOBS"] = strconv.FormatInt(r.RunningCount(), 10) + + runner := r.runHealthCheck + if runner == nil { + runner = executeHealthCheck + } + err := runner(ctx, script, r.cfg.HealthCheck.Timeout, env) + r.healthCheckLast = now + r.healthCheckReady = err == nil + if err != nil { + r.healthCheckReason = "runner health check failed: " + err.Error() + } else { + r.healthCheckReason = "ok" + } + return r.healthCheckReady, r.healthCheckReason +} + +func executeHealthCheck(ctx context.Context, script string, timeout time.Duration, env map[string]string) error { + checkCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + cmd := exec.CommandContext(checkCtx, script) + cmd.Env = envListFromMap(env) + cmd.SysProcAttr = process.SysProcAttr(script, false) + writer := common.NewLineWriter(func(line string) bool { + line = strings.TrimRight(line, "\r\n") + if line != "" { + common.Logger(ctx).Infof("health check: %s", line) + } + return true + }) + cmd.Stdout = writer + cmd.Stderr = writer + + treeKill := process.NewTreeKill(cmd) + if err := cmd.Start(); err != nil { + return fmt.Errorf("start: %w", err) + } + if killer, err := treeKill.Capture(cmd.Process); err == nil { + defer killer.Close() + } + err := cmd.Wait() + common.FlushWriter(writer) + if errors.Is(checkCtx.Err(), context.DeadlineExceeded) { + return fmt.Errorf("timed out after %s", timeout) + } + if err == nil { + return nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return fmt.Errorf("exited with code %d", exitErr.ExitCode()) + } + return err +} + +func processEnvironment() map[string]string { + environ := os.Environ() + env := make(map[string]string, len(environ)) + for _, value := range environ { + if key, item, ok := strings.Cut(value, "="); ok { + env[key] = item + } + } + return env +} diff --git a/internal/app/run/health_check_test.go b/internal/app/run/health_check_test.go new file mode 100644 index 00000000..f6e09673 --- /dev/null +++ b/internal/app/run/health_check_test.go @@ -0,0 +1,148 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package run + +import ( + "context" + "errors" + "testing" + "time" + + "gitea.com/gitea/runner/internal/pkg/config" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfiguredHealthCheckCachesAndRecovers(t *testing.T) { + cfg, err := config.LoadDefault("") + require.NoError(t, err) + cfg.HealthCheck.Enabled = true + cfg.HealthCheck.Script = "/health-check" + cfg.HealthCheck.Interval = time.Minute + cfg.HealthCheck.Timeout = time.Second + + now := time.Now() + calls := 0 + fail := true + r := &Runner{ + cfg: cfg, + name: "runner-1", + now: func() time.Time { return now }, + runHealthCheck: func(_ context.Context, script string, timeout time.Duration, env map[string]string) error { + calls++ + assert.Equal(t, "/health-check", script) + assert.Equal(t, time.Second, timeout) + assert.Equal(t, "true", env["GITEA_RUNNER_HEALTH_CHECK"]) + assert.Equal(t, "runner-1", env["GITEA_RUNNER_NAME"]) + if fail { + return errors.New("unhealthy") + } + return nil + }, + } + + ready, reason := r.CanAcceptTask(t.Context()) + assert.False(t, ready) + assert.Contains(t, reason, "unhealthy") + assert.Equal(t, 1, calls) + + fail = false + ready, _ = r.CanAcceptTask(t.Context()) + assert.False(t, ready, "the failed result should remain cached") + assert.Equal(t, 1, calls) + + now = now.Add(time.Minute) + ready, reason = r.CanAcceptTask(t.Context()) + assert.True(t, ready) + assert.Equal(t, "ok", reason) + assert.Equal(t, 2, calls) +} + +func TestConfiguredHealthCheckDisabled(t *testing.T) { + cfg, err := config.LoadDefault("") + require.NoError(t, err) + cfg.HealthCheck.MinFreeDiskSpaceMB = 1 << 40 + cfg.HealthCheck.Script = "/must-not-run" + r := &Runner{ + cfg: cfg, + runHealthCheck: func(_ context.Context, _ string, _ time.Duration, _ map[string]string) error { + t.Fatal("disabled health check executed") + return nil + }, + } + ready, reason := r.CanAcceptTask(t.Context()) + assert.True(t, ready) + assert.Equal(t, "ok", reason) +} + +func TestConfiguredHealthCheckDeferredWhileJobRuns(t *testing.T) { + cfg, err := config.LoadDefault("") + require.NoError(t, err) + cfg.HealthCheck.Enabled = true + cfg.HealthCheck.Script = "/health-check" + cfg.HealthCheck.Interval = time.Minute + + now := time.Now() + calls := 0 + fail := false + r := &Runner{ + cfg: cfg, + now: func() time.Time { return now }, + runHealthCheck: func(_ context.Context, _ string, _ time.Duration, _ map[string]string) error { + calls++ + if fail { + return errors.New("unhealthy") + } + return nil + }, + } + + ready, reason := r.CanAcceptTask(t.Context()) + assert.True(t, ready) + assert.Equal(t, "ok", reason) + assert.Equal(t, 1, calls) + + now = now.Add(time.Minute) + fail = true + r.runningCount.Store(1) + ready, reason = r.CanAcceptTask(t.Context()) + assert.True(t, ready, "the last result should be reused while a job runs") + assert.Equal(t, "ok", reason) + assert.Equal(t, 1, calls) + + r.runningCount.Store(0) + ready, reason = r.CanAcceptTask(t.Context()) + assert.False(t, ready) + assert.Contains(t, reason, "unhealthy") + assert.Equal(t, 2, calls) +} + +func TestConfiguredHealthCheckInitialRunDeferredWhileJobRuns(t *testing.T) { + cfg, err := config.LoadDefault("") + require.NoError(t, err) + cfg.HealthCheck.Enabled = true + cfg.HealthCheck.Script = "/health-check" + + calls := 0 + r := &Runner{ + cfg: cfg, + runHealthCheck: func(_ context.Context, _ string, _ time.Duration, _ map[string]string) error { + calls++ + return nil + }, + } + r.runningCount.Store(1) + + ready, reason := r.CanAcceptTask(t.Context()) + assert.True(t, ready) + assert.Contains(t, reason, "deferred") + assert.Zero(t, calls) + + r.runningCount.Store(0) + ready, reason = r.CanAcceptTask(t.Context()) + assert.True(t, ready) + assert.Equal(t, "ok", reason) + assert.Equal(t, 1, calls) +} diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index f2ed4f7a..7dabc3a4 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -66,6 +66,13 @@ type Runner struct { runningCount atomic.Int64 lastIdleCleanupUnixNano atomic.Int64 now func() time.Time + healthCheckLast time.Time + healthCheckReady bool + healthCheckReason string + healthStatusSet bool + healthStatusReady bool + healthStatusReason string + runHealthCheck func(context.Context, string, time.Duration, map[string]string) error } func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client) *Runner { @@ -109,13 +116,14 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client) envs["GITEA_ACTIONS_RUNNER_VERSION"] = ver.Version() runner := &Runner{ - name: reg.Name, - cfg: cfg, - client: cli, - labels: ls, - envs: envs, - cacheHandler: cacheHandler, - now: time.Now, + name: reg.Name, + cfg: cfg, + client: cli, + labels: ls, + envs: envs, + cacheHandler: cacheHandler, + now: time.Now, + runHealthCheck: executeHealthCheck, } return runner } @@ -579,6 +587,67 @@ func (r *Runner) RunningCount() int64 { return r.runningCount.Load() } +// CanAcceptTask checks local admission conditions without consuming a task. It is +// called only from the poll loop, so the cached health fields need no lock. +func (r *Runner) CanAcceptTask(ctx context.Context) (bool, string) { + if !r.cfg.HealthCheck.Enabled { + return true, "ok" + } + + if r.RunningCount() > 0 { + if !r.healthStatusSet { + return true, "health checks deferred while jobs are running" + } + return r.healthStatusReady, r.healthStatusReason + } + + if ready, reason := checkFreeDisk(r.cfg); !ready { + r.setHealthStatus(ready, reason) + return false, reason + } + ready, reason := r.checkConfiguredHealth(ctx) + r.setHealthStatus(ready, reason) + return ready, reason +} + +func (r *Runner) setHealthStatus(ready bool, reason string) { + r.healthStatusSet = true + r.healthStatusReady = ready + r.healthStatusReason = reason +} + +// checkFreeDisk evaluates the configured task-admission disk threshold. +func checkFreeDisk(cfg *config.Config) (bool, string) { + root := cfg.Host.WorkdirParent + if cfg.Container.BindWorkdir { + root = filepath.FromSlash("/" + strings.TrimLeft(cfg.Container.WorkdirParent, "/")) + } + root = nearestExistingPath(root) + available, err := freeDiskBytes(root) + if err != nil { + return false, fmt.Sprintf("cannot determine free disk space for %s: %v", root, err) + } + availableMB := available / (1024 * 1024) + if availableMB < uint64(cfg.HealthCheck.MinFreeDiskSpaceMB) { + return false, fmt.Sprintf("low disk space on %s: %d MiB available, %d MiB required", root, availableMB, cfg.HealthCheck.MinFreeDiskSpaceMB) + } + return true, "ok" +} + +func nearestExistingPath(path string) string { + for path != "" { + if _, err := os.Stat(path); err == nil { + return path + } + parent := filepath.Dir(path) + if parent == path { + return parent + } + path = parent + } + return "." +} + func (r *Runner) Declare(ctx context.Context, labels []string) (*connect.Response[runnerv1.DeclareResponse], error) { return r.client.Declare(ctx, connect.NewRequest(&runnerv1.DeclareRequest{ Version: ver.Version(), diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml index 95412e3b..0abee234 100644 --- a/internal/pkg/config/config.example.yaml +++ b/internal/pkg/config/config.example.yaml @@ -199,11 +199,29 @@ host: # If it's empty, $HOME/.cache/act/ will be used. 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 + # 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 + # Optional additional executable. A non-zero exit, timeout, or startup failure + # marks the runner unavailable. + script: '' + # How long a script result is cached and its maximum execution time. + interval: 30s + timeout: 10s + metrics: # Enable the Prometheus metrics endpoint. - # When enabled, metrics are served at http://<addr>/metrics and a liveness check at /healthz. + # When enabled, metrics are served at /metrics, liveness at /healthz, and + # task-admission readiness at /readyz. 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" + # Consecutive polling failures may last this long before /readyz returns 503. + readiness_grace: 30s diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 423ee9ed..c56b244e 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -95,18 +95,30 @@ type Host struct { // Metrics represents the configuration for the Prometheus metrics endpoint. type Metrics struct { - Enabled bool `yaml:"enabled"` // Enabled indicates whether the metrics endpoint is exposed. - Addr string `yaml:"addr"` // Addr specifies the listen address for the metrics HTTP server (e.g., ":9101"). + Enabled bool `yaml:"enabled"` // Enabled indicates whether the metrics endpoint is exposed. + Addr string `yaml:"addr"` // Addr specifies the listen address for the metrics HTTP server (e.g., ":9101"). + ReadinessGrace time.Duration `yaml:"readiness_grace"` // ReadinessGrace permits transient polling errors before /readyz becomes unhealthy. +} + +// HealthCheck represents local checks that control whether the runner accepts +// new tasks. The entire feature is opt-in through Enabled. +type HealthCheck struct { + Enabled bool `yaml:"enabled"` // Enabled activates local task-admission health checks. + MinFreeDiskSpaceMB int64 `yaml:"min_free_disk_space_mb"` // MinFreeDiskSpaceMB is the minimum free space required on the work volume. + Script string `yaml:"script"` // Script is an optional executable used as an additional health check. + Interval time.Duration `yaml:"interval"` // Interval controls how long a script result is cached. + Timeout time.Duration `yaml:"timeout"` // Timeout caps one health-check script invocation. } // Config represents the overall configuration. type Config struct { - Log Log `yaml:"log"` // Log represents the configuration for logging. - Runner Runner `yaml:"runner"` // Runner represents the configuration for the runner. - Cache Cache `yaml:"cache"` // Cache represents the configuration for caching. - Container Container `yaml:"container"` // Container represents the configuration for the container. - Host Host `yaml:"host"` // Host represents the configuration for the host. - Metrics Metrics `yaml:"metrics"` // Metrics represents the configuration for the Prometheus metrics endpoint. + Log Log `yaml:"log"` // Log represents the configuration for logging. + Runner Runner `yaml:"runner"` // Runner represents the configuration for the runner. + Cache Cache `yaml:"cache"` // Cache represents the configuration for caching. + Container Container `yaml:"container"` // Container represents the configuration for the container. + Host Host `yaml:"host"` // Host represents the configuration for the host. + Metrics Metrics `yaml:"metrics"` // Metrics represents the configuration for the Prometheus metrics endpoint. + HealthCheck HealthCheck `yaml:"health_check"` // HealthCheck controls opt-in local task-admission checks. } // LoadDefault returns the default configuration. @@ -220,9 +232,21 @@ func LoadDefault(file string) (*Config, error) { if cfg.Runner.PostTaskScript != "" && cfg.Runner.PostTaskScriptTimeout <= 0 { cfg.Runner.PostTaskScriptTimeout = DefaultPostTaskScriptTimeout } + if cfg.HealthCheck.MinFreeDiskSpaceMB <= 0 { + cfg.HealthCheck.MinFreeDiskSpaceMB = 1024 + } + if cfg.HealthCheck.Interval <= 0 { + cfg.HealthCheck.Interval = 30 * time.Second + } + if cfg.HealthCheck.Timeout <= 0 { + cfg.HealthCheck.Timeout = 10 * time.Second + } if cfg.Metrics.Addr == "" { cfg.Metrics.Addr = "127.0.0.1:9101" } + if cfg.Metrics.ReadinessGrace <= 0 { + cfg.Metrics.ReadinessGrace = 30 * time.Second + } // Validate and fix invalid config combinations to prevent confusing behavior. if cfg.Runner.FetchIntervalMax < cfg.Runner.FetchInterval { diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index 382aad48..f5aaa758 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -48,6 +48,41 @@ func TestLoadDefault_DefaultsWorkdirCleanupAge(t *testing.T) { assert.Equal(t, 10*time.Minute, cfg.Runner.IdleCleanupInterval) } +func TestLoadDefault_HealthChecksAreOptIn(t *testing.T) { + cfg, err := LoadDefault("") + require.NoError(t, err) + assert.False(t, cfg.HealthCheck.Enabled) + assert.Equal(t, int64(1024), cfg.HealthCheck.MinFreeDiskSpaceMB) + assert.Empty(t, cfg.HealthCheck.Script) + assert.Equal(t, 30*time.Second, cfg.HealthCheck.Interval) + assert.Equal(t, 10*time.Second, cfg.HealthCheck.Timeout) + assert.False(t, cfg.Metrics.Enabled) +} + +func TestLoadDefault_DiskAndReadinessSettings(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +health_check: + enabled: true + min_free_disk_space_mb: 4096 + script: /usr/local/bin/runner-health + interval: 15s + timeout: 3s +metrics: + readiness_grace: 45s +`), 0o600)) + + cfg, err := LoadDefault(path) + require.NoError(t, err) + assert.True(t, cfg.HealthCheck.Enabled) + assert.Equal(t, int64(4096), cfg.HealthCheck.MinFreeDiskSpaceMB) + assert.Equal(t, "/usr/local/bin/runner-health", cfg.HealthCheck.Script) + assert.Equal(t, 15*time.Second, cfg.HealthCheck.Interval) + assert.Equal(t, 3*time.Second, cfg.HealthCheck.Timeout) + assert.Equal(t, 45*time.Second, cfg.Metrics.ReadinessGrace) +} + func TestLoadDefault_UsesConfiguredWorkdirCleanupAge(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yaml") diff --git a/internal/pkg/metrics/metrics_test.go b/internal/pkg/metrics/metrics_test.go index 470c185c..cde3ece9 100644 --- a/internal/pkg/metrics/metrics_test.go +++ b/internal/pkg/metrics/metrics_test.go @@ -81,7 +81,7 @@ func TestRegisterRunningJobsFuncZeroCapacity(t *testing.T) { func TestStartServerCanBeCancelled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) - StartServer(ctx, "127.0.0.1:0") + StartServer(ctx, "127.0.0.1:0", nil) cancel() } diff --git a/internal/pkg/metrics/server.go b/internal/pkg/metrics/server.go index 44a58bcd..9876e081 100644 --- a/internal/pkg/metrics/server.go +++ b/internal/pkg/metrics/server.go @@ -13,19 +13,12 @@ import ( ) // StartServer starts an HTTP server that serves Prometheus metrics on /metrics -// and a liveness check on /healthz. The server shuts down when ctx is cancelled. +// and health checks. The server shuts down when ctx is cancelled. // Call Init() before StartServer to register metrics with the Registry. -func StartServer(ctx context.Context, addr string) { - mux := http.NewServeMux() - mux.Handle("/metrics", promhttp.HandlerFor(Registry, promhttp.HandlerOpts{})) - mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("ok")) - }) - +func StartServer(ctx context.Context, addr string, readiness func() (bool, string)) { srv := &http.Server{ Addr: addr, - Handler: mux, + Handler: NewHTTPHandler(readiness), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, @@ -48,3 +41,25 @@ func StartServer(ctx context.Context, addr string) { } }() } + +// NewHTTPHandler returns the metrics and health endpoints used by StartServer. +func NewHTTPHandler(readiness func() (bool, string)) http.Handler { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.HandlerFor(Registry, promhttp.HandlerOpts{})) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) { + ready, reason := true, "ok" + if readiness != nil { + ready, reason = readiness() + } + if !ready { + w.WriteHeader(http.StatusServiceUnavailable) + } + _, _ = w.Write([]byte(reason)) + }) + + return mux +} diff --git a/internal/pkg/metrics/server_test.go b/internal/pkg/metrics/server_test.go new file mode 100644 index 00000000..d3fa8a66 --- /dev/null +++ b/internal/pkg/metrics/server_test.go @@ -0,0 +1,43 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package metrics + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestReadyEndpoint(t *testing.T) { + tests := []struct { + name string + ready bool + reason string + status int + }{ + {"ready", true, "ok", http.StatusOK}, + {"not ready", false, "low disk space", http.StatusServiceUnavailable}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + handler := NewHTTPHandler(func() (bool, string) { return test.ready, test.reason }) + request := httptest.NewRequest(http.MethodGet, "/readyz", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + assert.Equal(t, test.status, response.Code) + assert.Equal(t, test.reason, response.Body.String()) + }) + } +} + +func TestHealthEndpointStaysLiveWhenNotReady(t *testing.T) { + handler := NewHTTPHandler(func() (bool, string) { return false, "low disk space" }) + request := httptest.NewRequest(http.MethodGet, "/healthz", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + assert.Equal(t, http.StatusOK, response.Code) + assert.Equal(t, "ok", response.Body.String()) +} From 6133d64270edfa7c211d2f23c1677af95bf93290 Mon Sep 17 00:00:00 2001 From: silverwind <me@silverwind.io> Date: Wed, 22 Jul 2026 19:01:06 +0000 Subject: [PATCH 23/26] fix: repair free-disk-space build on FreeBSD (#1098) `Statfs_t.Bavail` is unsigned on Linux but signed on FreeBSD, so `Bavail * uint64(Bsize)` in `internal/app/run/disk_unix.go` fails to compile for the freebsd targets goreleaser cross-builds, breaking the nightly release (introduced in https://gitea.com/gitea/runner/pulls/1090). The `checks` workflow only builds for the host, so it never cross-compiles freebsd and stayed green. Casting both operands to `uint64` makes the arithmetic signedness-agnostic across all unix variants. Reviewed-on: https://gitea.com/gitea/runner/pulls/1098 Reviewed-by: bircni <bircni@icloud.com> Co-authored-by: silverwind <me@silverwind.io> --- internal/app/run/disk_unix.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/app/run/disk_unix.go b/internal/app/run/disk_unix.go index 7c7294c8..1933bc27 100644 --- a/internal/app/run/disk_unix.go +++ b/internal/app/run/disk_unix.go @@ -12,5 +12,5 @@ func freeDiskBytes(path string) (uint64, error) { if err := unix.Statfs(path, &stat); err != nil { return 0, err } - return stat.Bavail * uint64(stat.Bsize), nil + return uint64(stat.Bavail) * uint64(stat.Bsize), nil //nolint:unconvert // Bavail/Bsize signedness differs by platform } From 0fd8602ac3a785bf88e6b8e53dc1c947568ed530 Mon Sep 17 00:00:00 2001 From: Renovate Bot <renovate-bot@gitea.com> Date: Thu, 23 Jul 2026 05:41:55 +0000 Subject: [PATCH 24/26] chore(deps): update actions/setup-go action to v7 (#1102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/setup-go](https://github.com/actions/setup-go) | action | major | `v6` β†’ `v7` | --- ### Release Notes <details> <summary>actions/setup-go (actions/setup-go)</summary> ### [`v7.0.0`](https://github.com/actions/setup-go/releases/tag/v7.0.0) [Compare Source](https://github.com/actions/setup-go/compare/v7.0.0...v7.0.0) ##### What's Changed - Migrate to ESM and upgrade dependencies by [@​priyagupta108](https://github.com/priyagupta108) in [#​763](https://github.com/actions/setup-go/pull/763) - chore(deps): bump [@​actions/cache](https://github.com/actions/cache) to 6.2.0 by [@​philip-gai](https://github.com/philip-gai) in [#​771](https://github.com/actions/setup-go/pull/771) ##### New Contributors - [@​philip-gai](https://github.com/philip-gai) made their first contribution in [#​771](https://github.com/actions/setup-go/pull/771) **Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v7.0.0> ### [`v7`](https://github.com/actions/setup-go/compare/v6.5.0...v7.0.0) [Compare Source](https://github.com/actions/setup-go/compare/v6.5.0...v7.0.0) ### [`v6.5.0`](https://github.com/actions/setup-go/releases/tag/v6.5.0) [Compare Source](https://github.com/actions/setup-go/compare/v6.4.0...v6.5.0) ##### What's Changed ##### Dependency update - Upgrade actions dependencies by [@​priyagupta108](https://github.com/priyagupta108) with [@​Copilot](https://github.com/Copilot) in [#​744](https://github.com/actions/setup-go/pull/744) - Upgrade [@​types/node](https://github.com/types/node) and typescript-eslint dependencies to resolve npm audit findings by [@​HarithaVattikuti](https://github.com/HarithaVattikuti) in [#​755](https://github.com/actions/setup-go/pull/755) - Upgrade [@​actions/cache](https://github.com/actions/cache) to 5.1.0, log cache write denied by [@​jasongin](https://github.com/jasongin) in [#​758](https://github.com/actions/setup-go/pull/758) - Upgrade version to 6.5.0 in package.json and package-lock.json by [@​HarithaVattikuti](https://github.com/HarithaVattikuti) in [#​762](https://github.com/actions/setup-go/pull/762) ##### New Contributors - [@​priyagupta108](https://github.com/priyagupta108) with [@​Copilot](https://github.com/Copilot) made their first contribution in [#​744](https://github.com/actions/setup-go/pull/744) - [@​jasongin](https://github.com/jasongin) made their first contribution in [#​758](https://github.com/actions/setup-go/pull/758) **Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.5.0> ### [`v6.4.0`](https://github.com/actions/setup-go/releases/tag/v6.4.0) [Compare Source](https://github.com/actions/setup-go/compare/v6.3.0...v6.4.0) ##### What's Changed ##### Enhancement - Add go-download-base-url input for custom Go distributions by [@​gdams](https://github.com/gdams) in [#​721](https://github.com/actions/setup-go/pull/721) ##### Dependency update - Upgrade minimatch from 3.1.2 to 3.1.5 by [@​dependabot](https://github.com/dependabot) in [#​727](https://github.com/actions/setup-go/pull/727) ##### Documentation update - Rearrange README.md, add advanced-usage.md by [@​priyagupta108](https://github.com/priyagupta108) in [#​724](https://github.com/actions/setup-go/pull/724) - Fix Microsoft build of Go link by [@​gdams](https://github.com/gdams) in [#​734](https://github.com/actions/setup-go/pull/734) ##### New Contributors - [@​gdams](https://github.com/gdams) made their first contribution in [#​721](https://github.com/actions/setup-go/pull/721) **Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.4.0> ### [`v6.3.0`](https://github.com/actions/setup-go/releases/tag/v6.3.0) [Compare Source](https://github.com/actions/setup-go/compare/v6.2.0...v6.3.0) ##### What's Changed - Update default Go module caching to use go.mod by [@​priyagupta108](https://github.com/priyagupta108) in [#​705](https://github.com/actions/setup-go/pull/705) - Fix golang download url to go.dev by [@​178inaba](https://github.com/178inaba) in [#​469](https://github.com/actions/setup-go/pull/469) **Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.3.0> ### [`v6.2.0`](https://github.com/actions/setup-go/releases/tag/v6.2.0) [Compare Source](https://github.com/actions/setup-go/compare/v6.1.0...v6.2.0) ##### What's Changed ##### Enhancements - Example for restore-only cache in documentation by [@​aparnajyothi-y](https://github.com/aparnajyothi-y) in [#​696](https://github.com/actions/setup-go/pull/696) - Update Node.js version in action.yml by [@​ccoVeille](https://github.com/ccoVeille) in [#​691](https://github.com/actions/setup-go/pull/691) - Documentation update of actions/checkout by [@​deining](https://github.com/deining) in [#​683](https://github.com/actions/setup-go/pull/683) ##### Dependency updates - Upgrade js-yaml from 3.14.1 to 3.14.2 by [@​dependabot](https://github.com/dependabot) in [#​682](https://github.com/actions/setup-go/pull/682) - Upgrade [@​actions/cache](https://github.com/actions/cache) to v5 by [@​salmanmkc](https://github.com/salmanmkc) in [#​695](https://github.com/actions/setup-go/pull/695) - Upgrade actions/checkout from 5 to 6 by [@​dependabot](https://github.com/dependabot) in [#​686](https://github.com/actions/setup-go/pull/686) - Upgrade qs from 6.14.0 to 6.14.1 by [@​dependabot](https://github.com/dependabot) in [#​703](https://github.com/actions/setup-go/pull/703) ##### New Contributors - [@​ccoVeille](https://github.com/ccoVeille) made their first contribution in [#​691](https://github.com/actions/setup-go/pull/691) - [@​deining](https://github.com/deining) made their first contribution in [#​683](https://github.com/actions/setup-go/pull/683) **Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.2.0> ### [`v6.1.0`](https://github.com/actions/setup-go/releases/tag/v6.1.0) [Compare Source](https://github.com/actions/setup-go/compare/v6...v6.1.0) ##### What's Changed ##### Enhancements - Fall back to downloading from go.dev/dl instead of storage.googleapis.com/golang by [@​nicholasngai](https://github.com/nicholasngai) in [#​665](https://github.com/actions/setup-go/pull/665) - Add support for .tool-versions file and update workflow by [@​priya-kinthali](https://github.com/priya-kinthali) in [#​673](https://github.com/actions/setup-go/pull/673) - Add comprehensive breaking changes documentation for v6 by [@​mahabaleshwars](https://github.com/mahabaleshwars) in [#​674](https://github.com/actions/setup-go/pull/674) ##### Dependency updates - Upgrade eslint-config-prettier from 10.0.1 to 10.1.8 and document breaking changes in v6 by [@​dependabot](https://github.com/dependabot) in [#​617](https://github.com/actions/setup-go/pull/617) - Upgrade actions/publish-action from 0.3.0 to 0.4.0 by [@​dependabot](https://github.com/dependabot) in [#​641](https://github.com/actions/setup-go/pull/641) - Upgrade semver and [@​types/semver](https://github.com/types/semver) by [@​dependabot](https://github.com/dependabot) in [#​652](https://github.com/actions/setup-go/pull/652) ##### New Contributors - [@​nicholasngai](https://github.com/nicholasngai) made their first contribution in [#​665](https://github.com/actions/setup-go/pull/665) - [@​priya-kinthali](https://github.com/priya-kinthali) made their first contribution in [#​673](https://github.com/actions/setup-go/pull/673) - [@​mahabaleshwars](https://github.com/mahabaleshwars) made their first contribution in [#​674](https://github.com/actions/setup-go/pull/674) **Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.1.0> </details> --- ### Configuration πŸ“… **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. πŸ”• **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xOTEuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE5MS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119--> Reviewed-on: https://gitea.com/gitea/runner/pulls/1102 Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: Renovate Bot <renovate-bot@gitea.com> --- .gitea/workflows/release-nightly.yml | 2 +- .gitea/workflows/release-tag.yml | 2 +- .gitea/workflows/test.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/release-nightly.yml b/.gitea/workflows/release-nightly.yml index 708e1621..1004e1e9 100644 --- a/.gitea/workflows/release-nightly.yml +++ b/.gitea/workflows/release-nightly.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: actions/setup-go@v6 + - uses: actions/setup-go@v7 with: go-version-file: "go.mod" - name: goreleaser diff --git a/.gitea/workflows/release-tag.yml b/.gitea/workflows/release-tag.yml index e9d76e5c..4209ab80 100644 --- a/.gitea/workflows/release-tag.yml +++ b/.gitea/workflows/release-tag.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 # all history for all branches and tags - - uses: actions/setup-go@v6 + - uses: actions/setup-go@v7 with: go-version-file: "go.mod" - name: Import GPG key diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index dd5bcc82..907e5edc 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -18,7 +18,7 @@ jobs: DOCKER_CONFIG: /tmp/docker-noauth steps: - uses: actions/checkout@v7 - - uses: actions/setup-go@v6 + - uses: actions/setup-go@v7 with: go-version-file: 'go.mod' - name: prepare anonymous docker config From b1a02cdd5da16cf4c4fded839178e8166ec86b09 Mon Sep 17 00:00:00 2001 From: Renovate Bot <renovate-bot@gitea.com> Date: Thu, 23 Jul 2026 06:19:27 +0000 Subject: [PATCH 25/26] chore(deps): update docker docker tag to v29.6.2 (#1101) Co-authored-by: Renovate Bot <renovate-bot@gitea.com> --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6a0acb1e..e609eed8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ RUN make clean && make build ### DIND VARIANT # # -FROM docker:29.6.1-dind AS dind +FROM docker:29.6.2-dind AS dind ARG VERSION=dev @@ -37,7 +37,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"] ### DIND-ROOTLESS VARIANT # # -FROM docker:29.6.1-dind-rootless AS dind-rootless +FROM docker:29.6.2-dind-rootless AS dind-rootless ARG VERSION=dev From c9c4957e3833a54c2c97fb50d4553bca755b8292 Mon Sep 17 00:00:00 2001 From: Zettat123 <zettat123@gmail.com> Date: Thu, 23 Jul 2026 06:26:10 +0000 Subject: [PATCH 26/26] feat: support reading `cache.external_secret` from a file (#1100) This PR adds a new `cache.external_secret_file` config, which points at a file holding the secret. So the secret can come from a mounted Kubernetes/Docker secret while the rest of the config stays plain text. ```yaml cache: external_server: "http://cache-host:8088/" external_secret_file: /path/to/cache_external_secret ``` --------- Co-authored-by: bircni <bircni@icloud.com> Reviewed-on: https://gitea.com/gitea/runner/pulls/1100 Reviewed-by: bircni <bircni@icloud.com> Co-authored-by: Zettat123 <zettat123@gmail.com> --- README.md | 2 + internal/app/cmd/cache-server.go | 2 +- internal/app/run/runner.go | 30 +++++++-- internal/pkg/config/config.example.yaml | 5 ++ internal/pkg/config/config.go | 43 +++++++++--- internal/pkg/config/config_test.go | 88 +++++++++++++++++++++++++ 6 files changed, 154 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e848a679..e2674f12 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ Run one dedicated `gitea-runner cache-server` that all runners point at. dir: /data/actcache port: 8088 external_secret: "replace-with-a-strong-random-secret" + # external_secret_file: /path/to/secret # secret can also be passed via a file ``` 2. Start the server: @@ -238,6 +239,7 @@ Run one dedicated `gitea-runner cache-server` that all runners point at. cache: external_server: "http://<cache-server-host>:8088/" external_secret: "replace-with-a-strong-random-secret" # must match the server + # external_secret_file: /path/to/secret # secret can also be passed via a file ``` Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it β€” simpler, but with weaker isolation between repositories. diff --git a/internal/app/cmd/cache-server.go b/internal/app/cmd/cache-server.go index 168fffef..1d63da05 100644 --- a/internal/app/cmd/cache-server.go +++ b/internal/app/cmd/cache-server.go @@ -50,7 +50,7 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co secret := cfg.Cache.ExternalSecret if secret == "" { - return errors.New("cache.external_secret must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server") + return errors.New("cache.external_secret (or cache.external_secret_file) must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server") } cacheHandler, err := artifactcache.StartHandler( dir, diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index 7dabc3a4..04a5484a 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -89,6 +89,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client) if cfg.Cache.ExternalServer != "" { envs["ACTIONS_CACHE_URL"] = cfg.Cache.ExternalServer } else { + warnIgnoredCacheSecret(cfg) handler, err := artifactcache.StartHandler( cfg.Cache.Dir, cfg.Cache.Host, @@ -512,14 +513,11 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. // function the caller must invoke (typically via defer) to revoke the // credential when the task finishes. // -// Three modes: +// Two modes: // - Embedded handler: register in-process via RegisterJob. -// - external_server + external_secret: POST to the remote server's -// /_internal/register, defer a POST to /_internal/revoke. This is what -// enables full per-job auth and repo scoping over the network. -// - external_server alone (no secret): no-op revoker. The remote server is -// in legacy openMode and ignores the runtime token; trust is at the -// network layer. +// - external_server: POST to the remote server's /_internal/register, defer a +// POST to /_internal/revoke. This is what enables full per-job auth and +// repo scoping over the network. // // Safe with an empty token (older Gitea did not issue one). func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Reporter) func() { @@ -532,6 +530,7 @@ func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Repor if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" { return r.registerExternalCacheJob(token, repo, reporter) } + // No cache server to register against: caching is disabled, or the built-in server failed to start. return func() {} } @@ -655,3 +654,20 @@ func (r *Runner) Declare(ctx context.Context, labels []string) (*connect.Respons Capabilities: RunnerCapabilities(), })) } + +// warnIgnoredCacheSecret flags an external cache server secret configured on a runner that uses the built-in cache server. +func warnIgnoredCacheSecret(cfg *config.Config) { + if cfg.Cache.ExternalServer != "" { + return + } + // Not using an external cache server, so any configured secret is ignored. + if cfg.Cache.ExternalSecret == "" { + return + } + // LoadDefault resolves external_secret_file into ExternalSecret, so report whichever key the operator actually wrote. + key := "cache.external_secret" + if cfg.Cache.ExternalSecretFile != "" { + key = "cache.external_secret_file" + } + log.Warnf("%s is set but cache.external_server is not; the built-in cache server does not use a shared secret, so the value is ignored", key) +} diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml index 0abee234..d463fed6 100644 --- a/internal/pkg/config/config.example.yaml +++ b/internal/pkg/config/config.example.yaml @@ -132,6 +132,11 @@ cache: # Required when external_server is set. Must be identical on every runner and the cache-server. # Generate with: openssl rand -hex 32 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: "" # 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. diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index c56b244e..77ac253e 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -9,6 +9,7 @@ import ( "maps" "os" "path/filepath" + "strings" "time" "github.com/joho/godotenv" @@ -57,13 +58,14 @@ type Runner struct { // Cache represents the configuration for caching. type Cache struct { - Enabled *bool `yaml:"enabled"` // Enabled indicates whether caching is enabled. It is a pointer to distinguish between false and not set. If not set, it will be true. - Dir string `yaml:"dir"` // Dir specifies the directory path for caching. - Host string `yaml:"host"` // Host specifies the caching host. - Port uint16 `yaml:"port"` // Port specifies the caching port. - ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server - ExternalSecret string `yaml:"external_secret"` // ExternalSecret is a shared secret between this runner and an external gitea-runner cache-server, enabling per-job ACTIONS_RUNTIME_TOKEN authentication and repo scoping over the network. Leave empty to keep the legacy unauthenticated behavior. - OfflineMode bool `yaml:"offline_mode"` // OfflineMode reuses a cached action without fetching from the remote; a moved tag or branch stays at the cached commit until the cache entry is removed. + Enabled *bool `yaml:"enabled"` // Enabled indicates whether caching is enabled. It is a pointer to distinguish between false and not set. If not set, it will be true. + Dir string `yaml:"dir"` // Dir specifies the directory path for caching. + Host string `yaml:"host"` // Host specifies the caching host. + Port uint16 `yaml:"port"` // Port specifies the caching port. + ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server + ExternalSecret string `yaml:"external_secret"` // ExternalSecret is a shared secret between this runner and an external gitea-runner cache-server, enabling per-job ACTIONS_RUNTIME_TOKEN authentication and repo scoping over the network. Required whenever ExternalServer is set; ExternalSecretFile is the alternative way to provide it. + ExternalSecretFile string `yaml:"external_secret_file"` // ExternalSecretFile is the path to a file holding the ExternalSecret value, so the secret can be mounted instead of stored in the config file. LoadDefault reads it into ExternalSecret; setting both is an error. + OfflineMode bool `yaml:"offline_mode"` // OfflineMode reuses a cached action without fetching from the remote; a moved tag or branch stays at the cached commit until the cache entry is removed. } // Container represents the configuration for the container. @@ -177,6 +179,10 @@ func LoadDefault(file string) (*Config, error) { b := true cfg.Cache.Enabled = &b } + // Resolved regardless of cache.enabled, because the `cache-server` command reads the secret from the same key without checking cache.enabled. + if err := resolveCacheExternalSecret(cfg); err != nil { + return nil, err + } if *cfg.Cache.Enabled { if cfg.Cache.Dir == "" { home, err := os.UserHomeDir() @@ -186,7 +192,7 @@ func LoadDefault(file string) (*Config, error) { cfg.Cache.Dir = filepath.Join(home, ".cache", "actcache") } if cfg.Cache.ExternalServer != "" && cfg.Cache.ExternalSecret == "" { - return nil, errors.New("cache.external_server is set but cache.external_secret is empty; configure the same external_secret on this runner and the gitea-runner cache-server") + return nil, errors.New("cache.external_server is set but no shared secret is configured; set cache.external_secret (or cache.external_secret_file) to the same value used by the gitea-runner cache-server") } } if cfg.Container.WorkdirParent == "" { @@ -301,3 +307,24 @@ func definedRunnerConfigKeys(content []byte) (map[string]bool, error) { return defined, nil } + +// resolveCacheExternalSecret loads cache.external_secret from the file named by cache.external_secret_file, +// so deployments can mount the secret instead of committing it to the config file. +func resolveCacheExternalSecret(cfg *Config) error { + if cfg.Cache.ExternalSecretFile == "" { + return nil + } + if cfg.Cache.ExternalSecret != "" { + return errors.New("cache.external_secret and cache.external_secret_file are both set; configure only one of them") + } + content, err := os.ReadFile(cfg.Cache.ExternalSecretFile) + if err != nil { + return fmt.Errorf("read cache.external_secret_file %q: %w", cfg.Cache.ExternalSecretFile, err) + } + secret := strings.TrimSpace(string(content)) + if secret == "" { + return fmt.Errorf("cache.external_secret_file %q contains no secret", cfg.Cache.ExternalSecretFile) + } + cfg.Cache.ExternalSecret = secret + return nil +} diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index f5aaa758..e3c75dcf 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -227,3 +227,91 @@ func TestContainerNetworkCreateOptions(t *testing.T) { assert.Nil(t, opts.EnableIPv6) }) } + +func TestLoadDefault_ReadsExternalSecretFromFile(t *testing.T) { + dir := t.TempDir() + secretPath := filepath.Join(dir, "cache.secret") + require.NoError(t, os.WriteFile(secretPath, []byte(" s3cr3t\n"), 0o600)) + + path := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +cache: + enabled: true + external_server: "http://cache.invalid/" + external_secret_file: "`+secretPath+`" +`), 0o600)) + + cfg, err := LoadDefault(path) + require.NoError(t, err) + assert.Equal(t, "s3cr3t", cfg.Cache.ExternalSecret) +} + +func TestLoadDefault_ReadsExternalSecretFromFileWhenCacheDisabled(t *testing.T) { + dir := t.TempDir() + secretPath := filepath.Join(dir, "cache.secret") + require.NoError(t, os.WriteFile(secretPath, []byte("s3cr3t"), 0o600)) + + // the file has to be resolved even when cache is disabled + path := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +cache: + enabled: false + external_secret_file: "`+secretPath+`" +`), 0o600)) + + cfg, err := LoadDefault(path) + require.NoError(t, err) + assert.Equal(t, "s3cr3t", cfg.Cache.ExternalSecret) +} + +func TestLoadDefault_RejectsBothExternalSecretAndFile(t *testing.T) { + dir := t.TempDir() + secretPath := filepath.Join(dir, "cache.secret") + require.NoError(t, os.WriteFile(secretPath, []byte("s3cr3t"), 0o600)) + + path := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +cache: + enabled: true + external_server: "http://cache.invalid/" + external_secret: "inline" + external_secret_file: "`+secretPath+`" +`), 0o600)) + + _, err := LoadDefault(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "both set") +} + +func TestLoadDefault_RejectsMissingExternalSecretFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +cache: + enabled: true + external_server: "http://cache.invalid/" + external_secret_file: "`+filepath.Join(dir, "absent.secret")+`" +`), 0o600)) + + _, err := LoadDefault(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "read cache.external_secret_file") +} + +func TestLoadDefault_RejectsEmptyExternalSecretFile(t *testing.T) { + dir := t.TempDir() + secretPath := filepath.Join(dir, "cache.secret") + require.NoError(t, os.WriteFile(secretPath, []byte("\n \n"), 0o600)) + + path := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +cache: + enabled: true + external_server: "http://cache.invalid/" + external_secret_file: "`+secretPath+`" +`), 0o600)) + + _, err := LoadDefault(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "contains no secret") +}